diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 04fb242..edd7fc1 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -40,6 +40,13 @@ jobs: docker push $IMAGE_BASE-api:${{ github.sha }} docker push $IMAGE_BASE-api:test + - name: Build and push collab image + run: | + docker build -f apps/collab/Dockerfile --build-arg APP_VERSION=${{ github.sha }} \ + -t $IMAGE_BASE-collab:${{ github.sha }} -t $IMAGE_BASE-collab:test . + docker push $IMAGE_BASE-collab:${{ github.sha }} + docker push $IMAGE_BASE-collab:test + deploy-test: name: Deploy to Test needs: build-push @@ -107,6 +114,7 @@ jobs: run: | docker buildx imagetools create -t $IMAGE_BASE-web:int $IMAGE_BASE-web:${{ github.sha }} docker buildx imagetools create -t $IMAGE_BASE-api:int $IMAGE_BASE-api:${{ github.sha }} + docker buildx imagetools create -t $IMAGE_BASE-collab:int $IMAGE_BASE-collab:${{ github.sha }} - name: Set up SSH run: | diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8cfb697..44f398d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -157,3 +157,6 @@ jobs: - name: Build api image run: docker build -f apps/api/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-api:ci . + + - name: Build collab image + run: docker build -f apps/collab/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-collab:ci . diff --git a/apps/collab/Dockerfile b/apps/collab/Dockerfile new file mode 100644 index 0000000..84285fe --- /dev/null +++ b/apps/collab/Dockerfile @@ -0,0 +1,26 @@ +# Build context is the repository root (workspace build): +# docker build -f apps/collab/Dockerfile . + +FROM node:22.15-alpine AS build +WORKDIR /repo +RUN npm install -g pnpm@11 +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ +COPY packages/shared ./packages/shared +COPY apps/collab ./apps/collab +RUN pnpm install --frozen-lockfile --filter @dorfteich/collab... \ + && pnpm --filter @dorfteich/shared build \ + && pnpm --filter @dorfteich/collab build \ + # Self-contained production bundle (prod deps only) at /out. + && pnpm --filter @dorfteich/collab deploy --prod --legacy /out \ + && cp -r apps/collab/dist /out/dist + +FROM node:22.15-alpine +ARG APP_VERSION=0.0.0-dev +ENV NODE_ENV=production APP_VERSION=${APP_VERSION} +WORKDIR /app +COPY --from=build --chown=node:node /out /app +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "dist/index.js"] diff --git a/apps/collab/package.json b/apps/collab/package.json index d34459c..e06c7aa 100644 --- a/apps/collab/package.json +++ b/apps/collab/package.json @@ -2,14 +2,27 @@ "name": "@dorfteich/collab", "version": "0.0.0", "private": true, + "type": "module", "description": "Dorfteich collaboration server (Yjs/Hocuspocus)", "license": "MIT", + "main": "dist/index.js", "scripts": { "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "start:dev": "tsx watch src/index.ts", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" }, + "dependencies": { + "@dorfteich/shared": "workspace:*", + "@hocuspocus/server": "^4.3.0", + "pg": "^8.16.0", + "pino": "^9.6.0" + }, "devDependencies": { + "@types/node": "^26.1.0", + "@types/pg": "^8.11.0", + "tsx": "^4.19.0", "vitest": "^3.0.0" } } diff --git a/apps/collab/src/db.ts b/apps/collab/src/db.ts new file mode 100644 index 0000000..5e2e51a --- /dev/null +++ b/apps/collab/src/db.ts @@ -0,0 +1,34 @@ +import { Pool } from 'pg'; + +import type { DatabaseProbe } from './health.js'; + +/** + * A small connection pool to the same PostgreSQL the api uses (ADR 0002). The + * collab skeleton (issue #33) only needs it for the health probe; the document + * persistence hooks (issue #35) will reuse this pool for load/store. + */ +export function createPool(connectionString: string): Pool { + return new Pool({ + connectionString, + max: 4, + // Fail fast on health probes instead of hanging when the db is unreachable. + connectionTimeoutMillis: 5000, + query_timeout: 5000, + }); +} + +/** Probe database reachability for the health endpoint. Never throws. */ +export async function pingDatabase(pool: Pool): Promise { + try { + await pool.query('SELECT 1'); + return { ok: true }; + } catch (error) { + return { ok: false, detail: shortMessage(error) }; + } +} + +function shortMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + // Keep the health output single-line and free of any connection string. + return message.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 200) ?? 'unknown error'; +} diff --git a/apps/collab/src/health.test.ts b/apps/collab/src/health.test.ts new file mode 100644 index 0000000..d63bd80 --- /dev/null +++ b/apps/collab/src/health.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { buildHealthReport, isHealthRequest } from './health.js'; + +describe('buildHealthReport', () => { + it('reports ok with HTTP 200 when the database is reachable', () => { + const { httpStatus, body } = buildHealthReport('1.2.3', { ok: true }); + expect(httpStatus).toBe(200); + expect(body.status).toBe('ok'); + expect(body.service).toBe('collab'); + expect(body.version).toBe('1.2.3'); + expect(body.checks).toEqual([{ name: 'database', status: 'ok' }]); + expect(typeof body.time).toBe('string'); + }); + + it('reports unhealthy with HTTP 503 and the detail when the database is down', () => { + const { httpStatus, body } = buildHealthReport('1.2.3', { + ok: false, + detail: 'connection refused', + }); + expect(httpStatus).toBe(503); + expect(body.status).toBe('unhealthy'); + expect(body.checks).toEqual([ + { name: 'database', status: 'failed', detail: 'connection refused' }, + ]); + }); +}); + +describe('isHealthRequest', () => { + it('matches the internal and proxied health paths on GET/HEAD', () => { + expect(isHealthRequest('GET', '/healthz')).toBe(true); + expect(isHealthRequest('HEAD', '/healthz')).toBe(true); + expect(isHealthRequest('GET', '/healthz?probe=1')).toBe(true); + expect(isHealthRequest('GET', '/collab/healthz')).toBe(true); + expect(isHealthRequest('GET', '/collab/healthz/')).toBe(true); + }); + + it('does not match other methods, the WebSocket path, or the root', () => { + expect(isHealthRequest('POST', '/healthz')).toBe(false); + expect(isHealthRequest('GET', '/collab')).toBe(false); + expect(isHealthRequest('GET', '/')).toBe(false); + expect(isHealthRequest('GET', undefined)).toBe(false); + }); +}); diff --git a/apps/collab/src/health.ts b/apps/collab/src/health.ts new file mode 100644 index 0000000..4f4b096 --- /dev/null +++ b/apps/collab/src/health.ts @@ -0,0 +1,64 @@ +import type { HealthResponse } from '@dorfteich/shared'; + +/** Result of a single dependency probe. */ +export interface DatabaseProbe { + ok: boolean; + detail?: string; +} + +/** + * Liveness + dependency report for the collab service. Unlike the api, which + * splits pure liveness (`/healthz`) from readiness (`/readyz`), the collab + * skeleton only needs the one endpoint the compose healthcheck and pipeline + * smoke test hit, so `/healthz` also carries the database probe (issue #33). + */ +export interface CollabHealthReport { + status: 'ok' | 'unhealthy'; + service: HealthResponse['service']; + version: string; + time: string; + checks: { name: 'database'; status: 'ok' | 'failed'; detail?: string }[]; +} + +/** Build the health payload and the HTTP status a monitor should see. */ +export function buildHealthReport( + version: string, + database: DatabaseProbe, +): { httpStatus: 200 | 503; body: CollabHealthReport } { + const body: CollabHealthReport = { + status: database.ok ? 'ok' : 'unhealthy', + service: 'collab', + version, + time: new Date().toISOString(), + checks: [ + { + name: 'database', + status: database.ok ? 'ok' : 'failed', + ...(database.detail ? { detail: database.detail } : {}), + }, + ], + }; + return { httpStatus: database.ok ? 200 : 503, body }; +} + +/** + * Whether a plain HTTP request is a health probe. Matches both the container- + * internal path (`/healthz`, used by the Docker healthcheck) and the path seen + * through the host reverse proxy, which routes `/collab*` to this service + * without stripping the prefix (deployment.md), i.e. `/collab/healthz`. + */ +export function isHealthRequest(method: string | undefined, url: string | undefined): boolean { + if (method !== undefined && method !== 'GET' && method !== 'HEAD') { + return false; + } + const pathname = pathnameOf(url); + return pathname === '/healthz' || pathname === '/collab/healthz'; +} + +function pathnameOf(url: string | undefined): string { + if (!url) { + return ''; + } + // The base only matters to parse a path-only request-target; it is discarded. + return new URL(url, 'http://collab.internal').pathname.replace(/\/+$/, '') || '/'; +} diff --git a/apps/collab/src/index.ts b/apps/collab/src/index.ts index 9d6de4d..24756bf 100644 --- a/apps/collab/src/index.ts +++ b/apps/collab/src/index.ts @@ -1,3 +1,35 @@ -// Placeholder entry point; the Hocuspocus server arrives with milestone M3 -// (issue #33). The package exists so workspace tooling covers it from day one. -export const SERVICE_NAME = 'collab'; +import { collabEnvSchema, parseEnv } from '@dorfteich/shared'; + +import { createPool, pingDatabase } from './db.js'; +import { createLogger } from './logger.js'; +import { createCollabServer } from './server.js'; + +/** + * Entry point of the collaboration server (ADR 0003). Validates the + * environment, opens a database pool for the health probe, and starts the + * Hocuspocus server. A clean shutdown closes both so the container stops fast. + */ +async function bootstrap(): Promise { + // Validate configuration up front; crash with a readable list on problems. + const env = parseEnv(collabEnvSchema, process.env); + const logger = createLogger(env); + const pool = createPool(env.DATABASE_URL); + + const server = createCollabServer({ + version: env.APP_VERSION, + logger, + pingDatabase: () => pingDatabase(pool), + }); + + await server.listen(env.PORT); + logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening'); + + const shutdown = (signal: NodeJS.Signals): void => { + logger.info({ event: 'shutdown', signal }, 'shutting down'); + void Promise.allSettled([server.destroy(), pool.end()]).then(() => process.exit(0)); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +void bootstrap(); diff --git a/apps/collab/src/logger.ts b/apps/collab/src/logger.ts new file mode 100644 index 0000000..74601b1 --- /dev/null +++ b/apps/collab/src/logger.ts @@ -0,0 +1,15 @@ +import { pino, type Logger } from 'pino'; + +import type { CollabEnv } from '@dorfteich/shared'; + +/** + * A pino JSON logger, matching the api's structured output (ADR 0003 asks the + * collab service to log consistently with the api). Every line carries + * `service: "collab"` so logs from the two containers are easy to tell apart. + */ +export function createLogger(env: Pick): Logger { + return pino({ + level: env.LOG_LEVEL, + base: { service: 'collab', version: env.APP_VERSION }, + }); +} diff --git a/apps/collab/src/server.test.ts b/apps/collab/src/server.test.ts new file mode 100644 index 0000000..f8c8af5 --- /dev/null +++ b/apps/collab/src/server.test.ts @@ -0,0 +1,65 @@ +import { pino } from 'pino'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { createCollabServer } from './server.js'; +import type { CollabHealthReport, DatabaseProbe } from './health.js'; + +// A silent logger; these tests assert behaviour, not log output. +const logger = pino({ enabled: false }); + +describe('collab server', () => { + let server: ReturnType; + let baseUrl: string; + let wsUrl: string; + const probe = vi.fn<() => Promise>(); + + beforeAll(async () => { + server = createCollabServer({ version: 'test-version', logger, pingDatabase: probe }); + // Port 0 binds an ephemeral free port. + await server.listen(0); + const { port } = server.address; + baseUrl = `http://127.0.0.1:${port}`; + wsUrl = `ws://127.0.0.1:${port}`; + }); + + afterAll(async () => { + await server.destroy(); + }); + + it('serves /healthz as 200 with the report when the database is reachable', async () => { + probe.mockResolvedValueOnce({ ok: true }); + const res = await fetch(`${baseUrl}/healthz`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = (await res.json()) as CollabHealthReport; + expect(body).toMatchObject({ status: 'ok', service: 'collab', version: 'test-version' }); + expect(body.checks).toEqual([{ name: 'database', status: 'ok' }]); + }); + + it('serves /healthz as 503 when the database probe fails', async () => { + probe.mockResolvedValueOnce({ ok: false, detail: 'boom' }); + const res = await fetch(`${baseUrl}/healthz`); + expect(res.status).toBe(503); + const body = (await res.json()) as CollabHealthReport; + expect(body.status).toBe('unhealthy'); + }); + + it('leaves non-health HTTP requests to Hocuspocus without probing the db', async () => { + const callsBefore = probe.mock.calls.length; + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('Hocuspocus'); + expect(probe.mock.calls.length).toBe(callsBefore); + }); + + it('accepts a WebSocket handshake on the collab path', async () => { + const socket = new WebSocket(`${wsUrl}/collab`); + await expect( + new Promise((resolve, reject) => { + socket.addEventListener('open', () => resolve()); + socket.addEventListener('error', () => reject(new Error('handshake failed'))); + }), + ).resolves.toBeUndefined(); + socket.close(); + }); +}); diff --git a/apps/collab/src/server.ts b/apps/collab/src/server.ts new file mode 100644 index 0000000..7de6cea --- /dev/null +++ b/apps/collab/src/server.ts @@ -0,0 +1,65 @@ +import { Server } from '@hocuspocus/server'; +import type { Logger } from 'pino'; + +import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js'; + +export interface CollabServerDeps { + /** Version string surfaced in health responses (image build arg). */ + version: string; + logger: Logger; + /** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */ + pingDatabase: () => Promise; +} + +/** + * The Hocuspocus collaboration server (ADR 0003). This skeleton (issue #33) + * wires structured connection logging and a `/healthz` endpoint; it does not + * yet authenticate connections (issue #34) or persist documents (issue #35), + * so any WebSocket handshake is currently accepted. + */ +export function createCollabServer(deps: CollabServerDeps): Server { + const { version, logger, pingDatabase } = deps; + + return new Server({ + name: 'dorfteich-collab', + // Suppress Hocuspocus' ASCII start screen; we emit our own pino logs. + quiet: true, + + async onConnect({ documentName, socketId }) { + logger.info( + { event: 'connection.open', documentName, socketId }, + 'collaboration connection opened', + ); + }, + + async onDisconnect({ documentName, socketId, clientsCount }) { + logger.info( + { event: 'connection.close', documentName, socketId, clientsCount }, + 'collaboration connection closed', + ); + }, + + async onRequest({ request, response }) { + if (!isHealthRequest(request.method, request.url)) { + // Not a health probe: let Hocuspocus handle the request. + return; + } + + const database = await pingDatabase(); + const { httpStatus, body } = buildHealthReport(version, database); + if (!database.ok) { + logger.warn( + { event: 'health.failed', detail: database.detail }, + 'health check failed: database unreachable', + ); + } + + response.writeHead(httpStatus, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); + + // Hocuspocus sends a default 200 unless the onRequest chain rejects with + // a falsy reason; the response is already written, so stop the chain. + return Promise.reject(); + }, + }); +} diff --git a/apps/collab/tsconfig.json b/apps/collab/tsconfig.json index b0a129d..1e20cd9 100644 --- a/apps/collab/tsconfig.json +++ b/apps/collab/tsconfig.json @@ -1,8 +1,6 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "CommonJS", - "moduleResolution": "Node", "outDir": "dist" }, "include": ["src"] diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 12571a7..311417f 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -23,3 +23,13 @@ test('api is live and ready', async ({ request }) => { const readyz = await request.get('/api/v1/readyz'); expect(readyz.ok(), `readyz: ${await readyz.text()}`).toBeTruthy(); }); + +test('collab service is live and reachable through the proxy', async ({ request }) => { + // The reverse proxy routes /collab to the Hocuspocus container (issue #33); + // its /healthz also pings the database, so 200 proves the whole path works. + const res = await request.get('/collab/healthz'); + expect(res.ok(), `collab healthz: ${await res.text()}`).toBeTruthy(); + const body = await res.json(); + expect(body.status).toBe('ok'); + expect(body.service).toBe('collab'); +}); diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index baa595e..535b5ec 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -13,10 +13,12 @@ IMAGE_PREFIX=dorfteich TAG=latest # --- ports (localhost only; the host reverse proxy routes to these) --------- -# Suggested per stage on the shared VPS: test 8100/8101, int 8110/8111, -# prod 8120/8121. +# Suggested per stage on the shared VPS: test 8100/8101/8102, +# int 8110/8111/8112, prod 8120/8121/8122 (web/api/collab). WEB_PORT=8100 API_PORT=8101 +# collab (Hocuspocus) WebSocket server; the proxy routes /collab here. +COLLAB_PORT=8102 # --- behavior ---------------------------------------------------------------- # pino log level: fatal|error|warn|info|debug|trace diff --git a/deploy/compose/compose.dev.yml b/deploy/compose/compose.dev.yml index 5daba9c..520eb5d 100644 --- a/deploy/compose/compose.dev.yml +++ b/deploy/compose/compose.dev.yml @@ -50,6 +50,27 @@ services: - api-shared-modules:/repo/packages/shared/node_modules - pnpm-store:/root/.local/share/pnpm/store + collab: + image: node:22.15-alpine + build: !reset null + working_dir: /repo + command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/shared build && pnpm --filter @dorfteich/collab start:dev" + environment: + NODE_ENV: development + PORT: '3000' + DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich + ports: !override + - '127.0.0.1:3002:3000' + volumes: + - ../..:/repo + - collab-root-modules:/repo/node_modules + - collab-app-modules:/repo/apps/collab/node_modules + - collab-shared-modules:/repo/packages/shared/node_modules + - pnpm-store:/root/.local/share/pnpm/store + # No healthcheck block here: the production image's HEALTHCHECK does not + # apply to this node:alpine dev image, and nothing depends on it. + healthcheck: !reset null + db: ports: # 5434 on the host to avoid colliding with other local PostgreSQL @@ -71,4 +92,7 @@ volumes: api-root-modules: api-app-modules: api-shared-modules: + collab-root-modules: + collab-app-modules: + collab-shared-modules: pnpm-store: diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index e980ced..b8e152f 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -67,6 +67,34 @@ services: condition: service_healthy <<: *logging + collab: + image: ${IMAGE_PREFIX:-dorfteich}-collab:${TAG:-latest} + build: + context: ../.. + dockerfile: apps/collab/Dockerfile + args: + APP_VERSION: ${TAG:-latest} + restart: unless-stopped + environment: + NODE_ENV: production + PORT: '3000' + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich + ports: + # The host reverse proxy routes /collab here with WebSocket upgrade + # (deployment.md, deploy/stages.md). + - '127.0.0.1:${COLLAB_PORT:-8102}:3000' + networks: [frontend, internal] + healthcheck: + test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1'] + interval: 30s + timeout: 5s + retries: 3 + depends_on: + db: + condition: service_healthy + <<: *logging + db: image: postgres:17.5-alpine restart: unless-stopped diff --git a/deploy/stages.md b/deploy/stages.md index 6310487..ae2a21b 100644 --- a/deploy/stages.md +++ b/deploy/stages.md @@ -7,10 +7,10 @@ owner; everything else can be done by CI or a deploy user. ## Overview -| Stage | Directory | Domain | Ports (localhost) | -| ----- | ------------------------------ | ---------------------- | ------------------ | -| Test | `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | web 8100, api 8101 | -| Int | `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | web 8110, api 8111 | +| Stage | Directory | Domain | Ports (localhost) | +| ----- | ------------------------------ | ---------------------- | ------------------------------- | +| Test | `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | web 8100, api 8101, collab 8102 | +| Int | `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | web 8110, api 8111, collab 8112 | ## 1. Stage directories **[root]** @@ -26,7 +26,10 @@ each stage directory. Set per stage in `.env` (mode 600): - `POSTGRES_PASSWORD`: unique random value per stage - `COMPOSE_PROJECT_NAME`: `dorfteich-test` / `dorfteich-int` -- `WEB_PORT`/`API_PORT`: 8100/8101 (test), 8110/8111 (int) +- `WEB_PORT`/`API_PORT`/`COLLAB_PORT`: 8100/8101/8102 (test), + 8110/8111/8112 (int). **`COLLAB_PORT` must be set per stage** — both + stacks share this host, so the compose default (8102) would make the + Int collab container collide with Test's; Int needs `COLLAB_PORT=8112`. - `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` - `TAG`: managed by the CD pipeline (`` on test, `int` on int) - `APP_BASE_URL`: `https://test.dorfteich.cloud` / `https://int.dorfteich.cloud` @@ -62,7 +65,7 @@ test.dorfteich.cloud { reverse_proxy 127.0.0.1:8101 } handle /collab* { - reverse_proxy 127.0.0.1:8102 # collab service arrives with M3 + reverse_proxy 127.0.0.1:8102 # Hocuspocus collab (issue #33); Caddy passes the WebSocket upgrade through automatically } handle { reverse_proxy 127.0.0.1:8100 @@ -117,6 +120,7 @@ The pipeline pushes images to the Gitea container registry - [ ] `https://test.dorfteich.cloud/healthz` → `ok` - [ ] `https://test.dorfteich.cloud/api/v1/readyz` → `{"status":"ok",…}` +- [ ] `https://test.dorfteich.cloud/collab/healthz` → `{"status":"ok","service":"collab",…}` - [ ] same for int - [ ] runner shows _online_ under Gitea → Settings → Actions → Runners - [ ] a test workflow run executes on the runner diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index 88d1d65..d29232c 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -35,6 +35,10 @@ host), routing: /media/ → api (permission-checked file streaming) ``` +The proxy forwards `/collab` without stripping the prefix, so the collab +service answers its health probe at `/collab/healthz` externally and at +`/healthz` for the container-internal Docker healthcheck. + Self-hosters without a proxy can enable the optional `caddy` Compose profile (bundled Caddy with automatic TLS). diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 1001d7e..b286ed8 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -6,19 +6,25 @@ import { z } from 'zod'; * 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', + }); + export const apiEnvSchema = z.object({ - NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + NODE_ENV: nodeEnv, PORT: z.coerce.number().int().min(1).max(65535).default(3000), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), - /** Version shown in health responses; injected at image build time. */ - APP_VERSION: z.string().default('0.0.0-dev'), - /** PostgreSQL connection string — required, there is no sensible default. */ - DATABASE_URL: z - .string() - .min(1) - .refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { - message: 'must be a postgresql:// connection string', - }), + LOG_LEVEL: logLevel, + APP_VERSION: appVersion, + DATABASE_URL: databaseUrl, /** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */ MIGRATE_ON_START: z .enum(['true', 'false']) @@ -51,6 +57,21 @@ export const apiEnvSchema = z.object({ export type ApiEnv = z.infer; +/** + * 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, +}); + +export type CollabEnv = z.infer; + export function parseEnv( schema: Schema, env: Record, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6985ec..9e78196 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,7 +143,29 @@ importers: version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) apps/collab: + dependencies: + '@dorfteich/shared': + specifier: workspace:* + version: link:../../packages/shared + '@hocuspocus/server': + specifier: ^4.3.0 + version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) + pg: + specifier: ^8.16.0 + version: 8.22.0 + pino: + specifier: ^9.6.0 + version: 9.14.0 devDependencies: + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + tsx: + specifier: ^4.19.0 + version: 4.23.0 vitest: specifier: ^3.0.0 version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) @@ -946,6 +968,16 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hocuspocus/common@4.3.0': + resolution: {integrity: sha512-8USsMvso01aMKOSSyvb8oTAlfES/nBM+Y74XjW5Fy5ovmOaVpoRRBrktIrEVwydrp8Cr6C66ivc0orcW/3P+Kg==} + + '@hocuspocus/server@4.3.0': + resolution: {integrity: sha512-GX9ohUJAr6aIa2ewS0EPeyf3w12wLCBBSGfc76ZMOuZJZPUzDH6BJWFjkVlaVW3OmwZ5+jxdXXFUf6sAi4EIrg==} + engines: {node: '>=22'} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} peerDependencies: @@ -1645,6 +1677,9 @@ packages: '@types/nodemailer@8.0.1': resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1917,6 +1952,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2158,6 +2196,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.9: + resolution: {integrity: sha512-iWx+1OMSG2aOHpjyf9AESOzkwsVdS49cXM9dVrI2PDhxU5l2RIWE/KG56gk4BbAnsMoycvniJ9OnOxO9LRzHVA==} + peerDependencies: + srvx: '>=0.11.5' + peerDependenciesMeta: + srvx: + optional: true + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2740,6 +2786,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3040,6 +3090,40 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3117,6 +3201,22 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3954,6 +4054,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y-prosemirror@1.3.7: resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -4491,6 +4595,22 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hocuspocus/common@4.3.0': + dependencies: + lib0: 0.2.117 + + '@hocuspocus/server@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@hocuspocus/common': 4.3.0 + async-mutex: 0.5.0 + crossws: 0.4.9 + kleur: 4.1.5 + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + transitivePeerDependencies: + - srvx + '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -5149,6 +5269,12 @@ snapshots: dependencies: '@types/node': 26.1.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 26.1.0 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5493,6 +5619,10 @@ snapshots: assertion-error@2.0.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -5722,6 +5852,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.9: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -6387,6 +6519,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6646,6 +6780,41 @@ snapshots: perfect-debounce@1.0.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -6737,6 +6906,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.9.4: {} @@ -7583,6 +7762,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31): dependencies: lib0: 0.2.117