chore(03): Gate 2 stack bring-up — serve PWA from API image, prod env, credential seed
- 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
This commit is contained in:
+15
-2
@@ -21,6 +21,17 @@ WORKDIR /app/apps/api
|
|||||||
COPY --from=builder /app /app
|
COPY --from=builder /app /app
|
||||||
CMD ["node", "--watch", "dist/index.js"]
|
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
|
FROM base AS production
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/api/package.json ./apps/api/
|
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...
|
RUN pnpm install --frozen-lockfile --prod --filter @familysync/api...
|
||||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||||
WORKDIR /app/apps/api
|
WORKDIR /app/apps/api
|
||||||
# PWA static assets (apps/pwa) are built and served separately; /health works
|
# PWA static assets built from apps/pwa and served by this API from ./public
|
||||||
# without them and the catch-all static route 404s gracefully.
|
# (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"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ services:
|
|||||||
dockerfile: apps/api/Dockerfile
|
dockerfile: apps/api/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
DB_HOST: mariadb
|
DB_HOST: mariadb
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_USER: familysync
|
DB_USER: familysync
|
||||||
@@ -16,6 +17,11 @@ services:
|
|||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
|
||||||
OIDC_AUTH_EXTERNAL_URL: ${OIDC_AUTH_EXTERNAL_URL:-}
|
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:-}
|
APP_PASSWORD_ENCRYPTION_KEY: ${APP_PASSWORD_ENCRYPTION_KEY:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
mariadb:
|
mariadb:
|
||||||
|
|||||||
Reference in New Issue
Block a user