dorfteich/packages/shared/src/env.ts
Claude Opus 4.8 621aa47244
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
Add plugin storage, install API, and directory watcher (#71)
Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md
§Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation.

- Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest
  jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) +
  `PluginInstanceMode` enum; migration 20260710130000_plugins.
- `PluginPackageService`: pure, stateless ZIP → validated package via
  fflate — structure check, manifest validation (SDK), apiVersion gate,
  kind/bundle/styles rules, CSS sanitation (no @import / external url() /
  expression()), zip-slip and unpacked-size guards. Each failure carries a
  stable PluginErrorCode; manifest issues travel as ApiError details.
- `PluginStorageService`: on-disk layout `<PLUGINS_DIR>/<id>/<version>/`;
  atomic writeVersion (staging dir + rename, no 404 window mid-update),
  removeVersion/removePlugin, traversal-safe asset resolution, dropzone +
  quarantine dirs.
- `PluginsService`: install/update (update only to a strictly higher
  version, preserving the admin's instance mode; files land before the
  metadata pointer flips) / uninstall (refused while required; soft-delete
  + files removed + pond activations dropped) / list / get.
- `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload),
  error→HTTP-status mapping. Public version-pinned static serving at
  `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for
  the installed current version.
- `PluginWatcherService`: watches `<PLUGINS_DIR>/_dropzone/`, runs the same
  validation, installs valid drops and quarantines invalid ones with the
  error logged; inert under NODE_ENV=test (tests drive processDropped).
- SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`,
  `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin
  error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`.
- Tests: package unit test (valid + each invalid class) and an e2e DB test
  (GUI install + immutable serving, non-admin 403, invalid-manifest details,
  dropzone install + quarantine, atomic higher-only update, required-guarded
  uninstall that removes files and tombstones metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:55:26 +02:00

126 lines
5.2 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'),
/**
* Base URL of the internal pandoc-server sidecar (ADR 0009, issue #62).
* The default matches the compose service name; native dev/test runs point
* it at a locally running container or leave it unreachable (the converter
* readiness check is warning-level, so an unset sidecar never fails readyz).
*/
PANDOC_URL: z.string().url().default('http://pandoc:3030'),
/**
* Base URL of the internal Gotenberg sidecar for PDF export (ADR 0009, issue
* #67). Like the converter, its readiness check is warning-level, so an
* unreachable renderer degrades PDF export without failing readyz. The default
* matches the compose service name.
*/
GOTENBERG_URL: z.string().url().default('http://gotenberg:3000'),
/**
* Directory of the self-hosted font catalog (WOFF2, ADR 0016), baked into the
* api image so the PDF exporter can inline a pond's fonts as base64. Native
* dev/test runs point this at the web app's built `public/fonts`.
*/
FONTS_DIR: z.string().min(1).default('./fonts'),
/**
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
* loads, plus a `_dropzone/` a Site Admin drops ZIPs into and a
* `_quarantine/` for rejected drops. In Docker a persistent volume mounts
* here; the relative default serves native dev/test runs.
*/
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
});
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;
}