All checks were successful
CD / Build and push images (push) Successful in 2m45s
CI / Lint, typecheck, test (push) Successful in 1m56s
CI / Auth e2e pack (push) Successful in 2m1s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 12s
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).
- packages/shared: browser-safe token schema/types in `collab-token`, and the
Node `crypto` sign/verify in `token-crypto` behind its own subpath export
(`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
`node:crypto`. Only HS256 is produced/accepted; the signature is checked in
constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
{token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
document name, stores {userId, mode} context, and enforces `ro` via
Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
.env.example, stage docs); a dev default keeps native dev/test/CI running.
Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).
Closes #34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Environment schemas live here so api, collab, and tooling validate their
|
|
* configuration the same way. Every service calls `parseEnv` once at startup
|
|
* and crashes with a readable list of problems instead of failing later at
|
|
* first use.
|
|
*/
|
|
/** Fields every service configures the same way; keep these in sync. */
|
|
const nodeEnv = z.enum(['development', 'test', 'production']).default('development');
|
|
const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info');
|
|
/** Version shown in health responses; injected at image build time. */
|
|
const appVersion = z.string().default('0.0.0-dev');
|
|
/** PostgreSQL connection string — required, there is no sensible default. */
|
|
const databaseUrl = z
|
|
.string()
|
|
.min(1)
|
|
.refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), {
|
|
message: 'must be a postgresql:// connection string',
|
|
});
|
|
/**
|
|
* Symmetric secret shared by the api (which signs) and the collab server
|
|
* (which verifies) for the short-lived collaboration tokens (issue #34,
|
|
* ADR 0007). The dev default only keeps native dev/test/CI running without
|
|
* extra setup; every real instance MUST set its own identical value in the
|
|
* `.env` of both services (the stage setup and the M8 wizard do this).
|
|
*/
|
|
const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me');
|
|
|
|
export const apiEnvSchema = z.object({
|
|
NODE_ENV: nodeEnv,
|
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
LOG_LEVEL: logLevel,
|
|
APP_VERSION: appVersion,
|
|
DATABASE_URL: databaseUrl,
|
|
COLLAB_TOKEN_SECRET: collabTokenSecret,
|
|
/** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */
|
|
MIGRATE_ON_START: z
|
|
.enum(['true', 'false'])
|
|
.default('true')
|
|
.transform((value) => value === 'true'),
|
|
/** Public base URL of this instance — used in e-mail links. */
|
|
APP_BASE_URL: z.string().url().default('http://localhost:5173'),
|
|
/**
|
|
* SMTP delivery. Defaults match the Mailpit container from the dev
|
|
* overlay; production instances configure their real relay here (the
|
|
* M8 setup wizard writes these).
|
|
*/
|
|
SMTP_HOST: z.string().default('localhost'),
|
|
SMTP_PORT: z.coerce.number().int().default(1025),
|
|
SMTP_SECURE: z
|
|
.enum(['true', 'false'])
|
|
.default('false')
|
|
.transform((value) => value === 'true'),
|
|
SMTP_USER: z.string().optional(),
|
|
SMTP_PASS: z.string().optional(),
|
|
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
|
/**
|
|
* Filesystem root for uploaded files (ADR 0011). The compose stack
|
|
* mounts the `uploads` volume at `/data/uploads` and sets this
|
|
* explicitly; the relative default only serves native (non-Docker)
|
|
* dev/test runs.
|
|
*/
|
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
|
});
|
|
|
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
|
|
|
/**
|
|
* Configuration for the collaboration server (Hocuspocus, ADR 0003). It is a
|
|
* thin real-time front-end to the same PostgreSQL database as the api; it does
|
|
* not run migrations (the api owns the schema) and needs no SMTP or uploads.
|
|
*/
|
|
export const collabEnvSchema = z.object({
|
|
NODE_ENV: nodeEnv,
|
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
LOG_LEVEL: logLevel,
|
|
APP_VERSION: appVersion,
|
|
DATABASE_URL: databaseUrl,
|
|
COLLAB_TOKEN_SECRET: collabTokenSecret,
|
|
});
|
|
|
|
export type CollabEnv = z.infer<typeof collabEnvSchema>;
|
|
|
|
export function parseEnv<Schema extends z.ZodTypeAny>(
|
|
schema: Schema,
|
|
env: Record<string, string | undefined>,
|
|
): z.infer<Schema> {
|
|
const result = schema.safeParse(env);
|
|
if (!result.success) {
|
|
const problems = result.error.issues
|
|
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
.join('\n');
|
|
throw new Error(`Invalid environment configuration:\n${problems}`);
|
|
}
|
|
return result.data;
|
|
}
|