Add collaboration server skeleton (Hocuspocus) with health, container, and CI/CD (#33)
All checks were successful
CD / Build and push images (push) Successful in 2m36s
CI / Lint, typecheck, test (push) Successful in 1m50s
CI / Auth e2e pack (push) Successful in 1m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s

Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003):
- pino JSON logging (service=collab) and shared Zod env validation
  (collabEnvSchema); structured connection open/close logs.
- /healthz endpoint (process liveness + PostgreSQL ping) served via the
  onRequest hook, matching the container-internal path and the proxied
  /collab/healthz path; any WebSocket handshake is accepted for now
  (authentication arrives with #34, persistence with #35).
- Dockerfile (ESM workspace build) and a compose service on the frontend
  and internal networks with a healthcheck; dev overlay service and a new
  COLLAB_PORT variable.
- CD builds, pushes, and promotes the collab image; CI builds it on PRs;
  the smoke suite asserts /collab/healthz through the reverse proxy.
- deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist.

Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.8 2026-07-08 14:53:44 +02:00
parent 49fcbc45a6
commit 8316c617d2
20 changed files with 665 additions and 24 deletions

View File

@ -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: |

View File

@ -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 .

26
apps/collab/Dockerfile Normal file
View File

@ -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"]

View File

@ -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"
}
}

34
apps/collab/src/db.ts Normal file
View File

@ -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<DatabaseProbe> {
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';
}

View File

@ -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);
});
});

64
apps/collab/src/health.ts Normal file
View File

@ -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(/\/+$/, '') || '/';
}

View File

@ -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<void> {
// 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();

15
apps/collab/src/logger.ts Normal file
View File

@ -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<CollabEnv, 'LOG_LEVEL' | 'APP_VERSION'>): Logger {
return pino({
level: env.LOG_LEVEL,
base: { service: 'collab', version: env.APP_VERSION },
});
}

View File

@ -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<typeof createCollabServer>;
let baseUrl: string;
let wsUrl: string;
const probe = vi.fn<() => Promise<DatabaseProbe>>();
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<void>((resolve, reject) => {
socket.addEventListener('open', () => resolve());
socket.addEventListener('error', () => reject(new Error('handshake failed')));
}),
).resolves.toBeUndefined();
socket.close();
});
});

65
apps/collab/src/server.ts Normal file
View File

@ -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<DatabaseProbe>;
}
/**
* 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();
},
});
}

View File

@ -1,8 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist"
},
"include": ["src"]

View File

@ -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');
});

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -8,9 +8,9 @@ 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 |
| ----- | ------------------------------ | ---------------------- | ------------------------------- |
| 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 (`<git-sha>` 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

View File

@ -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).

View File

@ -6,19 +6,25 @@ import { z } from 'zod';
* and crashes with a readable list of problems instead of failing later at
* first use.
*/
export const apiEnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
/** 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. */
APP_VERSION: z.string().default('0.0.0-dev'),
const appVersion = z.string().default('0.0.0-dev');
/** PostgreSQL connection string — required, there is no sensible default. */
DATABASE_URL: z
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: nodeEnv,
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
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<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,
});
export type CollabEnv = z.infer<typeof collabEnvSchema>;
export function parseEnv<Schema extends z.ZodTypeAny>(
schema: Schema,
env: Record<string, string | undefined>,

181
pnpm-lock.yaml generated
View File

@ -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