From 06b54747f1db43cf2f184135d7c16b02f3a411d6 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 4 Aug 2026 11:25:45 +0200 Subject: [PATCH] Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Stefan's manual clean install per the guide, both ending in an api restart loop that was hard to diagnose: - #324: the guide recommended `openssl rand -base64 32` for POSTGRES_PASSWORD, but the compose interpolates the password unescaped into DATABASE_URL — base64's `/`, `+`, `=` break the URL. Misleadingly, db stays healthy (it gets the password as a plain env var) while api/collab/backup crash. Guide and .env.example now recommend `openssl rand -hex 24` for both secrets and say why; Troubleshooting gained the symptom line. - #325: SETUP_ADMIN_PASSWORD's minimum (10 chars, packages/shared/src/auth.ts) was undocumented, and a violation crashed the boot with a raw ZodError naming schema fields and i18n keys. Failing the boot stays — deliberately, no half-seeded instance — but preseedFromEnv now translates validation errors into operator terms ("Pre-seeding failed: SETUP_ADMIN_PASSWORD must be at least 10 characters. Fix .env and recreate the api container."). Documented in the guide's first-run section, .env.example, and Troubleshooting; new test pins the message and that nothing is half-seeded afterwards. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P --- apps/api/src/setup/setup.e2e.db.test.ts | 36 +++++++++++++++++++++++ apps/api/src/setup/setup.service.ts | 38 +++++++++++++++++++++++-- deploy/compose/.env.example | 10 +++++-- docs/self-hosting/README.md | 22 ++++++++++++-- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/api/src/setup/setup.e2e.db.test.ts b/apps/api/src/setup/setup.e2e.db.test.ts index d2d719f..c2dcd55 100644 --- a/apps/api/src/setup/setup.e2e.db.test.ts +++ b/apps/api/src/setup/setup.e2e.db.test.ts @@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)' expect(locked.body.code).toBe('setup_locked'); }); }); + + describe('env pre-seeding with invalid values (issue #325)', () => { + const dbName = `dorfteich_preseed_bad_${suffix}`; + let app: INestApplication; + const badEnv = { + SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`, + SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`, + SETUP_ADMIN_PASSWORD: 'short', + } as const; + + beforeAll(async () => { + const url = await createFreshDatabase(dbName); + process.env.TEST_DATABASE_URL = url; + process.env.SECRETS_FILE = join( + mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')), + 'secrets.env', + ); + Object.assign(process.env, badEnv); + app = await createTestApp(); + }, 60_000); + + afterAll(async () => { + for (const key of Object.keys(badEnv)) delete process.env[key]; + await app.close(); + await dropDatabase(dbName); + }); + + it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => { + await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow( + /SETUP_ADMIN_PASSWORD must be at least 10 characters/, + ); + // Fail-fast left nothing half-seeded: the wizard is still pending. + const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200); + expect(status.body.status).toBe('required'); + }); + }); }); interface FakeSmtpServer { diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 8031272..0bf0165 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -15,6 +15,7 @@ import { } from '@dorfteich/shared'; import { User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; +import { ZodError } from 'zod'; import { SessionsService } from '../auth/sessions.service'; import { AppConfig } from '../config/app-config.service'; @@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit { if (!(await this.state.isPending())) return; // Fails the boot loudly on invalid values — a half-seeded instance - // would be much harder to diagnose than a startup error. - const input = setupAdminInputSchema.parse({ + // would be much harder to diagnose than a startup error. Translated + // into operator terms first: the raw ZodError names schema fields and + // i18n keys, not the SETUP_* variable to fix (issue #325). + const parsed = setupAdminInputSchema.safeParse({ username: env.SETUP_ADMIN_USERNAME, email: env.SETUP_ADMIN_EMAIL, password: env.SETUP_ADMIN_PASSWORD, displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME, locale: env.SETUP_DEFAULT_LOCALE, }); + if (!parsed.success) { + throw new Error( + `Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` + + 'Fix .env and recreate the api container.', + ); + } + const input = parsed.data; const admin = await this.createAdmin(input); if (env.SETUP_INSTANCE_NAME) { await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id); @@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit { return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0; } } + +/** The env variable behind each schema field of the pre-seeded admin. */ +const PRESEED_FIELD_TO_ENV: Record = { + username: 'SETUP_ADMIN_USERNAME', + email: 'SETUP_ADMIN_EMAIL', + password: 'SETUP_ADMIN_PASSWORD', + displayName: 'SETUP_ADMIN_DISPLAY_NAME', + locale: 'SETUP_DEFAULT_LOCALE', +}; + +function describePreseedIssues(error: ZodError): string { + return error.issues + .map((issue) => { + const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]); + if (issue.code === 'too_small' && issue.type === 'string') { + return `${variable} must be at least ${issue.minimum} characters`; + } + if (issue.code === 'invalid_string' && issue.validation === 'email') { + return `${variable} is not a valid e-mail address`; + } + return `${variable} is invalid (${issue.message})`; + }) + .join('; '); +} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 2ecf7fe..c81b6f2 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -2,14 +2,17 @@ # next to docker-compose.yml and adjust the values. # --- required --------------------------------------------------------------- -# PostgreSQL password for the `dorfteich` database user. +# PostgreSQL password for the `dorfteich` database user. URL-SAFE +# characters only (generate with `openssl rand -hex 24`): the compose file +# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=` +# break the URL — db stays healthy while api/collab/backup restart-loop. POSTGRES_PASSWORD=change-me # ROOT key of the token key hierarchy (ADR 0020, issue #188): every token # purpose (collaboration tokens, digest unsubscribe links) derives its own # HKDF subkey from this value — nothing signs with it directly. The api and # collab services share this one value; use a long random string -# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all +# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all # derived keys at once and invalidates outstanding tokens. COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string @@ -155,6 +158,9 @@ SMTP_FROM=Dorfteich # wizard. Automated deploys can skip it entirely by pre-seeding the Site # Admin here; the wizard then completes and locks itself at first boot. # All three SETUP_ADMIN_* values are required for pre-seeding to trigger. +# The wizard's validation applies: the password needs at least 10 +# characters — a violation fails the boot with a message naming the +# variable (deliberate: no half-seeded instance). #SETUP_ADMIN_USERNAME=admin #SETUP_ADMIN_EMAIL=admin@example.com #SETUP_ADMIN_PASSWORD=change-me-please diff --git a/docs/self-hosting/README.md b/docs/self-hosting/README.md index 8395205..65f1aa5 100644 --- a/docs/self-hosting/README.md +++ b/docs/self-hosting/README.md @@ -33,8 +33,12 @@ work, that is a bug (issue #88). ``` 2. Edit `.env` — the minimum: - - `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings - (`openssl rand -base64 32`). + - `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings — + generate both with `openssl rand -hex 24`. Stick to URL-safe + characters for the database password (hex is): it is interpolated + into a connection URL, and a `/`, `+` or `=` from base64 output + breaks it in a confusing way (db healthy, everything else + restart-looping — see Troubleshooting). - `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin the latest release tag (semver, e.g. `v0.14.0`) — the [release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases) @@ -81,7 +85,10 @@ and health endpoints with `503 setup_required` — that is not an error. Unattended installs skip the wizard by pre-seeding: set the `SETUP_ADMIN_*` variables in `.env` before the first start (see -`.env.example`). +`.env.example`). The same validation as in the wizard applies — +`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid +value deliberately fails the boot with a message naming the variable +(a half-seeded instance would be harder to diagnose). ## Updating @@ -185,6 +192,15 @@ and the OpenAPI document: [public-api.md](public-api.md). mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not match the URL in the browser (scheme and host must be identical). E-mail links point at the wrong host → same variable. +- api, collab **and** backup restart-looping while `db` is healthy → + `POSTGRES_PASSWORD` contains characters that break the connection URL + (base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and + recreate the stack. The db container looks fine because only its + clients build a URL from the password. +- api restart-looping right after the first start with a + `Pre-seeding failed` (or `validation.password.tooShort`) message → + `SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and + recreate the api container. - Wizard reappears after a restart → the database volume was not persisted; never run without the `db-data` volume. - `docker compose ps` shows `unhealthy` → that container's liveness check