From b46b25b26bda693628ffd534afc9e62b56ceebd4 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 6 Jun 2026 21:30:58 -0400 Subject: [PATCH] =?UTF-8?q?chore(03):=20Gate=202=20stack=20bring-up=20?= =?UTF-8?q?=E2=80=94=20serve=20PWA=20from=20API=20image,=20prod=20env,=20c?= =?UTF-8?q?redential=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: build apps/pwa into the production image's ./public so the API serves the PWA on a single port (:3000) for the Pangolin/newt tunnel - docker-compose.yml: set NODE_ENV=production (mount OIDC unconditionally) and constrain OIDC_SCOPES=openid profile email offline_access (Authelia rejected the empty-default's full scopes_supported with invalid_scope) - apps/api/scripts/seed-credential.mjs: operator tool to seed member_credentials (encrypted Fastmail app password) out-of-band — fills the documented gap --- apps/api/Dockerfile | 17 ++++++- apps/api/scripts/seed-credential.mjs | 74 ++++++++++++++++++++++++++++ docker-compose.yml | 6 +++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 apps/api/scripts/seed-credential.mjs diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index cfa80f4..d17ef0b 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -21,6 +21,17 @@ WORKDIR /app/apps/api COPY --from=builder /app /app CMD ["node", "--watch", "dist/index.js"] +# PWA build stage — produces apps/pwa/dist (relative API paths, env-agnostic). +# Runs in parallel with `builder` under BuildKit; output is copied into the +# production image's ./public so the API serves the PWA on the same port (:3000). +FROM base AS pwa-builder +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +COPY apps/api/package.json ./apps/api/ +COPY apps/pwa/package.json ./apps/pwa/ +RUN pnpm install --frozen-lockfile --filter @familysync/pwa... +COPY apps/pwa ./apps/pwa +RUN pnpm --filter @familysync/pwa build + FROM base AS production COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY apps/api/package.json ./apps/api/ @@ -28,6 +39,8 @@ COPY apps/pwa/package.json ./apps/pwa/ RUN pnpm install --frozen-lockfile --prod --filter @familysync/api... COPY --from=builder /app/apps/api/dist ./apps/api/dist WORKDIR /app/apps/api -# PWA static assets (apps/pwa) are built and served separately; /health works -# without them and the catch-all static route 404s gracefully. +# PWA static assets built from apps/pwa and served by this API from ./public +# (single-port deployment for the Pangolin/newt tunnel). serveStatic resolves +# ./public relative to the runtime CWD, which is this WORKDIR (/app/apps/api). +COPY --from=pwa-builder /app/apps/pwa/dist ./public CMD ["node", "dist/index.js"] diff --git a/apps/api/scripts/seed-credential.mjs b/apps/api/scripts/seed-credential.mjs new file mode 100644 index 0000000..a276c64 --- /dev/null +++ b/apps/api/scripts/seed-credential.mjs @@ -0,0 +1,74 @@ +/** + * Seed (or update) a member's encrypted Fastmail app-password credential. + * + * There is no onboarding UI — member_credentials is seeded out-of-band by the + * operator. This script encrypts the app password with the same AES-256-GCM + * helper the app uses (APP_PASSWORD_ENCRYPTION_KEY) and upserts one row. + * + * Run INSIDE the api container (where APP_PASSWORD_ENCRYPTION_KEY + DB_* are set): + * + * docker compose cp apps/api/scripts/seed-credential.mjs api:/app/apps/api/scripts/seed-credential.mjs + * docker compose exec \ + * -e FASTMAIL_APP_PASSWORD='xxxx-xxxx-xxxx-xxxx' \ + * -e FASTMAIL_EMAIL='me@lucasberger.ca' \ + * -e SEED_USER_ID=2 \ + * api node scripts/seed-credential.mjs + * + * The app password never leaves the operator's shell — it is passed as an env + * var to `docker compose exec` and encrypted at rest immediately. + */ +import { encryptPassword } from '../dist/broker/crypto.js' +import mysql from 'mysql2/promise' + +const email = process.env.FASTMAIL_EMAIL || 'me@lucasberger.ca' +const password = process.env.FASTMAIL_APP_PASSWORD +const userId = Number.parseInt(process.env.SEED_USER_ID || '', 10) + +if (!password) { + console.error('ERROR: set FASTMAIL_APP_PASSWORD') + process.exit(1) +} +if (!Number.isInteger(userId) || userId <= 0) { + console.error('ERROR: set SEED_USER_ID to the target users.id (integer)') + process.exit(1) +} + +// Encrypt with the app's key (read from APP_PASSWORD_ENCRYPTION_KEY at call time). +const encrypted = encryptPassword(password) + +const conn = await mysql.createConnection({ + host: process.env.DB_HOST || 'mariadb', + port: Number.parseInt(process.env.DB_PORT || '3306', 10), + user: process.env.DB_USER || 'familysync', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'familysync', +}) + +try { + // Confirm the user exists before linking a credential to it. + const [users] = await conn.execute('SELECT id FROM users WHERE id = ?', [userId]) + if (users.length === 0) { + console.error(`ERROR: no users row with id=${userId}. Log in first to create it.`) + process.exit(1) + } + + const [existing] = await conn.execute( + 'SELECT id FROM member_credentials WHERE user_id = ?', + [userId], + ) + if (existing.length > 0) { + await conn.execute( + 'UPDATE member_credentials SET encrypted_password = ?, fastmail_email = ? WHERE user_id = ?', + [encrypted, email, userId], + ) + console.log(`Updated member_credentials for user_id=${userId} (${email})`) + } else { + await conn.execute( + 'INSERT INTO member_credentials (user_id, encrypted_password, fastmail_email) VALUES (?, ?, ?)', + [userId, encrypted, email], + ) + console.log(`Inserted member_credentials for user_id=${userId} (${email})`) + } +} finally { + await conn.end() +} diff --git a/docker-compose.yml b/docker-compose.yml index 46ab567..ea139c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: dockerfile: apps/api/Dockerfile target: production environment: + NODE_ENV: production DB_HOST: mariadb DB_PORT: 3306 DB_USER: familysync @@ -16,6 +17,11 @@ services: OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} OIDC_AUTH_EXTERNAL_URL: ${OIDC_AUTH_EXTERNAL_URL:-} + # Constrain requested scopes — @hono/oidc-auth requests ALL of the IdP's + # scopes_supported when OIDC_SCOPES is empty (Authelia then rejects with + # invalid_scope). offline_access is required for refresh-token session + # persistence (D-12/AUTH-02) and must also be allowed on the Authelia client. + OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email offline_access} APP_PASSWORD_ENCRYPTION_KEY: ${APP_PASSWORD_ENCRYPTION_KEY:-} depends_on: mariadb: