From 950b844f2f4f8cc95eeddd9e18ad7d15c8984b41 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 14:13:31 -0400 Subject: [PATCH 01/42] Centralize all backend environment access into a single typed, validated, fail-fast server/src/config.ts that is the only file permitted to read process.env (enforced by an ESLint no-restricted-syntax ban), route all thirty-one scattered reads through it, unify the four disagreeing STORAGE_PATH/FOVEA_MODE/MODEL_SERVICE_URL/OTEL defaults, validate API_KEY_ENCRYPTION_KEY and the production SESSION_SECRET at startup, and bump every package to 0.5.0 to open the release cycle. --- CHANGELOG.md | 12 + annotation-tool/package.json | 2 +- docs/docs/project/changelog.md | 12 + docs/package.json | 2 +- model-service/package.json | 2 +- model-service/pyproject.toml | 2 +- package.json | 2 +- server/.eslintrc.json | 20 +- server/package.json | 2 +- server/src/app.ts | 22 +- server/src/config.ts | 540 ++++++++++++++++++ server/src/demo/anonymous-session.ts | 3 +- server/src/demo/config.ts | 11 +- server/src/demo/seed.ts | 5 +- server/src/index.ts | 21 +- server/src/lib/custom-tours.ts | 4 +- server/src/lib/demo-flags.ts | 13 +- server/src/lib/encryption.ts | 19 +- server/src/lib/fetchModelService.ts | 65 +-- server/src/lib/prisma.ts | 4 +- server/src/lib/session.ts | 5 +- server/src/middleware/auth.ts | 3 +- server/src/queues/setup.ts | 14 +- server/src/routes/auth.ts | 15 +- server/src/routes/config.ts | 30 +- server/src/routes/models.ts | 3 +- server/src/routes/ontology.ts | 16 +- server/src/routes/personas.ts | 7 +- server/src/routes/telemetry.ts | 3 +- server/src/routes/videos/detect.ts | 3 +- server/src/routes/videos/index.ts | 3 +- server/src/routes/videos/thumbnail.ts | 3 +- server/src/routes/videos/transcribe.ts | 3 +- server/src/routes/world.ts | 20 +- server/src/services/api-key-service.ts | 6 +- server/src/services/auth-service.ts | 13 +- .../src/services/system-config-propagator.ts | 6 +- server/src/services/user-service.ts | 12 +- server/src/services/video-access-service.ts | 4 +- server/src/services/videoStorage.ts | 35 +- server/src/tracing.ts | 4 +- server/test/services/videoStorage.test.ts | 4 +- server/test/setup.ts | 22 +- wikibase/pyproject.toml | 2 +- 44 files changed, 776 insertions(+), 223 deletions(-) create mode 100644 server/src/config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb9435c..eb59ce4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the Fovea project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-06-17 + +The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/architecture-review.md`): single sources of truth for configuration, containerization, the build/test surface, and cross-service contracts. Changes land incrementally on `release/0.5.x`; this section accumulates them. + +### Changed + +#### Backend Configuration Single-Source-of-Truth + +- All backend environment access is centralized in one typed, validated module, `server/src/config.ts`. It is the only file in `server/src` permitted to read `process.env` (enforced by a new ESLint `no-restricted-syntax` rule with a `config.ts`/`prisma`/`test` exemption), loads an optional local `.env` via `dotenv`, exposes a deep-frozen `config` object grouped by concern (`server`, `redis`, `auth`, `storage`, `modelService`, `rateLimit`, `cors`, `otel`, `mode`, `demo`, `tours`, `wikidata`, `externalLinks`, `defaultUser`), and centralizes every default and coercion. All 31 previously-scattered `process.env` reads across routes, services, queues, middleware, and libs now go through it. The two dynamic read patterns are exposed as typed helpers: `config.modelService.timeoutMs(endpoint)` and `config.getProviderApiKey(provider)`. +- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. +- Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/annotation-tool/package.json b/annotation-tool/package.json index 3ddc5067..23962eba 100644 --- a/annotation-tool/package.json +++ b/annotation-tool/package.json @@ -1,7 +1,7 @@ { "name": "@fovea/annotation-tool", "private": true, - "version": "0.4.4", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 239602e9..c5d4cbc8 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -11,6 +11,18 @@ All notable changes to the Fovea project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-06-17 + +The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/architecture-review.md`): single sources of truth for configuration, containerization, the build/test surface, and cross-service contracts. Changes land incrementally on `release/0.5.x`; this section accumulates them. + +### Changed + +#### Backend Configuration Single-Source-of-Truth + +- All backend environment access is centralized in one typed, validated module, `server/src/config.ts`. It is the only file in `server/src` permitted to read `process.env` (enforced by a new ESLint `no-restricted-syntax` rule with a `config.ts`/`prisma`/`test` exemption), loads an optional local `.env` via `dotenv`, exposes a deep-frozen `config` object grouped by concern (`server`, `redis`, `auth`, `storage`, `modelService`, `rateLimit`, `cors`, `otel`, `mode`, `demo`, `tours`, `wikidata`, `externalLinks`, `defaultUser`), and centralizes every default and coercion. All 31 previously-scattered `process.env` reads across routes, services, queues, middleware, and libs now go through it. The two dynamic read patterns are exposed as typed helpers: `config.modelService.timeoutMs(endpoint)` and `config.getProviderApiKey(provider)`. +- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. +- Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/docs/package.json b/docs/package.json index e58dc6db..c73b8c77 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.4.4", + "version": "0.5.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/model-service/package.json b/model-service/package.json index c31d4b36..055a8827 100644 --- a/model-service/package.json +++ b/model-service/package.json @@ -1,6 +1,6 @@ { "name": "fovea-model-service", - "version": "0.4.4", + "version": "0.5.0", "description": "AI model inference service for video annotation", "scripts": { "dev": "source venv/bin/activate && uvicorn src.main:app --reload --host 0.0.0.0 --port 8000", diff --git a/model-service/pyproject.toml b/model-service/pyproject.toml index b73c6970..9fc96816 100644 --- a/model-service/pyproject.toml +++ b/model-service/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fovea-model-service" -version = "0.4.4" +version = "0.5.0" description = "Model service for fovea video annotation tool" requires-python = ">=3.12" dependencies = [ diff --git a/package.json b/package.json index 65f0f727..a573bcc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fovea", "private": true, - "version": "0.4.4", + "version": "0.5.0", "description": "FOVEA - Flexible Ontology Visual Event Analyzer", "scripts": { "dev": "pnpm run dev:infra && pnpm run dev:services", diff --git a/server/.eslintrc.json b/server/.eslintrc.json index 36e2d7cd..45bd9267 100644 --- a/server/.eslintrc.json +++ b/server/.eslintrc.json @@ -28,6 +28,13 @@ } ] } + ], + "no-restricted-syntax": [ + "error", + { + "selector": "MemberExpression[object.name='process'][property.name='env']", + "message": "Read environment variables only via src/config.ts. Add the value to the typed config module and import `config` instead of reading process.env directly." + } ] }, "overrides": [ @@ -35,9 +42,20 @@ "files": ["src/demo/**/*"], "rules": { "no-restricted-imports": "off" } }, + { + "files": ["src/config.ts"], + "rules": { "no-restricted-syntax": "off" } + }, + { + "files": ["prisma/**/*"], + "rules": { "no-restricted-syntax": "off" } + }, { "files": ["test/**/*"], - "rules": { "no-restricted-imports": "off" } + "rules": { + "no-restricted-imports": "off", + "no-restricted-syntax": "off" + } } ], "ignorePatterns": ["dist", "node_modules", "coverage"] diff --git a/server/package.json b/server/package.json index a3e5ee7f..55744819 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fovea/server", - "version": "0.4.4", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/server/src/app.ts b/server/src/app.ts index cc7a835e..14e822ee 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' import { FastifyAdapter } from '@bull-board/fastify' import { videoSummarizationQueue, claimExtractionQueue, closeQueues } from './queues/setup.js' import { apiRequestCounter, apiRequestDuration } from './metrics.js' +import { config } from './config.js' import { prisma } from './lib/prisma.js' import { AppError, TooManyRequestsError } from './lib/errors.js' import { recordApiError } from './lib/errorMetrics.js' @@ -40,8 +41,8 @@ export async function buildApp() { const app = Fastify({ trustProxy: true, logger: { - level: process.env.LOG_LEVEL || 'info', - transport: process.env.NODE_ENV !== 'production' ? { + level: config.server.logLevel, + transport: !config.server.isProduction ? { target: 'pino-pretty', options: { colorize: true, @@ -70,12 +71,10 @@ export async function buildApp() { // their corpus: a deployment with many videos x personas fans a hard refresh // out into hundreds of per-persona / per-video requests, which trips a cap // sized for a small instance. Defaults match the historical 1000 / 1 minute. - if (process.env.NODE_ENV !== 'test') { - const rateLimitMax = Number(process.env.RATE_LIMIT_MAX) || 1000 - const rateLimitWindow = process.env.RATE_LIMIT_WINDOW || '1 minute' + if (!config.server.isTest) { await app.register(fastifyRateLimit, { - max: rateLimitMax, - timeWindow: rateLimitWindow, + max: config.rateLimit.max, + timeWindow: config.rateLimit.window, keyGenerator: (request) => { return request.ip } @@ -86,18 +85,13 @@ export async function buildApp() { // Include both localhost and 127.0.0.1 to handle browser differences // Some browsers treat localhost and 127.0.0.1 as different origins await app.register(fastifyCors, { - origin: process.env.ALLOWED_ORIGINS?.split(',') || [ - 'http://localhost:5173', - 'http://localhost:3000', - 'http://127.0.0.1:5173', - 'http://127.0.0.1:3000' - ], + origin: config.cors.allowedOrigins, credentials: true }) // Cookie support for session management await app.register(fastifyCookie, { - secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production', + secret: config.auth.sessionSecret, }) // OpenAPI documentation diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..47b039dc --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,540 @@ +/** + * Single source of truth for every environment-derived setting. + * + * This is the ONLY file in `server/src` permitted to read `process.env`. + * An ESLint `no-restricted-syntax` rule bans `process.env` everywhere + * else and points offenders here. Every default, every type coercion, + * and every required-value check lives in this module so a reader can + * answer "what does this deployment do when X is unset?" by looking in + * exactly one place. + * + * Shape: + * - `config` is a deep-frozen object grouped into nested namespaces by + * concern (server, db, redis, auth, storage, modelService, ...). + * - Leaf values are exposed as lazy getters or typed helper functions + * that re-read `process.env` at access time. This preserves the exact + * lazy `process.env.X || default` semantics the codebase relied on + * before centralization, so a handler that reads `config.mode.current` + * inside a request sees the same value the old inline read saw. + * - Coercions (integers, booleans, comma-split lists) are validated + * against a TypeBox schema and the raw env is validated eagerly at + * module load so an invalid integer fails fast at startup rather than + * surfacing as a `NaN` deep in a handler. + * + * Fail-fast: importing this module runs `assertStartupConfig()`, which + * validates the typed env surface and the required + * `API_KEY_ENCRYPTION_KEY`, and (in production) refuses an unset or + * dev-default `SESSION_SECRET`. Import this module FIRST in `index.ts`, + * before `./tracing.js`, so validation throws before any subsystem + * (OTEL, Fastify, Prisma) initializes. + * + * Default unifications applied here (the only intentional behavior + * change versus the pre-centralization code; every real docker/prod + * deployment sets these via env, so the default only fires in local + * dev where the localhost form is correct): + * 1. STORAGE_PATH default resolves to `/videos` (the index.ts + * computed form), replacing the inconsistent `/videos` literals in + * videoStorage.ts and routes/videos/index.ts. + * 2. FOVEA_MODE default is `multi-user` (the secure default used by 8 + * of 10 sites), replacing the `single-user` default in + * routes/config.ts and routes/auth.ts. + * 3. MODEL_SERVICE_URL default is `http://localhost:8000` (7 sites), + * replacing the `http://model-service:8000` default in + * routes/models.ts and services/system-config-propagator.ts. + * 4. OTEL_EXPORTER_OTLP_ENDPOINT default is `http://localhost:4318` + * (tracing.ts), replacing the `http://otel-collector:4318` default + * in routes/telemetry.ts. + * + * @module + */ + +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +import { Type, type Static } from '@sinclair/typebox' +import { Value } from '@sinclair/typebox/value' +import dotenv from 'dotenv' + +// Optional local `.env` support. A no-op in docker/prod where no `.env` +// file exists. Must run before any `process.env` read below. +dotenv.config() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// --------------------------------------------------------------------------- +// Low-level coercion helpers (the only place env strings are parsed). +// --------------------------------------------------------------------------- + +function readString(name: string): string | undefined { + const raw = process.env[name] + if (raw === undefined || raw === '') return undefined + return raw +} + +function readStringWithDefault(name: string, fallback: string): string { + return readString(name) ?? fallback +} + +function readInt(name: string, fallback: number): number { + const raw = readString(name) + if (raw === undefined) return fallback + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** True only when the env var equals the literal string `'true'`. */ +function readBooleanStrictTrue(name: string): boolean { + return process.env[name] === 'true' +} + +/** True when the env var is `'true'` or `'1'` (the demo-flag idiom). */ +function readBooleanTrueOrOne(name: string): boolean { + const raw = process.env[name] + return raw === 'true' || raw === '1' +} + +/** True unless the env var is explicitly `'false'` (default-on switch). */ +function readBooleanDefaultTrue(name: string): boolean { + return process.env[name] !== 'false' +} + +// --------------------------------------------------------------------------- +// Default constants. Centralized so every consumer shares one literal. +// --------------------------------------------------------------------------- + +const DEFAULT_STORAGE_PATH = join(dirname(__dirname), '..', 'videos') +const DEFAULT_MODE = 'multi-user' +const DEFAULT_MODEL_SERVICE_URL = 'http://localhost:8000' +const DEFAULT_OTEL_ENDPOINT = 'http://localhost:4318' +const DEV_SESSION_SECRET = 'dev-secret-change-in-production' + +/** The four localhost origins allowed by default when ALLOWED_ORIGINS is + * unset. Copied verbatim from the historical inline default. Includes both + * `localhost` and `127.0.0.1` because some browsers treat them as distinct + * origins. */ +const DEFAULT_ALLOWED_ORIGINS: readonly string[] = [ + 'http://localhost:5173', + 'http://localhost:3000', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:3000', +] as const + +const ENCRYPTION_KEY_BYTES = 32 + +/** Per-endpoint model-service timeout defaults (milliseconds). Each value + * is the upper bound the backend waits before aborting a forwarded request + * to the model-service and surfacing a 504. Defaults target a GPU-warm + * production deployment; CPU-cold first-load is materially slower, so + * deployments override via the matching `MODEL_SERVICE_TIMEOUT__MS` + * env var. */ +const MODEL_SERVICE_TIMEOUT_DEFAULTS = { + detection: { env: 'MODEL_SERVICE_TIMEOUT_DETECTION_MS', ms: 60_000 }, + thumbnails: { env: 'MODEL_SERVICE_TIMEOUT_THUMBNAILS_MS', ms: 30_000 }, + ontologyAugment: { env: 'MODEL_SERVICE_TIMEOUT_ONTOLOGY_AUGMENT_MS', ms: 60_000 }, + summarize: { env: 'MODEL_SERVICE_TIMEOUT_SUMMARIZE_MS', ms: 300_000 }, + extractClaims: { env: 'MODEL_SERVICE_TIMEOUT_EXTRACT_CLAIMS_MS', ms: 300_000 }, + synthesize: { env: 'MODEL_SERVICE_TIMEOUT_SYNTHESIZE_MS', ms: 300_000 }, + transcribe: { env: 'MODEL_SERVICE_TIMEOUT_TRANSCRIBE_MS', ms: 300_000 }, +} as const + +/** Names of the per-endpoint model-service timeouts. */ +export type ModelServiceTimeoutEndpoint = keyof typeof MODEL_SERVICE_TIMEOUT_DEFAULTS + +// --------------------------------------------------------------------------- +// Eagerly-validated typed env surface (fail-fast on malformed coercions). +// --------------------------------------------------------------------------- + +/** Schema for the env vars whose coercion can fail (integers). Validated + * once at startup so a non-numeric `PORT` or `REDIS_PORT` throws a clear + * error rather than yielding a `NaN` later. Strings that fall back to a + * default when unset do not need schema validation. */ +const NumericEnvSchema = Type.Object({ + PORT: Type.Optional(Type.String({ pattern: '^[0-9]+$' })), + REDIS_PORT: Type.Optional(Type.String({ pattern: '^[0-9]+$' })), + SESSION_TIMEOUT_DAYS: Type.Optional(Type.String({ pattern: '^[0-9]+$' })), + SESSION_IDLE_TIMEOUT_MINUTES: Type.Optional(Type.String({ pattern: '^[0-9]+$' })), + RATE_LIMIT_MAX: Type.Optional(Type.String({ pattern: '^[0-9]+$' })), +}) + +type NumericEnv = Static + +function collectNumericEnv(): NumericEnv { + const out: Record = {} + for (const key of [ + 'PORT', + 'REDIS_PORT', + 'SESSION_TIMEOUT_DAYS', + 'SESSION_IDLE_TIMEOUT_MINUTES', + 'RATE_LIMIT_MAX', + ]) { + const raw = process.env[key] + if (raw !== undefined && raw !== '') out[key] = raw + } + return out as NumericEnv +} + +/** + * Resolve and validate the API key encryption key. + * + * Reads `API_KEY_ENCRYPTION_KEY`, hex-decodes it, and requires exactly 32 + * bytes (AES-256). Throws a descriptive Error when unset or wrong length. + * Read at call time so callers always see the current env (the encryption + * tests mutate it between cases). + * + * @returns the 32-byte key buffer + * @throws {Error} when the key is unset or not 32 bytes after hex decode + * + * @example + * ```typescript + * const key = config.auth.encryptionKey() + * crypto.createCipheriv('aes-256-gcm', key, iv) + * ``` + */ +function resolveEncryptionKey(): Buffer { + const key = process.env.API_KEY_ENCRYPTION_KEY + if (!key) { + throw new Error('API_KEY_ENCRYPTION_KEY environment variable not set') + } + const keyBuffer = Buffer.from(key, 'hex') + if (keyBuffer.length !== ENCRYPTION_KEY_BYTES) { + throw new Error( + `API_KEY_ENCRYPTION_KEY must be ${ENCRYPTION_KEY_BYTES} bytes (${ENCRYPTION_KEY_BYTES * 2} hex characters)`, + ) + } + return keyBuffer +} + +/** + * Validate the startup configuration, throwing on any fatal problem. + * + * Runs at module load. Checks performed: + * - numeric env vars coerce cleanly (TypeBox `Value.Errors`) + * - `API_KEY_ENCRYPTION_KEY` is set and hex-decodes to 32 bytes + * - in production, `SESSION_SECRET` is set and is not the dev default + * + * @throws {Error} listing every offending key when validation fails + */ +function assertStartupConfig(): void { + const numericEnv = collectNumericEnv() + if (!Value.Check(NumericEnvSchema, numericEnv)) { + const problems = [...Value.Errors(NumericEnvSchema, numericEnv)].map( + (e) => `${e.path.replace(/^\//, '')}: ${e.message}`, + ) + throw new Error( + `Invalid environment configuration:\n ${problems.join('\n ')}`, + ) + } + + // Required: API key encryption key (fail fast before any subsystem boots). + resolveEncryptionKey() + + // Production guard: refuse an unset or dev-default session secret. + if (process.env.NODE_ENV === 'production') { + const secret = process.env.SESSION_SECRET + if (!secret || secret === DEV_SESSION_SECRET) { + throw new Error( + 'SESSION_SECRET must be set to a non-default value in production', + ) + } + } +} + +// --------------------------------------------------------------------------- +// The frozen config object. Leaf values are getters/functions that re-read +// env so runtime mutation (tests, hot-reconfigured deployments) is honored. +// --------------------------------------------------------------------------- + +/** Coerce a model-service timeout env var to a positive integer, falling + * back to the endpoint default when unset or malformed. */ +function resolveTimeoutMs(endpoint: ModelServiceTimeoutEndpoint): number { + const { env, ms } = MODEL_SERVICE_TIMEOUT_DEFAULTS[endpoint] + const raw = readString(env) + if (raw === undefined) return ms + const parsed = Number.parseInt(raw, 10) + if (Number.isFinite(parsed) && parsed > 0) return parsed + return ms +} + +const config = Object.freeze({ + server: Object.freeze({ + get port(): number { + return readInt('PORT', 3001) + }, + get nodeEnv(): string { + return readStringWithDefault('NODE_ENV', 'development') + }, + get isProduction(): boolean { + return process.env.NODE_ENV === 'production' + }, + get isTest(): boolean { + return process.env.NODE_ENV === 'test' + }, + get isDevelopment(): boolean { + return readStringWithDefault('NODE_ENV', 'development') === 'development' + }, + get logLevel(): string { + return readStringWithDefault('LOG_LEVEL', 'info') + }, + }), + + redis: Object.freeze({ + get host(): string { + return readStringWithDefault('REDIS_HOST', 'localhost') + }, + get port(): number { + return readInt('REDIS_PORT', 6379) + }, + }), + + auth: Object.freeze({ + /** Cookie-signing secret. Insecure dev default; production requires an + * override (enforced by `assertStartupConfig`). */ + get sessionSecret(): string { + return readStringWithDefault('SESSION_SECRET', DEV_SESSION_SECRET) + }, + get sessionTimeoutDays(): number { + return readInt('SESSION_TIMEOUT_DAYS', 7) + }, + get sessionIdleTimeoutMinutes(): number { + return readInt('SESSION_IDLE_TIMEOUT_MINUTES', 60) + }, + get allowRegistration(): boolean { + return readBooleanStrictTrue('ALLOW_REGISTRATION') + }, + get allowTestAdminBypass(): boolean { + return readBooleanStrictTrue('ALLOW_TEST_ADMIN_BYPASS') + }, + /** Validated 32-byte AES key. Throws when unset or wrong length. */ + encryptionKey(): Buffer { + return resolveEncryptionKey() + }, + }), + + storage: Object.freeze({ + get path(): string { + return readStringWithDefault('STORAGE_PATH', DEFAULT_STORAGE_PATH) + }, + get videoStorageType(): string { + return readStringWithDefault('VIDEO_STORAGE_TYPE', 'local') + }, + get videoBaseUrl(): string { + return readStringWithDefault('VIDEO_BASE_URL', '/api/videos') + }, + s3: Object.freeze({ + get bucket(): string | undefined { + return readString('S3_BUCKET') + }, + get region(): string | undefined { + return readString('S3_REGION') + }, + /** AWS-prefixed key preferred, S3-prefixed key as fallback. */ + get accessKeyId(): string | undefined { + return readString('AWS_ACCESS_KEY_ID') ?? readString('S3_ACCESS_KEY_ID') + }, + get secretAccessKey(): string | undefined { + return ( + readString('AWS_SECRET_ACCESS_KEY') ?? readString('S3_SECRET_ACCESS_KEY') + ) + }, + get endpoint(): string | undefined { + return readString('S3_ENDPOINT') + }, + get publicBucket(): boolean { + return readBooleanStrictTrue('S3_PUBLIC_BUCKET') + }, + }), + cdn: Object.freeze({ + get enabled(): boolean { + return readBooleanStrictTrue('CDN_ENABLED') + }, + get baseUrl(): string { + return readStringWithDefault('CDN_BASE_URL', '') + }, + get signedUrls(): boolean { + return readBooleanDefaultTrue('CDN_SIGNED_URLS') + }, + }), + thumbnails: Object.freeze({ + get storageType(): string { + return readStringWithDefault('THUMBNAIL_STORAGE_TYPE', 'local') + }, + get path(): string { + return readStringWithDefault('THUMBNAIL_PATH', '/videos/thumbnails') + }, + get s3Prefix(): string { + return readStringWithDefault('THUMBNAIL_S3_PREFIX', 'thumbnails/') + }, + }), + }), + + modelService: Object.freeze({ + get url(): string { + return readStringWithDefault('MODEL_SERVICE_URL', DEFAULT_MODEL_SERVICE_URL) + }, + get adminToken(): string | undefined { + return readString('MODEL_SERVICE_ADMIN_TOKEN') + }, + /** + * Per-endpoint client-side timeout in milliseconds. + * + * @param endpoint - which model-service call this timeout applies to + * @returns the configured timeout, or the endpoint default when the + * matching `MODEL_SERVICE_TIMEOUT__MS` env var is unset or + * non-positive + * + * @example + * ```typescript + * await fetchModelService(url, { timeoutMs: config.modelService.timeoutMs('detection') }) + * ``` + */ + timeoutMs(endpoint: ModelServiceTimeoutEndpoint): number { + return resolveTimeoutMs(endpoint) + }, + }), + + rateLimit: Object.freeze({ + get max(): number { + return readInt('RATE_LIMIT_MAX', 1000) + }, + get window(): string { + return readStringWithDefault('RATE_LIMIT_WINDOW', '1 minute') + }, + }), + + cors: Object.freeze({ + /** Comma-split allow-list, or the four localhost defaults when unset. */ + get allowedOrigins(): string[] { + const raw = readString('ALLOWED_ORIGINS') + if (raw === undefined) return [...DEFAULT_ALLOWED_ORIGINS] + return raw.split(',') + }, + }), + + otel: Object.freeze({ + get exporterEndpoint(): string { + return readStringWithDefault('OTEL_EXPORTER_OTLP_ENDPOINT', DEFAULT_OTEL_ENDPOINT) + }, + }), + + mode: Object.freeze({ + /** Current FOVEA_MODE (`multi-user` default). */ + get current(): string { + return readStringWithDefault('FOVEA_MODE', DEFAULT_MODE) + }, + get isSingleUser(): boolean { + return readStringWithDefault('FOVEA_MODE', DEFAULT_MODE) === 'single-user' + }, + }), + + demo: Object.freeze({ + /** FOVEA_DEMO_MODE master flag (`'true'` or `'1'`). */ + get enabled(): boolean { + return readBooleanTrueOrOne('FOVEA_DEMO_MODE') + }, + get allowAnonymousAuth(): boolean { + return readBooleanTrueOrOne('FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH') + }, + /** Shared seeder secret; null when unset or shorter than 32 chars. */ + get seedToken(): string | null { + const t = process.env.FOVEA_DEMO_SEED_TOKEN + if (!t || t.length < 32) return null + return t + }, + get clipsManifestPath(): string | undefined { + return readString('FOVEA_DEMO_CLIPS_MANIFEST') + }, + get fixturesDir(): string | undefined { + return readString('FOVEA_DEMO_FIXTURES_DIR') + }, + }), + + tours: Object.freeze({ + get dir(): string | undefined { + return readString('FOVEA_TOURS_DIR') + }, + }), + + wikidata: Object.freeze({ + get mode(): string { + return readStringWithDefault('WIKIDATA_MODE', 'online') + }, + get url(): string { + return readStringWithDefault('WIKIDATA_URL', 'https://www.wikidata.org/w/api.php') + }, + get idMappingPath(): string | undefined { + return readString('WIKIBASE_ID_MAPPING_PATH') + }, + }), + + externalLinks: Object.freeze({ + /** Master ALLOW_EXTERNAL_LINKS switch (on unless explicitly `'false'`). */ + get master(): boolean { + return readBooleanDefaultTrue('ALLOW_EXTERNAL_LINKS') + }, + /** + * Whether Wikidata links are allowed. + * + * Online mode always allows them. Offline mode is governed by + * `ALLOW_EXTERNAL_WIKIDATA_LINKS`, falling back to the master switch + * when that specific var is unset. + * + * @param wikidataMode - the resolved Wikidata mode (`online`/`offline`) + * @returns true when Wikidata links should be shown + */ + wikidata(wikidataMode: string): boolean { + return ( + wikidataMode === 'online' || + readBooleanStrictTrue('ALLOW_EXTERNAL_WIKIDATA_LINKS') || + (readBooleanDefaultTrue('ALLOW_EXTERNAL_WIKIDATA_LINKS') && + readBooleanDefaultTrue('ALLOW_EXTERNAL_LINKS')) + ) + }, + /** + * Whether external video-source links (uploader/webpage URLs) are + * allowed. Governed by `ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS`, falling + * back to the master switch when that specific var is unset. + * + * @returns true when video-source links should be shown + */ + get videoSources(): boolean { + return ( + readBooleanStrictTrue('ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS') || + (readBooleanDefaultTrue('ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS') && + readBooleanDefaultTrue('ALLOW_EXTERNAL_LINKS')) + ) + }, + }), + + defaultUser: Object.freeze({ + get username(): string { + return readStringWithDefault('DEFAULT_USER_USERNAME', 'default-user') + }, + get displayName(): string { + return readStringWithDefault('DEFAULT_USER_DISPLAY_NAME', 'Default User') + }, + }), + + /** + * Resolve a provider's API key from the conventional env var. + * + * Reads `_API_KEY` (provider upper-cased), e.g. `anthropic` + * reads `ANTHROPIC_API_KEY`. Used as a fallback when no admin-stored + * key exists for the provider. + * + * @param provider - provider name (case-insensitive), e.g. `anthropic` + * @returns the key string, or undefined when the env var is unset/empty + * + * @example + * ```typescript + * const key = config.getProviderApiKey('anthropic') + * ``` + */ + getProviderApiKey(provider: string): string | undefined { + return readString(`${provider.toUpperCase()}_API_KEY`) + }, +}) + +assertStartupConfig() + +export { config } diff --git a/server/src/demo/anonymous-session.ts b/server/src/demo/anonymous-session.ts index d0400a50..b01baa87 100644 --- a/server/src/demo/anonymous-session.ts +++ b/server/src/demo/anonymous-session.ts @@ -25,6 +25,7 @@ import crypto from 'node:crypto' import type { FastifyInstance, FastifyPluginAsync } from 'fastify' import { prisma } from '../lib/prisma.js' import { authService } from '../services/auth-service.js' +import { config } from '../config.js' import { isAnonymousAuthAllowed } from './config.js' interface AnonymousSessionResponse { @@ -109,7 +110,7 @@ const anonymousSessionPlugin: FastifyPluginAsync = async (app: FastifyInstance) reply.setCookie('session_token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.isProduction, sameSite: 'lax', expires: expiresAt, path: '/', diff --git a/server/src/demo/config.ts b/server/src/demo/config.ts index ef93bded..9b331cf0 100644 --- a/server/src/demo/config.ts +++ b/server/src/demo/config.ts @@ -23,6 +23,7 @@ // their existing import path. The implementation lives in lib/ so // product code can also import it without violating the demo->product // layering rule enforced by eslint no-restricted-imports. +import { config } from '../config.js' import { isDemoModeEnabled } from '../lib/demo-flags.js' export { isDemoModeEnabled } @@ -35,11 +36,7 @@ export { isDemoModeEnabled } * unauthenticated access on a production deployment. */ export function isAnonymousAuthAllowed(): boolean { - return ( - isDemoModeEnabled() && - (process.env.FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH === 'true' || - process.env.FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH === '1') - ) + return isDemoModeEnabled() && config.demo.allowAnonymousAuth } /** @@ -48,7 +45,5 @@ export function isAnonymousAuthAllowed(): boolean { * is checked via `X-Demo-Seed-Token` header — a 32+ char random secret. */ export function getSeedToken(): string | null { - const t = process.env.FOVEA_DEMO_SEED_TOKEN - if (!t || t.length < 32) return null - return t + return config.demo.seedToken } diff --git a/server/src/demo/seed.ts b/server/src/demo/seed.ts index 58ab35b1..6d99f62d 100644 --- a/server/src/demo/seed.ts +++ b/server/src/demo/seed.ts @@ -24,6 +24,7 @@ import { readFile } from 'node:fs/promises' import { resolve, join } from 'node:path' import { timingSafeEqual } from 'node:crypto' import type { FastifyInstance, FastifyPluginAsync } from 'fastify' +import { config } from '../config.js' import { prisma } from '../lib/prisma.js' import { getSeedToken, isDemoModeEnabled } from './config.js' import { @@ -59,7 +60,7 @@ interface ClipManifest { * from the server cwd to the monorepo location. */ function manifestPath(): string { - const overridden = process.env.FOVEA_DEMO_CLIPS_MANIFEST + const overridden = config.demo.clipsManifestPath if (overridden && overridden.length > 0) return overridden return resolve( process.cwd(), @@ -102,7 +103,7 @@ interface SeedSuccess { * pattern for custom tours). */ function fixturesDir(): string { - const overridden = process.env.FOVEA_DEMO_FIXTURES_DIR + const overridden = config.demo.fixturesDir if (overridden && overridden.length > 0) return overridden return resolve(process.cwd(), '..', 'annotation-tool', 'demo', 'fixtures') } diff --git a/server/src/index.ts b/server/src/index.ts index 9827af1c..d0cf7d23 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,22 +1,21 @@ -// Initialize OpenTelemetry tracing FIRST, before any other imports -// This allows auto-instrumentation to hook into libraries as they load +// Load and validate configuration FIRST, before any other import. +// `config` reads process.env once, fails fast on invalid/missing required +// values, and must throw before OTEL, Fastify, or Prisma initialize. +import { config } from './config.js' +// Initialize OpenTelemetry tracing before the remaining imports so +// auto-instrumentation can hook into libraries as they load. import './tracing.js' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' import fs from 'fs/promises' import { buildApp } from './app.js' import { ensureDefaultUser, isSingleUserMode } from './services/user-service.js' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - /** * Initializes the data directory for video storage. * Checks if the data directory exists and logs the result. */ async function initializeDataDirectory() { - const dataDir = process.env.STORAGE_PATH || join(dirname(__dirname), '..', 'videos') + const dataDir = config.storage.path try { await fs.access(dataDir) console.log(`Data directory found at: ${dataDir}`) @@ -45,10 +44,8 @@ async function initializeSingleUserMode() { * Production deployments should use manual sync via API. */ async function initializeVideoSync(app: Awaited>) { - const nodeEnv = process.env.NODE_ENV || 'development' - // Only auto-sync in dev/test - production should sync manually - if (nodeEnv !== 'production') { + if (!config.server.isProduction) { try { // Import storage modules const { loadStorageConfig, createVideoStorageProvider } = await import('./services/videoStorage.js') @@ -140,7 +137,7 @@ async function initializeSystemConfigReplay(app: Awaited { - const dir = process.env.FOVEA_TOURS_DIR + const dir = config.tours.dir if (!dir || dir.length === 0) { return { tours: [], failures: [] } } diff --git a/server/src/lib/demo-flags.ts b/server/src/lib/demo-flags.ts index 8c608a69..22d9618e 100644 --- a/server/src/lib/demo-flags.ts +++ b/server/src/lib/demo-flags.ts @@ -13,13 +13,14 @@ * re-exports through `demo/config.js` so its own callers do not * cross the boundary in either direction. * - * Keep this file dependency-free. It is read by both layers and a - * misplaced cross-import here would defeat the layering rule. + * Keep this file free of cross-layer imports. It reads the flag through + * the central config module (the single env reader) and is consumed by + * both layers; a misplaced cross-import here would defeat the layering + * rule. */ +import { config } from '../config.js' + export function isDemoModeEnabled(): boolean { - return ( - process.env.FOVEA_DEMO_MODE === 'true' || - process.env.FOVEA_DEMO_MODE === '1' - ) + return config.demo.enabled } diff --git a/server/src/lib/encryption.ts b/server/src/lib/encryption.ts index a4af522b..691c6e2f 100644 --- a/server/src/lib/encryption.ts +++ b/server/src/lib/encryption.ts @@ -5,29 +5,20 @@ import crypto from 'node:crypto'; +import { config } from '../config.js'; + const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; -const KEY_LENGTH = 32; /** - * Gets the encryption key from environment variable. + * Gets the validated encryption key from configuration. * * @returns Encryption key as Buffer - * @throws Error if API_KEY_ENCRYPTION_KEY not configured or invalid length + * @throws {Error} if API_KEY_ENCRYPTION_KEY is unset or not 32 bytes */ function getEncryptionKey(): Buffer { - const key = process.env.API_KEY_ENCRYPTION_KEY; - if (!key) { - throw new Error('API_KEY_ENCRYPTION_KEY environment variable not set'); - } - - const keyBuffer = Buffer.from(key, 'hex'); - if (keyBuffer.length !== KEY_LENGTH) { - throw new Error(`API_KEY_ENCRYPTION_KEY must be ${KEY_LENGTH} bytes (${KEY_LENGTH * 2} hex characters)`); - } - - return keyBuffer; + return config.auth.encryptionKey(); } /** diff --git a/server/src/lib/fetchModelService.ts b/server/src/lib/fetchModelService.ts index 2dc2d890..78622003 100644 --- a/server/src/lib/fetchModelService.ts +++ b/server/src/lib/fetchModelService.ts @@ -16,6 +16,8 @@ * fetch-based call sites. */ +import { config } from '../config.js' + export class ModelServiceTimeoutError extends Error { readonly endpoint: string readonly timeoutMs: number @@ -85,43 +87,42 @@ export async function fetchModelService( } } -/** Parse a positive-integer env var, falling back to a default when unset - * or malformed. Used by the per-endpoint timeouts below so deployments - * with slower upstream hardware (CPU-only model-service, cold-start - * cloud instances) can raise the ceiling without a code change. - */ -function envTimeoutMs(name: string, defaultMs: number): number { - const raw = process.env[name] - if (raw === undefined || raw === '') return defaultMs - const parsed = Number.parseInt(raw, 10) - if (Number.isFinite(parsed) && parsed > 0) return parsed - return defaultMs -} - -/** Per-endpoint timeout defaults. Each value is the upper bound the +/** Per-endpoint timeout accessors. Each value is the upper bound the * backend waits before aborting a forwarded request to the model- * service and surfacing 504 MODEL_SERVICE_TIMEOUT to the caller. The - * defaults below target a GPU-warm production deployment; CPU-cold - * first-load (e.g. the integration-models E2E stack) is materially - * slower (an LLM augmenter call took 94s on first invocation, beyond - * the prior 60s detection / augment ceilings), so deployments that - * need higher ceilings override these via the matching env vars. - */ -export const MODEL_SERVICE_TIMEOUTS = { + * defaults target a GPU-warm production deployment; CPU-cold first-load + * (e.g. the integration-models E2E stack) is materially slower, so + * deployments that need higher ceilings override via the matching + * `MODEL_SERVICE_TIMEOUT__MS` env var. Values are resolved through + * `config.modelService.timeoutMs` so the env surface lives in one place; + * each property reads at access time so an env override is honored. */ +export const MODEL_SERVICE_TIMEOUTS = Object.freeze({ /** Detection across N frames: heavier than a simple inference. */ - detection: envTimeoutMs('MODEL_SERVICE_TIMEOUT_DETECTION_MS', 60_000), + get detection(): number { + return config.modelService.timeoutMs('detection') + }, /** Single-frame thumbnail render. */ - thumbnails: envTimeoutMs('MODEL_SERVICE_TIMEOUT_THUMBNAILS_MS', 30_000), + get thumbnails(): number { + return config.modelService.timeoutMs('thumbnails') + }, /** LLM-backed ontology suggestion. */ - ontologyAugment: envTimeoutMs('MODEL_SERVICE_TIMEOUT_ONTOLOGY_AUGMENT_MS', 60_000), + get ontologyAugment(): number { + return config.modelService.timeoutMs('ontologyAugment') + }, /** Transcription / summarization over a whole video. */ - summarize: envTimeoutMs('MODEL_SERVICE_TIMEOUT_SUMMARIZE_MS', 300_000), + get summarize(): number { + return config.modelService.timeoutMs('summarize') + }, /** Claim extraction over a summary. */ - extractClaims: envTimeoutMs('MODEL_SERVICE_TIMEOUT_EXTRACT_CLAIMS_MS', 300_000), + get extractClaims(): number { + return config.modelService.timeoutMs('extractClaims') + }, /** Synthesizing a final summary from extracted claims. */ - synthesize: envTimeoutMs('MODEL_SERVICE_TIMEOUT_SYNTHESIZE_MS', 300_000), - /** Audio transcription over a video / audio file. faster-whisper-tiny - * on CPU does a 14 s clip in ~6 s cold, ~3 s warm, but a longer - * input or larger model can run minutes; default 5 min. */ - transcribe: envTimeoutMs('MODEL_SERVICE_TIMEOUT_TRANSCRIBE_MS', 300_000), -} as const + get synthesize(): number { + return config.modelService.timeoutMs('synthesize') + }, + /** Audio transcription over a video / audio file. */ + get transcribe(): number { + return config.modelService.timeoutMs('transcribe') + }, +}) diff --git a/server/src/lib/prisma.ts b/server/src/lib/prisma.ts index cd3675a4..6b497486 100644 --- a/server/src/lib/prisma.ts +++ b/server/src/lib/prisma.ts @@ -1,11 +1,13 @@ import { PrismaClient } from '@prisma/client' +import { config } from '../config.js' + /** * Prisma Client singleton instance. * Ensures only one instance is created and reused across the application. */ export const prisma = new PrismaClient({ - log: process.env.NODE_ENV === 'development' + log: config.server.isDevelopment ? ['query', 'error', 'warn'] : ['error'] }) diff --git a/server/src/lib/session.ts b/server/src/lib/session.ts index 99943174..07db5811 100644 --- a/server/src/lib/session.ts +++ b/server/src/lib/session.ts @@ -1,8 +1,7 @@ import crypto from 'crypto' +import { config } from '../config.js' import { prisma } from './prisma.js' -const SESSION_TIMEOUT_DAYS = parseInt(process.env.SESSION_TIMEOUT_DAYS || '7', 10) - /** * Generates a secure random session token. * @returns 32-byte hex string (64 characters) @@ -26,7 +25,7 @@ export async function createSession( userAgent?: string ) { const token = generateSessionToken() - const daysToExpire = rememberMe ? 30 : SESSION_TIMEOUT_DAYS + const daysToExpire = rememberMe ? 30 : config.auth.sessionTimeoutDays const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + daysToExpire) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index dc830717..cd813481 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,4 +1,5 @@ import { FastifyRequest, FastifyReply } from 'fastify' +import { config } from '../config.js' import { authService } from '../services/auth-service.js' import { UnauthorizedError, ForbiddenError } from '../lib/errors.js' @@ -73,7 +74,7 @@ export async function requireAdmin( ): Promise { // Test mode bypass for E2E tests // Allows admin API access without authentication in isolated test environment - if (process.env.NODE_ENV === 'test' && process.env.ALLOW_TEST_ADMIN_BYPASS === 'true') { + if (config.server.isTest && config.auth.allowTestAdminBypass) { // Auto-authenticate as test admin user request.user = { id: 'test-admin', diff --git a/server/src/queues/setup.ts b/server/src/queues/setup.ts index 9f8d9b07..abb80f88 100644 --- a/server/src/queues/setup.ts +++ b/server/src/queues/setup.ts @@ -8,6 +8,7 @@ import { modelServiceCounter, modelServiceDuration, } from "../metrics.js"; +import { config as appConfig } from "../config.js"; import { buildPersonaPrompts } from "../utils/queryBuilder.js"; import { fetchModelService, @@ -73,8 +74,8 @@ interface ModelSummarizeRequest { * Uses environment variables with localhost defaults for development. */ const connection = new Redis({ - host: process.env.REDIS_HOST || "localhost", - port: parseInt(process.env.REDIS_PORT || "6379"), + host: appConfig.redis.host, + port: appConfig.redis.port, maxRetriesPerRequest: null, }); @@ -251,8 +252,7 @@ export const videoWorker = new Worker< const modelVideoPath = video.path.replace('/data/', '/videos/'); // Call model service with metrics tracking - const modelServiceUrl = - process.env.MODEL_SERVICE_URL || "http://localhost:8000"; + const modelServiceUrl = appConfig.modelService.url; const modelStartTime = Date.now(); const camelCaseRequest = { @@ -582,8 +582,7 @@ export const claimWorker = new Worker< } // Call model service - const modelServiceUrl = - process.env.MODEL_SERVICE_URL || "http://localhost:8000"; + const modelServiceUrl = appConfig.modelService.url; const modelStartTime = Date.now(); await job.updateProgress(30); @@ -936,8 +935,7 @@ export const synthesisWorker = new Worker< const requestBody = snakecaseKeys(camelCaseRequestBody, { deep: true }); // Call model service - const modelServiceUrl = - process.env.MODEL_SERVICE_URL || "http://localhost:8000"; + const modelServiceUrl = appConfig.modelService.url; const modelStartTime = Date.now(); await job.updateProgress(30); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e255e890..0e4b2ccb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -15,8 +15,10 @@ import { recordSessionEvent, } from '../lib/authMetrics.js' import { requireAuth } from '../middleware/auth.js' +import { config } from '../config.js' import { buildAbilities } from '../middleware/abilities.js' import { serializeAbilities } from '../lib/abilities.js' +import { isSingleUserMode } from '../services/user-service.js' /** * Authentication routes for login, logout, registration. @@ -133,7 +135,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { // Set session cookie reply.setCookie('session_token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.isProduction, sameSite: 'lax', expires: expiresAt, path: '/', @@ -213,7 +215,6 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { const token = request.cookies.session_token - const mode = process.env.FOVEA_MODE || 'single-user' // Handle session-based authentication if (token) { @@ -236,7 +237,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { } // In single-user mode, auto-authenticate with default user - if (mode === 'single-user') { + if (isSingleUserMode()) { // Find default user by id (matches what seed.ts creates) const defaultUserId = 'default-user' let defaultUser = await prisma.user.findUnique({ @@ -270,7 +271,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { // Set session cookie reply.setCookie('session_token', sessionToken, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.isProduction, sameSite: 'lax', expires: expiresAt, path: '/', @@ -338,7 +339,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { // Check if registration is enabled - if (process.env.ALLOW_REGISTRATION !== 'true') { + if (!config.auth.allowRegistration) { throw new ForbiddenError('Registration is disabled') } @@ -378,7 +379,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { reply.setCookie('session_token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.isProduction, sameSite: 'lax', expires: expiresAt, path: '/', @@ -486,7 +487,7 @@ const authRoutes: FastifyPluginAsync = async (fastify) => { // Update cookie with new expiration reply.setCookie('session_token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.isProduction, sameSite: 'lax', expires: newExpiresAt, path: '/', diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts index ee514606..09322ca0 100644 --- a/server/src/routes/config.ts +++ b/server/src/routes/config.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify' import { existsSync, readFileSync } from 'fs' +import { config } from '../config.js' /** * Load ID mapping from file if it exists. @@ -29,33 +30,22 @@ export default async function configRoutes(fastify: FastifyInstance) { * @returns Configuration object with mode, allowRegistration, and wikidata settings */ fastify.get('/api/config', async () => { - const mode = process.env.FOVEA_MODE || 'single-user' - const allowRegistration = process.env.ALLOW_REGISTRATION === 'true' + const mode = config.mode.current + const allowRegistration = config.auth.allowRegistration // Wikidata/Wikibase configuration - const wikidataMode = process.env.WIKIDATA_MODE || 'online' - const wikidataUrl = - process.env.WIKIDATA_URL || 'https://www.wikidata.org/w/api.php' + const wikidataMode = config.wikidata.mode + const wikidataUrl = config.wikidata.url // Load ID mapping for offline mode - const idMappingPath = process.env.WIKIBASE_ID_MAPPING_PATH + const idMappingPath = config.wikidata.idMappingPath const idMapping = wikidataMode === 'offline' ? loadIdMapping(idMappingPath) : null - // External link controls - // ALLOW_EXTERNAL_LINKS is a master switch that defaults both specific settings - const masterExternalLinks = process.env.ALLOW_EXTERNAL_LINKS !== 'false' - - // Wikidata links: In online mode, always allowed. In offline mode, controlled by env var. - const allowExternalWikidataLinks = - wikidataMode === 'online' || - (process.env.ALLOW_EXTERNAL_WIKIDATA_LINKS === 'true' || - (process.env.ALLOW_EXTERNAL_WIKIDATA_LINKS !== 'false' && masterExternalLinks)) - - // Video source links (uploaderUrl, webpageUrl): controlled by env var - const allowExternalVideoSourceLinks = - process.env.ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS === 'true' || - (process.env.ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS !== 'false' && masterExternalLinks) + // External link controls. ALLOW_EXTERNAL_LINKS is a master switch + // that defaults both specific settings; the derivation lives in config. + const allowExternalWikidataLinks = config.externalLinks.wikidata(wikidataMode) + const allowExternalVideoSourceLinks = config.externalLinks.videoSources return { mode: mode as 'single-user' | 'multi-user', diff --git a/server/src/routes/models.ts b/server/src/routes/models.ts index 58ab744d..84f534af 100644 --- a/server/src/routes/models.ts +++ b/server/src/routes/models.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify' import axios, { AxiosError } from 'axios' import camelcaseKeys from 'camelcase-keys' +import { config } from '../config.js' import { InternalError, ValidationError } from '../lib/errors.js' /** @@ -56,7 +57,7 @@ function normalizeAndAssertTaskType(taskType: string): string { */ const modelsRoute: FastifyPluginAsync = async (fastify) => { - const MODEL_SERVICE_URL = process.env.MODEL_SERVICE_URL || 'http://model-service:8000' + const MODEL_SERVICE_URL = config.modelService.url /** * Get model configuration. diff --git a/server/src/routes/ontology.ts b/server/src/routes/ontology.ts index 23a29ffa..79c45fe2 100644 --- a/server/src/routes/ontology.ts +++ b/server/src/routes/ontology.ts @@ -3,8 +3,10 @@ import { FastifyPluginAsync } from 'fastify' import { Prisma } from '@prisma/client' import { subject } from '@casl/ability' import { requireAuth } from '../middleware/auth.js' +import { config } from '../config.js' import { buildAbilities } from '../middleware/abilities.js' import { NotFoundError, UnauthorizedError, InternalError, ForbiddenError, AppError } from '../lib/errors.js' +import { isSingleUserMode } from '../services/user-service.js' import { fetchModelService, MODEL_SERVICE_TIMEOUTS, @@ -83,16 +85,14 @@ const ontologyRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - const mode = process.env.FOVEA_MODE || 'multi-user' - // Get user ID: use authenticated user or find default user in single-user mode let userId: string if (request.user) { userId = request.user.id - } else if (mode === 'single-user') { + } else if (isSingleUserMode()) { // Find default user const defaultUser = await fastify.prisma.user.findFirst({ - where: { username: process.env.DEFAULT_USER_USERNAME || 'default-user' } + where: { username: config.defaultUser.username } }) if (!defaultUser) { throw new InternalError('Default user not found in single-user mode') @@ -197,16 +197,14 @@ const ontologyRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - const mode = process.env.FOVEA_MODE || 'multi-user' - // Get user ID: use authenticated user or find default user in single-user mode let userId: string if (request.user) { userId = request.user.id - } else if (mode === 'single-user') { + } else if (isSingleUserMode()) { // Find default user const defaultUser = await fastify.prisma.user.findFirst({ - where: { username: process.env.DEFAULT_USER_USERNAME || 'default-user' } + where: { username: config.defaultUser.username } }) if (!defaultUser) { throw new InternalError('Default user not found in single-user mode') @@ -467,7 +465,7 @@ const ontologyRoute: FastifyPluginAsync = async (fastify) => { } // Call model service - const modelServiceUrl = process.env.MODEL_SERVICE_URL || 'http://localhost:8000' + const modelServiceUrl = config.modelService.url const response = await fetchModelService(`${modelServiceUrl}/api/ontology/augment`, { method: 'POST', timeoutMs: MODEL_SERVICE_TIMEOUTS.ontologyAugment, diff --git a/server/src/routes/personas.ts b/server/src/routes/personas.ts index 7122a2c5..5c87d172 100644 --- a/server/src/routes/personas.ts +++ b/server/src/routes/personas.ts @@ -8,6 +8,7 @@ import { requireAuth, optionalAuth } from '@middleware/auth.js' import { buildAbilities } from '../middleware/abilities.js' import { NotFoundError, ForbiddenError } from '@lib/errors.js' import { isDemoModeEnabled } from '../lib/demo-flags.js' +import { isSingleUserMode } from '@services/user-service.js' import { personaOperationCounter } from '../metrics.js' import { updateGlossesInTypes, @@ -117,9 +118,7 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - const mode = process.env.FOVEA_MODE || 'multi-user' - - if (mode === 'single-user') { + if (isSingleUserMode()) { // Single-user mode: return all non-hidden personas const personas = await fastify.prisma.persona.findMany({ where: { hidden: false }, @@ -156,7 +155,7 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { // single-user deployment shows. This is gated on FOVEA_DEMO_MODE // so production multi-user deployments keep their per-user RBAC // intact. - if (process.env.FOVEA_DEMO_MODE === 'true') { + if (isDemoModeEnabled()) { // The seeded Automated persona ships with hidden=true so it // does not clutter a multi-user deployment's persona dropdown // for end users who do not need it. The tour flow that walks diff --git a/server/src/routes/telemetry.ts b/server/src/routes/telemetry.ts index cb7de6f0..2bfd2d78 100644 --- a/server/src/routes/telemetry.ts +++ b/server/src/routes/telemetry.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { Type, Static } from '@sinclair/typebox' +import { config } from '../config.js' import { recordFrontendError, type ErrorSeverity } from '../lib/errorMetrics.js' /** @@ -188,7 +189,7 @@ const telemetryRoutes: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://otel-collector:4318' + const otelEndpoint = config.otel.exporterEndpoint const tracesUrl = `${otelEndpoint}/v1/traces` try { diff --git a/server/src/routes/videos/detect.ts b/server/src/routes/videos/detect.ts index 4ce6581b..79a11c22 100644 --- a/server/src/routes/videos/detect.ts +++ b/server/src/routes/videos/detect.ts @@ -3,6 +3,7 @@ import { Type, Static } from '@sinclair/typebox' import { PrismaClient } from '@prisma/client' import camelcaseKeys from 'camelcase-keys' import snakecaseKeys from 'snakecase-keys' +import { config } from '../../config.js' import { buildDetectionQueryFromPersona, DetectionQueryOptions } from '../../utils/queryBuilder.js' import { VideoRepository } from '../../repositories/VideoRepository.js' import { DetectionRequestSchema, DetectionResponseSchema } from './schemas.js' @@ -128,7 +129,7 @@ export const detectRoutes: FastifyPluginAsync<{ // Backend uses /data, model service uses /videos const modelVideoPath = video.path.replace('/data/', '/videos/') - const modelServiceUrl = process.env.MODEL_SERVICE_URL || 'http://localhost:8000' + const modelServiceUrl = config.modelService.url const requestBody = snakecaseKeys({ videoId, diff --git a/server/src/routes/videos/index.ts b/server/src/routes/videos/index.ts index 91bf058c..c3ce2260 100644 --- a/server/src/routes/videos/index.ts +++ b/server/src/routes/videos/index.ts @@ -1,4 +1,5 @@ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify' +import { config } from '../../config.js' import { createVideoStorageProvider, loadStorageConfig } from '../../services/videoStorage.js' import { VideoRepository } from '../../repositories/VideoRepository.js' import { VideoAccessService } from '../../services/video-access-service.js' @@ -26,7 +27,7 @@ import { urlRoutes } from './url.js' * - Get video URLs */ const videosRoute: FastifyPluginAsync = async (fastify) => { - const STORAGE_PATH = process.env.STORAGE_PATH || '/videos' + const STORAGE_PATH = config.storage.path // Initialize storage provider const storageConfig = loadStorageConfig() diff --git a/server/src/routes/videos/thumbnail.ts b/server/src/routes/videos/thumbnail.ts index 9cd59ecc..cc5e5d25 100644 --- a/server/src/routes/videos/thumbnail.ts +++ b/server/src/routes/videos/thumbnail.ts @@ -3,6 +3,7 @@ import { Type } from '@sinclair/typebox' import { promises as fs } from 'fs' import path from 'path' import { createReadStream } from 'fs' +import { config } from '../../config.js' import { VideoRepository } from '../../repositories/VideoRepository.js' import { VideoStorageProvider, VideoStorageConfig } from '../../services/videoStorage.js' import { NotFoundError, InternalError, AppError } from '../../lib/errors.js' @@ -85,7 +86,7 @@ export const thumbnailRoutes: FastifyPluginAsync<{ } // Generate thumbnail via model service - const modelServiceUrl = process.env.MODEL_SERVICE_URL || 'http://localhost:8000' + const modelServiceUrl = config.modelService.url // Get video URL that model service can access // For local storage, this will be a file path diff --git a/server/src/routes/videos/transcribe.ts b/server/src/routes/videos/transcribe.ts index 08c6145f..1dfbda95 100644 --- a/server/src/routes/videos/transcribe.ts +++ b/server/src/routes/videos/transcribe.ts @@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify' import { Type } from '@sinclair/typebox' import { PrismaClient } from '@prisma/client' import camelcaseKeys from 'camelcase-keys' +import { config } from '../../config.js' import { VideoRepository } from '../../repositories/VideoRepository.js' import { NotFoundError, AppError, ErrorResponseSchema } from '../../lib/errors.js' import { @@ -137,7 +138,7 @@ export const transcribeRoutes: FastifyPluginAsync<{ } const modelVideoPath = video.path.replace('/data/', '/videos/') - const modelServiceUrl = process.env.MODEL_SERVICE_URL || 'http://localhost:8000' + const modelServiceUrl = config.modelService.url try { const transcribeRes = await fetchModelService(`${modelServiceUrl}/api/transcribe`, { diff --git a/server/src/routes/world.ts b/server/src/routes/world.ts index e20f24ba..1aaffe56 100644 --- a/server/src/routes/world.ts +++ b/server/src/routes/world.ts @@ -4,10 +4,12 @@ import { Prisma, type WorldState as PrismaWorldState } from '@prisma/client' import { accessibleBy } from '@casl/prisma' import { subject } from '@casl/ability' import { optionalAuth, requireAdmin, requireAuth } from '@middleware/auth.js' +import { config } from '../config.js' import { buildAbilities } from '../middleware/abilities.js' import type { AppAbility } from '../lib/abilities.js' import { NotFoundError, UnauthorizedError, InternalError, ForbiddenError } from '@lib/errors.js' import { isDemoModeEnabled } from '../lib/demo-flags.js' +import { isSingleUserMode } from '@services/user-service.js' import { convertObjectRefsToText, countObjectRefsInGlosses } from '@lib/reference-cleanup.js' import { asEntityTypes, @@ -97,16 +99,14 @@ const worldRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - const mode = process.env.FOVEA_MODE || 'multi-user' - // Get user ID: use authenticated user or find default user in single-user mode let userId: string if (request.user) { userId = request.user.id - } else if (mode === 'single-user') { + } else if (isSingleUserMode()) { // Find default user const defaultUser = await fastify.prisma.user.findFirst({ - where: { username: process.env.DEFAULT_USER_USERNAME || 'default-user' } + where: { username: config.defaultUser.username } }) if (!defaultUser) { throw new InternalError('Default user not found in single-user mode') @@ -219,16 +219,14 @@ const worldRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - const mode = process.env.FOVEA_MODE || 'multi-user' - // Get user ID: use authenticated user or find default user in single-user mode let userId: string if (request.user) { userId = request.user.id - } else if (mode === 'single-user') { + } else if (isSingleUserMode()) { // Find default user const defaultUser = await fastify.prisma.user.findFirst({ - where: { username: process.env.DEFAULT_USER_USERNAME || 'default-user' } + where: { username: config.defaultUser.username } }) if (!defaultUser) { throw new InternalError('Default user not found in single-user mode') @@ -387,13 +385,11 @@ const worldRoute: FastifyPluginAsync = async (fastify) => { * Helper to get user ID from request or default user. */ async function getUserId(request: { user?: { id: string } }): Promise { - const mode = process.env.FOVEA_MODE || 'multi-user' - if (request.user) { return request.user.id - } else if (mode === 'single-user') { + } else if (isSingleUserMode()) { const defaultUser = await fastify.prisma.user.findFirst({ - where: { username: process.env.DEFAULT_USER_USERNAME || 'default-user' } + where: { username: config.defaultUser.username } }) if (!defaultUser) { throw new InternalError('Default user not found in single-user mode') diff --git a/server/src/services/api-key-service.ts b/server/src/services/api-key-service.ts index 3725475f..7cf26e17 100644 --- a/server/src/services/api-key-service.ts +++ b/server/src/services/api-key-service.ts @@ -1,4 +1,5 @@ import { PrismaClient, ApiKey } from '@prisma/client' +import { config } from '../config.js' import { encryptApiKey, decryptApiKey } from '../lib/encryption.js' /** @@ -250,10 +251,7 @@ export async function resolveApiKey( } // Try environment variable - const envVarName = `${provider.toUpperCase()}_API_KEY` - const envKey = process.env[envVarName] - - return envKey || null + return config.getProviderApiKey(provider) ?? null } /** diff --git a/server/src/services/auth-service.ts b/server/src/services/auth-service.ts index 872315e7..2b8dceee 100644 --- a/server/src/services/auth-service.ts +++ b/server/src/services/auth-service.ts @@ -1,19 +1,10 @@ import crypto from 'crypto' +import { config } from '../config.js' import { prisma } from '../lib/prisma.js' import { User, Session } from '@prisma/client' import { AuthProvider, AuthCredentials, AuthResult } from './auth/types.js' import { PasswordAuthProvider } from './auth/password-provider.js' -/** - * Session idle timeout in minutes. - * Sessions are invalidated if there is no activity for this duration. - * Configurable via SESSION_IDLE_TIMEOUT_MINUTES environment variable. - */ -const SESSION_IDLE_TIMEOUT_MINUTES = parseInt( - process.env.SESSION_IDLE_TIMEOUT_MINUTES || '60', - 10 -) - /** * Authentication service managing providers and sessions. * Handles user authentication, session creation, and validation. @@ -120,7 +111,7 @@ export class AuthService { } // Check idle timeout - const idleTimeoutMs = SESSION_IDLE_TIMEOUT_MINUTES * 60 * 1000 + const idleTimeoutMs = config.auth.sessionIdleTimeoutMinutes * 60 * 1000 const lastActivity = session.lastActivityAt || session.createdAt if (now.getTime() - lastActivity.getTime() > idleTimeoutMs) { // Session has been idle too long diff --git a/server/src/services/system-config-propagator.ts b/server/src/services/system-config-propagator.ts index 6dd258b5..20d1c259 100644 --- a/server/src/services/system-config-propagator.ts +++ b/server/src/services/system-config-propagator.ts @@ -16,6 +16,8 @@ import axios, { AxiosError } from 'axios' import type { FastifyBaseLogger } from 'fastify' import type { PrismaClient } from '@prisma/client' +import { config } from '../config.js' + interface AdminPushPayload { key: string value: unknown @@ -42,8 +44,8 @@ export async function pushSystemConfigRow( log: Pick, payload: AdminPushPayload ): Promise { - const modelServiceUrl = process.env.MODEL_SERVICE_URL || 'http://model-service:8000' - const token = process.env.MODEL_SERVICE_ADMIN_TOKEN + const modelServiceUrl = config.modelService.url + const token = config.modelService.adminToken if (!token) { log.warn('MODEL_SERVICE_ADMIN_TOKEN not set; skipping SystemConfig propagation') return 'skipped-no-token' diff --git a/server/src/services/user-service.ts b/server/src/services/user-service.ts index 330ba28b..6c3027d5 100644 --- a/server/src/services/user-service.ts +++ b/server/src/services/user-service.ts @@ -1,3 +1,4 @@ +import { config } from '../config.js' import { prisma } from '../lib/prisma.js' import type { User } from '@prisma/client' @@ -14,10 +15,8 @@ import type { User } from '@prisma/client' * @returns Default user record */ export async function ensureDefaultUser(): Promise { - const mode = process.env.FOVEA_MODE || 'multi-user' - // Only relevant for single-user mode - if (mode !== 'single-user') { + if (!isSingleUserMode()) { throw new Error('ensureDefaultUser should only be called in single-user mode') } @@ -34,8 +33,8 @@ export async function ensureDefaultUser(): Promise { const defaultUser = await prisma.user.create({ data: { id: 'default-user', - username: process.env.DEFAULT_USER_USERNAME || 'default-user', - displayName: process.env.DEFAULT_USER_DISPLAY_NAME || 'Default User', + username: config.defaultUser.username, + displayName: config.defaultUser.displayName, email: null, passwordHash: null, // No password in single-user mode isAdmin: true @@ -62,6 +61,5 @@ export async function getDefaultUser(): Promise { * @returns True if single-user mode, false otherwise */ export function isSingleUserMode(): boolean { - const mode = process.env.FOVEA_MODE || 'multi-user' - return mode === 'single-user' + return config.mode.isSingleUser } diff --git a/server/src/services/video-access-service.ts b/server/src/services/video-access-service.ts index 5437c7ee..7933a473 100644 --- a/server/src/services/video-access-service.ts +++ b/server/src/services/video-access-service.ts @@ -7,6 +7,8 @@ import { PrismaClient } from '@prisma/client' import { trace } from '@opentelemetry/api' +import { isDemoModeEnabled } from '../lib/demo-flags.js' + const tracer = trace.getTracer('fovea-rbac') /** @@ -77,7 +79,7 @@ export class VideoAccessService { // mounts. This is gated on FOVEA_DEMO_MODE so a self-hosted // production deployment with the same code keeps its per-user // RBAC intact. - if (process.env.FOVEA_DEMO_MODE === 'true') { + if (isDemoModeEnabled()) { span.setAttribute('video_access.result_count', -1) span.setAttribute('video_access.demo_mode_override', true) span.end() diff --git a/server/src/services/videoStorage.ts b/server/src/services/videoStorage.ts index d27caf6c..c42d0847 100644 --- a/server/src/services/videoStorage.ts +++ b/server/src/services/videoStorage.ts @@ -13,6 +13,8 @@ import { import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { readFile } from 'fs/promises'; +import { config as appConfig } from '../config.js'; + /** * Video Storage Provider Interface * @@ -927,50 +929,49 @@ class HybridStorageProvider implements VideoStorageProvider { * Load storage configuration from environment variables */ export function loadStorageConfig(): VideoStorageConfig { - const storageType = (process.env.VIDEO_STORAGE_TYPE || 'local') as + const storageType = appConfig.storage.videoStorageType as | 'local' | 's3' | 'hybrid'; const config: VideoStorageConfig = { type: storageType, - localPath: process.env.STORAGE_PATH || '/videos', - baseUrl: process.env.VIDEO_BASE_URL || '/api/videos', + localPath: appConfig.storage.path, + baseUrl: appConfig.storage.videoBaseUrl, }; // S3 configuration if (storageType === 's3' || storageType === 'hybrid') { // In test environment, allow missing S3 credentials // Tests should mock storage operations or provide test credentials - if (process.env.NODE_ENV !== 'test' && (!process.env.S3_BUCKET || !process.env.S3_REGION)) { + if (!appConfig.server.isTest && (!appConfig.storage.s3.bucket || !appConfig.storage.s3.region)) { throw new Error('S3_BUCKET and S3_REGION are required for S3 storage'); } config.s3 = { - bucket: process.env.S3_BUCKET || 'test-bucket', - region: process.env.S3_REGION || 'us-east-1', - accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY, - endpoint: process.env.S3_ENDPOINT, - publicBucket: process.env.S3_PUBLIC_BUCKET === 'true', + bucket: appConfig.storage.s3.bucket || 'test-bucket', + region: appConfig.storage.s3.region || 'us-east-1', + accessKeyId: appConfig.storage.s3.accessKeyId, + secretAccessKey: appConfig.storage.s3.secretAccessKey, + endpoint: appConfig.storage.s3.endpoint, + publicBucket: appConfig.storage.s3.publicBucket, }; } // CDN configuration - if (process.env.CDN_ENABLED === 'true') { + if (appConfig.storage.cdn.enabled) { config.cdn = { enabled: true, - baseUrl: process.env.CDN_BASE_URL || '', - signedUrls: process.env.CDN_SIGNED_URLS !== 'false', + baseUrl: appConfig.storage.cdn.baseUrl, + signedUrls: appConfig.storage.cdn.signedUrls, }; } // Thumbnail storage configuration config.thumbnails = { - storageType: - (process.env.THUMBNAIL_STORAGE_TYPE as 'local' | 's3') || 'local', - localPath: process.env.THUMBNAIL_PATH || '/videos/thumbnails', - s3Prefix: process.env.THUMBNAIL_S3_PREFIX || 'thumbnails/', + storageType: appConfig.storage.thumbnails.storageType as 'local' | 's3', + localPath: appConfig.storage.thumbnails.path, + s3Prefix: appConfig.storage.thumbnails.s3Prefix, }; return config; diff --git a/server/src/tracing.ts b/server/src/tracing.ts index 4bf67114..13cd1f45 100644 --- a/server/src/tracing.ts +++ b/server/src/tracing.ts @@ -13,6 +13,8 @@ import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { resourceFromAttributes, defaultResource } from '@opentelemetry/resources' import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' +import { config } from './config.js' + /** * Initializes OpenTelemetry SDK with trace and metric exporters. * @@ -30,7 +32,7 @@ function initializeOpenTelemetry(): NodeSDK { ); // Build full OTLP endpoint URLs - const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318'; + const otlpEndpoint = config.otel.exporterEndpoint; const traceUrl = `${otlpEndpoint}/v1/traces`; const metricUrl = `${otlpEndpoint}/v1/metrics`; diff --git a/server/test/services/videoStorage.test.ts b/server/test/services/videoStorage.test.ts index f1802b95..39e440d4 100644 --- a/server/test/services/videoStorage.test.ts +++ b/server/test/services/videoStorage.test.ts @@ -162,7 +162,9 @@ describe('Video Storage Providers', () => { const config = loadStorageConfig() expect(config.type).toBe('local') - expect(config.localPath).toBe('/videos') + // STORAGE_PATH unset falls back to the unified repo-relative default + // (/videos), an absolute path ending in `videos`. + expect(config.localPath).toMatch(/[/\\]videos$/) expect(config.baseUrl).toBe('/api/videos') }) diff --git a/server/test/setup.ts b/server/test/setup.ts index 3d87a183..973de0b4 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -1,18 +1,20 @@ -import { beforeAll, afterAll, afterEach, vi } from 'vitest' +import { afterAll, afterEach, vi } from 'vitest' /** * Global test setup for backend tests. * This file is run before all tests via vitest.config.ts setupFiles. + * + * The base environment is set at module-eval (not inside `beforeAll`) so + * that the central config module sees a valid API_KEY_ENCRYPTION_KEY and + * test NODE_ENV the moment any test file imports something that loads + * `src/config.ts`. A `beforeAll` would run after the test file's static + * imports have already evaluated config and tripped its fail-fast check. */ - -beforeAll(() => { - // Set test environment variables - process.env.NODE_ENV = 'test' - process.env.FOVEA_MODE = 'multi-user' - process.env.MODEL_SERVICE_URL = 'http://localhost:8000' - process.env.API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' - process.env.SESSION_SECRET = 'test-session-secret-min-32-chars!!' -}) +process.env.NODE_ENV = 'test' +process.env.FOVEA_MODE = 'multi-user' +process.env.MODEL_SERVICE_URL = 'http://localhost:8000' +process.env.API_KEY_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +process.env.SESSION_SECRET = 'test-session-secret-min-32-chars!!' afterEach(() => { // Clear all mocks after each test diff --git a/wikibase/pyproject.toml b/wikibase/pyproject.toml index b98c4ed1..083eda6d 100644 --- a/wikibase/pyproject.toml +++ b/wikibase/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fovea-wikibase-loader" -version = "0.4.4" +version = "0.5.0" description = "Data loader for importing Wikidata into local Wikibase instances" readme = "README.md" requires-python = ">=3.12" From 360f1e8d0bafbc611caffb69b806b4b45e161629 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 14:28:47 -0400 Subject: [PATCH 02/42] Add a create-release job to release.yml that extracts the matching CHANGELOG.md section and creates or updates the GitHub Release on every version-tag push, kept independent of the image-build job so a Release is published even when the heavy model-service-gpu image hits the ninety-minute timeout, closing the gap that left v0.4.3 without a GitHub Release. --- .github/workflows/release.yml | 46 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 6 +++++ docs/docs/project/changelog.md | 6 +++++ 3 files changed, 58 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55b4b069..9cb79267 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,52 @@ jobs: cache-from: type=gha,scope=${{ matrix.service.name }} cache-to: type=gha,mode=max,scope=${{ matrix.service.name }} + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + # Deliberately independent of build-and-push-images (no `needs`): the + # GitHub Release and its notes do not depend on the images, and the + # heavy model-service-gpu image has historically hit the 90 min ceiling + # and been cancelled. Coupling the Release to that build is exactly how + # the v0.4.3 Release came to be missing. Keeping this job independent + # guarantees a tag always publishes a Release even if an image times out. + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Create or update the GitHub Release from the changelog + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + NOTES="$(mktemp)" + # Extract the "## [VERSION]" section body from CHANGELOG.md (from the + # version heading up to the next "## [" heading). `index(...)==1` + # anchors on a literal heading so version numbers are not treated as + # regex (the dots would otherwise match any character). + awk -v ver="## [$VERSION]" \ + 'index($0, ver) == 1 {f=1; next} f && /^## \[/ {exit} f' \ + CHANGELOG.md > "$NOTES" + if [ ! -s "$NOTES" ]; then + echo "::warning::No CHANGELOG section for $VERSION; using auto-generated notes" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" --title "$TAG" + else + gh release create "$TAG" --title "$TAG" --generate-notes --verify-tag + fi + exit 0 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; updating its notes from the changelog" + gh release edit "$TAG" --title "$TAG" --notes-file "$NOTES" + else + echo "Creating release $TAG from the changelog" + gh release create "$TAG" --title "$TAG" --notes-file "$NOTES" --verify-tag + fi + update-deployment: name: Update Deployment Manifests runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index eb59ce4c..54cb5ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. +### Fixed + +#### Release Workflow Now Publishes GitHub Releases + +- `release.yml` gains a `create-release` job that, on every `v*.*.*` tag push, extracts the matching `CHANGELOG.md` section and creates or updates the GitHub Release. Previously the workflow only built and pushed Docker images, so a tag published no GitHub Release unless one was made by hand (which is how v0.4.3's Release came to be missing). The job is deliberately independent of the image build, so a Release is published even when the heavy `model-service-gpu` image hits the 90-minute job timeout. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index c5d4cbc8..a5783508 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -23,6 +23,12 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. +### Fixed + +#### Release Workflow Now Publishes GitHub Releases + +- `release.yml` gains a `create-release` job that, on every `v*.*.*` tag push, extracts the matching `CHANGELOG.md` section and creates or updates the GitHub Release. Previously the workflow only built and pushed Docker images, so a tag published no GitHub Release unless one was made by hand (which is how v0.4.3's Release came to be missing). The job is deliberately independent of the image build, so a Release is published even when the heavy `model-service-gpu` image hits the 90-minute job timeout. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. From d0c633b9783d7eea4f9f38744e6538f47e0168d2 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 14:52:38 -0400 Subject: [PATCH 03/42] Centralize all frontend environment access into a single typed, deep-frozen annotation-tool/src/config.ts that is the only file permitted to read import.meta.env (enforced by an ESLint no-restricted-syntax ban), resolve the mode and demo build flags into one derived deploymentMode object with documented precedence, remove the two import.meta cast hacks and the dead VITE_MODEL_SERVICE_URL declaration, normalize the inconsistent VITE_E2E checks, and keep the three tree-shaking-critical MSW guards inline so Rollup still drops the mocks subtree from normal production builds. --- CHANGELOG.md | 5 + annotation-tool/.eslintrc.cjs | 28 ++ annotation-tool/src/App.tsx | 5 +- annotation-tool/src/api/client.ts | 12 +- .../src/components/admin/AdminPanel.tsx | 3 +- .../annotation/AnnotationWorkspace.tsx | 3 +- .../src/components/auth/LoginPage.tsx | 3 +- .../src/components/ontology/GlossEditor.tsx | 3 +- annotation-tool/src/components/ui/dialog.tsx | 3 +- .../components/video/VideoSummaryEditor.tsx | 3 +- .../workspaces/OntologyWorkspace.tsx | 3 +- annotation-tool/src/config.ts | 246 ++++++++++++++++++ annotation-tool/src/demo/api.ts | 10 +- annotation-tool/src/demo/config.ts | 16 +- annotation-tool/src/main.tsx | 23 +- annotation-tool/src/mocks/tourDemo/browser.ts | 7 +- .../src/mocks/tourDemo/handlers.ts | 4 + .../src/services/wikidataConfig.ts | 10 +- .../src/tours/engine/TourRunner.tsx | 3 +- .../src/tours/menu/TourProvider.tsx | 5 +- annotation-tool/src/utils/seedTestData.ts | 3 +- annotation-tool/src/vite-env.d.ts | 2 - docs/docs/project/changelog.md | 5 + 23 files changed, 355 insertions(+), 50 deletions(-) create mode 100644 annotation-tool/src/config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54cb5ab8..10f1049f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. +#### Frontend Configuration Single-Source-of-Truth + +- All frontend environment access is centralized in one typed module, `annotation-tool/src/config.ts`, the only file permitted to read `import.meta.env` (enforced by an ESLint `no-restricted-syntax` rule). It exposes a deep-frozen `config` grouped by concern (`env`, `api`, `wikidata`, `deploymentMode`, `testData`, `demo`) with all defaults and coercions centralized, removes the two `import.meta as unknown` cast hacks in `demo/config.ts` and `demo/api.ts`, drops the dead `VITE_MODEL_SERVICE_URL` declaration, and normalizes the previously-inconsistent `VITE_E2E` checks to `=== '1'`. +- The mode/demo build flags resolve into one derived `config.deploymentMode` with a typed `kind` (`normal` / `public-demo` / `tour-demo` / `legacy-demo-shell`) and documented precedence (legacy DemoShell short-circuits; `VITE_DEMO_PUBLIC` suppresses the runtime MSW worker even when the tour-demo mocks are built). The three tree-shaking-critical guards (the `VITE_TOUR_DEMO` MSW dynamic-import gates and the `VITE_E2E` fixture gate) deliberately keep their inline literal comparison so Rollup still drops the `mocks/tourDemo` subtree from normal production builds, verified by a build check. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/annotation-tool/.eslintrc.cjs b/annotation-tool/.eslintrc.cjs index cf9512f1..65aa5f38 100644 --- a/annotation-tool/.eslintrc.cjs +++ b/annotation-tool/.eslintrc.cjs @@ -16,6 +16,25 @@ module.exports = { ], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + // Single-source-of-truth for env: `import.meta.env` (VITE_* vars and the + // Vite built-ins PROD/DEV/MODE/BASE_URL) may only be read in src/config.ts. + // The selector targets the `.env` member of the `import.meta` MetaProperty, + // catching `import.meta.env.VITE_X`, `import.meta.env.PROD`, etc. Exempted: + // src/config.ts (the source of truth), src/vite-env.d.ts (the type + // declaration), and test files. The two MSW tree-shaking guards + // (main.tsx, mocks/tourDemo/browser.ts) and the data-layer fixture gate + // (mocks/tourDemo/handlers.ts) keep an inline read behind a scoped + // `eslint-disable-next-line no-restricted-syntax` so Rollup can statically + // fold them; routing those through config would defeat tree-shaking. + 'no-restricted-syntax': [ + 'error', + { + selector: + "MemberExpression[object.type='MetaProperty'][property.name='env']", + message: + 'Read env via the typed `config` object from src/config.ts, not `import.meta.env`. config.ts is the only module permitted to read import.meta.env. (The MSW tree-shaking guards keep a scoped eslint-disable inline.)', + }, + ], // Demo-layer isolation: product code (anywhere under src/ that is // not itself the demo layer) and tours code may not import from // src/demo/. The demo layer can import from anywhere; product code @@ -38,8 +57,17 @@ module.exports = { files: ['**/*.spec.ts', '**/*.test.ts', '**/*.spec.tsx', '**/*.test.tsx'], rules: { '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_|^test|^page$|^db$|^annotationWorkspace$|^ontologyWorkspace$|^objectWorkspace$|^videoBrowser$' }], + // Tests may stub/inspect import.meta.env directly (e.g. + // services/wikidataConfig.test.ts bulk-manipulates it). + 'no-restricted-syntax': 'off', }, }, + { + // The single source of truth and the Vite ambient type declaration are + // the only non-test files permitted to reference import.meta.env. + files: ['src/config.ts', 'src/vite-env.d.ts'], + rules: { 'no-restricted-syntax': 'off' }, + }, { // The demo layer itself is allowed to import from anywhere, // including its own internals. diff --git a/annotation-tool/src/App.tsx b/annotation-tool/src/App.tsx index 65065808..b4eba1f7 100644 --- a/annotation-tool/src/App.tsx +++ b/annotation-tool/src/App.tsx @@ -4,6 +4,7 @@ import { Spinner } from '@/components/ui/spinner' import Layout from '@components/layout/Layout' import VideoBrowser from '@components/video/VideoBrowser' import { TourCataloguePage } from '@/pages/TourCataloguePage' +import { config } from '@/config' /** * On demo.fovea.video the SPA is built with VITE_DEMO_PUBLIC=1. The @@ -22,7 +23,7 @@ import { TourCataloguePage } from '@/pages/TourCataloguePage' * first paint; the perf cost of the larger initial download is * the right trade-off for the demo's reliability. */ -const DEMO_PUBLIC = import.meta.env.VITE_DEMO_PUBLIC === '1' +const DEMO_PUBLIC = config.deploymentMode.publicBooth import AnnotationWorkspace from '@components/annotation/AnnotationWorkspace' import OntologyWorkspace from './components/workspaces/OntologyWorkspace' import ObjectWorkspace from './components/workspaces/ObjectWorkspace' @@ -183,7 +184,7 @@ function App() { // correct persona were already on the wire. useEffect(() => { if (personas.length === 0 || selectedPersonaId) return - const isDemoPublic = import.meta.env.VITE_DEMO_PUBLIC === '1' + const isDemoPublic = config.deploymentMode.publicBooth const preferred = isDemoPublic ? personas.find((p) => p.name === 'Automated') ?? personas[0] : personas[0] diff --git a/annotation-tool/src/api/client.ts b/annotation-tool/src/api/client.ts index bee6a3b7..64dd3053 100644 --- a/annotation-tool/src/api/client.ts +++ b/annotation-tool/src/api/client.ts @@ -7,6 +7,7 @@ import axios, { AxiosInstance, AxiosError } from 'axios' import { GlossItem } from '@models/types' import { TranscriptJson } from '@components/video/types' import { logError } from '@services/errorLogging' +import { config as appConfig } from '@/config' /** * Video summary data structure returned by the API. @@ -724,14 +725,7 @@ export interface ApiClientConfig { * the synchronous calls return in seconds), the default mirrors the * backend's prod default ceiling of 60_000 ms. */ -const INFERENCE_TIMEOUT_MS: number = (() => { - const raw = import.meta.env.VITE_INFERENCE_TIMEOUT_MS as string | undefined - if (typeof raw === 'string' && raw.length > 0) { - const parsed = Number.parseInt(raw, 10) - if (Number.isFinite(parsed) && parsed > 0) return parsed - } - return 60_000 -})() +const INFERENCE_TIMEOUT_MS: number = appConfig.api.inferenceTimeoutMs /** * HTTP client for backend API communication. @@ -753,7 +747,7 @@ export class ApiClient { // Use relative URLs by default to work with Vite proxy // This ensures SSH port forwarding works when only port 3000 is forwarded // The Vite proxy forwards /api/* to the backend server - const baseURL = config.baseURL ?? import.meta.env.VITE_API_URL ?? '' + const baseURL = config.baseURL ?? appConfig.api.url this.client = axios.create({ baseURL, diff --git a/annotation-tool/src/components/admin/AdminPanel.tsx b/annotation-tool/src/components/admin/AdminPanel.tsx index 6b16b6df..2d5d917b 100644 --- a/annotation-tool/src/components/admin/AdminPanel.tsx +++ b/annotation-tool/src/components/admin/AdminPanel.tsx @@ -45,6 +45,7 @@ import { PermissionsPage } from './PermissionsPage' import { ModelManagementPage } from './ModelManagementPage' import { SystemConfigPanel } from './SystemConfigPanel' import { DemoAdminPanel } from './DemoAdminPanel' +import { config } from '@/config' /** * Admin panel component. @@ -61,7 +62,7 @@ export function AdminPanel(): JSX.Element { // preview for any visitor — server-side mutation guards still // reject every POST/PUT/PATCH/DELETE from a non-admin so the // viewable preview is genuinely read-only. - const isDemoPublic = import.meta.env.VITE_DEMO_PUBLIC === '1' + const isDemoPublic = config.deploymentMode.publicBooth if (!isDemoPublic && !currentUser?.isAdmin) { return } diff --git a/annotation-tool/src/components/annotation/AnnotationWorkspace.tsx b/annotation-tool/src/components/annotation/AnnotationWorkspace.tsx index ebb22648..b3d57840 100644 --- a/annotation-tool/src/components/annotation/AnnotationWorkspace.tsx +++ b/annotation-tool/src/components/annotation/AnnotationWorkspace.tsx @@ -71,6 +71,7 @@ import { useModelConfig } from '@store/queries/useModelConfig' import { TimelineComponent } from './TimelineComponent' import { useCommands, useCommandContext } from '@hooks/commands' import { useAutoSave, SaveStatusIndicator } from '@hooks/data' +import { config } from '@/config' const DRAWER_WIDTH = 300 @@ -237,7 +238,7 @@ export default function AnnotationWorkspace() { // FIRST annotation (no seeded rows) so the empty-canvas narration // still lines up with reality, and only runs under VITE_DEMO_PUBLIC. useEffect(() => { - if (import.meta.env.VITE_DEMO_PUBLIC !== '1') return + if (!config.deploymentMode.publicBooth) return if (videoAnnotations.length === 0) return const fixtureRow = videoAnnotations.find((a) => { // The backend's `source` flag rides through the API client's diff --git a/annotation-tool/src/components/auth/LoginPage.tsx b/annotation-tool/src/components/auth/LoginPage.tsx index 8a05caea..0d1a3c4f 100644 --- a/annotation-tool/src/components/auth/LoginPage.tsx +++ b/annotation-tool/src/components/auth/LoginPage.tsx @@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useAuth } from '@hooks/auth' import { useAuthStore } from '@store/zustand/authStore' +import { config } from '@/config' /** * Login page component. @@ -48,7 +49,7 @@ export function LoginPage(): JSX.Element { try { await login(username, password, rememberMe) const params = new URLSearchParams(location.search) - const defaultDest = import.meta.env.VITE_DEMO_PUBLIC === '1' ? '/app' : '/' + const defaultDest = config.deploymentMode.publicBooth ? '/app' : '/' const from = params.get('redirect') || defaultDest navigate(from, { replace: true }) } catch (err) { diff --git a/annotation-tool/src/components/ontology/GlossEditor.tsx b/annotation-tool/src/components/ontology/GlossEditor.tsx index 3333d334..43afc366 100644 --- a/annotation-tool/src/components/ontology/GlossEditor.tsx +++ b/annotation-tool/src/components/ontology/GlossEditor.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' import { usePersonaOntology, useWorld, useAnnotations } from '@store/queries' import { GlossItem, TimeInstant, getAnnotationTimeBounds, Claim } from '@models/types' +import { config } from '@/config' interface GlossEditorProps { gloss: GlossItem[] @@ -619,7 +620,7 @@ export default function GlossEditor({ // document-level mousedown close is wholesale suppressed for // VITE_DEMO_PUBLIC=1. useEffect(() => { - if (import.meta.env.VITE_DEMO_PUBLIC === '1') return + if (config.deploymentMode.publicBooth) return const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node | null if (!target) return diff --git a/annotation-tool/src/components/ui/dialog.tsx b/annotation-tool/src/components/ui/dialog.tsx index 7e2ffabf..8abc27aa 100644 --- a/annotation-tool/src/components/ui/dialog.tsx +++ b/annotation-tool/src/components/ui/dialog.tsx @@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { config } from "@/config" import { XIcon } from "lucide-react" function Dialog({ onOpenChange, ...props }: DialogPrimitive.Root.Props) { @@ -18,7 +19,7 @@ function Dialog({ onOpenChange, ...props }: DialogPrimitive.Root.Props) { // button (closePress) or Escape key (escapeKey); the imperative // setOpen(false) calls from the workspace (closePress / imperative) // continue to work. - const demoPublic = import.meta.env.VITE_DEMO_PUBLIC === '1' + const demoPublic = config.deploymentMode.publicBooth const handleOpenChange: DialogPrimitive.Root.Props['onOpenChange'] = (open, details) => { if (demoPublic && open === false) { const reason = details?.reason diff --git a/annotation-tool/src/components/video/VideoSummaryEditor.tsx b/annotation-tool/src/components/video/VideoSummaryEditor.tsx index 1fd337ac..44d95c75 100644 --- a/annotation-tool/src/components/video/VideoSummaryEditor.tsx +++ b/annotation-tool/src/components/video/VideoSummaryEditor.tsx @@ -52,6 +52,7 @@ import { SaveStatusIndicator } from '@components/shared/SaveStatusIndicator' import { useAutoSave } from '@hooks/data/useAutoSave' import { GlossItem, Claim, ClaimExtractionConfig, ClaimTextSpan, UpdateClaimRequest } from '@models/types' import { logError, logWarning } from '@services/errorLogging' +import { config } from '@/config' interface VideoSummaryEditorProps { videoId: string @@ -482,7 +483,7 @@ const VideoSummaryEditor = forwardRef diff --git a/annotation-tool/src/components/workspaces/OntologyWorkspace.tsx b/annotation-tool/src/components/workspaces/OntologyWorkspace.tsx index 0b433ab5..09f77d3b 100644 --- a/annotation-tool/src/components/workspaces/OntologyWorkspace.tsx +++ b/annotation-tool/src/components/workspaces/OntologyWorkspace.tsx @@ -47,6 +47,7 @@ import { useModelConfig } from '@store/queries/useModelConfig' import { EntityType, RoleType, EventType, RelationType, GlossItem } from '@models/types' import { generateId } from '@utils/uuid' import { buildDuplicateOntologyType } from './duplicateOntologyType' +import { config } from '@/config' /** * Union type for any ontology type item that can be filtered/edited. @@ -165,7 +166,7 @@ export default function OntologyWorkspace() { // an admin expects new users to pick a persona deliberately. useEffect(() => { if ( - import.meta.env.VITE_DEMO_PUBLIC === '1' && + config.deploymentMode.publicBooth && !selectedPersonaId && personas.length > 0 ) { diff --git a/annotation-tool/src/config.ts b/annotation-tool/src/config.ts new file mode 100644 index 00000000..c8099b24 --- /dev/null +++ b/annotation-tool/src/config.ts @@ -0,0 +1,246 @@ +/** + * Single source of truth for every frontend environment-derived setting. + * + * This is the ONLY module in `annotation-tool/src` permitted to read + * `import.meta.env`. An ESLint `no-restricted-syntax` rule bans + * `import.meta.env` everywhere else and points offenders here, so a reader + * can answer "what does this build do when `VITE_X` is unset?" by looking in + * exactly one place. + * + * Why module-eval constants (not lazy getters): Vite inlines every `VITE_*` + * reference and the Vite built-ins (`PROD`, `DEV`, `MODE`, `BASE_URL`) at + * BUILD time, replacing each `import.meta.env.X` with a literal. There is no + * runtime env to re-read, so the values are computed once here as plain + * constants. This also means `config` is fully populated at import, before + * any side effect runs, so it is safe to import first in `main.tsx` and to + * read at module scope (e.g. `App.tsx` reads `config.deploymentMode.publicBooth` + * as a module-level const). + * + * No side effects on import: importing this module only reads inlined + * literals; it starts no workers, opens no sockets, and mutates no globals. + * + * One read deliberately stays OUT of this module: the MSW tour-demo + * dynamic-import guard `import.meta.env.VITE_TOUR_DEMO === '1'` in + * `main.tsx` and `mocks/tourDemo/browser.ts` is kept inline so Rollup can + * statically fold it and tree-shake the entire `src/mocks/tourDemo` subtree + * out of normal production builds. Routing that specific comparison through a + * cross-module property access would defeat the static analysis and ship the + * mocks (and their tour content) in every bundle. The same boolean is also + * exposed here as `config.deploymentMode.tourDemoMocksBuilt` for any + * non-tree-shaking-critical reference. + * + * @module + */ + +/** + * Deployment-mode taxonomy. + * + * - `legacy-demo-shell`: the older cvpr-demo path; mounts `` and + * short-circuits the normal app tree. Gated by `VITE_FOVEA_DEMO_MODE`. + * - `public-demo`: the public catalogue deployment (demo.fovea.video) that + * mounts `TourCataloguePage` at `/` and talks to a real backend. Gated by + * `VITE_DEMO_PUBLIC=1`. + * - `tour-demo`: a build that compiled in the MSW mock-model-service subtree + * (`VITE_TOUR_DEMO=1`) without `VITE_DEMO_PUBLIC` (E2E / local preview). + * - `normal`: a stock self-hosted build with none of the above set. + */ +export type DeploymentModeKind = + | 'normal' + | 'public-demo' + | 'tour-demo' + | 'legacy-demo-shell' + +/** + * Vite's built-in environment flags, mirrored as a typed namespace. + * + * `isProd`/`isDev` come from Vite's `PROD`/`DEV`; `mode` is the Vite mode + * string (`'production'`, `'development'`, `'test'`, ...); `baseUrl` is the + * app's public base path used to resolve the MSW service-worker URL. + */ +export interface EnvConfig { + /** True in a production build (`import.meta.env.PROD`). */ + readonly isProd: boolean + /** True in a development build (`import.meta.env.DEV`). */ + readonly isDev: boolean + /** The Vite mode string (`import.meta.env.MODE`). */ + readonly mode: string + /** The app's public base path (`import.meta.env.BASE_URL`). */ + readonly baseUrl: string +} + +/** Backend API client configuration. */ +export interface ApiConfig { + /** + * Base URL for the API client. Empty string means relative URLs, which + * work with the Vite dev proxy and same-origin production serving. + */ + readonly url: string + /** + * Per-call axios timeout (ms) for model-service-bound requests. Defaults + * to 60000 when `VITE_INFERENCE_TIMEOUT_MS` is unset or non-positive. + */ + readonly inferenceTimeoutMs: number +} + +/** Build-time Wikidata/Wikibase overrides. */ +export interface WikidataConfig { + /** + * `VITE_WIKIDATA_URL`, or undefined when unset. When set, takes precedence + * over the runtime `/api/config` value in `services/wikidataConfig.ts`. + */ + readonly url: string | undefined + /** + * `VITE_WIKIDATA_MODE` narrowed to `'online'`/`'offline'`, or undefined + * when unset or not one of those two literals. + */ + readonly mode: 'online' | 'offline' | undefined +} + +/** + * Resolved deployment mode plus the raw flags it derives from. + * + * Precedence (documented because the flags interact): + * - `legacyDemoShell` short-circuits the whole app tree (`main.tsx` mounts + * `` and nothing else), so it wins when set. + * - Otherwise `publicBooth` (`VITE_DEMO_PUBLIC=1`) selects `public-demo`. + * A public-demo build talks to a REAL backend, so it SUPPRESSES the + * runtime MSW worker start even when `tourDemoMocksBuilt` is also true + * (the override at `main.tsx`'s `maybeStartTourDemoMocking`). + * - Otherwise `tourDemoMocksBuilt` (`VITE_TOUR_DEMO=1`) selects `tour-demo`. + * - Otherwise `normal`. + */ +export interface DeploymentModeConfig { + /** Resolved mode label per the precedence above. */ + readonly kind: DeploymentModeKind + /** `VITE_DEMO_PUBLIC === '1'`: the public catalogue booth build. */ + readonly publicBooth: boolean + /** + * `VITE_TOUR_DEMO === '1'`: the MSW mock-model-service subtree was compiled + * in. Exposed for reference only; the tree-shaking-critical guard stays + * inline at the two MSW dynamic-import sites. + */ + readonly tourDemoMocksBuilt: boolean + /** `VITE_FOVEA_DEMO_MODE` is `'true'` or `'1'`: mount the legacy DemoShell. */ + readonly legacyDemoShell: boolean + /** `VITE_E2E === '1'`: running under the Playwright E2E harness. */ + readonly e2e: boolean +} + +/** Test-data seeding toggle. */ +export interface TestDataConfig { + /** `VITE_ENABLE_TEST_DATA === 'true'`: allow seeding demo/test rows. */ + readonly enabled: boolean +} + +/** Legacy demo-shell secrets and toggles. */ +export interface LegacyDemoConfig { + /** + * `VITE_FOVEA_DEMO_SEED_TOKEN`, or undefined when unset. Local-run seed + * token forwarded as `X-Demo-Seed-Token`; production injects this header + * at the edge instead, so it is normally unset in the browser bundle. + */ + readonly seedToken: string | undefined +} + +// --------------------------------------------------------------------------- +// Raw build-time reads. The ONLY `import.meta.env` accesses in src outside the +// two inline MSW tree-shaking guards. Each is inlined to a literal by Vite. +// --------------------------------------------------------------------------- + +const rawDemoPublic = import.meta.env.VITE_DEMO_PUBLIC +const rawTourDemo = import.meta.env.VITE_TOUR_DEMO +const rawLegacyDemoMode = import.meta.env.VITE_FOVEA_DEMO_MODE +const rawE2e = import.meta.env.VITE_E2E +const rawApiUrl = import.meta.env.VITE_API_URL +const rawInferenceTimeoutMs = import.meta.env.VITE_INFERENCE_TIMEOUT_MS +const rawWikidataUrl = import.meta.env.VITE_WIKIDATA_URL +const rawWikidataMode = import.meta.env.VITE_WIKIDATA_MODE +const rawEnableTestData = import.meta.env.VITE_ENABLE_TEST_DATA +const rawSeedToken = import.meta.env.VITE_FOVEA_DEMO_SEED_TOKEN + +// --------------------------------------------------------------------------- +// Coercions. Centralized so every consumer shares one interpretation. +// --------------------------------------------------------------------------- + +/** Default inference timeout (ms); mirrors the backend's prod ceiling. */ +const DEFAULT_INFERENCE_TIMEOUT_MS = 60_000 + +function resolveInferenceTimeoutMs(raw: string | undefined): number { + if (typeof raw === 'string' && raw.length > 0) { + const parsed = Number.parseInt(raw, 10) + if (Number.isFinite(parsed) && parsed > 0) return parsed + } + return DEFAULT_INFERENCE_TIMEOUT_MS +} + +function resolveWikidataMode( + raw: string | undefined, +): 'online' | 'offline' | undefined { + return raw === 'online' || raw === 'offline' ? raw : undefined +} + +const publicBooth = rawDemoPublic === '1' +const tourDemoMocksBuilt = rawTourDemo === '1' +const legacyDemoShell = rawLegacyDemoMode === 'true' || rawLegacyDemoMode === '1' +// `VITE_E2E` is normalized to the `=== '1'` form. The harness always sets +// `VITE_E2E=1`, so this agrees with the prior truthy checks while being the +// safe canonical comparison. +const e2e = rawE2e === '1' + +function resolveDeploymentModeKind(): DeploymentModeKind { + if (legacyDemoShell) return 'legacy-demo-shell' + if (publicBooth) return 'public-demo' + if (tourDemoMocksBuilt) return 'tour-demo' + return 'normal' +} + +/** + * The frozen, build-time-resolved frontend configuration. + * + * Grouped by concern: `env` (Vite built-ins), `api`, `wikidata`, + * `deploymentMode`, `testData`, and `demo` (legacy demo-shell secrets). + * + * @example + * ```typescript + * import { config } from '@/config' + * + * if (config.deploymentMode.publicBooth) { + * // booth-only behavior + * } + * const timeout = config.api.inferenceTimeoutMs + * ``` + */ +export const config = Object.freeze({ + env: Object.freeze({ + isProd: import.meta.env.PROD, + isDev: import.meta.env.DEV, + mode: import.meta.env.MODE, + baseUrl: import.meta.env.BASE_URL, + }), + + api: Object.freeze({ + url: rawApiUrl ?? '', + inferenceTimeoutMs: resolveInferenceTimeoutMs(rawInferenceTimeoutMs), + }), + + wikidata: Object.freeze({ + url: rawWikidataUrl, + mode: resolveWikidataMode(rawWikidataMode), + }), + + deploymentMode: Object.freeze({ + kind: resolveDeploymentModeKind(), + publicBooth, + tourDemoMocksBuilt, + legacyDemoShell, + e2e, + }), + + testData: Object.freeze({ + enabled: rawEnableTestData === 'true', + }), + + demo: Object.freeze({ + seedToken: rawSeedToken, + }), +}) diff --git a/annotation-tool/src/demo/api.ts b/annotation-tool/src/demo/api.ts index eb1b8e1a..7bf2532a 100644 --- a/annotation-tool/src/demo/api.ts +++ b/annotation-tool/src/demo/api.ts @@ -7,6 +7,8 @@ * forbidden by ESLint from importing this file. */ +import { config } from '@/config' + export interface AnonymousSession { userId: string sessionToken: string @@ -48,11 +50,11 @@ export async function seedFixture(args: { }): Promise<{ seeded: string[] }> { // Production demo deployments inject X-Demo-Seed-Token at the edge so // the token never reaches client JS. Local-run reads it from a Vite - // env var instead — set VITE_FOVEA_DEMO_SEED_TOKEN to match the - // backend's FOVEA_DEMO_SEED_TOKEN in run-demo-local.sh. - const env = (import.meta as unknown as { env?: Record }).env ?? {} + // env var instead (resolved in src/config.ts) — set + // VITE_FOVEA_DEMO_SEED_TOKEN to match the backend's + // FOVEA_DEMO_SEED_TOKEN in run-demo-local.sh. const headers: Record = { 'Content-Type': 'application/json' } - const localToken = env.VITE_FOVEA_DEMO_SEED_TOKEN + const localToken = config.demo.seedToken if (localToken) headers['X-Demo-Seed-Token'] = localToken const res = await fetch('/api/demo/seed', { diff --git a/annotation-tool/src/demo/config.ts b/annotation-tool/src/demo/config.ts index a2aa0585..cdf36bc5 100644 --- a/annotation-tool/src/demo/config.ts +++ b/annotation-tool/src/demo/config.ts @@ -9,16 +9,14 @@ * frontend; the in-app tour menu and tour engine are product features * that ship in every deployment, demo or not (see plan §6.7). * - * NOTE: by convention, Vite exposes `import.meta.env.VITE_*` vars to the - * client bundle. Setting `VITE_FOVEA_DEMO_MODE=true` at build time - * compiles in the demo routes; otherwise the demo module tree is - * tree-shaken out by the dynamic import in App.tsx. + * NOTE: by convention, Vite exposes `VITE_*` vars to the client bundle. + * Setting `VITE_FOVEA_DEMO_MODE=true` at build time compiles in the demo + * routes; otherwise the demo module tree is tree-shaken out by the dynamic + * import in App.tsx. The flag is resolved centrally in `src/config.ts`. */ +import { config } from '@/config' + export function isDemoModeEnabled(): boolean { - // Cast through unknown: import.meta.env's shape isn't typed by Vite - // without an ambient declaration, and we don't want to add one just - // for two strings. - const env = (import.meta as unknown as { env?: Record }).env ?? {} - return env.VITE_FOVEA_DEMO_MODE === 'true' || env.VITE_FOVEA_DEMO_MODE === '1' + return config.deploymentMode.legacyDemoShell } diff --git a/annotation-tool/src/main.tsx b/annotation-tool/src/main.tsx index 7c190a35..ea300e08 100644 --- a/annotation-tool/src/main.tsx +++ b/annotation-tool/src/main.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from 'next-themes' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import App from './App' +import { config } from '@/config' import { DemoShell } from './demo/DemoShell' import { isDemoModeEnabled } from './demo/config' import { TourProvider } from './tours' @@ -23,15 +24,15 @@ import { initErrorLogging } from '@services/errorLogging' // Initialize tracing first - must be before any other code that might make network requests initTracing({ - enabled: import.meta.env.PROD, - sampleRate: import.meta.env.PROD ? 0.2 : 1.0, // 20% in prod, 100% in dev + enabled: config.env.isProd, + sampleRate: config.env.isProd ? 0.2 : 1.0, // 20% in prod, 100% in dev }) // Initialize error logging with backend reporting initErrorLogging({ - enabled: import.meta.env.PROD, - sampleRate: import.meta.env.PROD ? 0.2 : 1.0, // 20% in prod, 100% in dev - consoleLogging: import.meta.env.DEV, + enabled: config.env.isProd, + sampleRate: config.env.isProd ? 0.2 : 1.0, // 20% in prod, 100% in dev + consoleLogging: config.env.isDev, }) // Initialize commands synchronously so they're available when component effects run @@ -103,6 +104,12 @@ async function isRealSignedInUser(): Promise { * context, breaking admin navigation on demo.fovea.video. */ async function maybeStartTourDemoMocking(): Promise { + // Kept INLINE (not routed through config): Rollup statically folds this + // literal comparison to tree-shake the entire `src/mocks/tourDemo` subtree + // (and its tour content) out of normal production builds. Replacing it with + // a cross-module config property access would defeat that analysis and ship + // the mocks in every bundle. See src/config.ts for the rationale. + // eslint-disable-next-line no-restricted-syntax if (import.meta.env.VITE_TOUR_DEMO !== '1') return // VITE_DEMO_PUBLIC builds run against a REAL backend that already // serves /api/auth/me, /api/personas, /api/videos, /api/world etc., @@ -118,7 +125,7 @@ async function maybeStartTourDemoMocking(): Promise { // without the worker getting in the way. Stock VITE_TOUR_DEMO=1 // builds without DEMO_PUBLIC (E2E / dev) still install the worker // because those flows need the mocks. - if (import.meta.env.VITE_DEMO_PUBLIC === '1') { + if (config.deploymentMode.publicBooth) { console.info( '[tour-demo] DEMO_PUBLIC build talks to a real backend; MSW worker NOT started.', ) @@ -163,7 +170,7 @@ async function maybeStartTourDemoMocking(): Promise { * Rollup tree-shakes the request out of those bundles. */ async function maybeBootstrapDemoSession(): Promise { - if (import.meta.env.VITE_DEMO_PUBLIC !== '1') return + if (!config.deploymentMode.publicBooth) return try { // Empty JSON body required — Fastify's defaultJsonParser rejects // a POST with Content-Type: application/json but no body @@ -321,7 +328,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( test/E2E (NODE_ENV=test or VITE_E2E=1) so Playwright can hit the FABs. */} - {import.meta.env.MODE !== 'test' && !import.meta.env.VITE_E2E && ( + {config.env.mode !== 'test' && !config.deploymentMode.e2e && ( )} diff --git a/annotation-tool/src/mocks/tourDemo/browser.ts b/annotation-tool/src/mocks/tourDemo/browser.ts index 008cd2c1..05a5d145 100644 --- a/annotation-tool/src/mocks/tourDemo/browser.ts +++ b/annotation-tool/src/mocks/tourDemo/browser.ts @@ -34,10 +34,15 @@ import { setupWorker } from 'msw/browser' import type { SetupWorker } from 'msw/browser' import { createTourDemoHandlers } from './handlers' import { loadTourContentBundle } from '@/tours/content/loader' +import { config } from '@/config' let activeWorker: SetupWorker | null = null export async function startTourDemoWorker(): Promise { + // Kept INLINE (not routed through config): this literal comparison is what + // lets Rollup tree-shake the entire `src/mocks/tourDemo` subtree out of + // normal production builds. See src/config.ts for the rationale. + // eslint-disable-next-line no-restricted-syntax if (import.meta.env.VITE_TOUR_DEMO !== '1') return // Load the bundle eagerly. If this fails we still want the tour // page to mount and emit a banner via the existing TourProvider @@ -58,7 +63,7 @@ export async function startTourDemoWorker(): Promise { await activeWorker.start({ onUnhandledRequest: 'bypass', serviceWorker: { - url: `${import.meta.env.BASE_URL}mockServiceWorker.js`, + url: `${config.env.baseUrl}mockServiceWorker.js`, }, }) console.info('[tour-demo] MSW worker active; model-service calls are mocked.') diff --git a/annotation-tool/src/mocks/tourDemo/handlers.ts b/annotation-tool/src/mocks/tourDemo/handlers.ts index 3174b35d..ddd86097 100644 --- a/annotation-tool/src/mocks/tourDemo/handlers.ts +++ b/annotation-tool/src/mocks/tourDemo/handlers.ts @@ -380,6 +380,10 @@ export function createTourDemoHandlers( // set VITE_E2E=1 — keeping the prod demo path honest while still // letting the rigorous walkthrough spec exercise the engine // against a stable, backend-free fixture. + // Kept INLINE (not routed through config): this literal comparison is what + // lets Vite statically drop the `dataLayerHandlers` array (and its fixture + // data) from any tour-demo build that does not set VITE_E2E=1. + // eslint-disable-next-line no-restricted-syntax const includeDataLayer = import.meta.env.VITE_E2E === '1' return [ ...(includeDataLayer ? dataLayerHandlers : []), diff --git a/annotation-tool/src/services/wikidataConfig.ts b/annotation-tool/src/services/wikidataConfig.ts index d37047d2..7d4c94e5 100644 --- a/annotation-tool/src/services/wikidataConfig.ts +++ b/annotation-tool/src/services/wikidataConfig.ts @@ -7,6 +7,8 @@ * 3. Default public Wikidata API */ +import { config as appConfig } from '@/config' + /** Default public Wikidata API endpoint */ const DEFAULT_WIKIDATA_URL = 'https://www.wikidata.org/w/api.php' @@ -85,7 +87,7 @@ async function fetchRuntimeConfig(): Promise { */ export async function getWikidataUrl(): Promise { // Build-time env var takes precedence - const envUrl = import.meta.env.VITE_WIKIDATA_URL + const envUrl = appConfig.wikidata.url if (envUrl) { return envUrl } @@ -107,7 +109,7 @@ export async function getWikidataUrl(): Promise { */ export async function getWikidataMode(): Promise<'online' | 'offline'> { // Build-time env var takes precedence - const envMode = import.meta.env.VITE_WIKIDATA_MODE + const envMode = appConfig.wikidata.mode if (envMode === 'online' || envMode === 'offline') { return envMode } @@ -124,8 +126,8 @@ export async function getWikidataMode(): Promise<'online' | 'offline'> { * @returns Promise resolving to WikidataConfig */ export async function getWikidataConfig(): Promise { - const envUrl = import.meta.env.VITE_WIKIDATA_URL - const envMode = import.meta.env.VITE_WIKIDATA_MODE + const envUrl = appConfig.wikidata.url + const envMode = appConfig.wikidata.mode // If both env vars are set, use them directly (but still fetch for idMapping and allowExternalLinks) if (envUrl && (envMode === 'online' || envMode === 'offline')) { diff --git a/annotation-tool/src/tours/engine/TourRunner.tsx b/annotation-tool/src/tours/engine/TourRunner.tsx index 019ae3f9..96b8fcfb 100644 --- a/annotation-tool/src/tours/engine/TourRunner.tsx +++ b/annotation-tool/src/tours/engine/TourRunner.tsx @@ -34,6 +34,7 @@ import { StepCard } from './StepCard' import { waitForAnchor } from './waitForAnchor' import { simulateAction } from './simulateAction' import type { TourScript, TourStep } from './types' +import { config } from '@/config' /** * Resolve a route template like `/app/annotate/:videoId` against a @@ -278,7 +279,7 @@ export function TourRunner({ // value) is real and the NEXT step's anchor mounts against // it. Stock builds skip simulation entirely — production // tours preserve the visitor-performs-the-action shape. - const demoPublic = import.meta.env.VITE_DEMO_PUBLIC === '1' + const demoPublic = config.deploymentMode.publicBooth // Resolve the anchor with up to one retry of the revealBy chain. // The first pass clicks the openers and waits for the target // anchor; if waitForAnchor times out (deep-stacked dialogs, diff --git a/annotation-tool/src/tours/menu/TourProvider.tsx b/annotation-tool/src/tours/menu/TourProvider.tsx index 5b65d48d..5e694d19 100644 --- a/annotation-tool/src/tours/menu/TourProvider.tsx +++ b/annotation-tool/src/tours/menu/TourProvider.tsx @@ -36,6 +36,7 @@ import { getBuiltInTours } from '../scripts' import { microventContent } from '../content/microvent' import type { TourContentBundle } from '../content/types' import { TourContext, type TourContextValue } from './tour-context' +import { config } from '@/config' /** * Same resolution logic the TourRunner uses for per-step routes, @@ -199,7 +200,7 @@ export function TourProvider({ // this (the catalogue isn't there and the user is already in /). const navigate = useNavigate() const location = useLocation() - const isDemoPublic = import.meta.env.VITE_DEMO_PUBLIC === '1' + const isDemoPublic = config.deploymentMode.publicBooth const captureTelemetry = useCallback( (e: TourTelemetryEvent) => { @@ -458,7 +459,7 @@ export function TourProvider({ ) useEffect(() => { - if (!import.meta.env.VITE_E2E) return undefined + if (!config.deploymentMode.e2e) return undefined const handle = { launch: async (tourId: string) => { const tour = findTour(tourId) diff --git a/annotation-tool/src/utils/seedTestData.ts b/annotation-tool/src/utils/seedTestData.ts index 99242d4b..c700d910 100644 --- a/annotation-tool/src/utils/seedTestData.ts +++ b/annotation-tool/src/utils/seedTestData.ts @@ -15,6 +15,7 @@ import { fetchWorldState, saveWorldState, WorldState } from '@store/queries/useWorld' import { Entity, EntityType, RoleType, EventType, GlossItem } from '@models/types' import { generateId } from './uuid' +import { config } from '@/config' interface SeedPersonaInput { name: string @@ -272,5 +273,5 @@ export async function seedTestData(): Promise { * Requires VITE_ENABLE_TEST_DATA to be explicitly set to 'true'. */ export function isTestDataEnabled(): boolean { - return import.meta.env.VITE_ENABLE_TEST_DATA === 'true' + return config.testData.enabled } diff --git a/annotation-tool/src/vite-env.d.ts b/annotation-tool/src/vite-env.d.ts index 24ee0a2b..2f933e9d 100644 --- a/annotation-tool/src/vite-env.d.ts +++ b/annotation-tool/src/vite-env.d.ts @@ -3,8 +3,6 @@ interface ImportMetaEnv { /** Backend API URL */ readonly VITE_API_URL?: string - /** Model service URL */ - readonly VITE_MODEL_SERVICE_URL?: string /** Enable test data mode */ readonly VITE_ENABLE_TEST_DATA?: string /** Wikidata/Wikibase API endpoint URL */ diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index a5783508..7b424e80 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -23,6 +23,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. +#### Frontend Configuration Single-Source-of-Truth + +- All frontend environment access is centralized in one typed module, `annotation-tool/src/config.ts`, the only file permitted to read `import.meta.env` (enforced by an ESLint `no-restricted-syntax` rule). It exposes a deep-frozen `config` grouped by concern (`env`, `api`, `wikidata`, `deploymentMode`, `testData`, `demo`) with all defaults and coercions centralized, removes the two `import.meta as unknown` cast hacks in `demo/config.ts` and `demo/api.ts`, drops the dead `VITE_MODEL_SERVICE_URL` declaration, and normalizes the previously-inconsistent `VITE_E2E` checks to `=== '1'`. +- The mode/demo build flags resolve into one derived `config.deploymentMode` with a typed `kind` (`normal` / `public-demo` / `tour-demo` / `legacy-demo-shell`) and documented precedence (legacy DemoShell short-circuits; `VITE_DEMO_PUBLIC` suppresses the runtime MSW worker even when the tour-demo mocks are built). The three tree-shaking-critical guards (the `VITE_TOUR_DEMO` MSW dynamic-import gates and the `VITE_E2E` fixture gate) deliberately keep their inline literal comparison so Rollup still drops the `mocks/tourDemo` subtree from normal production builds, verified by a build check. + ### Fixed #### Release Workflow Now Publishes GitHub Releases From 10038a127e1befa5fe10e14a595e3bb43cd67c27 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 15:28:24 -0400 Subject: [PATCH 04/42] Promote the model-service to a single typed pydantic-settings Settings module that owns all environment access, validated once and instantiated at the top of the FastAPI lifespan for fail-fast startup, route all thirteen scattered os.environ reads through it with a guard test asserting none remain elsewhere, subsume the two divergent MODEL_CONFIG_PATH resolution sites into one, and preserve the existing models.yaml discriminated-union catalog validation, the OTEL two-default behavior, and the HUGGING_FACE_HUB_TOKEN/HF_TOKEN alias semantics. --- CHANGELOG.md | 5 + docs/docs/project/changelog.md | 5 + model-service/pyproject.toml | 1 + .../application/services/model_management.py | 5 +- .../adapters/inbound/fastapi/dependencies.py | 5 +- .../adapters/inbound/fastapi/routes/admin.py | 4 +- .../inbound/fastapi/routes/diarize.py | 5 +- .../adapters/inbound/fastapi/routes/models.py | 7 +- .../inbound/fastapi/routes/thumbnails.py | 5 +- .../inbound/fastapi/routes/transcribe.py | 5 +- .../adapters/outbound/models/audio/loader.py | 4 +- .../adapters/outbound/video/processor.py | 24 +- .../src/infrastructure/config/settings.py | 197 +++++++++++++ .../infrastructure/observability/telemetry.py | 15 +- model-service/src/main.py | 17 +- model-service/test/config/test_settings.py | 261 ++++++++++++++++++ 16 files changed, 509 insertions(+), 56 deletions(-) create mode 100644 model-service/src/infrastructure/config/settings.py create mode 100644 model-service/test/config/test_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f1049f..c978856a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - All frontend environment access is centralized in one typed module, `annotation-tool/src/config.ts`, the only file permitted to read `import.meta.env` (enforced by an ESLint `no-restricted-syntax` rule). It exposes a deep-frozen `config` grouped by concern (`env`, `api`, `wikidata`, `deploymentMode`, `testData`, `demo`) with all defaults and coercions centralized, removes the two `import.meta as unknown` cast hacks in `demo/config.ts` and `demo/api.ts`, drops the dead `VITE_MODEL_SERVICE_URL` declaration, and normalizes the previously-inconsistent `VITE_E2E` checks to `=== '1'`. - The mode/demo build flags resolve into one derived `config.deploymentMode` with a typed `kind` (`normal` / `public-demo` / `tour-demo` / `legacy-demo-shell`) and documented precedence (legacy DemoShell short-circuits; `VITE_DEMO_PUBLIC` suppresses the runtime MSW worker even when the tour-demo mocks are built). The three tree-shaking-critical guards (the `VITE_TOUR_DEMO` MSW dynamic-import gates and the `VITE_E2E` fixture gate) deliberately keep their inline literal comparison so Rollup still drops the `mocks/tourDemo` subtree from normal production builds, verified by a build check. +#### Model-Service Configuration via pydantic-settings + +- The model-service now reads every environment variable through one typed `Settings(BaseSettings)` in `model-service/src/infrastructure/config/settings.py` (`pydantic-settings`), validated once and instantiated at the top of the FastAPI `lifespan` so an invalid environment fails fast at startup. All 13 scattered `os.environ`/`os.getenv` reads across the routes, services, adapters, and observability layers now route through it; a guard test asserts no env read remains outside the settings module. The existing `models.yaml` discriminated-union catalog validation is unchanged - `Settings` only resolves which catalog to load and feeds the DI container, subsuming the two previously-divergent `MODEL_CONFIG_PATH` resolution sites into one. +- The dynamic `_API_KEY` lookup becomes a typed `Settings.get_provider_api_key(provider)` helper, the `HUGGING_FACE_HUB_TOKEN`/`HF_TOKEN` pair becomes one alias-choice field, and the single `OTEL_EXPORTER_OTLP_ENDPOINT` fans out to the traces and metrics endpoints through derived properties that preserve the prior two-default behavior exactly. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 7b424e80..c3c94f52 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -28,6 +28,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - All frontend environment access is centralized in one typed module, `annotation-tool/src/config.ts`, the only file permitted to read `import.meta.env` (enforced by an ESLint `no-restricted-syntax` rule). It exposes a deep-frozen `config` grouped by concern (`env`, `api`, `wikidata`, `deploymentMode`, `testData`, `demo`) with all defaults and coercions centralized, removes the two `import.meta as unknown` cast hacks in `demo/config.ts` and `demo/api.ts`, drops the dead `VITE_MODEL_SERVICE_URL` declaration, and normalizes the previously-inconsistent `VITE_E2E` checks to `=== '1'`. - The mode/demo build flags resolve into one derived `config.deploymentMode` with a typed `kind` (`normal` / `public-demo` / `tour-demo` / `legacy-demo-shell`) and documented precedence (legacy DemoShell short-circuits; `VITE_DEMO_PUBLIC` suppresses the runtime MSW worker even when the tour-demo mocks are built). The three tree-shaking-critical guards (the `VITE_TOUR_DEMO` MSW dynamic-import gates and the `VITE_E2E` fixture gate) deliberately keep their inline literal comparison so Rollup still drops the `mocks/tourDemo` subtree from normal production builds, verified by a build check. +#### Model-Service Configuration via pydantic-settings + +- The model-service now reads every environment variable through one typed `Settings(BaseSettings)` in `model-service/src/infrastructure/config/settings.py` (`pydantic-settings`), validated once and instantiated at the top of the FastAPI `lifespan` so an invalid environment fails fast at startup. All 13 scattered `os.environ`/`os.getenv` reads across the routes, services, adapters, and observability layers now route through it; a guard test asserts no env read remains outside the settings module. The existing `models.yaml` discriminated-union catalog validation is unchanged - `Settings` only resolves which catalog to load and feeds the DI container, subsuming the two previously-divergent `MODEL_CONFIG_PATH` resolution sites into one. +- The dynamic `_API_KEY` lookup becomes a typed `Settings.get_provider_api_key(provider)` helper, the `HUGGING_FACE_HUB_TOKEN`/`HF_TOKEN` pair becomes one alias-choice field, and the single `OTEL_EXPORTER_OTLP_ENDPOINT` fans out to the traces and metrics endpoints through derived properties that preserve the prior two-default behavior exactly. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/model-service/pyproject.toml b/model-service/pyproject.toml index 9fc96816..ded42db1 100644 --- a/model-service/pyproject.toml +++ b/model-service/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ # converging. `>=` bounds let pip pick a single set in one pass. "uvicorn[standard]>=0.27", "pydantic>=2.9", + "pydantic-settings>=2.5", "python-multipart>=0.0.9", # opentelemetry was previously pinned to 1.28.0 (instrumentation # 0.49b0) across the board. That collides with pyannote.audio 4.x, diff --git a/model-service/src/application/services/model_management.py b/model-service/src/application/services/model_management.py index 0f5f6420..acb6ac9d 100644 --- a/model-service/src/application/services/model_management.py +++ b/model-service/src/application/services/model_management.py @@ -13,7 +13,6 @@ from __future__ import annotations import logging -import os import time from collections import OrderedDict from collections.abc import Callable @@ -474,8 +473,10 @@ def get_external_api_config(self, task_type: str) -> ExternalAPIConfigDTO: if not model_config.provider or not model_config.api_endpoint: raise ValueError(f"External API model {task_type} missing provider or endpoint") + from src.infrastructure.config.settings import get_settings + api_key_var = f"{model_config.provider.upper()}_API_KEY" - api_key = os.getenv(api_key_var) + api_key = get_settings().get_provider_api_key(model_config.provider) if model_config.requires_api_key and not api_key: raise ValueError(f"Missing API key: {api_key_var} environment variable not set") diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/dependencies.py b/model-service/src/infrastructure/adapters/inbound/fastapi/dependencies.py index 01217aa4..173e91d9 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/dependencies.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/dependencies.py @@ -22,18 +22,17 @@ def get_container_dep() -> Container: still receive a usable container; production lifespan initializes the global container explicitly. """ - from pathlib import Path # noqa: PLC0415 - from src.infrastructure.config.container import ( # noqa: PLC0415 Container, ContainerConfig, get_container, ) + from src.infrastructure.config.settings import get_settings # noqa: PLC0415 try: return get_container() except RuntimeError: - return Container(ContainerConfig(model_config_path=Path("config/models.yaml"))) + return Container(ContainerConfig(model_config_path=get_settings().model_config_path)) def get_model_manager() -> ModelManager: diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/admin.py b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/admin.py index d0a1dd83..e5dff683 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/admin.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/admin.py @@ -20,7 +20,6 @@ from __future__ import annotations import logging -import os from typing import Literal from fastapi import APIRouter, Header, HTTPException, status @@ -30,6 +29,7 @@ ModelManagerDep, # noqa: TC001 # FastAPI resolves this annotation at runtime ) from src.infrastructure.adapters.outbound.video.processor import reconfigure_roots +from src.infrastructure.config.settings import get_settings logger = logging.getLogger(__name__) router = APIRouter() @@ -106,7 +106,7 @@ class ReconfigureAck(BaseModel): def _require_admin_token(x_admin_token: str | None) -> None: """Reject callers that do not present the shared admin token.""" - expected = os.environ.get("MODEL_SERVICE_ADMIN_TOKEN") + expected = get_settings().model_service_admin_token if not expected: logger.warning("MODEL_SERVICE_ADMIN_TOKEN not configured; refusing reconfigure") raise HTTPException( diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/diarize.py b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/diarize.py index a3382d6f..e7d79e20 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/diarize.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/diarize.py @@ -30,6 +30,7 @@ from pydantic import BaseModel, Field from src.infrastructure.adapters.inbound.fastapi.dependencies import ModelManagerDep # noqa: TC001 +from src.infrastructure.config.settings import get_settings if TYPE_CHECKING: from src.infrastructure.adapters.outbound.models.audio.loader import DiarizationResult @@ -42,8 +43,8 @@ # barrier (realpath + startswith + raise) MUST be inlined at the use # site so CodeQL's taint engine recognises it; wrapping it in a helper # breaks the recognised StartswithCall pattern. -_VIDEO_DATA_PREFIX: str = os.path.realpath(os.environ.get("VIDEO_DATA_ROOT", "/videos")) + os.sep -_AUDIO_OUTPUT_PREFIX: str = os.path.realpath(os.environ.get("AUDIO_OUTPUT_ROOT", "/audio")) + os.sep +_VIDEO_DATA_PREFIX: str = os.path.realpath(str(get_settings().video_data_root)) + os.sep +_AUDIO_OUTPUT_PREFIX: str = os.path.realpath(str(get_settings().audio_output_root)) + os.sep _AUDIO_PATH_ROOTS: tuple[str, str] = (_VIDEO_DATA_PREFIX, _AUDIO_OUTPUT_PREFIX) diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/models.py b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/models.py index 74bb9876..a7e9f572 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/models.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/models.py @@ -5,15 +5,14 @@ """ import logging -import os from datetime import datetime, timezone -from pathlib import Path import torch from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from src.infrastructure.adapters.inbound.fastapi.dependencies import ModelManagerDep +from src.infrastructure.config.settings import get_settings router = APIRouter() logger = logging.getLogger(__name__) @@ -330,9 +329,7 @@ async def check_task_ready( "framework": model_config.framework, } - cache_dir = Path( - os.environ.get("TRANSFORMERS_CACHE", Path.home() / ".cache" / "huggingface" / "hub") - ) + cache_dir = get_settings().transformers_cache model_cache_name = f"models--{model_config.model_id.replace('/', '--')}" model_cache_path = cache_dir / model_cache_name cached = model_cache_path.is_dir() diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/thumbnails.py b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/thumbnails.py index 44be17af..4f7de262 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/thumbnails.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/thumbnails.py @@ -4,8 +4,6 @@ """ import logging -import os -from pathlib import Path from fastapi import APIRouter, HTTPException from opentelemetry import trace @@ -23,6 +21,7 @@ VideoProcessingError, extract_thumbnail, ) +from src.infrastructure.config.settings import get_settings router = APIRouter() tracer = trace.get_tracer(__name__) @@ -78,7 +77,7 @@ async def generate_thumbnail( # against. Defaults to ``/tmp/thumbnails`` so tests don't need to # mount a real video volume; operators point ``THUMBNAIL_OUTPUT_ROOT`` # at the shared data disk in production. - thumbnails_dir = Path(os.environ.get("THUMBNAIL_OUTPUT_ROOT", "/tmp/thumbnails")) # noqa: S108 + thumbnails_dir = get_settings().thumbnail_output_root thumbnails_dir.mkdir(parents=True, exist_ok=True) output_path = str(thumbnails_dir / f"{request.video_id}_{request.size}.jpg") diff --git a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py index 37a0159f..27bfaade 100644 --- a/model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py +++ b/model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py @@ -26,6 +26,7 @@ AudioTranscriptionLoader, TranscriptionResult, ) +from src.infrastructure.config.settings import get_settings router = APIRouter() logger = logging.getLogger(__name__) @@ -38,8 +39,8 @@ # normalised with a trailing separator so the StartswithCall guard # below cannot match a sibling directory whose name starts with # the root name. -_VIDEO_DATA_PREFIX: str = os.path.realpath(os.environ.get("VIDEO_DATA_ROOT", "/videos")) + os.sep -_AUDIO_OUTPUT_PREFIX: str = os.path.realpath(os.environ.get("AUDIO_OUTPUT_ROOT", "/audio")) + os.sep +_VIDEO_DATA_PREFIX: str = os.path.realpath(str(get_settings().video_data_root)) + os.sep +_AUDIO_OUTPUT_PREFIX: str = os.path.realpath(str(get_settings().audio_output_root)) + os.sep _AUDIO_PATH_ROOTS: tuple[str, str] = (_VIDEO_DATA_PREFIX, _AUDIO_OUTPUT_PREFIX) diff --git a/model-service/src/infrastructure/adapters/outbound/models/audio/loader.py b/model-service/src/infrastructure/adapters/outbound/models/audio/loader.py index e37c521c..f6e8eb3d 100644 --- a/model-service/src/infrastructure/adapters/outbound/models/audio/loader.py +++ b/model-service/src/infrastructure/adapters/outbound/models/audio/loader.py @@ -522,9 +522,9 @@ def load(self) -> None: # download without code changes; deployments without a # token surface a useful error instead of pyannote's # generic "could not download model" message. - import os + from src.infrastructure.config.settings import get_settings - hf_token = os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN") + hf_token = get_settings().hf_token # huggingface_hub 1.x renamed `use_auth_token` → `token`. # pyannote.audio 3.4 forwards the kwarg verbatim, so passing # the legacy name now raises "got an unexpected keyword diff --git a/model-service/src/infrastructure/adapters/outbound/video/processor.py b/model-service/src/infrastructure/adapters/outbound/video/processor.py index c76d8530..282b1354 100644 --- a/model-service/src/infrastructure/adapters/outbound/video/processor.py +++ b/model-service/src/infrastructure/adapters/outbound/video/processor.py @@ -27,6 +27,8 @@ import numpy as np from opentelemetry import trace +from src.infrastructure.config.settings import get_settings + if TYPE_CHECKING: from numpy.typing import NDArray @@ -41,22 +43,14 @@ class VideoProcessingError(Exception): """Raised when video processing operations fail.""" -def _resolve_root(env_var: str, default: str) -> Path: - """Resolve a root path from an env var, falling back to ``default``.""" - return Path(os.environ.get(env_var, default)).resolve(strict=False) - - # Roots constrain where videos may be read from and where outputs may be -# written. Override via environment variables at service start-up. -_VIDEO_DATA_ROOT: Path = _resolve_root("VIDEO_DATA_ROOT", "/videos") -_THUMBNAIL_OUTPUT_ROOT: Path = _resolve_root( - "THUMBNAIL_OUTPUT_ROOT", - "/tmp/thumbnails", # noqa: S108 -) -_AUDIO_OUTPUT_ROOT: Path = _resolve_root( - "AUDIO_OUTPUT_ROOT", - "/tmp/audio", # noqa: S108 -) +# written. Sourced from the typed settings (which own every environment +# read) and resolved here so later validation is symlink-safe. Override via +# the corresponding environment variables at service start-up. +_settings = get_settings() +_VIDEO_DATA_ROOT: Path = _settings.video_data_root.resolve(strict=False) +_THUMBNAIL_OUTPUT_ROOT: Path = _settings.thumbnail_output_root.resolve(strict=False) +_AUDIO_OUTPUT_ROOT: Path = _settings.processor_audio_output_root.resolve(strict=False) # Prefix constants with the trailing separator baked in. Each file-system # sink guards on ``realpath(user_input).startswith()`` — a single diff --git a/model-service/src/infrastructure/config/settings.py b/model-service/src/infrastructure/config/settings.py new file mode 100644 index 00000000..5355f5a3 --- /dev/null +++ b/model-service/src/infrastructure/config/settings.py @@ -0,0 +1,197 @@ +"""Typed application settings backed by environment variables. + +This module owns every environment-variable read in the model service. A +single :class:`Settings` instance is validated once at startup (fail-fast) +and shared via :func:`get_settings`; route handlers, adapters, and the +dependency-injection container read their configuration off that instance +instead of touching :mod:`os` directly. + +The discriminated-union model catalog in ``config/models.yaml`` keeps its +own validation. ``Settings`` only resolves *which* yaml file to load and +feeds that path to the container; it does not replace the catalog schema. +""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path + +from pydantic import AliasChoices, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _default_model_config_path() -> Path: + """Resolve the bundled ``config/models.yaml`` independent of the cwd. + + ``settings.py`` lives at ``src/infrastructure/config/``; the package + root is three parents up and the catalog sits in ``config/`` beside + it. Computing the default from ``__file__`` makes resolution stable + whether the service is started from the repo root, from ``src/``, or + from a test runner with an arbitrary working directory. + + Returns + ------- + Path + Absolute path to the bundled ``config/models.yaml``. + """ + return Path(__file__).resolve().parents[3] / "config" / "models.yaml" + + +class Settings(BaseSettings): + """Environment-derived configuration for the model service. + + All fields are populated from environment variables (or an optional + local ``.env`` file). Validation runs at construction time, so an + invalid environment fails fast when the application starts. + + Attributes + ---------- + model_config_path : Path + Filesystem path to the model catalog yaml. Defaults to the bundled + ``config/models.yaml``. Overridable via ``MODEL_CONFIG_PATH``. + otel_exporter_otlp_endpoint : str | None + Raw OTLP exporter endpoint from ``OTEL_EXPORTER_OTLP_ENDPOINT``. + When unset, the traces and metrics derived properties supply the + per-signal default URLs. + video_data_root : Path + Root directory videos are read from. Overridable via + ``VIDEO_DATA_ROOT``. + audio_output_root : Path + Root directory the transcribe/diarize routes accept audio from. + Overridable via ``AUDIO_OUTPUT_ROOT``; defaults to ``/audio``. + thumbnail_output_root : Path + Root directory thumbnails are written to. Overridable via + ``THUMBNAIL_OUTPUT_ROOT``. + transformers_cache : Path + HuggingFace hub cache directory. Defaults to + ``~/.cache/huggingface/hub``. Overridable via ``TRANSFORMERS_CACHE``. + model_service_admin_token : str | None + Shared secret for the admin reconfigure endpoint. Read from + ``MODEL_SERVICE_ADMIN_TOKEN``. + hf_token : str | None + HuggingFace Hub access token. Read from ``HUGGING_FACE_HUB_TOKEN``, + falling back to ``HF_TOKEN``. + """ + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + model_config_path: Path = Field( + default_factory=_default_model_config_path, + validation_alias="MODEL_CONFIG_PATH", + ) + + otel_exporter_otlp_endpoint: str | None = Field( + default=None, + validation_alias="OTEL_EXPORTER_OTLP_ENDPOINT", + ) + + video_data_root: Path = Field( + default=Path("/videos"), + validation_alias="VIDEO_DATA_ROOT", + ) + + audio_output_root: Path = Field( + default=Path("/audio"), + validation_alias="AUDIO_OUTPUT_ROOT", + ) + + # Raw, default-free view of AUDIO_OUTPUT_ROOT. The video processor and the + # transcribe/diarize routes both read AUDIO_OUTPUT_ROOT but apply different + # fallbacks when it is unset (the processor writes extracted audio under + # /tmp/audio, the routes accept inputs under /audio). Keeping the raw value + # here lets each consumer supply its own default without a second os.environ + # read leaking outside this module. + audio_output_root_raw: str | None = Field( + default=None, + validation_alias="AUDIO_OUTPUT_ROOT", + ) + + thumbnail_output_root: Path = Field( + default=Path("/tmp/thumbnails"), # noqa: S108 + validation_alias="THUMBNAIL_OUTPUT_ROOT", + ) + + transformers_cache: Path = Field( + default_factory=lambda: Path.home() / ".cache" / "huggingface" / "hub", + validation_alias="TRANSFORMERS_CACHE", + ) + + model_service_admin_token: str | None = Field( + default=None, + validation_alias="MODEL_SERVICE_ADMIN_TOKEN", + ) + + hf_token: str | None = Field( + default=None, + validation_alias=AliasChoices("HUGGING_FACE_HUB_TOKEN", "HF_TOKEN"), + ) + + @property + def processor_audio_output_root(self) -> Path: + """Root the video processor writes extracted audio under. + + Reads ``AUDIO_OUTPUT_ROOT`` and falls back to ``/tmp/audio`` when + unset. This differs from :attr:`audio_output_root` (which the + transcribe/diarize routes consume with a ``/audio`` fallback); + the processor's distinct default is preserved here. + """ + return Path(self.audio_output_root_raw or "/tmp/audio") # noqa: S108 + + @property + def otel_traces_endpoint(self) -> str: + """OTLP traces endpoint, with the per-signal default applied. + + Returns the raw ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set, otherwise + ``http://localhost:4318/v1/traces``. + """ + return self.otel_exporter_otlp_endpoint or "http://localhost:4318/v1/traces" + + @property + def otel_metrics_endpoint(self) -> str: + """OTLP metrics endpoint, with the per-signal default applied. + + Returns the raw ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set, otherwise + ``http://localhost:4318/v1/metrics``. + """ + return self.otel_exporter_otlp_endpoint or "http://localhost:4318/v1/metrics" + + def get_provider_api_key(self, provider: str) -> str | None: + """Read the API key for an external model provider. + + External-API model entries name their key by convention as + ``_API_KEY`` (for example ``ANTHROPIC_API_KEY``). The + set of providers is data-driven from the catalog, so the variable + name cannot be a static field; this is the one sanctioned dynamic + environment read in the service and it lives here, inside the + settings module. + + Parameters + ---------- + provider : str + Provider name from the model catalog (for example + ``"anthropic"``). Upper-cased to form the variable name. + + Returns + ------- + str | None + The configured key, or ``None`` when the variable is unset. + """ + return os.environ.get(f"{provider.upper()}_API_KEY") + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the process-wide :class:`Settings` instance. + + The instance is constructed (and therefore validated) on first call + and cached for the life of the process so validation runs exactly + once. Tests that need a fresh read of the environment can either call + :func:`get_settings.cache_clear` or construct ``Settings()`` directly. + + Returns + ------- + Settings + The cached, validated settings instance. + """ + return Settings() diff --git a/model-service/src/infrastructure/observability/telemetry.py b/model-service/src/infrastructure/observability/telemetry.py index 4d763b55..78924133 100644 --- a/model-service/src/infrastructure/observability/telemetry.py +++ b/model-service/src/infrastructure/observability/telemetry.py @@ -6,7 +6,6 @@ import asyncio import functools -import os import time from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager @@ -23,6 +22,8 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor +from src.infrastructure.config.settings import get_settings + if TYPE_CHECKING: from fastapi import FastAPI @@ -33,6 +34,8 @@ def configure_observability() -> None: Sets up trace and metric providers with OTLP exporters. Configures service resource attributes for identification in observability backend. """ + settings = get_settings() + resource = Resource.create( { "service.name": "fovea-model-service", @@ -42,18 +45,12 @@ def configure_observability() -> None: trace_provider = TracerProvider(resource=resource) trace_provider.add_span_processor( - BatchSpanProcessor( - OTLPSpanExporter( - endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces") - ) - ) + BatchSpanProcessor(OTLPSpanExporter(endpoint=settings.otel_traces_endpoint)) ) trace.set_tracer_provider(trace_provider) metric_reader = PeriodicExportingMetricReader( - OTLPMetricExporter( - endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/metrics") - ), + OTLPMetricExporter(endpoint=settings.otel_metrics_endpoint), export_interval_millis=60000, ) metric_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) diff --git a/model-service/src/main.py b/model-service/src/main.py index 8d187222..bf73735e 100644 --- a/model-service/src/main.py +++ b/model-service/src/main.py @@ -6,11 +6,9 @@ container to the FastAPI application. """ -import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import UTC, datetime -from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -22,6 +20,7 @@ init_container, shutdown_container, ) +from .infrastructure.config.settings import get_settings from .infrastructure.observability import configure_observability, instrument_app @@ -42,19 +41,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: None Control during application runtime. """ - # Startup + # Startup. Validate the environment once, up front, so a misconfigured + # deployment fails fast before observability or the container come up. + settings = get_settings() + configure_observability() # Initialize DI container - config_path = Path( - os.getenv( - "MODEL_CONFIG_PATH", - str(Path(__file__).parent.parent / "config" / "models.yaml"), - ) - ) - config = ContainerConfig( - model_config_path=config_path, + model_config_path=settings.model_config_path, enable_warmup=True, ) container = init_container(config) diff --git a/model-service/test/config/test_settings.py b/model-service/test/config/test_settings.py new file mode 100644 index 00000000..a663581e --- /dev/null +++ b/model-service/test/config/test_settings.py @@ -0,0 +1,261 @@ +"""Tests for the typed application settings and the os.environ ban. + +These tests exercise :class:`Settings` defaults, environment overrides, the +``HF_TOKEN`` / ``HUGGING_FACE_HUB_TOKEN`` alias choice, the dynamic +provider-key reader, and the OTLP per-signal default handling. A guard test +also asserts that no environment read leaks outside the settings module. + +The tests build ``Settings(_env_file=None)`` so they never pick up a stray +local ``.env`` file and stay deterministic regardless of the developer's +environment. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from src.infrastructure.config.settings import ( + Settings, + _default_model_config_path, + get_settings, +) + +if TYPE_CHECKING: + import pytest + +# Environment variables Settings consumes; cleared before each parse so the +# host environment cannot perturb default-resolution assertions. +_MANAGED_VARS = ( + "MODEL_CONFIG_PATH", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "VIDEO_DATA_ROOT", + "AUDIO_OUTPUT_ROOT", + "THUMBNAIL_OUTPUT_ROOT", + "TRANSFORMERS_CACHE", + "MODEL_SERVICE_ADMIN_TOKEN", + "HF_TOKEN", + "HUGGING_FACE_HUB_TOKEN", +) + +_SRC_ROOT = Path(__file__).resolve().parents[2] / "src" +_SETTINGS_FILE = _SRC_ROOT / "infrastructure" / "config" / "settings.py" + + +def _clear_managed_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove every Settings-managed variable from the environment.""" + for name in _MANAGED_VARS: + monkeypatch.delenv(name, raising=False) + + +def _settings(monkeypatch: pytest.MonkeyPatch) -> Settings: + """Build a fresh Settings with no .env and a cleared managed environment.""" + _clear_managed_env(monkeypatch) + return Settings(_env_file=None) + + +class TestSettingsDefaults: + """Default field values when no environment is set.""" + + def test_model_config_path_defaults_to_bundled_catalog( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The catalog path defaults to the packaged config/models.yaml.""" + settings = _settings(monkeypatch) + assert settings.model_config_path == _default_model_config_path() + assert settings.model_config_path.name == "models.yaml" + assert settings.model_config_path.is_absolute() + + def test_path_roots_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Video, audio, and thumbnail roots fall back to their defaults.""" + settings = _settings(monkeypatch) + assert settings.video_data_root == Path("/videos") + assert settings.audio_output_root == Path("/audio") + assert settings.thumbnail_output_root == Path("/tmp/thumbnails") + + def test_transformers_cache_defaults_under_home(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The HF cache defaults to ~/.cache/huggingface/hub.""" + settings = _settings(monkeypatch) + assert settings.transformers_cache == Path.home() / ".cache" / "huggingface" / "hub" + + def test_optional_tokens_default_to_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Admin token and HF token are None when unset.""" + settings = _settings(monkeypatch) + assert settings.model_service_admin_token is None + assert settings.hf_token is None + + +class TestSettingsOverrides: + """Environment variables override the corresponding fields.""" + + def test_model_config_path_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + """MODEL_CONFIG_PATH overrides the catalog path.""" + _clear_managed_env(monkeypatch) + monkeypatch.setenv("MODEL_CONFIG_PATH", "/custom/models.yaml") + settings = Settings(_env_file=None) + assert settings.model_config_path == Path("/custom/models.yaml") + + def test_path_root_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The three path roots honor their environment variables.""" + _clear_managed_env(monkeypatch) + monkeypatch.setenv("VIDEO_DATA_ROOT", "/data/videos") + monkeypatch.setenv("AUDIO_OUTPUT_ROOT", "/data/audio") + monkeypatch.setenv("THUMBNAIL_OUTPUT_ROOT", "/data/thumbs") + settings = Settings(_env_file=None) + assert settings.video_data_root == Path("/data/videos") + assert settings.audio_output_root == Path("/data/audio") + assert settings.thumbnail_output_root == Path("/data/thumbs") + + def test_transformers_cache_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + """TRANSFORMERS_CACHE overrides the HF cache directory.""" + _clear_managed_env(monkeypatch) + monkeypatch.setenv("TRANSFORMERS_CACHE", "/models/hf") + settings = Settings(_env_file=None) + assert settings.transformers_cache == Path("/models/hf") + + def test_admin_token_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + """MODEL_SERVICE_ADMIN_TOKEN populates the admin token.""" + _clear_managed_env(monkeypatch) + admin_value = "shared-secret" + monkeypatch.setenv("MODEL_SERVICE_ADMIN_TOKEN", admin_value) + settings = Settings(_env_file=None) + assert settings.model_service_admin_token == admin_value + + +class TestHfTokenAliasChoice: + """HF token reads HUGGING_FACE_HUB_TOKEN first, then HF_TOKEN.""" + + def test_reads_hf_token_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HF_TOKEN populates hf_token when the primary name is unset.""" + _clear_managed_env(monkeypatch) + fallback = "hf-fallback" + monkeypatch.setenv("HF_TOKEN", fallback) + settings = Settings(_env_file=None) + assert settings.hf_token == fallback + + def test_reads_hugging_face_hub_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HUGGING_FACE_HUB_TOKEN populates hf_token when set.""" + _clear_managed_env(monkeypatch) + primary = "hf-primary" + monkeypatch.setenv("HUGGING_FACE_HUB_TOKEN", primary) + settings = Settings(_env_file=None) + assert settings.hf_token == primary + + def test_primary_wins_over_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When both are set the primary name takes precedence.""" + _clear_managed_env(monkeypatch) + fallback = "hf-fallback" + primary = "hf-primary" + monkeypatch.setenv("HF_TOKEN", fallback) + monkeypatch.setenv("HUGGING_FACE_HUB_TOKEN", primary) + settings = Settings(_env_file=None) + assert settings.hf_token == primary + + +class TestProviderApiKey: + """The dynamic provider-key reader resolves _API_KEY.""" + + def test_reads_provider_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + """get_provider_api_key upper-cases the provider and reads the key.""" + settings = _settings(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic") + assert settings.get_provider_api_key("anthropic") == "sk-anthropic" + + def test_missing_provider_key_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An unset provider key resolves to None.""" + settings = _settings(monkeypatch) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert settings.get_provider_api_key("openai") is None + + def test_read_is_live_after_construction(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Provider keys are read at call time, not bound at construction.""" + settings = _settings(monkeypatch) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + assert settings.get_provider_api_key("google") is None + monkeypatch.setenv("GOOGLE_API_KEY", "sk-google") + assert settings.get_provider_api_key("google") == "sk-google" + + +class TestOtelEndpoints: + """OTLP exporter endpoints preserve the per-signal defaults.""" + + def test_defaults_split_traces_and_metrics(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When unset, traces and metrics resolve to distinct local paths.""" + settings = _settings(monkeypatch) + assert settings.otel_exporter_otlp_endpoint is None + assert settings.otel_traces_endpoint == "http://localhost:4318/v1/traces" + assert settings.otel_metrics_endpoint == "http://localhost:4318/v1/metrics" + + def test_set_endpoint_used_verbatim_for_both(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When set, both exporters use the raw endpoint with no suffix.""" + _clear_managed_env(monkeypatch) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318") + settings = Settings(_env_file=None) + assert settings.otel_traces_endpoint == "http://collector:4318" + assert settings.otel_metrics_endpoint == "http://collector:4318" + + +class TestProcessorAudioRoot: + """The processor's audio root keeps its distinct /tmp/audio default.""" + + def test_processor_audio_root_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Unset AUDIO_OUTPUT_ROOT yields /tmp/audio for the processor.""" + settings = _settings(monkeypatch) + assert settings.processor_audio_output_root == Path("/tmp/audio") + + def test_processor_audio_root_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A set AUDIO_OUTPUT_ROOT is shared by both audio consumers.""" + _clear_managed_env(monkeypatch) + monkeypatch.setenv("AUDIO_OUTPUT_ROOT", "/data/audio") + settings = Settings(_env_file=None) + assert settings.processor_audio_output_root == Path("/data/audio") + assert settings.audio_output_root == Path("/data/audio") + + +class TestGetSettings: + """The cached accessor returns a single validated instance.""" + + def test_returns_cached_singleton(self) -> None: + """get_settings caches one instance across calls.""" + get_settings.cache_clear() + first = get_settings() + second = get_settings() + assert first is second + + def test_fresh_instance_is_constructible(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Tests can build a fresh Settings independent of the cache.""" + fresh = _settings(monkeypatch) + assert isinstance(fresh, Settings) + + +class TestNoEnvironReadOutsideSettings: + """Guard: no os.environ / os.getenv read occurs outside settings.py.""" + + def test_no_env_reads_in_src(self) -> None: + """Grep src/ and assert env reads live only in settings.py.""" + pattern = re.compile(r"os\.environ(?:\.get|\[)|os\.getenv") + offenders: list[str] = [] + for path in _SRC_ROOT.rglob("*.py"): + if path == _SETTINGS_FILE: + continue + text = path.read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), start=1): + if pattern.search(line): + rel = path.relative_to(_SRC_ROOT) + offenders.append(f"{rel}:{lineno}: {line.strip()}") + assert not offenders, "os.environ/os.getenv reads outside settings.py:\n" + "\n".join( + offenders + ) + + def test_settings_module_owns_the_sole_dynamic_read(self) -> None: + """settings.py is the only file allowed an os.environ read.""" + pattern = re.compile(r"os\.environ\.get\(") + text = _SETTINGS_FILE.read_text(encoding="utf-8") + # The sanctioned dynamic read in get_provider_api_key. + code_lines = [ + line + for line in text.splitlines() + if pattern.search(line) and not line.lstrip().startswith("#") + ] + assert len(code_lines) == 1 From 075576c3fe359d306b103207b920b414f21c6d28 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 15:50:04 -0400 Subject: [PATCH 05/42] Add a CI configuration-drift guard (scripts/check-env-example.mjs, wired into the backend lint job via pnpm check:env) that fails when .env.example declares a duplicate key or omits a backend config key, document the previously-absent SESSION_IDLE_TIMEOUT_MINUTES, ALLOW_TEST_ADMIN_BYPASS, DEFAULT_USER, MODEL_SERVICE_ADMIN_TOKEN, AWS credential, and demo/tours variables in .env.example, derive one config.deploymentMode summary logged once at startup, and add an operations configuration guide documenting the single-source-of-truth model across all three services. --- .env.example | 27 +++++++ .github/workflows/ci.yml | 3 + CHANGELOG.md | 5 ++ docs/docs/operations/configuration.md | 73 ++++++++++++++++++ docs/docs/project/changelog.md | 5 ++ docs/sidebars.ts | 1 + package.json | 1 + scripts/check-env-example.mjs | 105 ++++++++++++++++++++++++++ server/src/config.ts | 35 +++++++++ server/src/index.ts | 2 + 10 files changed, 257 insertions(+) create mode 100644 docs/docs/operations/configuration.md create mode 100644 scripts/check-env-example.mjs diff --git a/.env.example b/.env.example index 08ff757e..cbd9f44a 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,14 @@ FOVEA_MODE=multi-user ALLOW_REGISTRATION=false SESSION_SECRET=your-secret-key-here-min-32-chars-use-openssl-rand-base64-32 SESSION_TIMEOUT_DAYS=7 +# Idle-session expiry in minutes (default 60). +# SESSION_IDLE_TIMEOUT_MINUTES=60 +# Test-only: allow an admin-bypass header. Never enable in production. +# ALLOW_TEST_ADMIN_BYPASS=false + +# Single-user mode identity (used only when FOVEA_MODE=single-user) +# DEFAULT_USER_USERNAME=default-user +# DEFAULT_USER_DISPLAY_NAME=Default User # Admin User Configuration (REQUIRED for multi-user mode) # This password is used when seeding the database to create the admin account @@ -51,6 +59,9 @@ VITE_MODEL_SERVICE_URL=http://model-service:8000 # Model Service Configuration TRANSFORMERS_CACHE=/models MODEL_CONFIG_PATH=/config/models.yaml +# Shared secret guarding the model-service admin reconfigure endpoint. When unset, +# the backend skips runtime model-service reconfiguration. +# MODEL_SERVICE_ADMIN_TOKEN=your-admin-token CUDA_VISIBLE_DEVICES=0,1,2,3 # Adjust based on available GPUs PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:512 @@ -70,6 +81,9 @@ VIDEO_BASE_URL=/api/videos # S3_REGION=us-east-1 # S3_ACCESS_KEY_ID=your-access-key-id # S3_SECRET_ACCESS_KEY=your-secret-access-key +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY take precedence over the S3_-prefixed pair when both are set. +# AWS_ACCESS_KEY_ID=your-access-key-id +# AWS_SECRET_ACCESS_KEY=your-secret-access-key # S3_ENDPOINT= # Optional: for S3-compatible services (MinIO, DigitalOcean Spaces, etc.) # S3_PUBLIC_BUCKET=false # Set to true if bucket allows public reads (not recommended) @@ -103,6 +117,19 @@ WIKIDATA_MODE=online # ALLOW_EXTERNAL_WIKIDATA_LINKS=true # Controls Wikidata entity page links # ALLOW_EXTERNAL_VIDEO_SOURCE_LINKS=true # Controls video source links (uploaderUrl, webpageUrl) +# Demo / Public-Booth Deployment (optional; off by default) +# FOVEA_DEMO_MODE enables the demo layer (anonymous sessions, seeded corpus, tour endpoints). +# FOVEA_DEMO_MODE=false +# Allow anonymous (no-login) sessions; requires FOVEA_DEMO_MODE. +# FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH=false +# Shared seeder secret (must be at least 32 chars to take effect). +# FOVEA_DEMO_SEED_TOKEN= +# Paths the demo seeder reads (defaults resolve under annotation-tool/demo). +# FOVEA_DEMO_CLIPS_MANIFEST= +# FOVEA_DEMO_FIXTURES_DIR= +# Directory of custom guided-tour definitions. +# FOVEA_TOURS_DIR= + # Port Configuration (host ports) # REDIS_PORT is defined once in the Redis Configuration section above. FRONTEND_PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20f7181e..dad709dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,9 @@ jobs: - name: Type check run: pnpm --filter @fovea/server exec tsc --noEmit + - name: Check .env.example covers backend config (no drift, no duplicate keys) + run: pnpm run check:env + test-backend: name: Backend / Unit Tests runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index c978856a..07124555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - The model-service now reads every environment variable through one typed `Settings(BaseSettings)` in `model-service/src/infrastructure/config/settings.py` (`pydantic-settings`), validated once and instantiated at the top of the FastAPI `lifespan` so an invalid environment fails fast at startup. All 13 scattered `os.environ`/`os.getenv` reads across the routes, services, adapters, and observability layers now route through it; a guard test asserts no env read remains outside the settings module. The existing `models.yaml` discriminated-union catalog validation is unchanged - `Settings` only resolves which catalog to load and feeds the DI container, subsuming the two previously-divergent `MODEL_CONFIG_PATH` resolution sites into one. - The dynamic `_API_KEY` lookup becomes a typed `Settings.get_provider_api_key(provider)` helper, the `HUGGING_FACE_HUB_TOKEN`/`HF_TOKEN` pair becomes one alias-choice field, and the single `OTEL_EXPORTER_OTLP_ENDPOINT` fans out to the traces and metrics endpoints through derived properties that preserve the prior two-default behavior exactly. +#### Configuration Drift Guard and Deployment-Mode Summary + +- A CI guard (`pnpm check:env`, run in the backend lint job) fails the build when `.env.example` declares a duplicate key or omits any variable the backend config module reads, so the committed template cannot silently drift from the code. `.env.example` is brought into sync: the previously-undocumented `SESSION_IDLE_TIMEOUT_MINUTES`, `ALLOW_TEST_ADMIN_BYPASS`, `DEFAULT_USER_USERNAME`/`DEFAULT_USER_DISPLAY_NAME`, `MODEL_SERVICE_ADMIN_TOKEN`, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and the demo/tours variables are now documented (as commented optional entries). +- The backend resolves its mode and demo flags into one derived `config.deploymentMode` object (typed `auth` and `demo`) and logs a one-line summary at startup (for example `[config] deployment mode: auth=multi-user demo=off`). A new operations guide (`docs/docs/operations/configuration.md`) documents the configuration model, fail-fast startup, and the deployment-mode taxonomy across all three services. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/docs/docs/operations/configuration.md b/docs/docs/operations/configuration.md new file mode 100644 index 00000000..02e0d53d --- /dev/null +++ b/docs/docs/operations/configuration.md @@ -0,0 +1,73 @@ +# Configuration + +Every Fovea service reads its environment through a single typed +configuration module, validated once at startup. This is the one place +each service answers the question "what does this deployment do when a +variable is unset?", so an operator never has to trace a setting through +scattered reads. + +## Where configuration lives + +| Service | Config module | Reads from | +| --- | --- | --- | +| Backend | `server/src/config.ts` | `process.env` (optionally a local `.env`) | +| Frontend | `annotation-tool/src/config.ts` | `import.meta.env` (build-time) | +| Model service | `model-service/src/infrastructure/config/settings.py` | `os.environ` (optionally a local `.env`) | + +Each module is the **only** file in its service permitted to read the +environment. The backend and frontend enforce this with an ESLint rule, +and the model service with a guard test. New configuration must be added +to the module, not read inline somewhere else. + +The committed template `.env.example` is the canonical list of supported +variables. A CI check (`pnpm check:env`) fails the build if `.env.example` +contains a duplicate key or omits any key the backend config module reads, +so the template can never silently drift from the code. + +## Fail-fast startup + +Each service validates its configuration once, at process start, and +refuses to boot on a fatal problem rather than failing later in a handler: + +- The backend imports `config.ts` first (before tracing), validates the + numeric variables, requires `API_KEY_ENCRYPTION_KEY` to decode to 32 + bytes, and refuses an unset or default `SESSION_SECRET` in production. +- The model service instantiates its `Settings` at the top of the FastAPI + lifespan. + +A misconfigured deployment therefore stops immediately with a clear error. + +## Deployment mode + +Two variables decide the high-level posture of a backend deployment: + +- `FOVEA_MODE` selects the authentication model: `multi-user` (login and + sessions, the secure default) or `single-user` (auto-login, no + passwords, for local development). +- `FOVEA_DEMO_MODE` enables the demo layer (a seeded corpus and tour + endpoints). With `FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH` it also permits + anonymous, no-login sessions for a public booth. + +The backend resolves these into one `config.deploymentMode` object with a +typed `auth` (`single-user` | `multi-user`) and `demo` (`off` | `on` | +`public`), and logs a one-line summary at startup, for example: + +``` +[config] deployment mode: auth=multi-user demo=off +``` + +Check that line first when a deployment behaves unexpectedly: it reports +exactly which mode the resolved configuration produced. + +The frontend mirrors this with its own `config.deploymentMode`, whose +`kind` is one of `normal`, `public-demo`, `tour-demo`, or +`legacy-demo-shell`, derived from the `VITE_DEMO_PUBLIC`, `VITE_TOUR_DEMO`, +and `VITE_FOVEA_DEMO_MODE` build flags. + +## Reference + +See `.env.example` for the full, commented list of variables with their +defaults. Storage, S3, CDN, Wikidata, external-link, and demo options are +shipped commented-out because they are optional; uncomment and set them +for deployments that need them. Host-port mappings live in the +`Port Configuration` section at the end of the file. diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index c3c94f52..e58baa5e 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -33,6 +33,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - The model-service now reads every environment variable through one typed `Settings(BaseSettings)` in `model-service/src/infrastructure/config/settings.py` (`pydantic-settings`), validated once and instantiated at the top of the FastAPI `lifespan` so an invalid environment fails fast at startup. All 13 scattered `os.environ`/`os.getenv` reads across the routes, services, adapters, and observability layers now route through it; a guard test asserts no env read remains outside the settings module. The existing `models.yaml` discriminated-union catalog validation is unchanged - `Settings` only resolves which catalog to load and feeds the DI container, subsuming the two previously-divergent `MODEL_CONFIG_PATH` resolution sites into one. - The dynamic `_API_KEY` lookup becomes a typed `Settings.get_provider_api_key(provider)` helper, the `HUGGING_FACE_HUB_TOKEN`/`HF_TOKEN` pair becomes one alias-choice field, and the single `OTEL_EXPORTER_OTLP_ENDPOINT` fans out to the traces and metrics endpoints through derived properties that preserve the prior two-default behavior exactly. +#### Configuration Drift Guard and Deployment-Mode Summary + +- A CI guard (`pnpm check:env`, run in the backend lint job) fails the build when `.env.example` declares a duplicate key or omits any variable the backend config module reads, so the committed template cannot silently drift from the code. `.env.example` is brought into sync: the previously-undocumented `SESSION_IDLE_TIMEOUT_MINUTES`, `ALLOW_TEST_ADMIN_BYPASS`, `DEFAULT_USER_USERNAME`/`DEFAULT_USER_DISPLAY_NAME`, `MODEL_SERVICE_ADMIN_TOKEN`, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and the demo/tours variables are now documented (as commented optional entries). +- The backend resolves its mode and demo flags into one derived `config.deploymentMode` object (typed `auth` and `demo`) and logs a one-line summary at startup (for example `[config] deployment mode: auth=multi-user demo=off`). A new operations guide (`docs/docs/operations/configuration.md`) documents the configuration model, fail-fast startup, and the deployment-mode taxonomy across all three services. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 1c848ce4..80cced02 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -90,6 +90,7 @@ const sidebars: SidebarsConfig = { link: {type: 'doc', id: 'operations/index'}, items: [ 'operations/production-deployment', + 'operations/configuration', 'operations/demo-fovea-deployment', 'operations/monitoring', 'operations/backup-restore', diff --git a/package.json b/package.json index a573bcc3..b11ec66f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:backend": "pnpm run --filter @fovea/server test", "lint": "pnpm run --filter @fovea/annotation-tool lint && pnpm run --filter @fovea/server lint", "type-check": "pnpm run --filter @fovea/annotation-tool type-check", + "check:env": "node scripts/check-env-example.mjs", "stop": "docker compose -f server/docker-compose.dev.yml down", "clean": "docker compose -f server/docker-compose.dev.yml down -v" }, diff --git a/scripts/check-env-example.mjs b/scripts/check-env-example.mjs new file mode 100644 index 00000000..274cb3f3 --- /dev/null +++ b/scripts/check-env-example.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * Configuration drift guard for `.env.example`. + * + * Enforces two invariants so the committed environment template cannot + * silently diverge from what the code actually reads: + * + * 1. `.env.example` declares no key twice. Duplicate dotenv keys are + * silently last-wins, which previously hid a real foot-gun. + * 2. Every environment variable the backend config module reads through + * its declarative `readString`/`readInt`/`readBoolean*` helpers + * (`server/src/config.ts`) is present in `.env.example`. Adding a new + * backend config key without documenting it in the template now fails + * CI rather than shipping an under-specified template. + * + * The dynamic reads in `config.ts` (the `MODEL_SERVICE_TIMEOUT__MS` + * family and the `_API_KEY` lookup) are intentionally not part of + * invariant 2 because they are optional and not enumerable as literals. + * + * Run via `pnpm check:env` (or directly with node). Exits non-zero on any + * violation with a precise, actionable message. + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const envExamplePath = join(repoRoot, '.env.example') +const configPath = join(repoRoot, 'server', 'src', 'config.ts') + +/** + * Parse `.env.example` keys. + * + * Returns the active keys (uncommented `KEY=` lines, in order, for the + * duplicate check) and the documented keys (active plus commented + * `# KEY=` lines, for the coverage check). A commented entry is treated + * as documentation: optional vars like the S3/CDN block are intentionally + * shipped commented-out, and that still tells an operator the key exists. + */ +function parseEnvKeys(text) { + const active = [] + const documented = new Set() + for (const rawLine of text.split('\n')) { + const line = rawLine.trim() + if (line === '') continue + const activeMatch = /^([A-Z_][A-Z0-9_]*)=/.exec(line) + if (activeMatch) { + active.push(activeMatch[1]) + documented.add(activeMatch[1]) + continue + } + const commentedMatch = /^#\s*([A-Z_][A-Z0-9_]*)=/.exec(line) + if (commentedMatch) documented.add(commentedMatch[1]) + } + return { active, documented } +} + +/** Env var names read through the declarative `readX('NAME', ...)` helpers. */ +function backendConfigKeys(text) { + const keys = new Set() + const pattern = + /read(?:String|StringWithDefault|Int|BooleanStrictTrue|BooleanTrueOrOne|BooleanDefaultTrue)\(\s*'([A-Z_][A-Z0-9_]*)'/g + let m + while ((m = pattern.exec(text)) !== null) keys.add(m[1]) + return keys +} + +const errors = [] + +const { active, documented } = parseEnvKeys(readFileSync(envExamplePath, 'utf8')) + +// Invariant 1: no duplicate active keys. +const seen = new Set() +const duplicates = new Set() +for (const key of active) { + if (seen.has(key)) duplicates.add(key) + seen.add(key) +} +if (duplicates.size > 0) { + errors.push( + `.env.example declares these keys more than once (duplicate dotenv keys are silently last-wins): ${[...duplicates].sort().join(', ')}`, + ) +} + +// Invariant 2: every declarative backend config key is documented (active or commented). +const configKeys = backendConfigKeys(readFileSync(configPath, 'utf8')) +const missing = [...configKeys].filter((k) => !documented.has(k)).sort() +if (missing.length > 0) { + errors.push( + `server/src/config.ts reads these keys but .env.example does not document them: ${missing.join(', ')}\n` + + ` Add them to .env.example (active or commented, with a comment) so the template stays in sync.`, + ) +} + +if (errors.length > 0) { + console.error('Configuration drift check FAILED:\n') + for (const e of errors) console.error(` - ${e}\n`) + process.exit(1) +} + +console.log( + `Configuration drift check passed: ${active.length} active keys in .env.example, ` + + `all ${configKeys.size} declarative backend config keys documented, no duplicates.`, +) diff --git a/server/src/config.ts b/server/src/config.ts index 47b039dc..30f26443 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -256,6 +256,23 @@ function resolveTimeoutMs(endpoint: ModelServiceTimeoutEndpoint): number { return ms } +/** Resolved authentication model for this deployment. */ +export type AuthMode = 'single-user' | 'multi-user' + +/** Resolved demo posture: `off`, `on` (seeded demo), or `public` (demo plus anonymous sessions). */ +export type DemoMode = 'off' | 'on' | 'public' + +function resolveAuthMode(): AuthMode { + return readStringWithDefault('FOVEA_MODE', DEFAULT_MODE) === 'single-user' + ? 'single-user' + : 'multi-user' +} + +function resolveDemoMode(): DemoMode { + if (!readBooleanTrueOrOne('FOVEA_DEMO_MODE')) return 'off' + return readBooleanTrueOrOne('FOVEA_DEMO_ALLOW_ANONYMOUS_AUTH') ? 'public' : 'on' +} + const config = Object.freeze({ server: Object.freeze({ get port(): number { @@ -427,6 +444,24 @@ const config = Object.freeze({ }, }), + /** + * One derived object resolving every mode/demo flag into the deployment's + * posture, so an admin can read "what kind of deployment is this" from one + * place instead of cross-referencing scattered flags. Logged once at startup. + */ + deploymentMode: Object.freeze({ + get auth(): AuthMode { + return resolveAuthMode() + }, + get demo(): DemoMode { + return resolveDemoMode() + }, + /** Compact human-readable summary, e.g. `auth=multi-user demo=off`. */ + get summary(): string { + return `auth=${resolveAuthMode()} demo=${resolveDemoMode()}` + }, + }), + demo: Object.freeze({ /** FOVEA_DEMO_MODE master flag (`'true'` or `'1'`). */ get enabled(): boolean { diff --git a/server/src/index.ts b/server/src/index.ts index d0cf7d23..fda75967 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -139,6 +139,8 @@ async function start() { const app = await buildApp() const PORT = config.server.port + app.log.info(`[config] deployment mode: ${config.deploymentMode.summary}`) + try { await connectDatabase() await initializeDataDirectory() From 96a74543a55c3ea3d96d260de5aee44efb8d4323 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 16:40:07 -0400 Subject: [PATCH 06/42] Pin Node and pnpm in one place by adding a root .nvmrc (Node 22) and a package.json packageManager field (pnpm@10.15.0) with engines.node, parameterize every Dockerfile Node base via ARG NODE_VERSION to converge the production images from Node 20 to 22, and switch every CI workflow to node-version-file plus packageManager-resolved pnpm while removing the stray Node 20 in the deploy workflow, with the backend and frontend images both validated by a local Node 22 build. --- .github/workflows/ci.yml | 29 ++++++------------------ .github/workflows/deploy.yml | 6 ++--- .github/workflows/docs.yml | 4 +--- .github/workflows/e2e-mock.yml | 7 +----- .github/workflows/e2e-real-models.yml | 7 +----- .github/workflows/security.yml | 8 ++----- .nvmrc | 1 + CHANGELOG.md | 4 ++++ annotation-tool/Dockerfile | 9 ++++++-- docs/docs/project/changelog.md | 4 ++++ package.json | 4 ++++ server/Dockerfile | 10 +++++--- test-utils/Dockerfile.mock-model-service | 4 +++- 13 files changed, 44 insertions(+), 53 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad709dd..097b9724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ on: branches: [main, develop, 'release/**'] env: - NODE_VERSION: '22' PYTHON_VERSION: '3.12' jobs: @@ -19,13 +18,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -44,13 +41,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -74,13 +69,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -104,13 +97,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -160,13 +151,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -203,13 +192,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -368,13 +355,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install workspace dependencies diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f909718c..a3a6336e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,13 +62,11 @@ jobs: uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -569,7 +567,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' - name: Set up SSH uses: webfactory/ssh-agent@v0.9.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cddcef88..dadaf2fa 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,13 +25,11 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Setup Python diff --git a/.github/workflows/e2e-mock.yml b/.github/workflows/e2e-mock.yml index 53dde7a4..05b3a341 100644 --- a/.github/workflows/e2e-mock.yml +++ b/.github/workflows/e2e-mock.yml @@ -30,9 +30,6 @@ on: schedule: - cron: '0 1 * * *' -env: - NODE_VERSION: '22' - jobs: test-e2e-mock: name: E2E mock model-service @@ -47,13 +44,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install workspace dependencies diff --git a/.github/workflows/e2e-real-models.yml b/.github/workflows/e2e-real-models.yml index 7b441a5e..12425245 100644 --- a/.github/workflows/e2e-real-models.yml +++ b/.github/workflows/e2e-real-models.yml @@ -27,9 +27,6 @@ on: schedule: - cron: '0 2 * * *' -env: - NODE_VERSION: '22' - jobs: test-e2e-real-models: name: E2E real model-service @@ -47,13 +44,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install workspace dependencies diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c30d4adc..f5376b0f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -19,13 +19,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies @@ -45,13 +43,11 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07124555..a35660aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - A CI guard (`pnpm check:env`, run in the backend lint job) fails the build when `.env.example` declares a duplicate key or omits any variable the backend config module reads, so the committed template cannot silently drift from the code. `.env.example` is brought into sync: the previously-undocumented `SESSION_IDLE_TIMEOUT_MINUTES`, `ALLOW_TEST_ADMIN_BYPASS`, `DEFAULT_USER_USERNAME`/`DEFAULT_USER_DISPLAY_NAME`, `MODEL_SERVICE_ADMIN_TOKEN`, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and the demo/tours variables are now documented (as commented optional entries). - The backend resolves its mode and demo flags into one derived `config.deploymentMode` object (typed `auth` and `demo`) and logs a one-line summary at startup (for example `[config] deployment mode: auth=multi-user demo=off`). A new operations guide (`docs/docs/operations/configuration.md`) documents the configuration model, fail-fast startup, and the deployment-mode taxonomy across all three services. +#### Node and pnpm Version Single-Source-of-Truth + +- Node and pnpm versions are now pinned in exactly one place. A root `.nvmrc` (Node `22`) and the root `package.json` `packageManager` field (`pnpm@10.15.0`, plus `engines.node >=22`) are the single sources: every Dockerfile takes `ARG NODE_VERSION` and every CI workflow reads `node-version-file: .nvmrc` and resolves pnpm from `packageManager` (the per-workflow `pnpm/action-setup` `version` pins and the `NODE_VERSION` env vars are removed). This closes the prior split where the production images ran Node 20 + pnpm 10.15.0 while every CI workflow ran Node 22 + pnpm 9, and fixes a stray Node 20 in the deploy workflow. The converged Node 22 images were validated by building them locally (the backend image runs Node 22 with its native modules and Prisma client intact). + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/annotation-tool/Dockerfile b/annotation-tool/Dockerfile index b64a4044..8b9c95ba 100644 --- a/annotation-tool/Dockerfile +++ b/annotation-tool/Dockerfile @@ -4,10 +4,15 @@ # Build context: repo root. docker-compose.yml sets context: . and routes # here via dockerfile: annotation-tool/Dockerfile. +# Node version is pinned once at the repo root (.nvmrc / package.json engines); +# override with --build-arg NODE_VERSION. pnpm is pinned by the root +# package.json "packageManager" field; the corepack line below mirrors it. +ARG NODE_VERSION=22 + # Build stage -FROM node:20-alpine AS builder +FROM node:${NODE_VERSION}-alpine AS builder -# Install pnpm +# Install pnpm (version mirrors the root package.json "packageManager" field) RUN corepack enable && corepack prepare pnpm@10.15.0 --activate WORKDIR /repo diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index e58baa5e..14a1af07 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -38,6 +38,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - A CI guard (`pnpm check:env`, run in the backend lint job) fails the build when `.env.example` declares a duplicate key or omits any variable the backend config module reads, so the committed template cannot silently drift from the code. `.env.example` is brought into sync: the previously-undocumented `SESSION_IDLE_TIMEOUT_MINUTES`, `ALLOW_TEST_ADMIN_BYPASS`, `DEFAULT_USER_USERNAME`/`DEFAULT_USER_DISPLAY_NAME`, `MODEL_SERVICE_ADMIN_TOKEN`, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, and the demo/tours variables are now documented (as commented optional entries). - The backend resolves its mode and demo flags into one derived `config.deploymentMode` object (typed `auth` and `demo`) and logs a one-line summary at startup (for example `[config] deployment mode: auth=multi-user demo=off`). A new operations guide (`docs/docs/operations/configuration.md`) documents the configuration model, fail-fast startup, and the deployment-mode taxonomy across all three services. +#### Node and pnpm Version Single-Source-of-Truth + +- Node and pnpm versions are now pinned in exactly one place. A root `.nvmrc` (Node `22`) and the root `package.json` `packageManager` field (`pnpm@10.15.0`, plus `engines.node >=22`) are the single sources: every Dockerfile takes `ARG NODE_VERSION` and every CI workflow reads `node-version-file: .nvmrc` and resolves pnpm from `packageManager` (the per-workflow `pnpm/action-setup` `version` pins and the `NODE_VERSION` env vars are removed). This closes the prior split where the production images ran Node 20 + pnpm 10.15.0 while every CI workflow ran Node 22 + pnpm 9, and fixes a stray Node 20 in the deploy workflow. The converged Node 22 images were validated by building them locally (the backend image runs Node 22 with its native modules and Prisma client intact). + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/package.json b/package.json index b11ec66f..759a9b2d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.5.0", "description": "FOVEA - Flexible Ontology Visual Event Analyzer", + "packageManager": "pnpm@10.15.0", + "engines": { + "node": ">=22" + }, "scripts": { "dev": "pnpm run dev:infra && pnpm run dev:services", "dev:services": "pnpm --parallel --filter @fovea/server --filter @fovea/annotation-tool run dev", diff --git a/server/Dockerfile b/server/Dockerfile index 2f2c5407..29cd94c2 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -5,8 +5,12 @@ # workspace package.json is visible). docker-compose.yml sets context: . and # routes to this Dockerfile via dockerfile: server/Dockerfile. +# Node version pinned once at the repo root (.nvmrc / package.json engines); +# override with --build-arg NODE_VERSION. This global ARG feeds every stage. +ARG NODE_VERSION=22 + # Build stage -FROM node:20-alpine AS builder +FROM node:${NODE_VERSION}-alpine AS builder # Install pnpm RUN corepack enable && corepack prepare pnpm@10.15.0 --activate @@ -37,7 +41,7 @@ RUN cd server && pnpm exec tsc prisma/seed.ts --outDir prisma --module commonjs mv prisma/seed.js prisma/seed.cjs # Production dependencies stage -FROM node:20-alpine AS deps +FROM node:${NODE_VERSION}-alpine AS deps RUN corepack enable && corepack prepare pnpm@10.15.0 --activate @@ -57,7 +61,7 @@ COPY --link server/prisma ./server/prisma RUN pnpm --filter @fovea/server exec prisma generate # Production stage -FROM node:20-alpine AS production +FROM node:${NODE_VERSION}-alpine AS production RUN corepack enable && corepack prepare pnpm@10.15.0 --activate diff --git a/test-utils/Dockerfile.mock-model-service b/test-utils/Dockerfile.mock-model-service index 9285002d..9bcab2fa 100644 --- a/test-utils/Dockerfile.mock-model-service +++ b/test-utils/Dockerfile.mock-model-service @@ -1,4 +1,6 @@ -FROM node:20-alpine +# Node version pinned once at the repo root (.nvmrc); override with --build-arg. +ARG NODE_VERSION=22 +FROM node:${NODE_VERSION}-alpine WORKDIR /app COPY mock-model-service.js . EXPOSE 8000 From 206314ad687898bbd1f602a57ce15a3df9802519 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 16:56:50 -0400 Subject: [PATCH 07/42] Add a verify-compose CI job that validates all ten docker compose configurations with their documented override chains and profiles and remove the prior two-file check from docker.yml, and set THUMBNAIL_OUTPUT_ROOT on the model-service and model-service-gpu services so generated thumbnails land in the shared /videos mount the backend reads instead of the container-local /tmp/thumbnails default. --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++++++++- .github/workflows/docker.yml | 18 ++---------------- CHANGELOG.md | 8 ++++++++ docker-compose.yml | 7 +++++++ docs/docs/project/changelog.md | 8 ++++++++ 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 097b9724..682407ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,28 @@ jobs: retention-days: 30 # Quality gate - all checks must pass + verify-compose: + name: Compose / Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate every docker compose configuration + run: | + set -euo pipefail + echo "Validating all compose files with their documented -f override chains..." + docker compose -f docker-compose.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.dev.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.tour-demo.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.tour-demo.yml -f docker-compose.demo.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.local-full.yml config --quiet + docker compose -f docker-compose.e2e.yml config --quiet + docker compose -f docker-compose.e2e.yml -f docker-compose.e2e.real-models.yml --profile with-models config --quiet + docker compose -f docker-compose.wikibase.yml config --quiet + docker compose -f server/docker-compose.dev.yml config --quiet + docker compose -f server/docker-compose.test.yml config --quiet + echo "All 10 compose configurations are valid." + quality-gate: name: Quality Gate runs-on: ubuntu-latest @@ -443,6 +465,7 @@ jobs: - lint-model-service - lint-wikibase - test-wikibase + - verify-compose # - test-e2e # disabled (job has if: false; gate must not require it) # test-model-service disabled due to disk space constraints if: always() @@ -470,6 +493,9 @@ jobs: echo "- Lint: ${{ needs.lint-wikibase.result }}" echo "- Tests: ${{ needs.test-wikibase.result }}" echo "" + echo "### Compose" + echo "- Validate: ${{ needs.verify-compose.result }}" + echo "" echo "### Integration" # echo "- E2E Tests: ${{ needs.test-e2e.result }}" — disabled } >> "$GITHUB_STEP_SUMMARY" @@ -484,6 +510,7 @@ jobs: LINT_MODEL="${{ needs.lint-model-service.result }}" LINT_WIKIBASE="${{ needs.lint-wikibase.result }}" TEST_WIKIBASE="${{ needs.test-wikibase.result }}" + VERIFY_COMPOSE="${{ needs.verify-compose.result }}" # TEST_E2E="${{ needs.test-e2e.result }}" # disabled if [ "$LINT_FRONTEND" != "success" ] || \ @@ -494,7 +521,8 @@ jobs: [ "$BUILD_BACKEND" != "success" ] || \ [ "$LINT_MODEL" != "success" ] || \ [ "$LINT_WIKIBASE" != "success" ] || \ - [ "$TEST_WIKIBASE" != "success" ]; then # E2E disabled + [ "$TEST_WIKIBASE" != "success" ] || \ + [ "$VERIFY_COMPOSE" != "success" ]; then # E2E disabled { echo "" echo "❌ Quality gate FAILED" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f45fb4a3..9d63c68a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -194,19 +194,5 @@ jobs: cache-to: type=gha,mode=max platforms: linux/amd64 - verify-compose: - name: Verify Docker Compose - runs-on: ubuntu-latest - needs: [build-frontend, build-backend, build-model-service, build-wikibase-loader] - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - - - name: Validate docker-compose.yml - run: docker compose config --quiet - - - name: Check compose file syntax - run: docker compose config > /dev/null - - - name: Validate wikibase compose file - run: docker compose -f docker-compose.wikibase.yml config --quiet +# Compose validation moved to ci.yml's `verify-compose` job, which validates +# all 10 compose files (with their override chains) and runs on release/** too. diff --git a/CHANGELOG.md b/CHANGELOG.md index a35660aa..5c941de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,12 +36,20 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Node and pnpm versions are now pinned in exactly one place. A root `.nvmrc` (Node `22`) and the root `package.json` `packageManager` field (`pnpm@10.15.0`, plus `engines.node >=22`) are the single sources: every Dockerfile takes `ARG NODE_VERSION` and every CI workflow reads `node-version-file: .nvmrc` and resolves pnpm from `packageManager` (the per-workflow `pnpm/action-setup` `version` pins and the `NODE_VERSION` env vars are removed). This closes the prior split where the production images ran Node 20 + pnpm 10.15.0 while every CI workflow ran Node 22 + pnpm 9, and fixes a stray Node 20 in the deploy workflow. The converged Node 22 images were validated by building them locally (the backend image runs Node 22 with its native modules and Prisma client intact). +#### CI Validates All Compose Configurations + +- A new `verify-compose` CI job validates all ten docker compose configurations (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. + ### Fixed #### Release Workflow Now Publishes GitHub Releases - `release.yml` gains a `create-release` job that, on every `v*.*.*` tag push, extracts the matching `CHANGELOG.md` section and creates or updates the GitHub Release. Previously the workflow only built and pushed Docker images, so a tag published no GitHub Release unless one was made by hand (which is how v0.4.3's Release came to be missing). The job is deliberately independent of the image build, so a Release is published even when the heavy `model-service-gpu` image hits the 90-minute job timeout. +#### Model-Service Thumbnails Written to a Container-Local Path + +- The `model-service` and `model-service-gpu` services in `docker-compose.yml` now set `THUMBNAIL_OUTPUT_ROOT` (following the backend's `THUMBNAIL_PATH`, default `/videos/thumbnails`) into the shared `/videos` mount. Previously the model-service defaulted to its container-local `/tmp/thumbnails`, so generated thumbnails landed where the backend could never read them. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/docker-compose.yml b/docker-compose.yml index 4d262e15..28564ff9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -128,6 +128,11 @@ services: - HF_TOKEN=${HF_TOKEN:-} - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 - MODEL_CONFIG_PATH=/config/models-cpu.yaml + # Write thumbnails into the shared /videos mount where the backend reads + # them (follows the backend's THUMBNAIL_PATH). Without this the model-service + # defaults to its container-local /tmp/thumbnails and the backend never + # finds them. + - THUMBNAIL_OUTPUT_ROOT=${THUMBNAIL_PATH:-/videos/thumbnails} volumes: - model-cache:/models - ./videos:/videos @@ -172,6 +177,8 @@ services: - MODEL_CONFIG_PATH=/config/models.yaml - CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0} - PYTORCH_CUDA_ALLOC_CONF=${PYTORCH_CUDA_ALLOC_CONF:-max_split_size_mb:512} + # Shared thumbnail output root (see the CPU model-service above). + - THUMBNAIL_OUTPUT_ROOT=${THUMBNAIL_PATH:-/videos/thumbnails} networks: fovea-network: aliases: diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 14a1af07..170feba8 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -42,12 +42,20 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Node and pnpm versions are now pinned in exactly one place. A root `.nvmrc` (Node `22`) and the root `package.json` `packageManager` field (`pnpm@10.15.0`, plus `engines.node >=22`) are the single sources: every Dockerfile takes `ARG NODE_VERSION` and every CI workflow reads `node-version-file: .nvmrc` and resolves pnpm from `packageManager` (the per-workflow `pnpm/action-setup` `version` pins and the `NODE_VERSION` env vars are removed). This closes the prior split where the production images ran Node 20 + pnpm 10.15.0 while every CI workflow ran Node 22 + pnpm 9, and fixes a stray Node 20 in the deploy workflow. The converged Node 22 images were validated by building them locally (the backend image runs Node 22 with its native modules and Prisma client intact). +#### CI Validates All Compose Configurations + +- A new `verify-compose` CI job validates all ten docker compose configurations (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. + ### Fixed #### Release Workflow Now Publishes GitHub Releases - `release.yml` gains a `create-release` job that, on every `v*.*.*` tag push, extracts the matching `CHANGELOG.md` section and creates or updates the GitHub Release. Previously the workflow only built and pushed Docker images, so a tag published no GitHub Release unless one was made by hand (which is how v0.4.3's Release came to be missing). The job is deliberately independent of the image build, so a Release is published even when the heavy `model-service-gpu` image hits the 90-minute job timeout. +#### Model-Service Thumbnails Written to a Container-Local Path + +- The `model-service` and `model-service-gpu` services in `docker-compose.yml` now set `THUMBNAIL_OUTPUT_ROOT` (following the backend's `THUMBNAIL_PATH`, default `/videos/thumbnails`) into the shared `/videos` mount. Previously the model-service defaulted to its container-local `/tmp/thumbnails`, so generated thumbnails landed where the backend could never read them. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. From eb03c3a2523d367e16f0dd3bacef49676b1f22c4 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 17:12:16 -0400 Subject: [PATCH 08/42] Delete the duplicate server/docker-compose.dev.yml that redefined Postgres, Redis, and the observability stack with conflicting user/password credentials and a separate volume, repoint the dev:infra, dev:infra:full, stop, and clean scripts onto the root docker-compose.yml and its dev overlay so local development shares one Postgres definition and the canonical fovea credentials, and drop its line from the verify-compose CI job. --- .github/workflows/ci.yml | 3 +- CHANGELOG.md | 6 ++- docs/docs/project/changelog.md | 6 ++- package.json | 8 +-- server/docker-compose.dev.yml | 93 ---------------------------------- 5 files changed, 15 insertions(+), 101 deletions(-) delete mode 100644 server/docker-compose.dev.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682407ef..b44073c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -448,9 +448,8 @@ jobs: docker compose -f docker-compose.e2e.yml config --quiet docker compose -f docker-compose.e2e.yml -f docker-compose.e2e.real-models.yml --profile with-models config --quiet docker compose -f docker-compose.wikibase.yml config --quiet - docker compose -f server/docker-compose.dev.yml config --quiet docker compose -f server/docker-compose.test.yml config --quiet - echo "All 10 compose configurations are valid." + echo "All compose configurations are valid." quality-gate: name: Quality Gate diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c941de0..5ccf3129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite #### CI Validates All Compose Configurations -- A new `verify-compose` CI job validates all ten docker compose configurations (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. +- A new `verify-compose` CI job validates every committed docker compose configuration (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. + +#### Dev Infrastructure Consolidated onto the Root Compose Stack + +- Deleted the duplicate `server/docker-compose.dev.yml`, which redefined Postgres, Redis, and the full observability stack with conflicting credentials (`user`/`password` versus the root stack's `fovea`/`fovea_password`) and a separate volume. The `dev:infra`, `dev:infra:full`, `stop`, and `clean` scripts now drive the root `docker-compose.yml` (plus its `dev` overlay for the observability services), so local development and the full stack share one Postgres definition and one set of credentials. Local dev databases that relied on the old `user`/`password` credentials must point their `DATABASE_URL` at the canonical `fovea`/`fovea_password` (matching `.env.example`). ### Fixed diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 170feba8..df608d02 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -44,7 +44,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite #### CI Validates All Compose Configurations -- A new `verify-compose` CI job validates all ten docker compose configurations (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. +- A new `verify-compose` CI job validates every committed docker compose configuration (each with its documented `-f` override chain and profile) on every relevant PR, replacing the prior check that covered only two of them. It catches structural drift, such as an overlay referencing a service its base does not define, before it reaches a deploy. + +#### Dev Infrastructure Consolidated onto the Root Compose Stack + +- Deleted the duplicate `server/docker-compose.dev.yml`, which redefined Postgres, Redis, and the full observability stack with conflicting credentials (`user`/`password` versus the root stack's `fovea`/`fovea_password`) and a separate volume. The `dev:infra`, `dev:infra:full`, `stop`, and `clean` scripts now drive the root `docker-compose.yml` (plus its `dev` overlay for the observability services), so local development and the full stack share one Postgres definition and one set of credentials. Local dev databases that relied on the old `user`/`password` credentials must point their `DATABASE_URL` at the canonical `fovea`/`fovea_password` (matching `.env.example`). ### Fixed diff --git a/package.json b/package.json index 759a9b2d..c5b2b01e 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "dev:services": "pnpm --parallel --filter @fovea/server --filter @fovea/annotation-tool run dev", "dev:frontend": "pnpm run --filter @fovea/annotation-tool dev", "dev:backend": "pnpm run --filter @fovea/server dev", - "dev:infra": "docker compose -f server/docker-compose.dev.yml up -d redis postgres", - "dev:infra:full": "docker compose -f server/docker-compose.dev.yml up -d", + "dev:infra": "docker compose -f docker-compose.yml up -d postgres redis", + "dev:infra:full": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres redis otel-collector jaeger prometheus grafana", "dev:docker": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up", "build": "pnpm run --filter @fovea/annotation-tool build && pnpm run --filter @fovea/server build", "build:docker": "docker compose build", @@ -23,8 +23,8 @@ "lint": "pnpm run --filter @fovea/annotation-tool lint && pnpm run --filter @fovea/server lint", "type-check": "pnpm run --filter @fovea/annotation-tool type-check", "check:env": "node scripts/check-env-example.mjs", - "stop": "docker compose -f server/docker-compose.dev.yml down", - "clean": "docker compose -f server/docker-compose.dev.yml down -v" + "stop": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down", + "clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down -v" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/server/docker-compose.dev.yml b/server/docker-compose.dev.yml deleted file mode 100644 index 3111a7e8..00000000 --- a/server/docker-compose.dev.yml +++ /dev/null @@ -1,93 +0,0 @@ -# Development Docker Compose for Fovea Server Dependencies -# This provides PostgreSQL with pgvector, Redis, and observability stack for local development - -services: - postgres: - image: pgvector/pgvector:pg16 - container_name: fovea-postgres-dev - environment: - POSTGRES_USER: user - POSTGRES_PASSWORD: password - POSTGRES_DB: fovea - ports: - - "5432:5432" - volumes: - - postgres-dev-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U user"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: fovea-redis-dev - command: redis-server --appendonly yes - ports: - - "6379:6379" - volumes: - - redis-dev-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - - otel-collector: - image: otel/opentelemetry-collector-contrib:latest - container_name: fovea-otel-collector-dev - command: ["--config=/etc/otel-collector-config.yaml"] - volumes: - - ../otel-collector-config.yaml:/etc/otel-collector-config.yaml - ports: - - "4317:4317" # OTLP gRPC - - "4318:4318" # OTLP HTTP - - "8889:8889" # Prometheus metrics - restart: unless-stopped - - jaeger: - image: jaegertracing/all-in-one:latest - container_name: fovea-jaeger-dev - environment: - COLLECTOR_OTLP_ENABLED: true - ports: - - "16686:16686" # Jaeger UI - - "4317" # OTLP gRPC - restart: unless-stopped - - prometheus: - image: prom/prometheus:latest - container_name: fovea-prometheus-dev - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - volumes: - - ../prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-dev-data:/prometheus - ports: - - "9090:9090" - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: fovea-grafana-dev - environment: - GF_SECURITY_ADMIN_PASSWORD: admin - GF_USERS_ALLOW_SIGN_UP: false - volumes: - - grafana-dev-data:/var/lib/grafana - - ../grafana-dashboards/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml - - ../grafana-dashboards/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml - ports: - - "3002:3000" - depends_on: - - prometheus - restart: unless-stopped - -volumes: - postgres-dev-data: - redis-dev-data: - prometheus-dev-data: - grafana-dev-data: From 9b2541c2d2a4edae2c842008ab4eec8ad215bcbc Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 17:28:23 -0400 Subject: [PATCH 09/42] Consolidate the model-service's orphaned tests directory into the configured test tree by moving tests/infrastructure into test/infrastructure and deleting the duplicate, so the 118 architecture-registry and per-family loader-factory tests that pytest.ini's testpaths=test never collected now run in the standard suite. --- CHANGELOG.md | 4 ++++ docs/docs/project/changelog.md | 4 ++++ .../adapters/outbound}/__init__.py | 0 .../adapters/outbound/models}/__init__.py | 0 .../adapters/outbound/models/audio}/__init__.py | 0 .../outbound/models/audio/test_factory.py | 0 .../outbound/models/detection}/__init__.py | 0 .../outbound/models/detection/test_factory.py | 0 .../adapters/outbound/models/llm}/__init__.py | 0 .../outbound/models/llm/test_factory.py | 0 .../adapters/outbound/models/test_registry.py | 0 .../outbound/models/tracking}/__init__.py | 0 .../outbound/models/tracking/test_factory.py | 0 .../adapters/outbound/models/vlm}/__init__.py | 0 .../outbound/models/vlm/test_factory.py | 0 model-service/tests/conftest.py | 17 ----------------- .../adapters/outbound/models/llm/__init__.py | 0 .../outbound/models/tracking/__init__.py | 0 .../adapters/outbound/models/vlm/__init__.py | 0 19 files changed, 8 insertions(+), 17 deletions(-) rename model-service/{tests => test/infrastructure/adapters/outbound}/__init__.py (100%) rename model-service/{tests/infrastructure => test/infrastructure/adapters/outbound/models}/__init__.py (100%) rename model-service/{tests/infrastructure/adapters => test/infrastructure/adapters/outbound/models/audio}/__init__.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/audio/test_factory.py (100%) rename model-service/{tests/infrastructure/adapters/outbound => test/infrastructure/adapters/outbound/models/detection}/__init__.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/detection/test_factory.py (100%) rename model-service/{tests/infrastructure/adapters/outbound/models => test/infrastructure/adapters/outbound/models/llm}/__init__.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/llm/test_factory.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/test_registry.py (100%) rename model-service/{tests/infrastructure/adapters/outbound/models/audio => test/infrastructure/adapters/outbound/models/tracking}/__init__.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/tracking/test_factory.py (100%) rename model-service/{tests/infrastructure/adapters/outbound/models/detection => test/infrastructure/adapters/outbound/models/vlm}/__init__.py (100%) rename model-service/{tests => test}/infrastructure/adapters/outbound/models/vlm/test_factory.py (100%) delete mode 100644 model-service/tests/conftest.py delete mode 100644 model-service/tests/infrastructure/adapters/outbound/models/llm/__init__.py delete mode 100644 model-service/tests/infrastructure/adapters/outbound/models/tracking/__init__.py delete mode 100644 model-service/tests/infrastructure/adapters/outbound/models/vlm/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ccf3129..1b1c8d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - The `model-service` and `model-service-gpu` services in `docker-compose.yml` now set `THUMBNAIL_OUTPUT_ROOT` (following the backend's `THUMBNAIL_PATH`, default `/videos/thumbnails`) into the shared `/videos` mount. Previously the model-service defaulted to its container-local `/tmp/thumbnails`, so generated thumbnails landed where the backend could never read them. +#### Orphaned Model-Service Tests Now Run + +- The model-service had two parallel test trees: `test/` (the configured `testpaths`) and a separate `tests/` holding the architecture-registry and per-family loader-factory suites. Because `pytest.ini` pins `testpaths = test`, the `tests/` tree was never collected, so 118 tests silently did not run. Moved `tests/infrastructure/` into `test/infrastructure/` and deleted the duplicate tree, bringing those 118 tests into the standard model-service suite. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index df608d02..714b66ce 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -60,6 +60,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - The `model-service` and `model-service-gpu` services in `docker-compose.yml` now set `THUMBNAIL_OUTPUT_ROOT` (following the backend's `THUMBNAIL_PATH`, default `/videos/thumbnails`) into the shared `/videos` mount. Previously the model-service defaulted to its container-local `/tmp/thumbnails`, so generated thumbnails landed where the backend could never read them. +#### Orphaned Model-Service Tests Now Run + +- The model-service had two parallel test trees: `test/` (the configured `testpaths`) and a separate `tests/` holding the architecture-registry and per-family loader-factory suites. Because `pytest.ini` pins `testpaths = test`, the `tests/` tree was never collected, so 118 tests silently did not run. Moved `tests/infrastructure/` into `test/infrastructure/` and deleted the duplicate tree, bringing those 118 tests into the standard model-service suite. + ## [0.4.4] - 2026-06-17 The first installment of the architecture-modularization roadmap (`notes/architecture-review.md`, Phase 0): reversible cleanups with no user-facing behavior change — dead dependencies removed, a configuration template de-duplicated, and one latent model-loader gap closed with a regression guard. diff --git a/model-service/tests/__init__.py b/model-service/test/infrastructure/adapters/outbound/__init__.py similarity index 100% rename from model-service/tests/__init__.py rename to model-service/test/infrastructure/adapters/outbound/__init__.py diff --git a/model-service/tests/infrastructure/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/__init__.py similarity index 100% rename from model-service/tests/infrastructure/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/__init__.py diff --git a/model-service/tests/infrastructure/adapters/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/audio/__init__.py similarity index 100% rename from model-service/tests/infrastructure/adapters/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/audio/__init__.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/audio/test_factory.py b/model-service/test/infrastructure/adapters/outbound/models/audio/test_factory.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/audio/test_factory.py rename to model-service/test/infrastructure/adapters/outbound/models/audio/test_factory.py diff --git a/model-service/tests/infrastructure/adapters/outbound/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/detection/__init__.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/detection/__init__.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/detection/test_factory.py b/model-service/test/infrastructure/adapters/outbound/models/detection/test_factory.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/detection/test_factory.py rename to model-service/test/infrastructure/adapters/outbound/models/detection/test_factory.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/llm/__init__.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/llm/__init__.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/llm/test_factory.py b/model-service/test/infrastructure/adapters/outbound/models/llm/test_factory.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/llm/test_factory.py rename to model-service/test/infrastructure/adapters/outbound/models/llm/test_factory.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/test_registry.py b/model-service/test/infrastructure/adapters/outbound/models/test_registry.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/test_registry.py rename to model-service/test/infrastructure/adapters/outbound/models/test_registry.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/audio/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/tracking/__init__.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/audio/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/tracking/__init__.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/tracking/test_factory.py b/model-service/test/infrastructure/adapters/outbound/models/tracking/test_factory.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/tracking/test_factory.py rename to model-service/test/infrastructure/adapters/outbound/models/tracking/test_factory.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/detection/__init__.py b/model-service/test/infrastructure/adapters/outbound/models/vlm/__init__.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/detection/__init__.py rename to model-service/test/infrastructure/adapters/outbound/models/vlm/__init__.py diff --git a/model-service/tests/infrastructure/adapters/outbound/models/vlm/test_factory.py b/model-service/test/infrastructure/adapters/outbound/models/vlm/test_factory.py similarity index 100% rename from model-service/tests/infrastructure/adapters/outbound/models/vlm/test_factory.py rename to model-service/test/infrastructure/adapters/outbound/models/vlm/test_factory.py diff --git a/model-service/tests/conftest.py b/model-service/tests/conftest.py deleted file mode 100644 index a722f04c..00000000 --- a/model-service/tests/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Pytest configuration for the architecture-keyed registry test tree. - -This conftest sits above the ``tests/infrastructure/...`` tree (parallel -to ``test/`` which hosts the legacy loader integration suite). It exists -purely to make ``src`` importable when pytest is invoked with ``tests/`` -as the rootpath. The src layout is shared with the rest of the package -so a single ``sys.path`` entry pointing at the project root is enough. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -_PROJECT_ROOT = Path(__file__).resolve().parent.parent -if str(_PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(_PROJECT_ROOT)) diff --git a/model-service/tests/infrastructure/adapters/outbound/models/llm/__init__.py b/model-service/tests/infrastructure/adapters/outbound/models/llm/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/model-service/tests/infrastructure/adapters/outbound/models/tracking/__init__.py b/model-service/tests/infrastructure/adapters/outbound/models/tracking/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/model-service/tests/infrastructure/adapters/outbound/models/vlm/__init__.py b/model-service/tests/infrastructure/adapters/outbound/models/vlm/__init__.py deleted file mode 100644 index e69de29b..00000000 From 7453b6effa2ae780584ebb78e306507376b8d6dd Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 17:43:38 -0400 Subject: [PATCH 10/42] Add a root Makefile as the single install/lint/typecheck/test/build entrypoint that fans out to all four components (Node via pnpm, Python via uv) and repoint the README and CONTRIBUTING test and quality instructions at it, replacing the previously-divergent per-package recipes that had drifted between npm and pnpm and between bare pytest and uv run pytest. --- .gitignore | 3 ++ CHANGELOG.md | 4 ++ CONTRIBUTING.md | 40 +++++------------ Makefile | 78 ++++++++++++++++++++++++++++++++++ README.md | 34 ++++++--------- docs/docs/project/changelog.md | 4 ++ 6 files changed, 112 insertions(+), 51 deletions(-) create mode 100644 Makefile diff --git a/.gitignore b/.gitignore index c112dd48..a47ca1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ notes/ model-service/uv.lock .demo-local/ .demo-local-bringup.log + +# wikibase uses pip in CI; its uv.lock is a local `uv run` artifact, not committed +wikibase/uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1c8d42..f514224f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Deleted the duplicate `server/docker-compose.dev.yml`, which redefined Postgres, Redis, and the full observability stack with conflicting credentials (`user`/`password` versus the root stack's `fovea`/`fovea_password`) and a separate volume. The `dev:infra`, `dev:infra:full`, `stop`, and `clean` scripts now drive the root `docker-compose.yml` (plus its `dev` overlay for the observability services), so local development and the full stack share one Postgres definition and one set of credentials. Local dev databases that relied on the old `user`/`password` credentials must point their `DATABASE_URL` at the canonical `fovea`/`fovea_password` (matching `.env.example`). +#### Unified Build and Test Entrypoint + +- A root `Makefile` is now the single source for the install/lint/typecheck/test/build recipe across all four components (Node via pnpm, Python via uv): `make lint`, `make typecheck`, `make test`, `make build`, plus per-suite targets such as `make test-model-service` (run `make help` to list them). The README and CONTRIBUTING guides point at it, replacing their previously-divergent per-package instructions, which had drifted between `npm` and `pnpm` and between bare `pytest` and `uv run pytest`. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7190b259..64d5b789 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,19 +140,15 @@ Before contributing, ensure you have: uvicorn src.main:app --reload --port 8000 ``` -7. **Verify your setup** by running tests: +7. **Verify your setup** by running tests through the root `Makefile` (the + single source for every build/lint/test recipe; run `make help` to list + targets): ```bash - # Frontend tests - cd annotation-tool - npm run test - - # Backend tests - cd server - npm run test - - # Model service tests - cd model-service - pytest + make test # every suite, or: + make test-frontend + make test-backend # needs Postgres + Redis: make dev-infra + make test-model-service + make test-wikibase ``` For detailed setup instructions, see the [Manual Setup Guide](docs/docs/getting-started/manual-setup.md). @@ -294,27 +290,13 @@ git rebase --continue 1. **Ensure all tests pass**: ```bash - # Run all test suites - cd annotation-tool && npm run test && npm run test:e2e - cd server && npm run test - cd model-service && pytest + make test ``` 2. **Check code quality**: ```bash - # Frontend - cd annotation-tool - npm run lint - npm run type-check - - # Backend - cd server - npm run lint - - # Model service - cd model-service - ruff check . - mypy src/ + make lint + make typecheck ``` 3. **Update documentation** if you've changed APIs or added features diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..b15edc0a --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +# Single entrypoint for installing, linting, type-checking, testing, and +# building every Fovea component: the Node packages (frontend, backend) via +# pnpm and the Python services (model-service, wikibase) via uv. CI mirrors +# these recipes, and README / CONTRIBUTING / DOCKER_QUICK_REFERENCE point +# here, so the build-and-test recipe lives in exactly one place. +# +# Run `make` or `make help` to list targets. + +.DEFAULT_GOAL := help +.PHONY: help install generate \ + lint lint-frontend lint-backend lint-model-service lint-wikibase \ + typecheck test test-frontend test-backend test-model-service test-wikibase \ + build dev-infra stop + +help: ## List available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +install: ## Install all dependencies (pnpm workspace + uv for the Python services) + pnpm install --frozen-lockfile + cd model-service && uv sync + cd wikibase && uv sync + +generate: ## Generate the Prisma client (needed before backend typecheck/test) + pnpm --filter @fovea/server exec prisma generate + +## --- Lint ----------------------------------------------------------------- + +lint: lint-frontend lint-backend lint-model-service lint-wikibase ## Lint every component + +lint-frontend: ## Lint the frontend + pnpm --filter @fovea/annotation-tool lint + +lint-backend: ## Lint the backend and check the .env.example drift guard + pnpm --filter @fovea/server lint + pnpm run check:env + +lint-model-service: ## Lint the model-service (ruff check + format) + cd model-service && uv run ruff check . && uv run ruff format --check . + +lint-wikibase: ## Lint the wikibase loader (ruff check + format) + cd wikibase && uv run ruff check . && uv run ruff format --check . + +## --- Typecheck ------------------------------------------------------------ + +typecheck: generate ## Type-check every component (tsc + mypy) + pnpm --filter @fovea/annotation-tool exec tsc --noEmit + pnpm --filter @fovea/server exec tsc --noEmit + cd model-service && uv run mypy src/ + cd wikibase && uv run mypy scripts/ + +## --- Test ----------------------------------------------------------------- + +test: test-frontend test-backend test-model-service test-wikibase ## Run every test suite + +test-frontend: ## Frontend unit tests + pnpm --filter @fovea/annotation-tool test -- --run + +test-backend: generate ## Backend unit + integration tests (needs Postgres + Redis: `make dev-infra`) + pnpm --filter @fovea/server test -- --run + +test-model-service: ## Model-service tests (excludes heavy model downloads) + cd model-service && uv run pytest -m "not requires_models" + +test-wikibase: ## Wikibase loader tests + cd wikibase && uv run pytest + +## --- Build / dev ---------------------------------------------------------- + +build: generate ## Build the frontend and backend + pnpm --filter @fovea/annotation-tool build + pnpm --filter @fovea/server build + +dev-infra: ## Start local Postgres + Redis (for backend dev/tests) + pnpm run dev:infra + +stop: ## Stop the local dev stack + pnpm run stop diff --git a/README.md b/README.md index d74f47f3..d5643b70 100644 --- a/README.md +++ b/README.md @@ -185,32 +185,22 @@ Includes hot-reload volumes, Jaeger tracing at [localhost:16686](http://localhos ### Running tests -**Frontend:** +All install, lint, type-check, test, and build recipes live in one place: the +root `Makefile` (Node via pnpm, Python via uv). Run `make help` to list every +target. ```bash -cd annotation-tool -npm run test # Vitest unit tests -npm run test:e2e # Playwright E2E tests -npm run lint # ESLint -npx tsc --noEmit # Type check +make install # Install all dependencies (pnpm workspace + uv) +make lint # Lint every component +make typecheck # Type-check every component (tsc + mypy) +make test # Run every test suite +make build # Build the frontend and backend ``` -**Backend:** - -```bash -cd server -npm run test # Vitest unit tests -npm run lint # ESLint -``` - -**Model Service:** - -```bash -cd model-service -uv run pytest # Unit tests -uv run ruff check src/ # Lint -uv run mypy src/ # Type check -``` +Per-suite targets are also available, e.g. `make test-frontend`, +`make test-backend`, `make test-model-service`, `make test-wikibase` (and the +matching `lint-*`). Backend tests need Postgres and Redis — start them first +with `make dev-infra`. ### Monitoring diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 714b66ce..556371b1 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -50,6 +50,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Deleted the duplicate `server/docker-compose.dev.yml`, which redefined Postgres, Redis, and the full observability stack with conflicting credentials (`user`/`password` versus the root stack's `fovea`/`fovea_password`) and a separate volume. The `dev:infra`, `dev:infra:full`, `stop`, and `clean` scripts now drive the root `docker-compose.yml` (plus its `dev` overlay for the observability services), so local development and the full stack share one Postgres definition and one set of credentials. Local dev databases that relied on the old `user`/`password` credentials must point their `DATABASE_URL` at the canonical `fovea`/`fovea_password` (matching `.env.example`). +#### Unified Build and Test Entrypoint + +- A root `Makefile` is now the single source for the install/lint/typecheck/test/build recipe across all four components (Node via pnpm, Python via uv): `make lint`, `make typecheck`, `make test`, `make build`, plus per-suite targets such as `make test-model-service` (run `make help` to list them). The README and CONTRIBUTING guides point at it, replacing their previously-divergent per-package instructions, which had drifted between `npm` and `pnpm` and between bare `pytest` and `uv run pytest`. + ### Fixed #### Release Workflow Now Publishes GitHub Releases From 2994852a07bba678b99d8a00f989eca5b8b30bcc Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Wed, 17 Jun 2026 18:17:22 -0400 Subject: [PATCH 11/42] Reconcile CI documentation with reality by rewriting the stale workflows README to match the actual jobs and triggers, removing the permanently disabled test-e2e job from ci.yml since end-to-end tests run in the dedicated e2e-mock and e2e-real-models workflows, listing test-model-service in the quality gate as advisory rather than falsely reporting it as skipped for disk-space, and correcting DOCKER_QUICK_REFERENCE to reference SESSION_SECRET instead of the non-existent COOKIE_SECRET. --- .github/workflows/README.md | 173 ++++++++------------------------- .github/workflows/ci.yml | 102 ++----------------- CHANGELOG.md | 4 + DOCKER_QUICK_REFERENCE.md | 2 +- docs/docs/project/changelog.md | 4 + 5 files changed, 57 insertions(+), 228 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 24b6ace7..fcb9a977 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,151 +1,60 @@ # GitHub Actions Workflows -This directory contains CI/CD workflows organized by responsibility following industry best practices. +This file describes what each workflow actually does. The workflows are the +source of truth; keep this in sync when you change them. Local equivalents of +the CI recipes live in the root `Makefile` (`make lint`, `make typecheck`, +`make test`, `make build`). -## Workflows +## CI and quality gate -### ci.yml -**Primary CI pipeline** that runs on every push and PR to `main` and `develop` branches. +### ci.yml — primary lint/test/build gate +Runs on push and PR to `main`, `develop`, and `release/**`. -**Jobs:** -- **Frontend** (parallel execution): - - `lint-frontend`: ESLint + TypeScript type checking - - `test-frontend`: Unit tests with coverage (451 tests) - - `build-frontend`: Production build validation +Jobs (all run in parallel; service containers `pgvector/pgvector:pg16` + `redis:7-alpine` back the backend tests): +- Frontend: `lint-frontend` (ESLint + `tsc --noEmit`), `test-frontend` (Vitest + coverage), `build-frontend`. +- Backend: `lint-backend` (ESLint + `tsc --noEmit` + the `.env.example` drift guard `check:env`), `test-backend` (Vitest with Postgres + Redis), `build-backend`. +- Model service: `lint-model-service` (`ruff check` + `ruff format --check` + `mypy src/`), `test-model-service` (`pytest -m "not requires_models"`). +- Wikibase loader: `lint-wikibase` (ruff + mypy), `test-wikibase` (pytest). +- `verify-compose`: validates every committed docker compose configuration with its override chain. +- `quality-gate`: aggregates results into one required status check. -- **Backend** (parallel execution): - - `lint-backend`: ESLint + TypeScript type checking - - `test-backend`: Unit tests with PostgreSQL + Redis (130 tests) - - `build-backend`: Production build validation +The quality gate requires: the frontend lint/test/build, backend lint/test/build, model-service lint, wikibase lint/test, and `verify-compose`. **`test-model-service` runs but is advisory** (not required) — its `pip install -e ".[dev]"` pulls the full ML stack and is disk-sensitive on shared runners, so a failure is surfaced but does not block the gate. There is no in-CI `test-e2e` job; end-to-end tests live in the dedicated e2e workflows below. -- **Model Service** (parallel execution): - - `lint-model-service`: Ruff + mypy checks - - `test-model-service`: pytest with coverage (288 tests) +## Image builds -- **Integration**: - - `test-e2e`: Playwright E2E tests (runs after unit tests pass) +### docker.yml — dev image builds +Runs on push and PR to `main`/`develop`. Builds the `frontend`, `backend`, `model-service` (CPU, `minimal`), and `wikibase-loader` images (all `linux/amd64`). On `push` to `main`/`develop` it pushes to `ghcr.io///`; on PRs it builds without pushing. -- **Quality Gate**: - - Aggregates all results and enforces pass/fail - - Provides summary of all check results +### release.yml — tag-driven release images + GitHub Release +Runs on `v*.*.*` tag push. `build-and-push-images` builds and pushes four images (`frontend`, `backend`, `model-service-cpu`, `model-service-gpu`), all `linux/amd64` (no arm64 emulation), tagged by `docker/metadata-action` semver. `create-release` extracts the matching `CHANGELOG.md` section and publishes the GitHub Release, independent of the image build (so a Release publishes even if the heavy GPU image times out). -**Total duration:** ~5-8 minutes (with maximum parallelization) +## Security -### docker.yml -**Docker image builds** for all services. +### security.yml +Runs on push/PR to `main`/`develop` and weekly. `pnpm audit` (frontend/backend), `pip`/`safety` (model service), CodeQL (JavaScript + Python), and TruffleHog secret scanning. Findings are surfaced but `continue-on-error`, so they do not block CI. -**Jobs:** -- `build-frontend`: Builds frontend container image -- `build-backend`: Builds backend container image -- `build-model-service`: Matrix strategy builds both CPU and GPU variants -- `verify-compose`: Validates docker-compose.yml syntax +## End-to-end tests -**Behavior:** -- **On PR**: Builds images for validation (no push) -- **On push to main/develop**: Builds and pushes to GitHub Container Registry +### e2e-mock.yml / e2e-real-models.yml +Label-gated and nightly (not part of the per-PR gate). `e2e-mock` runs the Playwright smoke/functional/regression/accessibility suites against a mock model-service when a PR carries the `e2e-mock` label (or nightly / `workflow_dispatch`). `e2e-real-models` runs the `integration-models` suite against a real CPU model-service under the `e2e-models` label. -**Registry:** `ghcr.io///:` +## Docs and ops -### security.yml -**Security scanning** pipeline. - -**Jobs:** -- `dependency-scan-*`: npm audit / pip safety checks -- `codeql-analysis`: GitHub CodeQL for JavaScript and Python -- `secret-scan`: TruffleHog for exposed secrets - -**Trigger:** -- Every push/PR -- Weekly scheduled scan (Sundays at 00:00 UTC) - -### release.yml -**Release automation** for tagged versions. - -**Jobs:** -- `create-release`: Generates changelog and creates GitHub release -- `build-and-push-images`: Multi-platform Docker images (amd64 + arm64) -- `update-deployment`: Updates deployment manifests - -**Trigger:** Push to version tags (`v*.*.*`) - -**Image tags:** -- `v1.2.3` (exact version) -- `v1.2` (minor version) -- `v1` (major version) -- `latest` (always latest release) - -## Architecture Decisions - -### Parallel Execution -All service jobs (lint, test, build) run in parallel for maximum speed. E2E tests run only after unit tests pass. - -### Job Independence -Each job is independent and can be run in isolation. No hidden dependencies between jobs except explicit `needs:` declarations. - -### Artifact Retention -- Coverage reports: 30 days -- Build artifacts: 7 days -- Docker images: Per registry policy - -### Quality Gate Pattern -The `quality-gate` job aggregates all results and provides a single pass/fail status. This allows: -- Clear visibility into which checks failed -- Branch protection rules to require just one status check -- Detailed summary in GitHub UI - -### Separated Workflows -Workflows are separated by concern: -- **ci.yml**: Fast feedback loop (tests, lints, builds) -- **docker.yml**: Container image management -- **security.yml**: Security posture -- **release.yml**: Release process - -This separation allows: -- Independent triggering -- Clear responsibilities -- Easier maintenance -- Selective CI runs (e.g., skip Docker builds on draft PRs) - -## Best Practices Implemented - -1. ✅ **Caching**: npm and pip caching enabled -2. ✅ **Matrix builds**: Model service CPU/GPU variants -3. ✅ **Service containers**: PostgreSQL + Redis for backend tests -4. ✅ **Artifact uploads**: Coverage and build outputs preserved -5. ✅ **Conditional execution**: Docker pushes only on main/develop -6. ✅ **Health checks**: Database services wait for readiness -7. ✅ **Fail-fast disabled**: Security scans don't block CI -8. ✅ **Shellcheck validation**: All bash scripts validated -9. ✅ **Permissions**: Least-privilege GITHUB_TOKEN scopes -10. ✅ **Multi-platform builds**: amd64 + arm64 support - -## Local Testing - -Validate workflows locally: -```bash -# Install actionlint -brew install actionlint +- **docs.yml** — builds and deploys the Docusaurus site (push to `main`, paths-filtered; PRs build only). +- **docs-links.yml** — markdown link checker on PRs touching docs and weekly. +- **dev-build.yml** — brings up the dev compose stack and verifies frontend↔backend connectivity (push/PR to `main`/`develop`). +- **deploy.yml** — deploys demo.fovea.video over SSH on push to `main` (and `workflow_dispatch`). +- **rollback.yml** — `workflow_dispatch` rollback to a given commit. +- **health-check.yml** — cron health/SSL checks of the live sites every 15 minutes. -# Validate all workflows +## Validating workflow changes + +```bash +# Lint the workflow YAML (optional) actionlint .github/workflows/*.yml -# Run tests locally (matches CI exactly) -cd annotation-tool && npm run test:coverage -cd server && npm run test:coverage -cd model-service && pytest --cov=src +# Reproduce the CI recipes locally +make lint +make typecheck +make test ``` - -## Monitoring - -- **GitHub Actions UI**: View workflow runs and logs -- **Status badges**: Add to README for visibility -- **Branch protection**: Require `quality-gate` status check - -## Future Enhancements - -Potential improvements: -- [ ] Add performance benchmarking job -- [ ] Integrate Codecov/Coveralls for coverage tracking -- [ ] Add automatic dependency updates (Dependabot/Renovate) -- [ ] Implement canary deployments -- [ ] Add infrastructure-as-code validation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b44073c8..e2fcd7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -343,93 +343,6 @@ jobs: path: wikibase/coverage/ retention-days: 30 - # E2E tests are skipped in CI: the full Playwright suite takes ~90 minutes - # under docker compose and is run locally instead. Set `if:` back to `always()` - # (or remove it) to re-enable when the runtime budget allows. - test-e2e: - name: E2E Tests - runs-on: ubuntu-latest - needs: [test-frontend, test-backend] - if: false - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install workspace dependencies - run: pnpm install --frozen-lockfile - - - name: Install Playwright browsers - run: pnpm --filter @fovea/annotation-tool exec playwright install --with-deps chromium - - - name: Start Docker Compose services - run: | - docker compose -f docker-compose.e2e.yml up -d --build - echo "Waiting for services to be healthy..." - for i in $(seq 1 60); do - if docker compose -f docker-compose.e2e.yml ps backend | grep -q "(healthy)" \ - && docker compose -f docker-compose.e2e.yml ps frontend | grep -q "(healthy)"; then - echo "All services healthy" - break - fi - if [ $i -eq 60 ]; then - echo "Timeout waiting for services" - docker compose -f docker-compose.e2e.yml ps - docker compose -f docker-compose.e2e.yml logs --tail=200 backend - docker compose -f docker-compose.e2e.yml logs --tail=100 frontend - exit 1 - fi - sleep 5 - done - docker compose -f docker-compose.e2e.yml ps - - - name: Run E2E tests (smoke + functional + regression + accessibility) - env: - E2E_BASE_URL: http://localhost:3000 - CI: 'true' - # 90-minute budget covers the full 4-project suite (~379 tests at 2 - # workers under docker compose). Reporter list is added so the job log - # records per-test pass/fail, which the default html-only reporter omits. - run: pnpm --filter @fovea/annotation-tool exec playwright test --project=smoke --project=functional --project=regression --project=accessibility --reporter=list,html,json,junit - timeout-minutes: 90 - - - name: Dump service logs on failure - if: failure() - run: | - echo "=== Backend Logs ===" - docker compose -f docker-compose.e2e.yml logs --tail=300 backend - echo "=== Frontend Logs ===" - docker compose -f docker-compose.e2e.yml logs --tail=200 frontend - echo "=== Mock Model Service Logs ===" - docker compose -f docker-compose.e2e.yml logs --tail=100 mock-model-service - - - name: Stop Docker Compose services - if: always() - run: docker compose -f docker-compose.e2e.yml down -v - - - name: Upload Playwright report - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: annotation-tool/playwright-report/ - retention-days: 30 - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: e2e-test-results - path: annotation-tool/test-results/ - retention-days: 30 - - # Quality gate - all checks must pass verify-compose: name: Compose / Validate runs-on: ubuntu-latest @@ -465,8 +378,11 @@ jobs: - lint-wikibase - test-wikibase - verify-compose - # - test-e2e # disabled (job has if: false; gate must not require it) - # test-model-service disabled due to disk space constraints + # test-model-service is listed so its result shows in the summary, but it is + # advisory (not enforced below): its full ML-stack install is disk-sensitive + # on shared runners. End-to-end tests run in the dedicated e2e-mock.yml / + # e2e-real-models.yml workflows, not in this gate. + - test-model-service if: always() steps: - name: Check all jobs passed @@ -486,7 +402,7 @@ jobs: echo "" echo "### Model Service" echo "- Lint: ${{ needs.lint-model-service.result }}" - echo "- Tests: skipped (disk space constraints)" + echo "- Tests: ${{ needs.test-model-service.result }} (advisory, not required)" echo "" echo "### Wikibase Loader" echo "- Lint: ${{ needs.lint-wikibase.result }}" @@ -494,9 +410,6 @@ jobs: echo "" echo "### Compose" echo "- Validate: ${{ needs.verify-compose.result }}" - echo "" - echo "### Integration" - # echo "- E2E Tests: ${{ needs.test-e2e.result }}" — disabled } >> "$GITHUB_STEP_SUMMARY" # Fail if any required job failed @@ -510,7 +423,6 @@ jobs: LINT_WIKIBASE="${{ needs.lint-wikibase.result }}" TEST_WIKIBASE="${{ needs.test-wikibase.result }}" VERIFY_COMPOSE="${{ needs.verify-compose.result }}" - # TEST_E2E="${{ needs.test-e2e.result }}" # disabled if [ "$LINT_FRONTEND" != "success" ] || \ [ "$TEST_FRONTEND" != "success" ] || \ @@ -521,7 +433,7 @@ jobs: [ "$LINT_MODEL" != "success" ] || \ [ "$LINT_WIKIBASE" != "success" ] || \ [ "$TEST_WIKIBASE" != "success" ] || \ - [ "$VERIFY_COMPOSE" != "success" ]; then # E2E disabled + [ "$VERIFY_COMPOSE" != "success" ]; then { echo "" echo "❌ Quality gate FAILED" diff --git a/CHANGELOG.md b/CHANGELOG.md index f514224f..4ff38e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - A root `Makefile` is now the single source for the install/lint/typecheck/test/build recipe across all four components (Node via pnpm, Python via uv): `make lint`, `make typecheck`, `make test`, `make build`, plus per-suite targets such as `make test-model-service` (run `make help` to list them). The README and CONTRIBUTING guides point at it, replacing their previously-divergent per-package instructions, which had drifted between `npm` and `pnpm` and between bare `pytest` and `uv run pytest`. +#### CI Reconciled with Reality + +- Rewrote `.github/workflows/README.md` to match what the workflows actually do; it had claimed an in-CI e2e gate, a GPU docker-image matrix, and multi-arch release images, none of which happen. Removed the permanently-disabled (`if: false`) `test-e2e` job from `ci.yml` (end-to-end tests run in the dedicated `e2e-mock.yml` / `e2e-real-models.yml` workflows). The `test-model-service` job is now listed in the quality gate as **advisory** (its result is reported but does not block, since its full ML-stack install is disk-sensitive on shared runners), replacing the summary's inaccurate "skipped (disk space constraints)". Corrected `DOCKER_QUICK_REFERENCE.md` to reference `SESSION_SECRET` (the variable the stack uses) instead of the non-existent `COOKIE_SECRET`. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/DOCKER_QUICK_REFERENCE.md b/DOCKER_QUICK_REFERENCE.md index 1595902d..24b4facd 100644 --- a/DOCKER_QUICK_REFERENCE.md +++ b/DOCKER_QUICK_REFERENCE.md @@ -228,7 +228,7 @@ Key variables to configure in `.env`: - `FOVEA_MODE`: Authentication mode - `single-user` (default) or `multi-user` - `ALLOW_REGISTRATION`: Allow user self-registration - `true` or `false` -- `COOKIE_SECRET`: Secret for session cookie signing (min 32 characters, required in multi-user mode) +- `SESSION_SECRET`: Secret for session cookie signing (min 32 characters, required in multi-user mode) - `SESSION_TIMEOUT_DAYS`: Session expiration in days (default: 7) - `API_KEY_ENCRYPTION_KEY`: 32-byte hex key for API key encryption at rest diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 556371b1..6a568f6b 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -54,6 +54,10 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - A root `Makefile` is now the single source for the install/lint/typecheck/test/build recipe across all four components (Node via pnpm, Python via uv): `make lint`, `make typecheck`, `make test`, `make build`, plus per-suite targets such as `make test-model-service` (run `make help` to list them). The README and CONTRIBUTING guides point at it, replacing their previously-divergent per-package instructions, which had drifted between `npm` and `pnpm` and between bare `pytest` and `uv run pytest`. +#### CI Reconciled with Reality + +- Rewrote `.github/workflows/README.md` to match what the workflows actually do; it had claimed an in-CI e2e gate, a GPU docker-image matrix, and multi-arch release images, none of which happen. Removed the permanently-disabled (`if: false`) `test-e2e` job from `ci.yml` (end-to-end tests run in the dedicated `e2e-mock.yml` / `e2e-real-models.yml` workflows). The `test-model-service` job is now listed in the quality gate as **advisory** (its result is reported but does not block, since its full ML-stack install is disk-sensitive on shared runners), replacing the summary's inaccurate "skipped (disk space constraints)". Corrected `DOCKER_QUICK_REFERENCE.md` to reference `SESSION_SECRET` (the variable the stack uses) instead of the non-existent `COOKIE_SECRET`. + ### Fixed #### Release Workflow Now Publishes GitHub Releases From c7cfc3b99f3c82b96433273291c602eb97c771f1 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 18 Jun 2026 10:09:16 -0400 Subject: [PATCH 12/42] Stop the startup config validation from requiring API_KEY_ENCRYPTION_KEY at boot, which crash-looped every backend container whose compose file does not set that feature-gated key, by validating the key format only when it is set while leaving the lazy use-time check intact so deployments without API-key management boot normally. --- CHANGELOG.md | 2 +- docs/docs/project/changelog.md | 2 +- server/src/config.ts | 24 +++++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff38e73..b20c658d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite #### Backend Configuration Single-Source-of-Truth - All backend environment access is centralized in one typed, validated module, `server/src/config.ts`. It is the only file in `server/src` permitted to read `process.env` (enforced by a new ESLint `no-restricted-syntax` rule with a `config.ts`/`prisma`/`test` exemption), loads an optional local `.env` via `dotenv`, exposes a deep-frozen `config` object grouped by concern (`server`, `redis`, `auth`, `storage`, `modelService`, `rateLimit`, `cors`, `otel`, `mode`, `demo`, `tours`, `wikidata`, `externalLinks`, `defaultUser`), and centralizes every default and coercion. All 31 previously-scattered `process.env` reads across routes, services, queues, middleware, and libs now go through it. The two dynamic read patterns are exposed as typed helpers: `config.modelService.timeoutMs(endpoint)` and `config.getProviderApiKey(provider)`. -- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. +- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, validates the `API_KEY_ENCRYPTION_KEY` format at boot **when it is set** (so a misconfigured wrong-length key fails fast rather than silently corrupting data later, while deployments that do not use API-key management still boot without it; the key is otherwise validated lazily at first use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. #### Frontend Configuration Single-Source-of-Truth diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 6a568f6b..7f50fb88 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -20,7 +20,7 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite #### Backend Configuration Single-Source-of-Truth - All backend environment access is centralized in one typed, validated module, `server/src/config.ts`. It is the only file in `server/src` permitted to read `process.env` (enforced by a new ESLint `no-restricted-syntax` rule with a `config.ts`/`prisma`/`test` exemption), loads an optional local `.env` via `dotenv`, exposes a deep-frozen `config` object grouped by concern (`server`, `redis`, `auth`, `storage`, `modelService`, `rateLimit`, `cors`, `otel`, `mode`, `demo`, `tours`, `wikidata`, `externalLinks`, `defaultUser`), and centralizes every default and coercion. All 31 previously-scattered `process.env` reads across routes, services, queues, middleware, and libs now go through it. The two dynamic read patterns are exposed as typed helpers: `config.modelService.timeoutMs(endpoint)` and `config.getProviderApiKey(provider)`. -- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, requires `API_KEY_ENCRYPTION_KEY` to hex-decode to 32 bytes at boot (previously validated lazily at first key use), and refuses an unset or dev-default `SESSION_SECRET` in production. +- Configuration is now validated once at startup and fails fast: `config.ts` is imported first in `server/src/index.ts` (before tracing), validates numeric env vars through a TypeBox schema, validates the `API_KEY_ENCRYPTION_KEY` format at boot **when it is set** (so a misconfigured wrong-length key fails fast rather than silently corrupting data later, while deployments that do not use API-key management still boot without it; the key is otherwise validated lazily at first use), and refuses an unset or dev-default `SESSION_SECRET` in production. - Four environment defaults that previously disagreed across read sites are unified to one value each: `STORAGE_PATH` (the repo-relative `videos` path), `FOVEA_MODE` (`multi-user`), `MODEL_SERVICE_URL` (`http://localhost:8000`), and `OTEL_EXPORTER_OTLP_ENDPOINT` (`http://localhost:4318`). These defaults only apply when the variable is unset; every docker/production deployment sets them explicitly, so deployed behavior is unchanged. Single-user and demo-mode branching now route through the single `isSingleUserMode()` / `isDemoModeEnabled()` predicates (reading from `config`) instead of inline per-handler `process.env` comparisons. #### Frontend Configuration Single-Source-of-Truth diff --git a/server/src/config.ts b/server/src/config.ts index 30f26443..623b2744 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -22,8 +22,8 @@ * surfacing as a `NaN` deep in a handler. * * Fail-fast: importing this module runs `assertStartupConfig()`, which - * validates the typed env surface and the required - * `API_KEY_ENCRYPTION_KEY`, and (in production) refuses an unset or + * validates the typed env surface, validates the `API_KEY_ENCRYPTION_KEY` + * format when it is set, and (in production) refuses an unset or * dev-default `SESSION_SECRET`. Import this module FIRST in `index.ts`, * before `./tracing.js`, so validation throws before any subsystem * (OTEL, Fastify, Prisma) initializes. @@ -210,7 +210,8 @@ function resolveEncryptionKey(): Buffer { * * Runs at module load. Checks performed: * - numeric env vars coerce cleanly (TypeBox `Value.Errors`) - * - `API_KEY_ENCRYPTION_KEY` is set and hex-decodes to 32 bytes + * - `API_KEY_ENCRYPTION_KEY`, when set, hex-decodes to 32 bytes (an unset + * key is allowed; the API-key feature validates it lazily at use-time) * - in production, `SESSION_SECRET` is set and is not the dev default * * @throws {Error} listing every offending key when validation fails @@ -226,8 +227,21 @@ function assertStartupConfig(): void { ) } - // Required: API key encryption key (fail fast before any subsystem boots). - resolveEncryptionKey() + // Validate the API key encryption key's FORMAT at startup only when it is + // set, so a misconfigured (wrong-length) key fails fast before it can corrupt + // data. An unset key is allowed at boot: API-key management is an optional + // feature, and `config.auth.encryptionKey()` throws at use-time if a caller + // needs the key and it is missing. (Requiring it at boot broke every stack + // whose compose file does not set the key.) + const encryptionKey = process.env.API_KEY_ENCRYPTION_KEY + if (encryptionKey !== undefined && encryptionKey !== '') { + const keyBuffer = Buffer.from(encryptionKey, 'hex') + if (keyBuffer.length !== ENCRYPTION_KEY_BYTES) { + throw new Error( + `API_KEY_ENCRYPTION_KEY must be ${ENCRYPTION_KEY_BYTES} bytes (${ENCRYPTION_KEY_BYTES * 2} hex characters)`, + ) + } + } // Production guard: refuse an unset or dev-default session secret. if (process.env.NODE_ENV === 'production') { From b09a6554da6297df4bcea92aa0bdac21e04a6e41 Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 18 Jun 2026 09:47:19 -0400 Subject: [PATCH 13/42] Extract the personas domain into a PersonaRepository (pure data access) and PersonaService (orchestration plus RBAC), reducing routes/personas.ts from 1641 lines with 44 direct prisma calls to 640 thin lines with none, and expose Persona.projectId in the response schema and frontend type for project-scoped persona browsing. --- CHANGELOG.md | 5 + annotation-tool/src/models/user.ts | 2 + docs/docs/project/changelog.md | 5 + server/src/repositories/PersonaRepository.ts | 193 +++ server/src/routes/personas.ts | 1275 ++---------------- server/src/services/persona-service.ts | 1032 ++++++++++++++ 6 files changed, 1374 insertions(+), 1138 deletions(-) create mode 100644 server/src/repositories/PersonaRepository.ts create mode 100644 server/src/services/persona-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b20c658d..5b3ad3a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Rewrote `.github/workflows/README.md` to match what the workflows actually do; it had claimed an in-CI e2e gate, a GPU docker-image matrix, and multi-arch release images, none of which happen. Removed the permanently-disabled (`if: false`) `test-e2e` job from `ci.yml` (end-to-end tests run in the dedicated `e2e-mock.yml` / `e2e-real-models.yml` workflows). The `test-model-service` job is now listed in the quality gate as **advisory** (its result is reported but does not block, since its full ML-stack install is disk-sensitive on shared runners), replacing the summary's inaccurate "skipped (disk space constraints)". Corrected `DOCKER_QUICK_REFERENCE.md` to reference `SESSION_SECRET` (the variable the stack uses) instead of the non-existent `COOKIE_SECRET`. +#### Backend Modularization: Service/Repository Layer + +- Extracted the **personas** domain into a `PersonaRepository` (pure Prisma data access) and a `PersonaService` (orchestration, RBAC, and response mapping), reducing `server/src/routes/personas.ts` from 1641 lines with 44 direct `prisma.*` calls to 640 thin lines with zero. Routes now validate input and dispatch to the service; the service owns the CASL ability checks, the list-mode branching, the `isSystemGenerated` coercion, and the ontology type-deletion / world-state-cleanup orchestration; the repository owns every query. RBAC decisions, response shapes, and behavior are unchanged (the full personas route-test suite and the local E2E suite pass). This establishes the pattern for the remaining domain extractions. +- The persona API response and the frontend `Persona` type now expose `projectId`, which had been silently stripped by the response schema, enabling project-scoped persona browsing. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/annotation-tool/src/models/user.ts b/annotation-tool/src/models/user.ts index a8cd7dd7..c0238063 100644 --- a/annotation-tool/src/models/user.ts +++ b/annotation-tool/src/models/user.ts @@ -39,6 +39,8 @@ export interface Persona { details: string /** ID of the user who owns this persona (optional) */ userId?: string + /** ID of the project this persona is scoped to, or null for the personal workspace */ + projectId?: string | null /** Whether this persona was system-generated (e.g., Automated persona) */ isSystemGenerated?: boolean /** Whether this persona should be hidden from the UI */ diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 7f50fb88..01ffbd79 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -58,6 +58,11 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Rewrote `.github/workflows/README.md` to match what the workflows actually do; it had claimed an in-CI e2e gate, a GPU docker-image matrix, and multi-arch release images, none of which happen. Removed the permanently-disabled (`if: false`) `test-e2e` job from `ci.yml` (end-to-end tests run in the dedicated `e2e-mock.yml` / `e2e-real-models.yml` workflows). The `test-model-service` job is now listed in the quality gate as **advisory** (its result is reported but does not block, since its full ML-stack install is disk-sensitive on shared runners), replacing the summary's inaccurate "skipped (disk space constraints)". Corrected `DOCKER_QUICK_REFERENCE.md` to reference `SESSION_SECRET` (the variable the stack uses) instead of the non-existent `COOKIE_SECRET`. +#### Backend Modularization: Service/Repository Layer + +- Extracted the **personas** domain into a `PersonaRepository` (pure Prisma data access) and a `PersonaService` (orchestration, RBAC, and response mapping), reducing `server/src/routes/personas.ts` from 1641 lines with 44 direct `prisma.*` calls to 640 thin lines with zero. Routes now validate input and dispatch to the service; the service owns the CASL ability checks, the list-mode branching, the `isSystemGenerated` coercion, and the ontology type-deletion / world-state-cleanup orchestration; the repository owns every query. RBAC decisions, response shapes, and behavior are unchanged (the full personas route-test suite and the local E2E suite pass). This establishes the pattern for the remaining domain extractions. +- The persona API response and the frontend `Persona` type now expose `projectId`, which had been silently stripped by the response schema, enabling project-scoped persona browsing. + ### Fixed #### Release Workflow Now Publishes GitHub Releases diff --git a/server/src/repositories/PersonaRepository.ts b/server/src/repositories/PersonaRepository.ts new file mode 100644 index 00000000..fac2b1eb --- /dev/null +++ b/server/src/repositories/PersonaRepository.ts @@ -0,0 +1,193 @@ +import { PrismaClient, Persona, Ontology, Prisma } from '@prisma/client' + +/** + * Persona row joined with its ontology. + * + * Used by read paths that need the ontology in the same round-trip + * (deletion preview, ontology endpoints, and the type-deletion flows). + */ +export type PersonaWithOntology = Prisma.PersonaGetPayload<{ + include: { ontology: true } +}> + +/** + * Repository for all Persona and Ontology database access. + * + * This class owns every Prisma call in the personas domain. It performs no + * authorization: callers (the PersonaService) decide who may invoke a method. + * Methods return raw Prisma model types and propagate Prisma errors (for + * example P2025 on a missing update target) to their callers. + * + * @example + * ```typescript + * const repo = new PersonaRepository(fastify.prisma) + * const persona = await repo.findById(id) + * if (!persona) { + * throw new NotFoundError('Persona', id) + * } + * ``` + */ +export class PersonaRepository { + /** + * Creates a new PersonaRepository instance. + * + * @param prisma - Prisma client instance for database access + */ + constructor(private readonly prisma: PrismaClient) {} + + /** + * Finds personas matching a WHERE clause, newest first. + * + * Used by the list endpoint; the caller supplies the mode-specific filter + * (single-user, unauthenticated, demo, or CASL-scoped authenticated). + * + * @param where - Prisma WHERE clause selecting the visible personas + * @returns matching personas ordered by creation date descending + */ + async findManyForList(where: Prisma.PersonaWhereInput): Promise { + return this.prisma.persona.findMany({ + where, + orderBy: { createdAt: 'desc' } + }) + } + + /** + * Finds a persona by ID. + * + * @param id - Persona UUID + * @returns the persona, or null if not found + */ + async findById(id: string): Promise { + return this.prisma.persona.findUnique({ where: { id } }) + } + + /** + * Finds a persona by ID with its ontology included. + * + * @param id - Persona UUID + * @returns the persona with its ontology, or null if not found + */ + async findByIdWithOntology(id: string): Promise { + return this.prisma.persona.findUnique({ + where: { id }, + include: { ontology: true } + }) + } + + /** + * Finds many personas by ID with their ontologies included. + * + * Used by the batch ontology endpoint. Personas without an ontology are + * still returned; the caller filters them out. + * + * @param ids - Persona UUIDs to fetch + * @returns matching personas, each with its ontology + */ + async findManyWithOntology(ids: string[]): Promise { + return this.prisma.persona.findMany({ + where: { id: { in: ids } }, + include: { ontology: true } + }) + } + + /** + * Creates a persona together with an empty ontology in a single call. + * + * @param data - Prisma create input (must nest an ontology create) + * @returns the created persona + */ + async createWithOntology(data: Prisma.PersonaCreateInput): Promise { + return this.prisma.persona.create({ data }) + } + + /** + * Updates a persona. + * + * @param id - Persona UUID + * @param data - Prisma update input + * @returns the updated persona + * @throws {Prisma.PrismaClientKnownRequestError} P2025 if the persona does not exist + */ + async update(id: string, data: Prisma.PersonaUpdateInput): Promise { + return this.prisma.persona.update({ where: { id }, data }) + } + + /** + * Deletes a persona (cascades to its ontology, summaries, and annotations). + * + * @param id - Persona UUID + * @returns the deleted persona + * @throws {Prisma.PrismaClientKnownRequestError} P2025 if the persona does not exist + */ + async delete(id: string): Promise { + return this.prisma.persona.delete({ where: { id } }) + } + + /** + * Updates a persona's ontology, keyed by personaId. + * + * @param personaId - Persona UUID owning the ontology + * @param data - Prisma ontology update input + * @returns the updated ontology + */ + async updateOntology(personaId: string, data: Prisma.OntologyUpdateInput): Promise { + return this.prisma.ontology.update({ + where: { personaId }, + data + }) + } + + /** + * Counts annotations matching a WHERE clause. + * + * @param where - Prisma annotation WHERE clause + * @returns number of matching annotations + */ + async countAnnotations(where: Prisma.AnnotationWhereInput): Promise { + return this.prisma.annotation.count({ where }) + } + + /** + * Counts video summaries for a persona. + * + * @param personaId - Persona UUID + * @returns number of summaries for the persona + */ + async countVideoSummaries(personaId: string): Promise { + return this.prisma.videoSummary.count({ where: { personaId } }) + } + + /** + * Deletes annotations matching a WHERE clause. + * + * @param where - Prisma annotation WHERE clause + * @returns Prisma batch payload with the deleted count + */ + async deleteAnnotations(where: Prisma.AnnotationWhereInput): Promise { + return this.prisma.annotation.deleteMany({ where }) + } + + /** + * Finds a user's personal world state (the row scoped to the user with no + * project). This is the world state the persona-deletion and type-deletion + * cleanup paths mutate. + * + * @param userId - owning user ID + * @returns the personal world state, or null if the user has none + */ + async findPersonalWorldState(userId: string) { + return this.prisma.worldState.findFirst({ + where: { userId, projectId: null } + }) + } + + /** + * Updates a world state row by ID. + * + * @param id - World state ID + * @param data - Prisma world state update input + */ + async updateWorldState(id: string, data: Prisma.WorldStateUpdateInput): Promise { + await this.prisma.worldState.update({ where: { id }, data }) + } +} diff --git a/server/src/routes/personas.ts b/server/src/routes/personas.ts index 5c87d172..2a245f44 100644 --- a/server/src/routes/personas.ts +++ b/server/src/routes/personas.ts @@ -1,48 +1,18 @@ import { Type } from '@sinclair/typebox' -import { FastifyPluginAsync } from 'fastify' +import { FastifyPluginAsync, FastifyRequest } from 'fastify' import { z } from 'zod' -import { Prisma } from '@prisma/client' -import { accessibleBy } from '@casl/prisma' -import { subject } from '@casl/ability' import { requireAuth, optionalAuth } from '@middleware/auth.js' import { buildAbilities } from '../middleware/abilities.js' -import { NotFoundError, ForbiddenError } from '@lib/errors.js' -import { isDemoModeEnabled } from '../lib/demo-flags.js' -import { isSingleUserMode } from '@services/user-service.js' import { personaOperationCounter } from '../metrics.js' -import { - updateGlossesInTypes, - countTypeRefsInGlosses, - removeTypeAssignmentsFromEntities, - removeEventInterpretationsFromEvents, - countTypeAssignments, - countEventInterpretations, -} from '@lib/reference-cleanup.js' -import { - asTypesWithGloss, - asEntities, - asEvents, -} from '@lib/prisma-json.js' +import { PersonaRepository } from '../repositories/PersonaRepository.js' +import { PersonaService } from '../services/persona-service.js' /** - * Converts a typed array to Prisma.InputJsonValue for storage in JSON columns. - * Prisma JSON columns accept any serializable value at runtime; this function - * bridges the TypeScript gap without an unsafe cast. + * Nullable-string helper for response schemas. Using Type.Unsafe with the + * array type form keeps null values from being coerced to "" by + * fast-json-stringify. */ -function toJson(value: unknown): Prisma.InputJsonValue { - return value as Prisma.InputJsonValue -} - -/** - * Request body for ontology update endpoint. - */ -interface OntologyUpdateBody { - entities?: unknown[]; - roles?: unknown[]; - events?: unknown[]; - relationTypes?: unknown[]; - relations?: unknown[]; -} +const NullableString = Type.Unsafe({ type: ['string', 'null'] }) /** * TypeBox schema for Persona response. @@ -53,13 +23,27 @@ const PersonaSchema = Type.Object({ name: Type.String({ minLength: 1 }), role: Type.String(), informationNeed: Type.String(), - details: Type.Union([Type.String(), Type.Null()]), + details: NullableString, + projectId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), isSystemGenerated: Type.Boolean(), hidden: Type.Boolean(), createdAt: Type.String({ format: 'date-time' }), updatedAt: Type.String({ format: 'date-time' }) }) +/** TypeBox schema for the API ontology response shape. */ +const OntologyResponseSchema = Type.Object({ + id: Type.String(), + personaId: Type.String(), + entities: Type.Array(Type.Any()), + roles: Type.Array(Type.Any()), + events: Type.Array(Type.Any()), + relationTypes: Type.Array(Type.Any()), + relations: Type.Array(Type.Any()), + createdAt: Type.String(), + updatedAt: Type.String() +}) + /** * Zod schema for creating a new persona. * Validates request body for POST /api/personas. @@ -89,24 +73,43 @@ const updatePersonaSchema = z.object({ /** * Fastify plugin for persona-related routes. - * Provides CRUD operations for personas using Prisma ORM. + * + * Routes perform HTTP concerns only: schema validation, request parsing, and + * dispatch to a per-request PersonaService that owns business rules and RBAC. + * The PersonaRepository owns all Prisma access. * * Routes: - * - GET /api/personas - List all personas - * - POST /api/personas - Create a new persona - * - GET /api/personas/:id - Get a specific persona + * - GET /api/personas - List personas + * - POST /api/personas - Create a persona with an empty ontology + * - GET /api/personas/:id - Get a persona * - PUT /api/personas/:id - Update a persona + * - GET /api/personas/:id/deletion-preview - Preview a persona deletion * - DELETE /api/personas/:id - Delete a persona + * - GET /api/personas/:id/ontology - Get a persona's ontology + * - POST /api/personas/ontologies - Batch-fetch ontologies + * - PUT /api/personas/:id/ontology - Update a persona's ontology + * - GET/DELETE the entity/role/event/relation-type endpoints with cleanup */ const personasRoute: FastifyPluginAsync = async (fastify) => { + // Request-independent: one repository for the plugin's lifetime. + const repository = new PersonaRepository(fastify.prisma) + /** - * List all personas. - * In single-user mode, returns all personas. - * In multi-user mode with authentication, returns current user's personas only. - * Without authentication, returns public/system personas. + * Builds a per-request service from the request-scoped CASL ability and the + * authenticated user's id and system role. + */ + const serviceFor = (request: FastifyRequest): PersonaService => + new PersonaService( + repository, + request.ability ?? null, + request.user?.id, + request.user?.systemRole ?? undefined + ) + + /** + * List personas visible to the caller. * * @route GET /api/personas - * @returns Array of personas */ fastify.get('/api/personas', { onRequest: [optionalAuth, buildAbilities], @@ -118,82 +121,15 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - if (isSingleUserMode()) { - // Single-user mode: return all non-hidden personas - const personas = await fastify.prisma.persona.findMany({ - where: { hidden: false }, - orderBy: { createdAt: 'desc' } - }) - return reply.send(personas) - } - - if (!request.user || !request.ability) { - // Unauthenticated: return only non-hidden system personas - const personas = await fastify.prisma.persona.findMany({ - where: { isSystemGenerated: true, hidden: false }, - orderBy: { createdAt: 'desc' } - }) - return reply.send(personas) - } - - // FOVEA_DEMO_MODE override: the booth flow that ships on - // demo.fovea.video auto-issues demo-anonymous-* sessions - // (server/src/demo/anonymous-session.ts) so the visitor is - // technically authenticated and the unauthenticated branch above - // does not fire — but the auto-issued anon user has no CASL - // grants of their own, so the per-user accessibleBy filter below - // returns the empty set and the persona dropdown reads "No - // personas found". The Persona Builder workspace and every tour - // that drives a persona-rooted ontology (ontology-authoring, - // wikidata-augmentation, world-layer, etc.) then has no anchor - // to mount against because the workspace's tab list is gated on - // persona selection. Inside FOVEA_DEMO_MODE we explicitly widen - // the read scope to every non-hidden system-generated persona — - // the seeded "Automated" persona plus any future - // isSystemGenerated=true rows an admin promotes — so the tours - // run end-to-end against the same seeded ontology a self-hosted - // single-user deployment shows. This is gated on FOVEA_DEMO_MODE - // so production multi-user deployments keep their per-user RBAC - // intact. - if (isDemoModeEnabled()) { - // The seeded Automated persona ships with hidden=true so it - // does not clutter a multi-user deployment's persona dropdown - // for end users who do not need it. The tour flow that walks - // visitors through the persona-rooted workspaces needs it - // visible, so the override drops the hidden filter — the - // visible result is every isSystemGenerated persona regardless - // of the hidden flag. Production deployments without - // FOVEA_DEMO_MODE keep their per-user RBAC + the hidden filter - // exactly as before. - const personas = await fastify.prisma.persona.findMany({ - where: { isSystemGenerated: true }, - orderBy: { createdAt: 'desc' } - }) - return reply.send(personas) - } - - // Authenticated: filter by CASL abilities - const personas = await fastify.prisma.persona.findMany({ - where: { - AND: [ - { hidden: false }, - accessibleBy(request.ability, 'read').Persona, - ], - }, - orderBy: { createdAt: 'desc' } - }) + const service = serviceFor(request) + const personas = await service.list() return reply.send(personas) }) /** - * Create a new persona. - * Creates a persona and its associated ontology in the database. - * In single-user mode, uses default user if not authenticated. - * In multi-user mode, requires authentication. + * Create a persona and its empty ontology in one call. * * @route POST /api/personas - * @param request.body - Persona data - * @returns Created persona */ fastify.post('/api/personas', { onRequest: [requireAuth, buildAbilities], @@ -216,63 +152,17 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } } }, async (request, reply) => { - if (!request.ability) throw new ForbiddenError('No abilities defined') - const userId = request.user!.id - const validatedData = createPersonaSchema.parse(request.body) - const projectId = validatedData.projectId || null - - // Pre-authorize: verify the caller can create a Persona in this scope - const candidate = subject('Persona', { - userId, - projectId, - }) - if (!request.ability.can('create', candidate)) { - throw new ForbiddenError('Cannot create Persona in this scope') - } - - // Only system_admin may flag a persona as system-generated, since - // system personas are visible to unauthenticated visitors via the - // unauthenticated GET /api/personas branch. A non-admin attempting - // to set this flag has it silently coerced to false rather than 403 - // so legitimate clients that send the field unconditionally still - // succeed. - const isSystemGenerated = request.user?.systemRole === 'system_admin' - ? validatedData.isSystemGenerated - : false - - const persona = await fastify.prisma.persona.create({ - data: { - name: validatedData.name, - role: validatedData.role, - informationNeed: validatedData.informationNeed, - details: validatedData.details || null, - isSystemGenerated, - hidden: validatedData.hidden, - userId, - projectId, - ontology: { - create: { - entityTypes: [], - eventTypes: [], - roleTypes: [], - relationTypes: [] - } - } - } - }) - + const service = serviceFor(request) + const persona = await service.create(validatedData) personaOperationCounter.add(1, { operation: 'create', status: 'success' }) return reply.code(201).send(persona) }) /** - * Get a specific persona by ID. - * In multi-user mode, verifies user owns the persona or it's a system persona. + * Get a persona by ID. * * @route GET /api/personas/:id - * @param request.params.id - Persona UUID - * @returns Persona object */ fastify.get<{ Params: { id: string } }>('/api/personas/:id', { onRequest: [optionalAuth, buildAbilities], @@ -284,50 +174,20 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { }), response: { 200: PersonaSchema, - 403: Type.Object({ - error: Type.String() - }), - 404: Type.Object({ - error: Type.String() - }) + 403: Type.Object({ error: Type.String() }), + 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id } - }) - - if (!persona) { - throw new NotFoundError('Persona', id) - } - - // Unauthenticated callers can only see public system personas - if (!request.user || !request.ability) { - if (!persona.isSystemGenerated || persona.hidden) { - throw new NotFoundError('Persona', id) - } - return reply.send(persona) - } - - // Authenticated: CASL instance-level check - if (!request.ability.can('read', subject('Persona', persona))) { - throw new ForbiddenError('Access denied') - } - + const service = serviceFor(request) + const persona = await service.getById(request.params.id) return reply.send(persona) }) /** * Update a persona. - * Performs partial update of persona fields. - * Requires authentication and ownership verification. * * @route PUT /api/personas/:id - * @param request.params.id - Persona UUID - * @param request.body - Fields to update - * @returns Updated persona */ fastify.put<{ Params: { id: string } }>('/api/personas/:id', { onRequest: [requireAuth, buildAbilities], @@ -347,62 +207,22 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { }), response: { 200: PersonaSchema, - 403: Type.Object({ - error: Type.String() - }), - 404: Type.Object({ - error: Type.String() - }) + 403: Type.Object({ error: Type.String() }), + 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - if (!request.ability) throw new ForbiddenError('No abilities defined') const validatedData = updatePersonaSchema.parse(request.body) - - const existingPersona = await fastify.prisma.persona.findUnique({ - where: { id } - }) - - if (!existingPersona) { - throw new NotFoundError('Persona', id) - } - - if (!request.ability.can('update', subject('Persona', existingPersona))) { - throw new ForbiddenError('Cannot update this Persona') - } - - // Only system_admin may toggle isSystemGenerated; strip it from - // non-admin updates so a regular user cannot publish their persona to - // anonymous visitors via the unauthenticated GET /api/personas branch. - const updatePayload = { ...validatedData } - if (request.user?.systemRole !== 'system_admin') { - delete (updatePayload as { isSystemGenerated?: boolean }).isSystemGenerated - } - - try { - const persona = await fastify.prisma.persona.update({ - where: { id }, - data: updatePayload - }) - personaOperationCounter.add(1, { operation: 'update', status: 'success' }) - return reply.send(persona) - } catch (error: unknown) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'P2025') { - throw new NotFoundError('Persona', id) - } - throw error - } + const service = serviceFor(request) + const persona = await service.update(request.params.id, validatedData) + personaOperationCounter.add(1, { operation: 'update', status: 'success' }) + return reply.send(persona) }) /** - * Get deletion preview for a persona. - * Returns counts of items that will be affected when the persona is deleted. - * Used to show a warning to the user before deletion. + * Get a deletion preview for a persona. * * @route GET /api/personas/:id/deletion-preview - * @param request.params.id - Persona UUID - * @returns Counts of affected items */ fastify.get<{ Params: { id: string } }>('/api/personas/:id/deletion-preview', { onRequest: [requireAuth, buildAbilities], @@ -419,97 +239,20 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { summaryCount: Type.Number(), worldAssignmentCount: Type.Number() }), - 403: Type.Object({ - error: Type.String() - }), - 404: Type.Object({ - error: Type.String() - }) + 403: Type.Object({ error: Type.String() }), + 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - if (!request.ability) throw new ForbiddenError('No abilities defined') - - const persona = await fastify.prisma.persona.findUnique({ - where: { id }, - include: { ontology: true } - }) - - if (!persona) { - throw new NotFoundError('Persona', id) - } - - if (!request.ability.can('delete', subject('Persona', persona))) { - throw new ForbiddenError('Cannot access this Persona') - } - - // Count types in ontology - const entityTypes = Array.isArray(persona.ontology?.entityTypes) ? persona.ontology.entityTypes : [] - const roleTypes = Array.isArray(persona.ontology?.roleTypes) ? persona.ontology.roleTypes : [] - const eventTypes = Array.isArray(persona.ontology?.eventTypes) ? persona.ontology.eventTypes : [] - const relationTypes = Array.isArray(persona.ontology?.relationTypes) ? persona.ontology.relationTypes : [] - const typeCount = entityTypes.length + roleTypes.length + eventTypes.length + relationTypes.length - - // Count annotations with this personaId - const annotationCount = await fastify.prisma.annotation.count({ - where: { personaId: id } - }) - - // Count video summaries for this persona - const summaryCount = await fastify.prisma.videoSummary.count({ - where: { personaId: id } - }) - - // Count world state assignments for this persona - let worldAssignmentCount = 0 - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: persona.userId, projectId: null } - }) - - if (worldState) { - // Count Entity.typeAssignments with this personaId - const entities = (worldState.entities as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] - for (const entity of entities) { - worldAssignmentCount += (entity.typeAssignments || []).filter(a => a.personaId === id).length - } - - // Count Event.personaInterpretations with this personaId - const events = (worldState.events as Array<{ personaInterpretations?: Array<{ personaId: string }> }>) || [] - for (const event of events) { - worldAssignmentCount += (event.personaInterpretations || []).filter(i => i.personaId === id).length - } - - // Count EntityCollection.typeAssignments with this personaId - const entityCollections = (worldState.entityCollections as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] - for (const collection of entityCollections) { - worldAssignmentCount += (collection.typeAssignments || []).filter(a => a.personaId === id).length - } - - // Count EventCollection.typeAssignments with this personaId - const eventCollections = (worldState.eventCollections as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] - for (const collection of eventCollections) { - worldAssignmentCount += (collection.typeAssignments || []).filter(a => a.personaId === id).length - } - } - - return reply.send({ - typeCount, - annotationCount, - summaryCount, - worldAssignmentCount - }) + const service = serviceFor(request) + const preview = await service.getDeletionPreview(request.params.id) + return reply.send(preview) }) /** - * Delete a persona. - * Deletes the persona and its associated ontology (cascade). - * Also cleans up orphaned type assignments in world state. - * Requires authentication and ownership verification. + * Delete a persona and clean its world-state references. * * @route DELETE /api/personas/:id - * @param request.params.id - Persona UUID - * @returns Success message */ fastify.delete<{ Params: { id: string } }>('/api/personas/:id', { onRequest: [requireAuth, buildAbilities], @@ -520,109 +263,22 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { id: Type.String({ format: 'uuid' }) }), response: { - 200: Type.Object({ - message: Type.String() - }), - 403: Type.Object({ - error: Type.String() - }), - 404: Type.Object({ - error: Type.String() - }) + 200: Type.Object({ message: Type.String() }), + 403: Type.Object({ error: Type.String() }), + 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - if (!request.ability) throw new ForbiddenError('No abilities defined') - - const existingPersona = await fastify.prisma.persona.findUnique({ - where: { id } - }) - - if (!existingPersona) { - throw new NotFoundError('Persona', id) - } - - if (!request.ability.can('delete', subject('Persona', existingPersona))) { - throw new ForbiddenError('Cannot delete this Persona') - } - - // Clean up world state: remove type assignments and interpretations for this persona - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: existingPersona.userId, projectId: null } - }) - - if (worldState) { - // Helper type definitions for world state items - interface EntityWithAssignments { - typeAssignments?: Array<{ personaId: string; [key: string]: unknown }> - [key: string]: unknown - } - interface EventWithInterpretations { - personaInterpretations?: Array<{ personaId: string; [key: string]: unknown }> - [key: string]: unknown - } - interface CollectionWithAssignments { - typeAssignments?: Array<{ personaId: string; [key: string]: unknown }> - [key: string]: unknown - } - - // Clean Entity.typeAssignments - const entities = (worldState.entities as EntityWithAssignments[]) || [] - const cleanedEntities = entities.map(entity => ({ - ...entity, - typeAssignments: (entity.typeAssignments || []).filter(a => a.personaId !== id) - })) - - // Clean Event.personaInterpretations - const events = (worldState.events as EventWithInterpretations[]) || [] - const cleanedEvents = events.map(event => ({ - ...event, - personaInterpretations: (event.personaInterpretations || []).filter(i => i.personaId !== id) - })) - - // Clean EntityCollection.typeAssignments - const entityCollections = (worldState.entityCollections as CollectionWithAssignments[]) || [] - const cleanedEntityCollections = entityCollections.map(collection => ({ - ...collection, - typeAssignments: (collection.typeAssignments || []).filter(a => a.personaId !== id) - })) - - // Clean EventCollection.typeAssignments - const eventCollections = (worldState.eventCollections as CollectionWithAssignments[]) || [] - const cleanedEventCollections = eventCollections.map(collection => ({ - ...collection, - typeAssignments: (collection.typeAssignments || []).filter(a => a.personaId !== id) - })) - - // Update world state with cleaned data - await fastify.prisma.worldState.update({ - where: { id: worldState.id }, - data: { - entities: toJson(cleanedEntities), - events: toJson(cleanedEvents), - entityCollections: toJson(cleanedEntityCollections), - eventCollections: toJson(cleanedEventCollections) - } - }) - } - - try { - await fastify.prisma.persona.delete({ - where: { id } - }) - personaOperationCounter.add(1, { operation: 'delete', status: 'success' }) - return reply.send({ message: 'Persona deleted successfully' }) - } catch (error: unknown) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'P2025') { - throw new NotFoundError('Persona', id) - } - throw error - } + const service = serviceFor(request) + await service.delete(request.params.id) + personaOperationCounter.add(1, { operation: 'delete', status: 'success' }) + return reply.send({ message: 'Persona deleted successfully' }) }) /** - * Get ontology for a specific persona. + * Get a persona's ontology. + * + * @route GET /api/personas/:id/ontology */ fastify.get<{ Params: { id: string } }>('/api/personas/:id/ontology', { onRequest: [optionalAuth, buildAbilities], @@ -633,75 +289,20 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { id: Type.String({ format: 'uuid' }) }), response: { - 200: Type.Object({ - id: Type.String(), - personaId: Type.String(), - entities: Type.Array(Type.Any()), - roles: Type.Array(Type.Any()), - events: Type.Array(Type.Any()), - relationTypes: Type.Array(Type.Any()), - relations: Type.Array(Type.Any()), - createdAt: Type.String(), - updatedAt: Type.String() - }), + 200: OntologyResponseSchema, 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', id) - } - - // Unauthenticated: only system personas are visible - if (!request.user || !request.ability) { - if (!persona.isSystemGenerated || persona.hidden) { - throw new NotFoundError('Persona or ontology', id) - } - } else if (!request.ability.can('read', subject('Persona', persona))) { - // FOVEA_DEMO_MODE override: in demo mode every system- - // generated persona is part of the deployment's public - // tour catalogue and must be readable by any caller whose - // CASL ability is scoped to their own data (anonymous demo - // sessions, non-admin users opening a tour). Without this - // widening the VideoBrowser cannot fetch the seeded - // persona's ontology and every video card renders blank. - if (!isDemoModeEnabled() || !persona.isSystemGenerated) { - throw new ForbiddenError('Access denied') - } - } - - // Map database field names to API field names - return reply.send({ - id: persona.ontology.id, - personaId: persona.ontology.personaId, - entities: persona.ontology.entityTypes || [], - roles: persona.ontology.roleTypes || [], - events: persona.ontology.eventTypes || [], - relationTypes: persona.ontology.relationTypes || [], - relations: [], - createdAt: persona.ontology.createdAt.toISOString(), - updatedAt: persona.ontology.updatedAt.toISOString() - }) + const service = serviceFor(request) + const ontology = await service.getOntology(request.params.id) + return reply.send(ontology) }) /** * Batch-fetch ontologies for many personas in one round-trip. * - * The VideoBrowser needs every visible persona's ontology on initial load. - * Fetching them one at a time (GET /api/personas/:id/ontology per persona) - * turns a single page load into one request per persona, which on a large - * deployment fans out far enough to trip the rate limit. This endpoint - * applies the same per-persona read-permission rules as the single GET and - * returns only the ontologies the caller may read (personas that are missing, - * have no ontology, or are not readable are simply omitted). Each entry - * carries its `personaId` so the client can index the result. + * @route POST /api/personas/ontologies */ fastify.post<{ Body: { personaIds: string[] } }>('/api/personas/ontologies', { onRequest: [optionalAuth, buildAbilities], @@ -712,63 +313,27 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { personaIds: Type.Array(Type.String({ format: 'uuid' }), { maxItems: 100000 }) }), response: { - 200: Type.Array(Type.Object({ - id: Type.String(), - personaId: Type.String(), - entities: Type.Array(Type.Any()), - roles: Type.Array(Type.Any()), - events: Type.Array(Type.Any()), - relationTypes: Type.Array(Type.Any()), - relations: Type.Array(Type.Any()), - createdAt: Type.String(), - updatedAt: Type.String() - })) + 200: Type.Array(OntologyResponseSchema) } } }, async (request, reply) => { - const { personaIds } = request.body - if (personaIds.length === 0) { - return reply.send([]) - } - - const personas = await fastify.prisma.persona.findMany({ - where: { id: { in: personaIds } }, - include: { ontology: true } - }) - - const ontologies = [] - for (const persona of personas) { - if (!persona.ontology) continue - - // Mirror the read checks from GET /api/personas/:id/ontology exactly. - if (!request.user || !request.ability) { - // Unauthenticated: only visible system personas. - if (!persona.isSystemGenerated || persona.hidden) continue - } else if (!request.ability.can('read', subject('Persona', persona))) { - // Demo mode widens read to seeded system personas (see single GET). - if (!isDemoModeEnabled() || !persona.isSystemGenerated) continue - } - - ontologies.push({ - id: persona.ontology.id, - personaId: persona.ontology.personaId, - entities: persona.ontology.entityTypes || [], - roles: persona.ontology.roleTypes || [], - events: persona.ontology.eventTypes || [], - relationTypes: persona.ontology.relationTypes || [], - relations: [], - createdAt: persona.ontology.createdAt.toISOString(), - updatedAt: persona.ontology.updatedAt.toISOString() - }) - } - + const service = serviceFor(request) + const ontologies = await service.getOntologies(request.body.personaIds) return reply.send(ontologies) }) /** - * Update ontology for a specific persona. + * Update a persona's ontology. + * + * @route PUT /api/personas/:id/ontology */ - fastify.put<{ Params: { id: string }; Body: OntologyUpdateBody }>('/api/personas/:id/ontology', { + fastify.put<{ Params: { id: string }; Body: { + entities?: unknown[] + roles?: unknown[] + events?: unknown[] + relationTypes?: unknown[] + relations?: unknown[] + } }>('/api/personas/:id/ontology', { onRequest: [requireAuth, buildAbilities], schema: { description: 'Update ontology for a specific persona', @@ -784,61 +349,14 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { relations: Type.Optional(Type.Array(Type.Any())) }), response: { - 200: Type.Object({ - id: Type.String(), - personaId: Type.String(), - entities: Type.Array(Type.Any()), - roles: Type.Array(Type.Any()), - events: Type.Array(Type.Any()), - relationTypes: Type.Array(Type.Any()), - relations: Type.Array(Type.Any()), - createdAt: Type.String(), - updatedAt: Type.String() - }), + 200: OntologyResponseSchema, 404: Type.Object({ error: Type.String() }) } } }, async (request, reply) => { - const { id } = request.params - if (!request.ability) throw new ForbiddenError('No abilities defined') - const updateData = request.body - - const persona = await fastify.prisma.persona.findUnique({ - where: { id }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', id) - } - - if (!request.ability.can('update', subject('Persona', persona))) { - throw new ForbiddenError('Cannot update this Persona') - } - - // Map API field names to database field names - const updatedOntology = await fastify.prisma.ontology.update({ - where: { personaId: id }, - data: { - entityTypes: updateData.entities !== undefined ? (updateData.entities as Prisma.InputJsonValue) : (persona.ontology.entityTypes ?? undefined), - roleTypes: updateData.roles !== undefined ? (updateData.roles as Prisma.InputJsonValue) : (persona.ontology.roleTypes ?? undefined), - eventTypes: updateData.events !== undefined ? (updateData.events as Prisma.InputJsonValue) : (persona.ontology.eventTypes ?? undefined), - relationTypes: updateData.relationTypes !== undefined ? (updateData.relationTypes as Prisma.InputJsonValue) : (persona.ontology.relationTypes ?? undefined) - } - }) - - // Map database field names back to API field names in response - return reply.send({ - id: updatedOntology.id, - personaId: updatedOntology.personaId, - entities: updatedOntology.entityTypes || [], - roles: updatedOntology.roleTypes || [], - events: updatedOntology.eventTypes || [], - relationTypes: updatedOntology.relationTypes || [], - relations: [], - createdAt: updatedOntology.createdAt.toISOString(), - updatedAt: updatedOntology.updatedAt.toISOString() - }) + const service = serviceFor(request) + const ontology = await service.updateOntology(request.params.id, request.body) + return reply.send(ontology) }) // ========================================================================== @@ -846,8 +364,7 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { // ========================================================================== /** - * Get deletion preview for an entity type. - * Returns counts of items that will be affected. + * Deletion preview for an entity type. * * @route GET /api/personas/:personaId/ontology/entities/:typeId/deletion-preview */ @@ -873,71 +390,14 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('read', subject('Persona', persona))) { - throw new ForbiddenError('Cannot access this Persona') - } - - // Check if type exists - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const targetType = entityTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Entity type', typeId) - } - - // Count gloss references across all types - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes.filter(t => t.id !== typeId), typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'entity') - - // Count annotations with this typeId - const annotationCount = await fastify.prisma.annotation.count({ - where: { - personaId, - type: 'entity', - label: typeId - } - }) - - // Count world state type assignments - let worldAssignmentCount = 0 - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: persona.userId, projectId: null } - }) - - if (worldState) { - const entities = asEntities(worldState.entities) - worldAssignmentCount = countTypeAssignments(entities, typeId, personaId) - } - - return reply.send({ - glossReferences, - annotationCount, - worldAssignmentCount - }) + return reply.send(await service.getEntityTypeDeletionPreview(personaId, typeId)) } ) /** * Delete an entity type with reference cleanup. - * Converts typeRef items in glosses to plain text and clears related annotations/assignments. * * @route DELETE /api/personas/:personaId/ontology/entities/:typeId */ @@ -966,102 +426,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('delete', subject('Persona', persona))) { - throw new ForbiddenError('Cannot modify this Persona') - } - - // Find and remove the type - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const targetType = entityTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Entity type', typeId) - } - - const typeName = targetType.name - const updatedEntityTypes = entityTypes.filter(t => t.id !== typeId) - - // Convert typeRefs in glosses across all types - let glossReferences = 0 - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - // Count before updating - glossReferences += countTypeRefsInGlosses(updatedEntityTypes, typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'entity') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'entity') - - // Update glosses - const cleanedEntityTypes = updateGlossesInTypes(updatedEntityTypes, typeId, personaId, 'entity', typeName) - const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'entity', typeName) - const cleanedEventTypes = updateGlossesInTypes(eventTypes, typeId, personaId, 'entity', typeName) - const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'entity', typeName) - - // Delete annotations with this type - const deleteResult = await fastify.prisma.annotation.deleteMany({ - where: { - personaId, - type: 'entity', - label: typeId - } - }) - - // Clean up world state type assignments - let worldAssignments = 0 - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: persona.userId, projectId: null } - }) - - if (worldState) { - const entities = asEntities(worldState.entities) - worldAssignments = countTypeAssignments(entities, typeId, personaId) - const cleanedEntities = removeTypeAssignmentsFromEntities(entities, typeId, personaId) - - await fastify.prisma.worldState.update({ - where: { id: worldState.id }, - data: { - entities: toJson(cleanedEntities) - } - }) - } - - // Update ontology - await fastify.prisma.ontology.update({ - where: { personaId }, - data: { - entityTypes: toJson(cleanedEntityTypes), - roleTypes: toJson(cleanedRoleTypes), - eventTypes: toJson(cleanedEventTypes), - relationTypes: toJson(cleanedRelationTypes) - } - }) - - return reply.send({ - message: `Entity type "${typeName}" deleted successfully`, - cleanedUp: { - glossReferences, - annotations: deleteResult.count, - worldAssignments - } - }) + return reply.send(await service.deleteEntityType(personaId, typeId)) } ) /** - * Get deletion preview for a role type. + * Deletion preview for a role type. + * + * @route GET /api/personas/:personaId/ontology/roles/:typeId/deletion-preview */ fastify.get<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/roles/:typeId/deletion-preview', @@ -1085,72 +459,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('read', subject('Persona', persona))) { - throw new ForbiddenError('Cannot access this Persona') - } - - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const targetType = roleTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Role type', typeId) - } - - // Count gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const eventTypesForGloss = asTypesWithGloss(persona.ontology.eventTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(roleTypes.filter(t => t.id !== typeId), typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(eventTypesForGloss, typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'role') - - // Count annotations with this role type - const annotationCount = await fastify.prisma.annotation.count({ - where: { - personaId, - type: 'role', - label: typeId - } - }) - - // Count event types that use this role - need full EventType for roles property - const eventTypesRaw = persona.ontology.eventTypes - let eventRoleReferences = 0 - if (Array.isArray(eventTypesRaw)) { - for (const eventType of eventTypesRaw) { - if (eventType && typeof eventType === 'object' && 'roles' in eventType) { - const roles = (eventType as { roles?: Array<{ roleTypeId: string }> }).roles - if (roles) { - eventRoleReferences += roles.filter(r => r.roleTypeId === typeId).length - } - } - } - } - - return reply.send({ - glossReferences, - annotationCount, - eventRoleReferences - }) + return reply.send(await service.getRoleTypeDeletionPreview(personaId, typeId)) } ) /** * Delete a role type with reference cleanup. + * + * @route DELETE /api/personas/:personaId/ontology/roles/:typeId */ fastify.delete<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/roles/:typeId', @@ -1177,108 +495,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('delete', subject('Persona', persona))) { - throw new ForbiddenError('Cannot modify this Persona') - } - - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const targetType = roleTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Role type', typeId) - } - - const typeName = targetType.name - const updatedRoleTypes = roleTypes.filter(t => t.id !== typeId) - - // Count and update gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const eventTypesForGloss = asTypesWithGloss(persona.ontology.eventTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(updatedRoleTypes, typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(eventTypesForGloss, typeId, personaId, 'role') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'role') - - const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'role', typeName) - const cleanedRoleTypes = updateGlossesInTypes(updatedRoleTypes, typeId, personaId, 'role', typeName) - const cleanedEventTypesGloss = updateGlossesInTypes(eventTypesForGloss, typeId, personaId, 'role', typeName) - const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'role', typeName) - - // Remove role from event type role slots - need full EventType for roles property - const eventTypesRaw = persona.ontology.eventTypes - let eventRoleReferences = 0 - let cleanedEventTypes = cleanedEventTypesGloss - if (Array.isArray(eventTypesRaw)) { - for (const eventType of eventTypesRaw) { - if (eventType && typeof eventType === 'object' && 'roles' in eventType) { - const roles = (eventType as { roles?: Array<{ roleTypeId: string }> }).roles - if (roles) { - eventRoleReferences += roles.filter(r => r.roleTypeId === typeId).length - } - } - } - // Update cleanedEventTypes to also remove role references - cleanedEventTypes = cleanedEventTypesGloss.map(et => { - const rawEvent = eventTypesRaw.find(raw => - raw && typeof raw === 'object' && 'id' in raw && (raw as { id: string }).id === et.id - ) - if (rawEvent && typeof rawEvent === 'object' && 'roles' in rawEvent) { - const roles = (rawEvent as { roles?: Array<{ roleTypeId: string }> }).roles - if (roles) { - return { ...et, roles: roles.filter(r => r.roleTypeId !== typeId) } - } - } - return et - }) - } - - // Delete annotations - const deleteResult = await fastify.prisma.annotation.deleteMany({ - where: { - personaId, - type: 'role', - label: typeId - } - }) - - // Update ontology - await fastify.prisma.ontology.update({ - where: { personaId }, - data: { - entityTypes: toJson(cleanedEntityTypes), - roleTypes: toJson(cleanedRoleTypes), - eventTypes: toJson(cleanedEventTypes), - relationTypes: toJson(cleanedRelationTypes) - } - }) - - return reply.send({ - message: `Role type "${typeName}" deleted successfully`, - cleanedUp: { - glossReferences, - annotations: deleteResult.count, - eventRoleReferences - } - }) + return reply.send(await service.deleteRoleType(personaId, typeId)) } ) /** - * Get deletion preview for an event type. + * Deletion preview for an event type. + * + * @route GET /api/personas/:personaId/ontology/events/:typeId/deletion-preview */ fastify.get<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/events/:typeId/deletion-preview', @@ -1302,69 +528,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('read', subject('Persona', persona))) { - throw new ForbiddenError('Cannot access this Persona') - } - - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - const targetType = eventTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Event type', typeId) - } - - // Count gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(eventTypes.filter(t => t.id !== typeId), typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'event') - - // Count annotations - const annotationCount = await fastify.prisma.annotation.count({ - where: { - personaId, - type: 'event', - label: typeId - } - }) - - // Count world state event interpretations - let worldInterpretationCount = 0 - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: persona.userId, projectId: null } - }) - - if (worldState) { - const events = asEvents(worldState.events) - worldInterpretationCount = countEventInterpretations(events, typeId, personaId) - } - - return reply.send({ - glossReferences, - annotationCount, - worldInterpretationCount - }) + return reply.send(await service.getEventTypeDeletionPreview(personaId, typeId)) } ) /** * Delete an event type with reference cleanup. + * + * @route DELETE /api/personas/:personaId/ontology/events/:typeId */ fastify.delete<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/events/:typeId', @@ -1391,99 +564,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('delete', subject('Persona', persona))) { - throw new ForbiddenError('Cannot modify this Persona') - } - - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - const targetType = eventTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Event type', typeId) - } - - const typeName = targetType.name - const updatedEventTypes = eventTypes.filter(t => t.id !== typeId) - - // Count and update gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(updatedEventTypes, typeId, personaId, 'event') - glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'event') - - const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'event', typeName) - const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'event', typeName) - const cleanedEventTypes = updateGlossesInTypes(updatedEventTypes, typeId, personaId, 'event', typeName) - const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'event', typeName) - - // Delete annotations - const deleteResult = await fastify.prisma.annotation.deleteMany({ - where: { - personaId, - type: 'event', - label: typeId - } - }) - - // Clean up world state event interpretations - let worldInterpretations = 0 - const worldState = await fastify.prisma.worldState.findFirst({ - where: { userId: persona.userId, projectId: null } - }) - - if (worldState) { - const events = asEvents(worldState.events) - worldInterpretations = countEventInterpretations(events, typeId, personaId) - const cleanedEvents = removeEventInterpretationsFromEvents(events, typeId, personaId) - - await fastify.prisma.worldState.update({ - where: { id: worldState.id }, - data: { - events: toJson(cleanedEvents) - } - }) - } - - // Update ontology - await fastify.prisma.ontology.update({ - where: { personaId }, - data: { - entityTypes: toJson(cleanedEntityTypes), - roleTypes: toJson(cleanedRoleTypes), - eventTypes: toJson(cleanedEventTypes), - relationTypes: toJson(cleanedRelationTypes) - } - }) - - return reply.send({ - message: `Event type "${typeName}" deleted successfully`, - cleanedUp: { - glossReferences, - annotations: deleteResult.count, - worldInterpretations - } - }) + return reply.send(await service.deleteEventType(personaId, typeId)) } ) /** - * Get deletion preview for a relation type. + * Deletion preview for a relation type. + * + * @route GET /api/personas/:personaId/ontology/relation-types/:typeId/deletion-preview */ fastify.get<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/relation-types/:typeId/deletion-preview', @@ -1506,52 +596,16 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('read', subject('Persona', persona))) { - throw new ForbiddenError('Cannot access this Persona') - } - - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - const targetType = relationTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Relation type', typeId) - } - - // Count gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(relationTypes.filter(t => t.id !== typeId), typeId, personaId, 'relation') - - // Note: Relation instances would need to be stored somewhere to count them - // For now, we return 0 as they're handled client-side - const relationInstanceCount = 0 - - return reply.send({ - glossReferences, - relationInstanceCount - }) + return reply.send(await service.getRelationTypeDeletionPreview(personaId, typeId)) } ) /** * Delete a relation type with reference cleanup. + * + * @route DELETE /api/personas/:personaId/ontology/relation-types/:typeId */ fastify.delete<{ Params: { personaId: string; typeId: string } }>( '/api/personas/:personaId/ontology/relation-types/:typeId', @@ -1576,64 +630,9 @@ const personasRoute: FastifyPluginAsync = async (fastify) => { } }, async (request, reply) => { + const service = serviceFor(request) const { personaId, typeId } = request.params - - const persona = await fastify.prisma.persona.findUnique({ - where: { id: personaId }, - include: { ontology: true } - }) - - if (!persona || !persona.ontology) { - throw new NotFoundError('Persona or ontology', personaId) - } - - if (!request.ability) throw new ForbiddenError('No abilities defined') - if (!request.ability.can('delete', subject('Persona', persona))) { - throw new ForbiddenError('Cannot modify this Persona') - } - - const relationTypes = asTypesWithGloss(persona.ontology.relationTypes) - const targetType = relationTypes.find(t => t.id === typeId) - if (!targetType) { - throw new NotFoundError('Relation type', typeId) - } - - const typeName = targetType.name - const updatedRelationTypes = relationTypes.filter(t => t.id !== typeId) - - // Count and update gloss references - const entityTypes = asTypesWithGloss(persona.ontology.entityTypes) - const roleTypes = asTypesWithGloss(persona.ontology.roleTypes) - const eventTypes = asTypesWithGloss(persona.ontology.eventTypes) - - let glossReferences = 0 - glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'relation') - glossReferences += countTypeRefsInGlosses(updatedRelationTypes, typeId, personaId, 'relation') - - const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'relation', typeName) - const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'relation', typeName) - const cleanedEventTypes = updateGlossesInTypes(eventTypes, typeId, personaId, 'relation', typeName) - const cleanedRelationTypes = updateGlossesInTypes(updatedRelationTypes, typeId, personaId, 'relation', typeName) - - // Update ontology - await fastify.prisma.ontology.update({ - where: { personaId }, - data: { - entityTypes: toJson(cleanedEntityTypes), - roleTypes: toJson(cleanedRoleTypes), - eventTypes: toJson(cleanedEventTypes), - relationTypes: toJson(cleanedRelationTypes) - } - }) - - return reply.send({ - message: `Relation type "${typeName}" deleted successfully`, - cleanedUp: { - glossReferences - } - }) + return reply.send(await service.deleteRelationType(personaId, typeId)) } ) } diff --git a/server/src/services/persona-service.ts b/server/src/services/persona-service.ts new file mode 100644 index 00000000..84e301c5 --- /dev/null +++ b/server/src/services/persona-service.ts @@ -0,0 +1,1032 @@ +import { Persona, Prisma } from '@prisma/client' +import { accessibleBy } from '@casl/prisma' +import { subject } from '@casl/ability' +import type { AppAbility } from '../lib/abilities.js' +import { NotFoundError, ForbiddenError } from '../lib/errors.js' +import { isDemoModeEnabled } from '../lib/demo-flags.js' +import { isSingleUserMode } from './user-service.js' +import { + PersonaRepository, + PersonaWithOntology +} from '../repositories/PersonaRepository.js' +import { + updateGlossesInTypes, + countTypeRefsInGlosses, + removeTypeAssignmentsFromEntities, + removeEventInterpretationsFromEvents, + countTypeAssignments, + countEventInterpretations +} from '../lib/reference-cleanup.js' +import { asTypesWithGloss, asEntities, asEvents } from '../lib/prisma-json.js' + +/** + * Converts a typed array to Prisma.InputJsonValue for storage in JSON columns. + * Prisma JSON columns accept any serializable value at runtime; this bridges + * the TypeScript gap without an unsafe cast. + */ +function toJson(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue +} + +/** RefType categories used by the gloss reference cleanup helpers. */ +type RefType = 'entity' | 'role' | 'event' | 'relation' + +/** Maps a type-deletion endpoint to the ontology array it primarily targets. */ +type TypeCategory = 'entity' | 'role' | 'event' | 'relation' + +/** + * API-facing ontology shape. + * + * The database stores `entityTypes` / `roleTypes` / `eventTypes`; the API + * exposes them as `entities` / `roles` / `events` and always returns an empty + * `relations` array. Dates are ISO strings. + */ +export interface OntologyResponse { + id: string + personaId: string + entities: unknown[] + roles: unknown[] + events: unknown[] + relationTypes: unknown[] + relations: never[] + createdAt: string + updatedAt: string +} + +/** Validated, coerced fields for creating a persona. */ +export interface CreatePersonaInput { + name: string + role: string + informationNeed: string + details?: string + projectId?: string + isSystemGenerated?: boolean + hidden?: boolean +} + +/** Validated fields for updating a persona (all optional). */ +export interface UpdatePersonaInput { + name?: string + role?: string + informationNeed?: string + details?: string + isSystemGenerated?: boolean + hidden?: boolean +} + +/** Request body shape for the ontology update endpoint. */ +export interface OntologyUpdateInput { + entities?: unknown[] + roles?: unknown[] + events?: unknown[] + relationTypes?: unknown[] + relations?: unknown[] +} + +/** Counts returned by the persona deletion preview. */ +export interface PersonaDeletionPreview { + typeCount: number + annotationCount: number + summaryCount: number + worldAssignmentCount: number +} + +/** + * Owns persona business rules and RBAC, delegating all data access to a + * PersonaRepository. Construct one per request from the request-scoped CASL + * ability and the authenticated user's id and system role. + * + * The service performs every authorization decision (mode-branch selection, + * `accessibleBy` filters, instance-level `can()` checks, the create + * pre-check, and the `isSystemGenerated` coercion). The repository performs + * none. + * + * @example + * ```typescript + * const service = new PersonaService(repository, request.ability, request.user?.id, request.user?.systemRole) + * const personas = await service.list() + * ``` + */ +export class PersonaService { + constructor( + private readonly repository: PersonaRepository, + private readonly ability: AppAbility | null, + private readonly userId: string | undefined, + private readonly systemRole: string | undefined + ) {} + + /** + * Asserts that a CASL ability is present, returning it narrowed. + * + * Mirrors the per-request `if (!request.ability) throw new ForbiddenError(...)` + * guard the route used on every authenticated handler. + */ + private requireAbility(): AppAbility { + if (!this.ability) { + throw new ForbiddenError('No abilities defined') + } + return this.ability + } + + /** + * Lists the personas visible to the caller. + * + * Applies the four mode branches in order: + * - single-user mode: every non-hidden persona; + * - unauthenticated: non-hidden system personas; + * - demo mode: every system persona (hidden or not); + * - authenticated: non-hidden personas the caller may read (CASL). + * + * @returns the visible personas, newest first + */ + async list(): Promise { + if (isSingleUserMode()) { + return this.repository.findManyForList({ hidden: false }) + } + + if (!this.userId || !this.ability) { + return this.repository.findManyForList({ isSystemGenerated: true, hidden: false }) + } + + if (isDemoModeEnabled()) { + return this.repository.findManyForList({ isSystemGenerated: true }) + } + + return this.repository.findManyForList({ + AND: [ + { hidden: false }, + accessibleBy(this.ability, 'read').Persona + ] + }) + } + + /** + * Creates a persona with an empty ontology. + * + * Verifies the caller may create a persona in the target scope, then coerces + * `isSystemGenerated` to false for non-admins (only system_admin may set it). + * + * @param input - validated, coerced create fields + * @returns the created persona + * @throws {ForbiddenError} when no ability is present or the create is denied + */ + async create(input: CreatePersonaInput): Promise { + const ability = this.requireAbility() + const userId = this.userId! + const projectId = input.projectId ?? null + + const candidate = subject('Persona', { userId, projectId }) + if (!ability.can('create', candidate)) { + throw new ForbiddenError('Cannot create Persona in this scope') + } + + // Only system_admin may flag a persona as system-generated, since system + // personas are visible to unauthenticated visitors via the unauthenticated + // list branch. A non-admin attempt is silently coerced to false rather + // than 403 so clients that always send the field still succeed. + const isSystemGenerated = this.systemRole === 'system_admin' + ? input.isSystemGenerated + : false + + return this.repository.createWithOntology({ + name: input.name, + role: input.role, + informationNeed: input.informationNeed, + details: input.details || null, + isSystemGenerated, + hidden: input.hidden, + user: { connect: { id: userId } }, + ...(projectId ? { project: { connect: { id: projectId } } } : {}), + ontology: { + create: { + entityTypes: [], + eventTypes: [], + roleTypes: [], + relationTypes: [] + } + } + }) + } + + /** + * Gets a persona by ID, enforcing read access. + * + * Unauthenticated callers may only read non-hidden system personas; for them + * a non-system or hidden persona is reported as not found. Authenticated + * callers must pass the CASL read check. + * + * @param id - Persona UUID + * @returns the persona + * @throws {NotFoundError} when the persona does not exist or is not visible to an anonymous caller + * @throws {ForbiddenError} when an authenticated caller lacks read access + */ + async getById(id: string): Promise { + const persona = await this.repository.findById(id) + if (!persona) { + throw new NotFoundError('Persona', id) + } + + if (!this.userId || !this.ability) { + if (!persona.isSystemGenerated || persona.hidden) { + throw new NotFoundError('Persona', id) + } + return persona + } + + if (!this.ability.can('read', subject('Persona', persona))) { + throw new ForbiddenError('Access denied') + } + + return persona + } + + /** + * Updates a persona after verifying update access. + * + * Strips `isSystemGenerated` from non-admin updates so a regular user cannot + * publish their persona to anonymous visitors. + * + * @param id - Persona UUID + * @param input - validated update fields + * @returns the updated persona + * @throws {NotFoundError} when the persona does not exist + * @throws {ForbiddenError} when the caller lacks update access + */ + async update(id: string, input: UpdatePersonaInput): Promise { + const ability = this.requireAbility() + + const existing = await this.repository.findById(id) + if (!existing) { + throw new NotFoundError('Persona', id) + } + + if (!ability.can('update', subject('Persona', existing))) { + throw new ForbiddenError('Cannot update this Persona') + } + + const updatePayload: UpdatePersonaInput = { ...input } + if (this.systemRole !== 'system_admin') { + delete updatePayload.isSystemGenerated + } + + try { + return await this.repository.update(id, updatePayload) + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2025') { + throw new NotFoundError('Persona', id) + } + throw error + } + } + + /** + * Computes the deletion preview for a persona: counts of ontology types, + * annotations, summaries, and personal world-state assignments that the + * delete would affect. + * + * @param id - Persona UUID + * @returns the affected-item counts + * @throws {NotFoundError} when the persona does not exist + * @throws {ForbiddenError} when the caller lacks delete access + */ + async getDeletionPreview(id: string): Promise { + const ability = this.requireAbility() + + const persona = await this.repository.findByIdWithOntology(id) + if (!persona) { + throw new NotFoundError('Persona', id) + } + + if (!ability.can('delete', subject('Persona', persona))) { + throw new ForbiddenError('Cannot access this Persona') + } + + const entityTypes = Array.isArray(persona.ontology?.entityTypes) ? persona.ontology.entityTypes : [] + const roleTypes = Array.isArray(persona.ontology?.roleTypes) ? persona.ontology.roleTypes : [] + const eventTypes = Array.isArray(persona.ontology?.eventTypes) ? persona.ontology.eventTypes : [] + const relationTypes = Array.isArray(persona.ontology?.relationTypes) ? persona.ontology.relationTypes : [] + const typeCount = entityTypes.length + roleTypes.length + eventTypes.length + relationTypes.length + + const annotationCount = await this.repository.countAnnotations({ personaId: id }) + const summaryCount = await this.repository.countVideoSummaries(id) + + let worldAssignmentCount = 0 + const worldState = await this.repository.findPersonalWorldState(persona.userId) + if (worldState) { + const entities = (worldState.entities as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] + for (const entity of entities) { + worldAssignmentCount += (entity.typeAssignments || []).filter(a => a.personaId === id).length + } + + const events = (worldState.events as Array<{ personaInterpretations?: Array<{ personaId: string }> }>) || [] + for (const event of events) { + worldAssignmentCount += (event.personaInterpretations || []).filter(i => i.personaId === id).length + } + + const entityCollections = (worldState.entityCollections as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] + for (const collection of entityCollections) { + worldAssignmentCount += (collection.typeAssignments || []).filter(a => a.personaId === id).length + } + + const eventCollections = (worldState.eventCollections as Array<{ typeAssignments?: Array<{ personaId: string }> }>) || [] + for (const collection of eventCollections) { + worldAssignmentCount += (collection.typeAssignments || []).filter(a => a.personaId === id).length + } + } + + return { typeCount, annotationCount, summaryCount, worldAssignmentCount } + } + + /** + * Deletes a persona and cleans the owner's personal world state of the + * persona's type assignments and interpretations before removing the row. + * + * @param id - Persona UUID + * @throws {NotFoundError} when the persona does not exist + * @throws {ForbiddenError} when the caller lacks delete access + */ + async delete(id: string): Promise { + const ability = this.requireAbility() + + const existing = await this.repository.findById(id) + if (!existing) { + throw new NotFoundError('Persona', id) + } + + if (!ability.can('delete', subject('Persona', existing))) { + throw new ForbiddenError('Cannot delete this Persona') + } + + const worldState = await this.repository.findPersonalWorldState(existing.userId) + if (worldState) { + interface EntityWithAssignments { + typeAssignments?: Array<{ personaId: string; [key: string]: unknown }> + [key: string]: unknown + } + interface EventWithInterpretations { + personaInterpretations?: Array<{ personaId: string; [key: string]: unknown }> + [key: string]: unknown + } + interface CollectionWithAssignments { + typeAssignments?: Array<{ personaId: string; [key: string]: unknown }> + [key: string]: unknown + } + + const entities = (worldState.entities as EntityWithAssignments[]) || [] + const cleanedEntities = entities.map(entity => ({ + ...entity, + typeAssignments: (entity.typeAssignments || []).filter(a => a.personaId !== id) + })) + + const events = (worldState.events as EventWithInterpretations[]) || [] + const cleanedEvents = events.map(event => ({ + ...event, + personaInterpretations: (event.personaInterpretations || []).filter(i => i.personaId !== id) + })) + + const entityCollections = (worldState.entityCollections as CollectionWithAssignments[]) || [] + const cleanedEntityCollections = entityCollections.map(collection => ({ + ...collection, + typeAssignments: (collection.typeAssignments || []).filter(a => a.personaId !== id) + })) + + const eventCollections = (worldState.eventCollections as CollectionWithAssignments[]) || [] + const cleanedEventCollections = eventCollections.map(collection => ({ + ...collection, + typeAssignments: (collection.typeAssignments || []).filter(a => a.personaId !== id) + })) + + await this.repository.updateWorldState(worldState.id, { + entities: toJson(cleanedEntities), + events: toJson(cleanedEvents), + entityCollections: toJson(cleanedEntityCollections), + eventCollections: toJson(cleanedEventCollections) + }) + } + + try { + await this.repository.delete(id) + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2025') { + throw new NotFoundError('Persona', id) + } + throw error + } + } + + /** + * Gets a persona's ontology mapped to the API response shape. + * + * Read access mirrors getById, with the demo-mode widening: in demo mode any + * caller may read a system persona's ontology. + * + * @param id - Persona UUID + * @returns the ontology in API shape + * @throws {NotFoundError} when the persona or its ontology does not exist, or is not visible to an anonymous caller + * @throws {ForbiddenError} when an authenticated caller lacks read access + */ + async getOntology(id: string): Promise { + const persona = await this.repository.findByIdWithOntology(id) + if (!persona || !persona.ontology) { + throw new NotFoundError('Persona or ontology', id) + } + + if (!this.userId || !this.ability) { + if (!persona.isSystemGenerated || persona.hidden) { + throw new NotFoundError('Persona or ontology', id) + } + } else if (!this.ability.can('read', subject('Persona', persona))) { + // Demo mode widens read access to seeded system personas so the public + // tour catalogue can fetch their ontologies. + if (!isDemoModeEnabled() || !persona.isSystemGenerated) { + throw new ForbiddenError('Access denied') + } + } + + return this.mapOntologyResponse(persona.ontology) + } + + /** + * Batch-fetches ontologies for many personas, applying the same per-persona + * read rules as getOntology. Personas that are missing, have no ontology, or + * are not readable are omitted. + * + * @param personaIds - Persona UUIDs to fetch + * @returns the readable ontologies, in API shape + */ + async getOntologies(personaIds: string[]): Promise { + if (personaIds.length === 0) { + return [] + } + + const personas = await this.repository.findManyWithOntology(personaIds) + const result: OntologyResponse[] = [] + + for (const persona of personas) { + if (!persona.ontology) continue + + if (!this.userId || !this.ability) { + if (!persona.isSystemGenerated || persona.hidden) continue + } else if (!this.ability.can('read', subject('Persona', persona))) { + if (!isDemoModeEnabled() || !persona.isSystemGenerated) continue + } + + result.push(this.mapOntologyResponse(persona.ontology)) + } + + return result + } + + /** + * Updates a persona's ontology from the API request body and returns the + * updated ontology in API shape. Fields omitted from the body are preserved. + * + * @param id - Persona UUID + * @param input - ontology update body (entities/roles/events/relationTypes) + * @returns the updated ontology in API shape + * @throws {NotFoundError} when the persona or its ontology does not exist + * @throws {ForbiddenError} when the caller lacks update access + */ + async updateOntology(id: string, input: OntologyUpdateInput): Promise { + const ability = this.requireAbility() + + const persona = await this.repository.findByIdWithOntology(id) + if (!persona || !persona.ontology) { + throw new NotFoundError('Persona or ontology', id) + } + + if (!ability.can('update', subject('Persona', persona))) { + throw new ForbiddenError('Cannot update this Persona') + } + + const updated = await this.repository.updateOntology(id, { + entityTypes: input.entities !== undefined ? (input.entities as Prisma.InputJsonValue) : (persona.ontology.entityTypes ?? undefined), + roleTypes: input.roles !== undefined ? (input.roles as Prisma.InputJsonValue) : (persona.ontology.roleTypes ?? undefined), + eventTypes: input.events !== undefined ? (input.events as Prisma.InputJsonValue) : (persona.ontology.eventTypes ?? undefined), + relationTypes: input.relationTypes !== undefined ? (input.relationTypes as Prisma.InputJsonValue) : (persona.ontology.relationTypes ?? undefined) + }) + + return this.mapOntologyResponse(updated) + } + + /** + * Loads a persona with its ontology and enforces read access for the type + * deletion-preview endpoints. Demo-mode widening does not apply here (these + * endpoints require authentication). + * + * @throws {NotFoundError} when the persona or its ontology does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + private async loadForTypeRead(personaId: string): Promise { + const persona = await this.repository.findByIdWithOntology(personaId) + if (!persona || !persona.ontology) { + throw new NotFoundError('Persona or ontology', personaId) + } + + const ability = this.requireAbility() + if (!ability.can('read', subject('Persona', persona))) { + throw new ForbiddenError('Cannot access this Persona') + } + + return persona + } + + /** + * Loads a persona with its ontology and enforces delete access for the type + * deletion endpoints. + * + * @throws {NotFoundError} when the persona or its ontology does not exist + * @throws {ForbiddenError} when no ability is present or delete access is denied + */ + private async loadForTypeDelete(personaId: string): Promise { + const persona = await this.repository.findByIdWithOntology(personaId) + if (!persona || !persona.ontology) { + throw new NotFoundError('Persona or ontology', personaId) + } + + const ability = this.requireAbility() + if (!ability.can('delete', subject('Persona', persona))) { + throw new ForbiddenError('Cannot modify this Persona') + } + + return persona + } + + /** + * Counts gloss references to a type across all four ontology type arrays, + * with the target array filtered to exclude the type itself. + */ + private countGlossRefsAcrossOntology( + ontology: PersonaWithOntology['ontology'], + personaId: string, + typeId: string, + refType: RefType, + selfCategory: TypeCategory + ): number { + const entityTypes = asTypesWithGloss(ontology!.entityTypes) + const roleTypes = asTypesWithGloss(ontology!.roleTypes) + const eventTypes = asTypesWithGloss(ontology!.eventTypes) + const relationTypes = asTypesWithGloss(ontology!.relationTypes) + + const exclude = (types: typeof entityTypes, category: TypeCategory) => + category === selfCategory ? types.filter(t => t.id !== typeId) : types + + let count = 0 + count += countTypeRefsInGlosses(exclude(entityTypes, 'entity'), typeId, personaId, refType) + count += countTypeRefsInGlosses(exclude(roleTypes, 'role'), typeId, personaId, refType) + count += countTypeRefsInGlosses(exclude(eventTypes, 'event'), typeId, personaId, refType) + count += countTypeRefsInGlosses(exclude(relationTypes, 'relation'), typeId, personaId, refType) + return count + } + + /** + * Deletion preview for an entity type. + * + * @returns gloss reference, annotation, and world-assignment counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when read access is denied + */ + async getEntityTypeDeletionPreview( + personaId: string, + typeId: string + ): Promise<{ glossReferences: number; annotationCount: number; worldAssignmentCount: number }> { + const persona = await this.loadForTypeRead(personaId) + + const entityTypes = asTypesWithGloss(persona.ontology!.entityTypes) + if (!entityTypes.find(t => t.id === typeId)) { + throw new NotFoundError('Entity type', typeId) + } + + const glossReferences = this.countGlossRefsAcrossOntology(persona.ontology, personaId, typeId, 'entity', 'entity') + + const annotationCount = await this.repository.countAnnotations({ + personaId, + type: 'entity', + label: typeId + }) + + let worldAssignmentCount = 0 + const worldState = await this.repository.findPersonalWorldState(persona.userId) + if (worldState) { + const entities = asEntities(worldState.entities) + worldAssignmentCount = countTypeAssignments(entities, typeId, personaId) + } + + return { glossReferences, annotationCount, worldAssignmentCount } + } + + /** + * Deletes an entity type, converting gloss references to text, deleting + * matching annotations, and cleaning the personal world state. + * + * @returns the success message and the cleaned-up counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when delete access is denied + */ + async deleteEntityType( + personaId: string, + typeId: string + ): Promise<{ message: string; cleanedUp: { glossReferences: number; annotations: number; worldAssignments: number } }> { + const persona = await this.loadForTypeDelete(personaId) + const ontology = persona.ontology! + + const entityTypes = asTypesWithGloss(ontology.entityTypes) + const targetType = entityTypes.find(t => t.id === typeId) + if (!targetType) { + throw new NotFoundError('Entity type', typeId) + } + + const typeName = targetType.name + const updatedEntityTypes = entityTypes.filter(t => t.id !== typeId) + + const roleTypes = asTypesWithGloss(ontology.roleTypes) + const eventTypes = asTypesWithGloss(ontology.eventTypes) + const relationTypes = asTypesWithGloss(ontology.relationTypes) + + let glossReferences = 0 + glossReferences += countTypeRefsInGlosses(updatedEntityTypes, typeId, personaId, 'entity') + glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'entity') + glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'entity') + glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'entity') + + const cleanedEntityTypes = updateGlossesInTypes(updatedEntityTypes, typeId, personaId, 'entity', typeName) + const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'entity', typeName) + const cleanedEventTypes = updateGlossesInTypes(eventTypes, typeId, personaId, 'entity', typeName) + const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'entity', typeName) + + const deleteResult = await this.repository.deleteAnnotations({ + personaId, + type: 'entity', + label: typeId + }) + + let worldAssignments = 0 + const worldState = await this.repository.findPersonalWorldState(persona.userId) + if (worldState) { + const entities = asEntities(worldState.entities) + worldAssignments = countTypeAssignments(entities, typeId, personaId) + const cleanedEntities = removeTypeAssignmentsFromEntities(entities, typeId, personaId) + await this.repository.updateWorldState(worldState.id, { + entities: toJson(cleanedEntities) + }) + } + + await this.repository.updateOntology(personaId, { + entityTypes: toJson(cleanedEntityTypes), + roleTypes: toJson(cleanedRoleTypes), + eventTypes: toJson(cleanedEventTypes), + relationTypes: toJson(cleanedRelationTypes) + }) + + return { + message: `Entity type "${typeName}" deleted successfully`, + cleanedUp: { + glossReferences, + annotations: deleteResult.count, + worldAssignments + } + } + } + + /** + * Deletion preview for a role type. + * + * @returns gloss reference, annotation, and event-role reference counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when read access is denied + */ + async getRoleTypeDeletionPreview( + personaId: string, + typeId: string + ): Promise<{ glossReferences: number; annotationCount: number; eventRoleReferences: number }> { + const persona = await this.loadForTypeRead(personaId) + const ontology = persona.ontology! + + const roleTypes = asTypesWithGloss(ontology.roleTypes) + if (!roleTypes.find(t => t.id === typeId)) { + throw new NotFoundError('Role type', typeId) + } + + const glossReferences = this.countGlossRefsAcrossOntology(ontology, personaId, typeId, 'role', 'role') + + const annotationCount = await this.repository.countAnnotations({ + personaId, + type: 'role', + label: typeId + }) + + const eventRoleReferences = this.countEventRoleReferences(ontology.eventTypes, typeId) + + return { glossReferences, annotationCount, eventRoleReferences } + } + + /** + * Deletes a role type, converting gloss references to text, removing the role + * from event-type role slots, and deleting matching annotations. + * + * @returns the success message and the cleaned-up counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when delete access is denied + */ + async deleteRoleType( + personaId: string, + typeId: string + ): Promise<{ message: string; cleanedUp: { glossReferences: number; annotations: number; eventRoleReferences: number } }> { + const persona = await this.loadForTypeDelete(personaId) + const ontology = persona.ontology! + + const roleTypes = asTypesWithGloss(ontology.roleTypes) + const targetType = roleTypes.find(t => t.id === typeId) + if (!targetType) { + throw new NotFoundError('Role type', typeId) + } + + const typeName = targetType.name + const updatedRoleTypes = roleTypes.filter(t => t.id !== typeId) + + const entityTypes = asTypesWithGloss(ontology.entityTypes) + const eventTypesForGloss = asTypesWithGloss(ontology.eventTypes) + const relationTypes = asTypesWithGloss(ontology.relationTypes) + + let glossReferences = 0 + glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'role') + glossReferences += countTypeRefsInGlosses(updatedRoleTypes, typeId, personaId, 'role') + glossReferences += countTypeRefsInGlosses(eventTypesForGloss, typeId, personaId, 'role') + glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'role') + + const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'role', typeName) + const cleanedRoleTypes = updateGlossesInTypes(updatedRoleTypes, typeId, personaId, 'role', typeName) + const cleanedEventTypesGloss = updateGlossesInTypes(eventTypesForGloss, typeId, personaId, 'role', typeName) + const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'role', typeName) + + // Remove the role from event-type role slots, preserving every other field + // on each event type. The raw event types carry the `roles` array, which + // the gloss-only mapping above does not. + const eventTypesRaw = ontology.eventTypes + const eventRoleReferences = this.countEventRoleReferences(eventTypesRaw, typeId) + let cleanedEventTypes = cleanedEventTypesGloss + if (Array.isArray(eventTypesRaw)) { + cleanedEventTypes = cleanedEventTypesGloss.map(et => { + const rawEvent = eventTypesRaw.find(raw => + raw && typeof raw === 'object' && 'id' in raw && (raw as { id: string }).id === et.id + ) + if (rawEvent && typeof rawEvent === 'object' && 'roles' in rawEvent) { + const roles = (rawEvent as { roles?: Array<{ roleTypeId: string }> }).roles + if (roles) { + return { ...et, roles: roles.filter(r => r.roleTypeId !== typeId) } + } + } + return et + }) + } + + const deleteResult = await this.repository.deleteAnnotations({ + personaId, + type: 'role', + label: typeId + }) + + await this.repository.updateOntology(personaId, { + entityTypes: toJson(cleanedEntityTypes), + roleTypes: toJson(cleanedRoleTypes), + eventTypes: toJson(cleanedEventTypes), + relationTypes: toJson(cleanedRelationTypes) + }) + + return { + message: `Role type "${typeName}" deleted successfully`, + cleanedUp: { + glossReferences, + annotations: deleteResult.count, + eventRoleReferences + } + } + } + + /** + * Counts the references to a role type across event-type role slots in a raw + * ontology event-types JSON value. + */ + private countEventRoleReferences(eventTypesRaw: Prisma.JsonValue, roleTypeId: string): number { + let count = 0 + if (Array.isArray(eventTypesRaw)) { + for (const eventType of eventTypesRaw) { + if (eventType && typeof eventType === 'object' && 'roles' in eventType) { + const roles = (eventType as { roles?: Array<{ roleTypeId: string }> }).roles + if (roles) { + count += roles.filter(r => r.roleTypeId === roleTypeId).length + } + } + } + } + return count + } + + /** + * Deletion preview for an event type. + * + * @returns gloss reference, annotation, and world-interpretation counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when read access is denied + */ + async getEventTypeDeletionPreview( + personaId: string, + typeId: string + ): Promise<{ glossReferences: number; annotationCount: number; worldInterpretationCount: number }> { + const persona = await this.loadForTypeRead(personaId) + const ontology = persona.ontology! + + const eventTypes = asTypesWithGloss(ontology.eventTypes) + if (!eventTypes.find(t => t.id === typeId)) { + throw new NotFoundError('Event type', typeId) + } + + const glossReferences = this.countGlossRefsAcrossOntology(ontology, personaId, typeId, 'event', 'event') + + const annotationCount = await this.repository.countAnnotations({ + personaId, + type: 'event', + label: typeId + }) + + let worldInterpretationCount = 0 + const worldState = await this.repository.findPersonalWorldState(persona.userId) + if (worldState) { + const events = asEvents(worldState.events) + worldInterpretationCount = countEventInterpretations(events, typeId, personaId) + } + + return { glossReferences, annotationCount, worldInterpretationCount } + } + + /** + * Deletes an event type, converting gloss references to text, deleting + * matching annotations, and cleaning event interpretations from the personal + * world state. + * + * @returns the success message and the cleaned-up counts + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when delete access is denied + */ + async deleteEventType( + personaId: string, + typeId: string + ): Promise<{ message: string; cleanedUp: { glossReferences: number; annotations: number; worldInterpretations: number } }> { + const persona = await this.loadForTypeDelete(personaId) + const ontology = persona.ontology! + + const eventTypes = asTypesWithGloss(ontology.eventTypes) + const targetType = eventTypes.find(t => t.id === typeId) + if (!targetType) { + throw new NotFoundError('Event type', typeId) + } + + const typeName = targetType.name + const updatedEventTypes = eventTypes.filter(t => t.id !== typeId) + + const entityTypes = asTypesWithGloss(ontology.entityTypes) + const roleTypes = asTypesWithGloss(ontology.roleTypes) + const relationTypes = asTypesWithGloss(ontology.relationTypes) + + let glossReferences = 0 + glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'event') + glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'event') + glossReferences += countTypeRefsInGlosses(updatedEventTypes, typeId, personaId, 'event') + glossReferences += countTypeRefsInGlosses(relationTypes, typeId, personaId, 'event') + + const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'event', typeName) + const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'event', typeName) + const cleanedEventTypes = updateGlossesInTypes(updatedEventTypes, typeId, personaId, 'event', typeName) + const cleanedRelationTypes = updateGlossesInTypes(relationTypes, typeId, personaId, 'event', typeName) + + const deleteResult = await this.repository.deleteAnnotations({ + personaId, + type: 'event', + label: typeId + }) + + let worldInterpretations = 0 + const worldState = await this.repository.findPersonalWorldState(persona.userId) + if (worldState) { + const events = asEvents(worldState.events) + worldInterpretations = countEventInterpretations(events, typeId, personaId) + const cleanedEvents = removeEventInterpretationsFromEvents(events, typeId, personaId) + await this.repository.updateWorldState(worldState.id, { + events: toJson(cleanedEvents) + }) + } + + await this.repository.updateOntology(personaId, { + entityTypes: toJson(cleanedEntityTypes), + roleTypes: toJson(cleanedRoleTypes), + eventTypes: toJson(cleanedEventTypes), + relationTypes: toJson(cleanedRelationTypes) + }) + + return { + message: `Event type "${typeName}" deleted successfully`, + cleanedUp: { + glossReferences, + annotations: deleteResult.count, + worldInterpretations + } + } + } + + /** + * Deletion preview for a relation type. + * + * Relation instances are tracked client-side, so the instance count is + * always 0. + * + * @returns gloss reference count and the (always-zero) relation instance count + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when read access is denied + */ + async getRelationTypeDeletionPreview( + personaId: string, + typeId: string + ): Promise<{ glossReferences: number; relationInstanceCount: number }> { + const persona = await this.loadForTypeRead(personaId) + const ontology = persona.ontology! + + const relationTypes = asTypesWithGloss(ontology.relationTypes) + if (!relationTypes.find(t => t.id === typeId)) { + throw new NotFoundError('Relation type', typeId) + } + + const glossReferences = this.countGlossRefsAcrossOntology(ontology, personaId, typeId, 'relation', 'relation') + + return { glossReferences, relationInstanceCount: 0 } + } + + /** + * Deletes a relation type, converting gloss references to text. + * + * @returns the success message and the cleaned-up gloss reference count + * @throws {NotFoundError} when the persona, ontology, or type does not exist + * @throws {ForbiddenError} when delete access is denied + */ + async deleteRelationType( + personaId: string, + typeId: string + ): Promise<{ message: string; cleanedUp: { glossReferences: number } }> { + const persona = await this.loadForTypeDelete(personaId) + const ontology = persona.ontology! + + const relationTypes = asTypesWithGloss(ontology.relationTypes) + const targetType = relationTypes.find(t => t.id === typeId) + if (!targetType) { + throw new NotFoundError('Relation type', typeId) + } + + const typeName = targetType.name + const updatedRelationTypes = relationTypes.filter(t => t.id !== typeId) + + const entityTypes = asTypesWithGloss(ontology.entityTypes) + const roleTypes = asTypesWithGloss(ontology.roleTypes) + const eventTypes = asTypesWithGloss(ontology.eventTypes) + + let glossReferences = 0 + glossReferences += countTypeRefsInGlosses(entityTypes, typeId, personaId, 'relation') + glossReferences += countTypeRefsInGlosses(roleTypes, typeId, personaId, 'relation') + glossReferences += countTypeRefsInGlosses(eventTypes, typeId, personaId, 'relation') + glossReferences += countTypeRefsInGlosses(updatedRelationTypes, typeId, personaId, 'relation') + + const cleanedEntityTypes = updateGlossesInTypes(entityTypes, typeId, personaId, 'relation', typeName) + const cleanedRoleTypes = updateGlossesInTypes(roleTypes, typeId, personaId, 'relation', typeName) + const cleanedEventTypes = updateGlossesInTypes(eventTypes, typeId, personaId, 'relation', typeName) + const cleanedRelationTypes = updateGlossesInTypes(updatedRelationTypes, typeId, personaId, 'relation', typeName) + + await this.repository.updateOntology(personaId, { + entityTypes: toJson(cleanedEntityTypes), + roleTypes: toJson(cleanedRoleTypes), + eventTypes: toJson(cleanedEventTypes), + relationTypes: toJson(cleanedRelationTypes) + }) + + return { + message: `Relation type "${typeName}" deleted successfully`, + cleanedUp: { glossReferences } + } + } + + /** + * Maps a database ontology row to the API response shape: database field + * names are renamed (`entityTypes` to `entities`, etc.), `relations` is + * always empty, and dates are ISO strings. + */ + private mapOntologyResponse(ontology: NonNullable): OntologyResponse { + return { + id: ontology.id, + personaId: ontology.personaId, + entities: (ontology.entityTypes as unknown[]) || [], + roles: (ontology.roleTypes as unknown[]) || [], + events: (ontology.eventTypes as unknown[]) || [], + relationTypes: (ontology.relationTypes as unknown[]) || [], + relations: [], + createdAt: ontology.createdAt.toISOString(), + updatedAt: ontology.updatedAt.toISOString() + } + } +} From 25dddccc152d112c0f100f16a4c250779fda033d Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 18 Jun 2026 12:21:46 -0400 Subject: [PATCH 14/42] Make the persistent test persona name unique per test (workerIndex plus userId plus testId) so two persistence tests on the same worker no longer create two same-named personas, which made the persona dropdown resolve to multiple options and fail the strict-mode locator under concurrent load. --- annotation-tool/test/e2e/fixtures/test-context.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/annotation-tool/test/e2e/fixtures/test-context.ts b/annotation-tool/test/e2e/fixtures/test-context.ts index 39182ee6..d2f1a07a 100644 --- a/annotation-tool/test/e2e/fixtures/test-context.ts +++ b/annotation-tool/test/e2e/fixtures/test-context.ts @@ -451,11 +451,17 @@ export const test = base.extend({ * worker's lifecycle (the test does NOT delete on teardown so * persisted-annotation data survives the reload assertion). */ - // Name includes workerIndex + first 8 chars of testUser.id so admin - // sessions see globally-unique options even when previous test runs - // left orphans in the database. + // Name includes workerIndex + first 8 chars of testUser.id AND the per-test + // id so each option is globally unique. Without the per-test id, two tests + // on the same worker created two personas with the SAME name (workerIndex + // and userId are both worker-stable and the fixture does not delete on + // teardown), so the persona dropdown resolved to N same-named options and + // failed the strict-mode locator. A retry of the same test reuses its persona + // via findPersonaByName below (the testId is stable across retries). Worker + // cleanup deletes by userId, so the longer name does not affect teardown. testPersonaPersistent: async ({ db, testUser, workerSessionToken }, use, testInfo) => { - const personaName = `Test Analyst (Persistent W${testInfo.workerIndex}-${testUser.id.slice(0, 8)})` + const perTestId = testInfo.testId.replace(/[^a-zA-Z0-9]/g, '').slice(-12) + const personaName = `Test Analyst (Persistent W${testInfo.workerIndex}-${testUser.id.slice(0, 8)}-${perTestId})` const existing = await db.findPersonaByName(testUser.id, personaName, workerSessionToken) const persona = existing ?? await db.createPersona({ userId: testUser.id, From ca12444194546c7a53aa8cc005f32642162422ca Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 18 Jun 2026 13:58:51 -0400 Subject: [PATCH 15/42] Extract the projects domain into a ProjectRepository and ProjectService and migrate its authorization onto CASL by making Project a first-class subject that replaces the inline project_owner/project_manager role-string and isAdmin checks, and add a project-scoped assignable-users endpoint so the member-add picker works for non-admin owners and managers. --- CHANGELOG.md | 3 + .../src/pages/ProjectDetailPage.test.tsx | 35 + .../src/pages/ProjectDetailPage.tsx | 10 +- .../src/store/queries/useProjects.ts | 37 + annotation-tool/test/mocks/handlers.ts | 4 + docs/docs/project/changelog.md | 3 + server/src/lib/abilities.ts | 33 +- server/src/repositories/ProjectRepository.ts | 429 ++++++++++ server/src/routes/projects.ts | 733 +++------------- server/src/services/project-service.ts | 789 ++++++++++++++++++ server/test/helpers/rbac-test-setup.ts | 20 + server/test/lib/abilities.test.ts | 127 +++ server/test/routes/projects.test.ts | 186 +++++ 13 files changed, 1789 insertions(+), 620 deletions(-) create mode 100644 server/src/repositories/ProjectRepository.ts create mode 100644 server/src/services/project-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3ad3a3..3faca477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,9 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Extracted the **personas** domain into a `PersonaRepository` (pure Prisma data access) and a `PersonaService` (orchestration, RBAC, and response mapping), reducing `server/src/routes/personas.ts` from 1641 lines with 44 direct `prisma.*` calls to 640 thin lines with zero. Routes now validate input and dispatch to the service; the service owns the CASL ability checks, the list-mode branching, the `isSystemGenerated` coercion, and the ontology type-deletion / world-state-cleanup orchestration; the repository owns every query. RBAC decisions, response shapes, and behavior are unchanged (the full personas route-test suite and the local E2E suite pass). This establishes the pattern for the remaining domain extractions. - The persona API response and the frontend `Persona` type now expose `projectId`, which had been silently stripped by the response schema, enabling project-scoped persona browsing. +- Extracted the **projects** domain into a `ProjectRepository` (pure Prisma data access, including the create-project-with-owner-membership transaction) and a `ProjectService` (orchestration, authorization, and response mapping), reducing `server/src/routes/projects.ts` from 1072 lines with 32 direct `prisma.*` calls to 581 thin lines with zero, following the personas pattern. +- Migrated project authorization onto CASL, the same engine every other domain already uses. The projects routes previously enforced access with bespoke inline checks (`['project_owner','project_manager'].includes(myRole)` role-string comparisons and a `request.user.isAdmin` boolean); `Project` is now a first-class CASL subject in `server/src/lib/abilities.ts`, so reads, updates, deletes, member management, and project creation resolve through the same `RolePermission` matrix and request-scoped `ability` as personas, claims, world state, and video. The database already granted these project permissions (`project_owner` → update/delete/manage_members, `project_manager` → update/manage_members, every project role → read, `group_owner`/`group_admin` → create) and `manage_members` was an already-defined-but-unused CASL action, so the policy is unchanged - this removes a duplicated, divergent authorization path rather than changing who may do what. The group-scoped create permission is now correctly limited to the group a candidate project belongs to (a group administrator could previously be granted creation against any group). One deliberate consistency change: a project administrator is now recognized through the CASL system role (`systemRole === 'system_admin'`), matching every other domain, rather than the separate `isAdmin` boolean column; seeded and provisioned administrators carry both, so no real administrator is affected. +- Added `GET /api/projects/:projectId/assignable-users` (authorized by `manage_members`), returning the users who are not already members of a project. The "Add member" picker on the project detail page previously called the admin-only `/api/admin/users` endpoint, so it returned a 403 and showed an empty list to project owners and managers who were not system administrators; it now reads from the scoped endpoint and works for anyone who can manage the project's members. ### Fixed diff --git a/annotation-tool/src/pages/ProjectDetailPage.test.tsx b/annotation-tool/src/pages/ProjectDetailPage.test.tsx index f4f7c5bb..e06b3e4f 100644 --- a/annotation-tool/src/pages/ProjectDetailPage.test.tsx +++ b/annotation-tool/src/pages/ProjectDetailPage.test.tsx @@ -70,8 +70,20 @@ const membersResponse = [ }, ] +const assignableUsersResponse = [ + { + id: 'user-3', + username: 'carol', + displayName: 'Carol', + email: 'carol@example.com', + }, +] + describe('ProjectDetailPage', () => { beforeEach(() => { + // jsdom does not implement scrollIntoView; the cmdk Command list in the + // add-member picker calls it on mount. + Element.prototype.scrollIntoView = vi.fn() useAuthStore.getState().reset() useAuthStore.getState().loginSuccess({ id: 'user-1', @@ -222,4 +234,27 @@ describe('ProjectDetailPage', () => { expect(screen.getByText('No personas assigned to this project.')).toBeInTheDocument() }) + + it('lists assignable users in the add-member picker', async () => { + const user = userEvent.setup() + server.use( + http.get('*/api/projects/:projectId', () => HttpResponse.json(projectResponse)), + http.get('*/api/projects/:projectId/members', () => HttpResponse.json(membersResponse)), + http.get('*/api/projects/:projectId/personas', () => HttpResponse.json([])), + http.get('*/api/projects/:projectId/assignable-users', () => HttpResponse.json(assignableUsersResponse)) + ) + + renderWithRoute('proj-1') + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add member/i })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /add member/i })) + await user.click(screen.getByText('Select user...')) + + await waitFor(() => { + expect(screen.getByText('carol (Carol)')).toBeInTheDocument() + }) + }) }) diff --git a/annotation-tool/src/pages/ProjectDetailPage.tsx b/annotation-tool/src/pages/ProjectDetailPage.tsx index f049dc61..65462e51 100644 --- a/annotation-tool/src/pages/ProjectDetailPage.tsx +++ b/annotation-tool/src/pages/ProjectDetailPage.tsx @@ -53,6 +53,7 @@ import { useProject, useProjectMembers, useProjectPersonas, + useAssignableUsers, useAddProjectMember, useUpdateProjectMember, useRemoveProjectMember, @@ -60,7 +61,6 @@ import { type ProjectMember, type ProjectPersona, } from '@store/queries/useProjects' -import { useUsers } from '@store/queries/admin/useUsers' import { useProjectContextStore } from '@store/zustand/projectContextStore' import { useAuthStore } from '@store/zustand/authStore' @@ -79,7 +79,7 @@ export default function ProjectDetailPage(): JSX.Element { const updateMember = useUpdateProjectMember() const removeMember = useRemoveProjectMember() const updateProject = useUpdateProject() - const { data: allUsers = [] } = useUsers() + const { data: assignableUsers = [] } = useAssignableUsers(projectId) const [addDialogOpen, setAddDialogOpen] = useState(false) const [newUserId, setNewUserId] = useState('') @@ -301,8 +301,8 @@ export default function ProjectDetailPage(): JSX.Element { className="flex h-8 w-full items-center justify-between rounded-lg border border-input bg-transparent px-2.5 text-sm" > {newUserId - ? allUsers.find((u) => u.id === newUserId) - ? `${allUsers.find((u) => u.id === newUserId)!.username} (${allUsers.find((u) => u.id === newUserId)!.displayName})` + ? assignableUsers.find((u) => u.id === newUserId) + ? `${assignableUsers.find((u) => u.id === newUserId)!.username} (${assignableUsers.find((u) => u.id === newUserId)!.displayName})` : newUserId : 'Select user...'} @@ -312,7 +312,7 @@ export default function ProjectDetailPage(): JSX.Element { No users found. - {allUsers.map((user) => ( + {assignableUsers.map((user) => ( [...projectKeys.all, 'detail', projectId] as const, members: (projectId: string) => [...projectKeys.all, 'members', projectId] as const, personas: (projectId: string) => [...projectKeys.all, 'personas', projectId] as const, + assignableUsers: (projectId: string) => [...projectKeys.all, 'assignable-users', projectId] as const, } /** Summary of a project as returned by the list endpoint. */ @@ -62,6 +63,14 @@ export interface ProjectPersona { role: string } +/** A user who can be added as a project member (not already a member). */ +export interface AssignableUser { + id: string + username: string + displayName: string + email: string | null +} + // ============= Fetch Functions ============= async function fetchMyProjects(scope?: string): Promise { @@ -97,6 +106,14 @@ async function fetchProjectPersonas(projectId: string): Promise { + const response = await fetch(`/api/projects/${projectId}/assignable-users`, { credentials: 'include' }) + if (!response.ok) { + throw new Error('Failed to fetch assignable users') + } + return response.json() +} + // ============= Query Hooks ============= /** @@ -164,6 +181,23 @@ export function useProjectPersonas(projectId: string | undefined) { }) } +/** + * Hook to fetch the users who can be added as members of a project (those not + * already members). Authorized for project owners, managers, and system + * admins; non-managers receive a 403 and an empty result. + * + * @param projectId - the project ID whose assignable users to fetch + * @returns TanStack Query result with the assignable users array + */ +export function useAssignableUsers(projectId: string | undefined) { + return useQuery({ + queryKey: projectKeys.assignableUsers(projectId || ''), + queryFn: () => fetchAssignableUsers(projectId!), + enabled: !!projectId, + staleTime: 2 * 60 * 1000, + }) +} + // ============= Mutation Hooks ============= /** @@ -249,6 +283,7 @@ export function useDeleteProject() { queryClient.removeQueries({ queryKey: projectKeys.detail(projectId) }) queryClient.removeQueries({ queryKey: projectKeys.members(projectId) }) queryClient.removeQueries({ queryKey: projectKeys.personas(projectId) }) + queryClient.removeQueries({ queryKey: projectKeys.assignableUsers(projectId) }) queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) }, }) @@ -278,6 +313,7 @@ export function useAddProjectMember() { onSuccess: (_, { projectId }) => { queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) }) queryClient.invalidateQueries({ queryKey: projectKeys.members(projectId) }) + queryClient.invalidateQueries({ queryKey: projectKeys.assignableUsers(projectId) }) queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) }, }) @@ -332,6 +368,7 @@ export function useRemoveProjectMember() { onSuccess: (_, { projectId }) => { queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) }) queryClient.invalidateQueries({ queryKey: projectKeys.members(projectId) }) + queryClient.invalidateQueries({ queryKey: projectKeys.assignableUsers(projectId) }) queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) }, }) diff --git a/annotation-tool/test/mocks/handlers.ts b/annotation-tool/test/mocks/handlers.ts index a940c363..42674f22 100644 --- a/annotation-tool/test/mocks/handlers.ts +++ b/annotation-tool/test/mocks/handlers.ts @@ -2190,6 +2190,10 @@ export const handlers = [ return HttpResponse.json([]) }), + http.get('*/api/projects/:projectId/assignable-users', () => { + return HttpResponse.json([]) + }), + http.post('*/api/projects/:projectId/members', () => { return HttpResponse.json({ success: true }, { status: 201 }) }), diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index 01ffbd79..ef6e78af 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -62,6 +62,9 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Extracted the **personas** domain into a `PersonaRepository` (pure Prisma data access) and a `PersonaService` (orchestration, RBAC, and response mapping), reducing `server/src/routes/personas.ts` from 1641 lines with 44 direct `prisma.*` calls to 640 thin lines with zero. Routes now validate input and dispatch to the service; the service owns the CASL ability checks, the list-mode branching, the `isSystemGenerated` coercion, and the ontology type-deletion / world-state-cleanup orchestration; the repository owns every query. RBAC decisions, response shapes, and behavior are unchanged (the full personas route-test suite and the local E2E suite pass). This establishes the pattern for the remaining domain extractions. - The persona API response and the frontend `Persona` type now expose `projectId`, which had been silently stripped by the response schema, enabling project-scoped persona browsing. +- Extracted the **projects** domain into a `ProjectRepository` (pure Prisma data access, including the create-project-with-owner-membership transaction) and a `ProjectService` (orchestration, authorization, and response mapping), reducing `server/src/routes/projects.ts` from 1072 lines with 32 direct `prisma.*` calls to 581 thin lines with zero, following the personas pattern. +- Migrated project authorization onto CASL, the same engine every other domain already uses. The projects routes previously enforced access with bespoke inline checks (`['project_owner','project_manager'].includes(myRole)` role-string comparisons and a `request.user.isAdmin` boolean); `Project` is now a first-class CASL subject in `server/src/lib/abilities.ts`, so reads, updates, deletes, member management, and project creation resolve through the same `RolePermission` matrix and request-scoped `ability` as personas, claims, world state, and video. The database already granted these project permissions (`project_owner` → update/delete/manage_members, `project_manager` → update/manage_members, every project role → read, `group_owner`/`group_admin` → create) and `manage_members` was an already-defined-but-unused CASL action, so the policy is unchanged - this removes a duplicated, divergent authorization path rather than changing who may do what. The group-scoped create permission is now correctly limited to the group a candidate project belongs to (a group administrator could previously be granted creation against any group). One deliberate consistency change: a project administrator is now recognized through the CASL system role (`systemRole === 'system_admin'`), matching every other domain, rather than the separate `isAdmin` boolean column; seeded and provisioned administrators carry both, so no real administrator is affected. +- Added `GET /api/projects/:projectId/assignable-users` (authorized by `manage_members`), returning the users who are not already members of a project. The "Add member" picker on the project detail page previously called the admin-only `/api/admin/users` endpoint, so it returned a 403 and showed an empty list to project owners and managers who were not system administrators; it now reads from the scoped endpoint and works for anyone who can manage the project's members. ### Fixed diff --git a/server/src/lib/abilities.ts b/server/src/lib/abilities.ts index a62c5724..13454cfe 100644 --- a/server/src/lib/abilities.ts +++ b/server/src/lib/abilities.ts @@ -145,10 +145,16 @@ export function defineAbilitiesFor( .map(pr => pr.projectId) if (matchingProjects.length > 0) { + // The Project model *is* the project: its identity column is `id`, + // whereas every content model references its enclosing project via + // `projectId`. Scope the condition against the right column so a + // project-scoped Project permission (read/update/delete/manage_members) + // resolves against the project's own id. + const projectKey = modelName === 'Project' ? 'id' : 'projectId' if (perm.ownOnly) { - rules.push({ action, subject: modelName, conditions: { projectId: { in: matchingProjects }, [ownField]: userId } }) + rules.push({ action, subject: modelName, conditions: { [projectKey]: { in: matchingProjects }, [ownField]: userId } }) } else { - rules.push({ action, subject: modelName, conditions: { projectId: { in: matchingProjects } } }) + rules.push({ action, subject: modelName, conditions: { [projectKey]: { in: matchingProjects } } }) } } } else if (perm.scope === 'group') { @@ -157,7 +163,14 @@ export function defineAbilitiesFor( .map(gr => gr.groupId) if (matchingGroups.length > 0) { - if (perm.ownOnly) { + if (modelName === 'Project') { + // A group-scoped Project permission (e.g. project:create granted to + // group_owner / group_admin) applies only to projects owned by a + // group the user administers. Scope by the candidate project's + // ownerGroupId so a group admin cannot act on another group's + // projects. + rules.push({ action, subject: modelName, conditions: { ownerGroupId: { in: matchingGroups } } }) + } else if (perm.ownOnly) { rules.push({ action, subject: modelName, conditions: { [ownField]: userId } }) } else { rules.push({ action, subject: modelName }) @@ -202,6 +215,20 @@ export function defineAbilitiesFor( rules.push({ action: 'update', subject: 'WorldState', conditions: { userId } }) rules.push({ action: 'delete', subject: 'WorldState', conditions: { userId } }) + // Projects: every user can fully manage projects they personally own + // (ownerUserId === userId). The create baseline is load-bearing — personal + // project creation has no pre-existing project_memberships row to authorize + // against, so without it a user could never create their own project; the + // route sets the candidate's ownerUserId to request.user.id, so the rule + // matches a personal project and an attempt to forge ownerUserId=otherUser + // fails. Group-owned projects (ownerUserId === null) are governed entirely + // by membership and the group-scoped create rule above. + rules.push({ action: 'create', subject: 'Project', conditions: { ownerUserId: userId } }) + rules.push({ action: 'read', subject: 'Project', conditions: { ownerUserId: userId } }) + rules.push({ action: 'update', subject: 'Project', conditions: { ownerUserId: userId } }) + rules.push({ action: 'delete', subject: 'Project', conditions: { ownerUserId: userId } }) + rules.push({ action: 'manage_members', subject: 'Project', conditions: { ownerUserId: userId } }) + // All authenticated users can read videos (filtered by VideoAccessService) rules.push({ action: 'read', subject: 'Video' }) diff --git a/server/src/repositories/ProjectRepository.ts b/server/src/repositories/ProjectRepository.ts new file mode 100644 index 00000000..9bd91cbe --- /dev/null +++ b/server/src/repositories/ProjectRepository.ts @@ -0,0 +1,429 @@ +import { PrismaClient, Project, ProjectMembership, Persona, WorldState, Prisma } from '@prisma/client' + +/** + * Project row joined with its members (each carrying the public user + * projection) and the video-assignment count. Returned by the project-detail + * read path. + */ +export type ProjectWithMembersAndCounts = Prisma.ProjectGetPayload<{ + include: { + members: { + include: { + user: { + select: { id: true; username: true; displayName: true; email: true } + } + } + } + _count: { select: { videoAssignments: true } } + } +}> + +/** + * Project membership row joined with the public user projection. Returned by + * the add-member and change-role write paths and the member-list read path. + */ +export type MembershipWithUser = Prisma.ProjectMembershipGetPayload<{ + include: { + user: { + select: { id: true; username: true; displayName: true; email: true } + } + } +}> + +/** + * Project row joined with the caller's own membership (at most one row), used + * by the list endpoint to compute `myRole` and the `_count.members` metadata. + */ +export type ProjectWithMyMembership = Prisma.ProjectGetPayload<{ + include: { + _count: { select: { members: true } } + members: { select: { role: true } } + } +}> + +/** + * Public user projection used by the assignable-users picker. + */ +export type AssignableUser = Prisma.UserGetPayload<{ + select: { id: true; username: true; displayName: true; email: true } +}> + +/** + * Repository for all Project and ProjectMembership database access. + * + * This class owns every Prisma call in the projects domain. It performs no + * authorization: callers (the ProjectService) decide who may invoke a method. + * Methods return raw Prisma model types and propagate Prisma errors (for + * example a unique-constraint violation on a duplicate membership) to their + * callers. + * + * @example + * ```typescript + * const repo = new ProjectRepository(fastify.prisma) + * const project = await repo.findById(id) + * if (!project) { + * throw new NotFoundError('Project', id) + * } + * ``` + */ +export class ProjectRepository { + /** + * Creates a new ProjectRepository instance. + * + * @param prisma - Prisma client instance for database access + */ + constructor(private readonly prisma: PrismaClient) {} + + /** + * Finds a project by its slug. + * + * Used by create to enforce slug uniqueness before the create transaction. + * + * @param slug - the project slug + * @returns the project, or null if no project has that slug + */ + async findBySlug(slug: string): Promise { + return this.prisma.project.findUnique({ where: { slug } }) + } + + /** + * Finds a project by ID. + * + * @param id - Project UUID + * @returns the project, or null if not found + */ + async findById(id: string): Promise { + return this.prisma.project.findUnique({ where: { id } }) + } + + /** + * Finds a project by ID with its members (public user projection) and the + * video-assignment count. + * + * @param id - Project UUID + * @returns the project with members and counts, or null if not found + */ + async findByIdWithMembersAndCounts(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + include: { + members: { + include: { + user: { + select: { id: true, username: true, displayName: true, email: true }, + }, + }, + }, + _count: { select: { videoAssignments: true } }, + }, + }) + } + + /** + * Finds a project by ID with its members (public user projection) only. + * + * @param id - Project UUID + * @returns the project with members, or null if not found + */ + async findByIdWithMembers(id: string): Promise | null> { + return this.prisma.project.findUnique({ + where: { id }, + include: { + members: { + include: { + user: { + select: { id: true, username: true, displayName: true, email: true }, + }, + }, + }, + }, + }) + } + + /** + * Lists projects matching a WHERE clause, newest first, with the + * `_count.members` metadata and the caller's own membership role. + * + * @param where - Prisma WHERE clause selecting the candidate projects + * @param userId - the caller, whose membership role is included as the + * single `members` row (used to compute `myRole`) + * @returns matching projects, newest first + */ + async findManyForList(where: Prisma.ProjectWhereInput, userId: string): Promise { + return this.prisma.project.findMany({ + where, + include: { + _count: { select: { members: true } }, + members: { + where: { userId }, + select: { role: true }, + take: 1, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + } + + /** + * Lists the group IDs the user belongs to. + * + * @param userId - the user whose group memberships to list + * @returns the group IDs the user is a member of + */ + async findGroupIdsForUser(userId: string): Promise { + const memberships = await this.prisma.groupMembership.findMany({ + where: { userId }, + select: { groupId: true }, + }) + return memberships.map((gm) => gm.groupId) + } + + /** + * Finds a user's group membership in a single group. + * + * @param userId - the user + * @param groupId - the group + * @returns the membership, or null if the user is not a member + */ + async findGroupMembership(userId: string, groupId: string): Promise<{ role: string } | null> { + return this.prisma.groupMembership.findUnique({ + where: { userId_groupId: { userId, groupId } }, + select: { role: true }, + }) + } + + /** + * Creates a project together with the creator's `project_owner` membership in + * a single transaction. + * + * @param data - project create input + * @param ownerUserId - the user who receives the `project_owner` membership + * @returns the created project + */ + async createWithOwnerMembership( + data: Prisma.ProjectUncheckedCreateInput, + ownerUserId: string + ): Promise { + return this.prisma.$transaction(async (tx) => { + const created = await tx.project.create({ data }) + + await tx.projectMembership.create({ + data: { + userId: ownerUserId, + projectId: created.id, + role: 'project_owner', + }, + }) + + return created + }) + } + + /** + * Updates a project. + * + * @param id - Project UUID + * @param data - Prisma project update input + * @returns the updated project + */ + async update(id: string, data: Prisma.ProjectUpdateInput): Promise { + return this.prisma.project.update({ where: { id }, data }) + } + + /** + * Deletes a project. Cascade deletes (memberships, assignments) are handled + * by the schema's `onDelete: Cascade` relations. + * + * @param id - Project UUID + * @returns the deleted project + */ + async delete(id: string): Promise { + return this.prisma.project.delete({ where: { id } }) + } + + /** + * Lists the user IDs of every member of a project. + * + * Used to snapshot the affected members before a project delete so each can + * have their cached abilities invalidated. + * + * @param projectId - Project UUID + * @returns the member user IDs + */ + async findMemberUserIds(projectId: string): Promise { + const members = await this.prisma.projectMembership.findMany({ + where: { projectId }, + select: { userId: true }, + }) + return members.map((m) => m.userId) + } + + /** + * Finds a single membership by (userId, projectId). + * + * @param userId - the member user + * @param projectId - Project UUID + * @returns the membership, or null if the user is not a member + */ + async findMembership(userId: string, projectId: string): Promise { + return this.prisma.projectMembership.findUnique({ + where: { userId_projectId: { userId, projectId } }, + }) + } + + /** + * Creates a project membership, returning it with the public user projection. + * + * @param userId - the member user + * @param projectId - Project UUID + * @param role - the membership role + * @returns the created membership with its user + */ + async createMembership(userId: string, projectId: string, role: string): Promise { + return this.prisma.projectMembership.create({ + data: { userId, projectId, role }, + include: { + user: { + select: { id: true, username: true, displayName: true, email: true }, + }, + }, + }) + } + + /** + * Updates a membership's role, returning it with the public user projection. + * + * @param userId - the member user + * @param projectId - Project UUID + * @param role - the new role + * @returns the updated membership with its user + */ + async updateMembershipRole(userId: string, projectId: string, role: string): Promise { + return this.prisma.projectMembership.update({ + where: { userId_projectId: { userId, projectId } }, + data: { role }, + include: { + user: { + select: { id: true, username: true, displayName: true, email: true }, + }, + }, + }) + } + + /** + * Deletes a membership by (userId, projectId). + * + * @param userId - the member user + * @param projectId - Project UUID + */ + async deleteMembership(userId: string, projectId: string): Promise { + await this.prisma.projectMembership.delete({ + where: { userId_projectId: { userId, projectId } }, + }) + } + + /** + * Counts the memberships holding a given role in a project. + * + * Used to enforce the last-`project_owner` protection. + * + * @param projectId - Project UUID + * @param role - the role to count + * @returns number of memberships with that role + */ + async countMembershipsWithRole(projectId: string, role: string): Promise { + return this.prisma.projectMembership.count({ + where: { projectId, role }, + }) + } + + /** + * Finds a user by ID. + * + * @param id - User UUID + * @returns the user, or null if not found + */ + async findUserById(id: string): Promise<{ id: string } | null> { + return this.prisma.user.findUnique({ + where: { id }, + select: { id: true }, + }) + } + + /** + * Lists users who are not yet members of a project, projected to the fields + * the member-add picker needs. + * + * @param projectId - Project UUID + * @returns users with no membership in the project + */ + async findAssignableUsers(projectId: string): Promise { + return this.prisma.user.findMany({ + where: { projectMemberships: { none: { projectId } } }, + select: { id: true, username: true, displayName: true, email: true }, + }) + } + + /** + * Lists the personas scoped to a project, newest first. + * + * @param projectId - Project UUID + * @returns project-scoped personas, newest first + */ + async findProjectPersonas(projectId: string): Promise { + return this.prisma.persona.findMany({ + where: { projectId }, + orderBy: { createdAt: 'desc' }, + }) + } + + /** + * Finds the caller's world state for a project. + * + * @param userId - the caller + * @param projectId - Project UUID + * @returns the world state, or null if none exists yet + */ + async findWorldState(userId: string, projectId: string): Promise { + return this.prisma.worldState.findUnique({ + where: { userId_projectId: { userId, projectId } }, + }) + } + + /** + * Creates a world state for the caller in a project. + * + * @param data - Prisma world state create input + * @returns the created world state + */ + async createWorldState(data: Prisma.WorldStateUncheckedCreateInput): Promise { + return this.prisma.worldState.create({ data }) + } + + /** + * Updates the caller's world state in a project. + * + * @param userId - the caller + * @param projectId - Project UUID + * @param data - Prisma world state update input + * @returns the updated world state + */ + async updateWorldState( + userId: string, + projectId: string, + data: Prisma.WorldStateUpdateInput + ): Promise { + return this.prisma.worldState.update({ + where: { userId_projectId: { userId, projectId } }, + data, + }) + } +} diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 7ddb2afe..9ce9bea7 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,29 +1,23 @@ /** * API routes for project management. * - * Provides endpoints for creating, listing, updating, and deleting projects, - * managing project memberships, and accessing project-scoped personas and - * world state. + * Routes perform HTTP concerns only: schema validation, request parsing, and + * dispatch to a per-request ProjectService that owns business rules and CASL + * authorization. The ProjectRepository owns all Prisma access. + * + * Endpoints for creating, listing, updating, and deleting projects, managing + * project memberships, listing assignable users, and accessing project-scoped + * personas and world state. */ import { Type, Static } from '@sinclair/typebox' -import { FastifyPluginAsync } from 'fastify' -import { Prisma } from '@prisma/client' +import { FastifyPluginAsync, FastifyRequest } from 'fastify' import { requireAuth } from '@middleware/auth.js' - -/** Convert a value to Prisma JSON without type assertions. */ -function toJson(value: unknown): Prisma.InputJsonValue { - return JSON.parse(JSON.stringify(value)) -} +import { buildAbilities } from '@middleware/abilities.js' import { projectOperationCounter } from '../metrics.js' -import { buildAbilities, invalidateUserAbilities } from '@middleware/abilities.js' -import { - NotFoundError, - ValidationError, - ForbiddenError, - ConflictError, - ErrorResponseSchema, -} from '@lib/errors.js' +import { ErrorResponseSchema } from '@lib/errors.js' +import { ProjectRepository } from '../repositories/ProjectRepository.js' +import { ProjectService } from '../services/project-service.js' // --------------------------------------------------------------------------- // Nullable type helpers for fast-json-stringify compatibility. @@ -94,6 +88,13 @@ const MembershipSchema = Type.Object({ }), }) +const AssignableUserSchema = Type.Object({ + id: Type.String({ format: 'uuid' }), + username: Type.String(), + displayName: Type.String(), + email: NullableString, +}) + const PersonaSchema = Type.Object({ id: Type.String({ format: 'uuid' }), name: Type.String(), @@ -122,28 +123,62 @@ const WorldStateResponseSchema = Type.Object({ }) // --------------------------------------------------------------------------- -// Allowed membership roles (excluding project_owner, which is assigned only -// during project creation). +// Body schemas // --------------------------------------------------------------------------- -const ASSIGNABLE_ROLES = ['project_manager', 'annotator', 'reviewer', 'viewer'] as const -type AssignableRole = typeof ASSIGNABLE_ROLES[number] +const CreateProjectBody = Type.Object({ + name: Type.String({ minLength: 1 }), + description: Type.Optional(Type.String()), + slug: Type.String({ minLength: 1, pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' }), + ownerGroupId: Type.Optional(Type.String({ format: 'uuid' })), +}) -/** - * Returns true if the value is a valid assignable project role. - * - * @param value - string to check - * @returns whether the value is a valid assignable role - */ -function isAssignableRole(value: string): value is AssignableRole { - return (ASSIGNABLE_ROLES as readonly string[]).includes(value) -} +const UpdateProjectBody = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + description: Type.Optional(Type.String()), + settings: Type.Optional(Type.Unknown()), + isArchived: Type.Optional(Type.Boolean()), +}) + +const AddMemberBody = Type.Object({ + userId: Type.String({ format: 'uuid' }), + role: Type.String(), +}) + +const ChangeRoleBody = Type.Object({ + role: Type.String(), +}) + +const UpdateWorldBody = Type.Object({ + entities: Type.Optional(Type.Array(Type.Unknown())), + events: Type.Optional(Type.Array(Type.Unknown())), + times: Type.Optional(Type.Array(Type.Unknown())), + entityCollections: Type.Optional(Type.Array(Type.Unknown())), + eventCollections: Type.Optional(Type.Array(Type.Unknown())), + timeCollections: Type.Optional(Type.Array(Type.Unknown())), + relations: Type.Optional(Type.Array(Type.Unknown())), +}) // --------------------------------------------------------------------------- // Route plugin // --------------------------------------------------------------------------- const projectsRoute: FastifyPluginAsync = async (fastify) => { + // Request-independent: one repository for the plugin's lifetime. + const repository = new ProjectRepository(fastify.prisma) + + /** + * Builds a per-request service from the request-scoped CASL ability and the + * authenticated user's id and system role. + */ + const serviceFor = (request: FastifyRequest): ProjectService => + new ProjectService( + repository, + request.ability ?? null, + request.user?.id, + request.user?.systemRole ?? undefined + ) + // ========================================================================= // POST /api/projects - Create a project // ========================================================================= @@ -167,53 +202,8 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { name, description, slug, ownerGroupId } = request.body - - // Validate slug uniqueness - const existing = await fastify.prisma.project.findUnique({ - where: { slug }, - }) - if (existing) { - throw new ConflictError(`Project slug "${slug}" is already taken`) - } - - // If group-owned, verify the user is group_admin or group_owner - if (ownerGroupId) { - const membership = await fastify.prisma.groupMembership.findUnique({ - where: { userId_groupId: { userId, groupId: ownerGroupId } }, - }) - if (!membership || !['group_admin', 'group_owner'].includes(membership.role)) { - throw new ForbiddenError('You must be a group admin or owner to create a project for this group') - } - } - - // Create project and initial membership in a transaction - const project = await fastify.prisma.$transaction(async (tx) => { - const created = await tx.project.create({ - data: { - name, - description: description ?? null, - slug, - ownerUserId: ownerGroupId ? null : userId, - ownerGroupId: ownerGroupId ?? null, - createdBy: userId, - }, - }) - - await tx.projectMembership.create({ - data: { - userId, - projectId: created.id, - role: 'project_owner', - }, - }) - - return created - }) - - // Creator is now a project_owner; their abilities have changed - invalidateUserAbilities(userId) + const service = serviceFor(request) + const project = await service.create(request.body) projectOperationCounter.add(1, { operation: 'create', status: 'success' }) return reply.status(201).send(project) }, @@ -247,75 +237,9 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { async (request, reply) => { const userId = request.user!.id const scope = request.query.scope ?? 'all' - - // Build the OR conditions based on scope - const conditions: Prisma.ProjectWhereInput[] = [] - - if (scope === 'personal' || scope === 'all') { - conditions.push({ ownerUserId: userId }) - } - - if (scope === 'group' || scope === 'all') { - // Find groups the user belongs to - const groupMemberships = await fastify.prisma.groupMembership.findMany({ - where: { userId }, - select: { groupId: true }, - }) - const groupIds = groupMemberships.map((gm) => gm.groupId) - if (groupIds.length > 0) { - conditions.push({ ownerGroupId: { in: groupIds } }) - } - } - - if (scope === 'all') { - // Projects where user has direct membership - conditions.push({ - members: { some: { userId } }, - }) - } - - if (conditions.length === 0) { - return reply.send([]) - } - - const projects = await fastify.prisma.project.findMany({ - where: { OR: conditions }, - include: { - _count: { select: { members: true } }, - members: { - where: { userId }, - select: { role: true }, - take: 1, - }, - }, - orderBy: { createdAt: 'desc' }, - }) - - // Deduplicate (a project can match multiple conditions) - const seen = new Set() - const unique = projects.filter((p) => { - if (seen.has(p.id)) return false - seen.add(p.id) - return true - }) - - const result = unique.map((p) => ({ - id: p.id, - name: p.name, - description: p.description, - slug: p.slug, - ownerUserId: p.ownerUserId, - ownerGroupId: p.ownerGroupId, - settings: p.settings, - isArchived: p.isArchived, - createdBy: p.createdBy, - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt.toISOString(), - _count: p._count, - myRole: p.members[0]?.role ?? null, - })) - - return reply.send(result) + const service = serviceFor(request) + const projects = await service.list(userId, scope) + return reply.send(projects) }, ) @@ -353,56 +277,9 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { projectId } = request.params - - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { - members: { - include: { - user: { - select: { id: true, username: true, displayName: true, email: true }, - }, - }, - }, - _count: { select: { videoAssignments: true } }, - }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - // Must be a member or system_admin - const isMember = project.members.some((m) => m.userId === userId) - const isAdmin = request.user!.isAdmin - if (!isMember && !isAdmin) { - throw new ForbiddenError('You must be a project member to view this project') - } - - return reply.send({ - id: project.id, - name: project.name, - description: project.description, - slug: project.slug, - ownerUserId: project.ownerUserId, - ownerGroupId: project.ownerGroupId, - settings: project.settings, - isArchived: project.isArchived, - createdBy: project.createdBy, - createdAt: project.createdAt.toISOString(), - updatedAt: project.updatedAt.toISOString(), - members: project.members.map((m) => ({ - id: m.id, - userId: m.userId, - projectId: m.projectId, - role: m.role, - joinedAt: m.joinedAt.toISOString(), - user: m.user, - })), - videoAssignmentCount: project._count.videoAssignments, - }) + const service = serviceFor(request) + const project = await service.getById(request.params.projectId) + return reply.send(project) }, ) @@ -430,34 +307,8 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { projectId } = request.params - const { name, description, settings, isArchived } = request.body - - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { members: { where: { userId }, take: 1 } }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const myRole = project.members[0]?.role - if (!myRole || !['project_owner', 'project_manager'].includes(myRole)) { - throw new ForbiddenError('Only project owners and managers can update the project') - } - - const updated = await fastify.prisma.project.update({ - where: { id: projectId }, - data: { - ...(name !== undefined && { name }), - ...(description !== undefined && { description }), - ...(settings !== undefined && { settings: toJson(settings) }), - ...(isArchived !== undefined && { isArchived }), - }, - }) - + const service = serviceFor(request) + const updated = await service.update(request.params.projectId, request.body) projectOperationCounter.add(1, { operation: 'update', status: 'success' }) return reply.send(updated) }, @@ -483,38 +334,8 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { projectId } = request.params - - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { members: { where: { userId }, take: 1 } }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const isOwnerRole = project.members[0]?.role === 'project_owner' - const isAdmin = request.user!.isAdmin - if (!isOwnerRole && !isAdmin) { - throw new ForbiddenError('Only the project owner or a system admin can delete a project') - } - - // Snapshot member ids before the cascade deletes project memberships - const doomedMembers = await fastify.prisma.projectMembership.findMany({ - where: { projectId }, - select: { userId: true }, - }) - - // Cascade deletes are handled by Prisma's onDelete: Cascade on memberships - // and assignments. Delete the project directly. - await fastify.prisma.project.delete({ where: { id: projectId } }) - - // Every former member loses project-scope access - for (const { userId: memberId } of doomedMembers) { - invalidateUserAbilities(memberId) - } + const service = serviceFor(request) + await service.delete(request.params.projectId) projectOperationCounter.add(1, { operation: 'delete', status: 'success' }) return reply.send({ message: 'Project deleted successfully' }) }, @@ -546,64 +367,12 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const callerUserId = request.user!.id const { projectId } = request.params const { userId: targetUserId, role } = request.body - - if (!isAssignableRole(role)) { - throw new ValidationError( - `Invalid role "${role}". Must be one of: ${ASSIGNABLE_ROLES.join(', ')}`, - ) - } - - // Verify caller has permission - const callerMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: callerUserId, projectId } }, - }) - if (!callerMembership || !['project_owner', 'project_manager'].includes(callerMembership.role)) { - throw new ForbiddenError('Only project owners and managers can add members') - } - - // Verify target user exists - const targetUser = await fastify.prisma.user.findUnique({ - where: { id: targetUserId }, - }) - if (!targetUser) { - throw new NotFoundError('User', targetUserId) - } - - // Check for existing membership - const existingMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: targetUserId, projectId } }, - }) - if (existingMembership) { - throw new ConflictError('User is already a member of this project') - } - - const membership = await fastify.prisma.projectMembership.create({ - data: { - userId: targetUserId, - projectId, - role, - }, - include: { - user: { - select: { id: true, username: true, displayName: true, email: true }, - }, - }, - }) - - // Newly added member picks up project-scope role permissions - invalidateUserAbilities(targetUserId) + const service = serviceFor(request) + const membership = await service.addMember(projectId, targetUserId, role) projectOperationCounter.add(1, { operation: 'add_member', status: 'success' }) - return reply.status(201).send({ - id: membership.id, - userId: membership.userId, - projectId: membership.projectId, - role: membership.role, - joinedAt: membership.joinedAt.toISOString(), - user: membership.user, - }) + return reply.status(201).send(membership) }, ) @@ -627,42 +396,35 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { projectId } = request.params - - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { - members: { - include: { - user: { - select: { id: true, username: true, displayName: true, email: true }, - }, - }, - }, - }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const isMember = project.members.some((m) => m.userId === userId) - const isAdmin = request.user!.isAdmin - if (!isMember && !isAdmin) { - throw new ForbiddenError('You must be a project member to list members') - } + const service = serviceFor(request) + const members = await service.listMembers(request.params.projectId) + return reply.send(members) + }, + ) - const result = project.members.map((m) => ({ - id: m.id, - userId: m.userId, - projectId: m.projectId, - role: m.role, - joinedAt: m.joinedAt.toISOString(), - user: m.user, - })) + // ========================================================================= + // GET /api/projects/:projectId/assignable-users - List addable users + // ========================================================================= - return reply.send(result) + fastify.get<{ Params: Static }>( + '/api/projects/:projectId/assignable-users', + { + onRequest: [requireAuth, buildAbilities], + schema: { + description: 'List users who can be added as members (not already members)', + tags: ['projects'], + params: ProjectIdParams, + response: { + 200: Type.Array(AssignableUserSchema), + 403: ErrorResponseSchema, + 404: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const service = serviceFor(request) + const users = await service.listAssignableUsers(request.params.projectId) + return reply.send(users) }, ) @@ -694,55 +456,10 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { const callerUserId = request.user!.id const { projectId, userId: targetUserId } = request.params const { role } = request.body - - if (!isAssignableRole(role)) { - throw new ValidationError( - `Invalid role "${role}". Must be one of: ${ASSIGNABLE_ROLES.join(', ')}`, - ) - } - - // Cannot change own role - if (callerUserId === targetUserId) { - throw new ValidationError('You cannot change your own role') - } - - // Verify caller has permission - const callerMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: callerUserId, projectId } }, - }) - if (!callerMembership || !['project_owner', 'project_manager'].includes(callerMembership.role)) { - throw new ForbiddenError('Only project owners and managers can change member roles') - } - - // Verify target membership exists - const targetMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: targetUserId, projectId } }, - }) - if (!targetMembership) { - throw new NotFoundError('ProjectMembership', targetUserId) - } - - const updated = await fastify.prisma.projectMembership.update({ - where: { userId_projectId: { userId: targetUserId, projectId } }, - data: { role }, - include: { - user: { - select: { id: true, username: true, displayName: true, email: true }, - }, - }, - }) - - // Role change alters the member's effective permissions - invalidateUserAbilities(targetUserId) + const service = serviceFor(request) + const updated = await service.changeMemberRole(projectId, targetUserId, callerUserId, role) projectOperationCounter.add(1, { operation: 'update', status: 'success' }) - return reply.send({ - id: updated.id, - userId: updated.userId, - projectId: updated.projectId, - role: updated.role, - joinedAt: updated.joinedAt.toISOString(), - user: updated.user, - }) + return reply.send(updated) }, ) @@ -769,41 +486,8 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { async (request, reply) => { const callerUserId = request.user!.id const { projectId, userId: targetUserId } = request.params - - const targetMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: targetUserId, projectId } }, - }) - - if (!targetMembership) { - throw new NotFoundError('ProjectMembership', targetUserId) - } - - // If removing someone else, caller must be owner or manager - if (callerUserId !== targetUserId) { - const callerMembership = await fastify.prisma.projectMembership.findUnique({ - where: { userId_projectId: { userId: callerUserId, projectId } }, - }) - if (!callerMembership || !['project_owner', 'project_manager'].includes(callerMembership.role)) { - throw new ForbiddenError('Only project owners and managers can remove members') - } - } - - // Cannot remove the last project_owner - if (targetMembership.role === 'project_owner') { - const ownerCount = await fastify.prisma.projectMembership.count({ - where: { projectId, role: 'project_owner' }, - }) - if (ownerCount <= 1) { - throw new ValidationError('Cannot remove the last project owner') - } - } - - await fastify.prisma.projectMembership.delete({ - where: { userId_projectId: { userId: targetUserId, projectId } }, - }) - - // Removed member loses project-scope permissions immediately - invalidateUserAbilities(targetUserId) + const service = serviceFor(request) + await service.removeMember(projectId, targetUserId, callerUserId) projectOperationCounter.add(1, { operation: 'remove_member', status: 'success' }) return reply.send({ message: 'Member removed successfully' }) }, @@ -829,30 +513,8 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const userId = request.user!.id - const { projectId } = request.params - - // Verify project exists and user is a member - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { members: { where: { userId }, take: 1 } }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const isMember = project.members.length > 0 - const isAdmin = request.user!.isAdmin - if (!isMember && !isAdmin) { - throw new ForbiddenError('You must be a project member to view personas') - } - - const personas = await fastify.prisma.persona.findMany({ - where: { projectId }, - orderBy: { createdAt: 'desc' }, - }) - + const service = serviceFor(request) + const personas = await service.listProjectPersonas(request.params.projectId) return reply.send(personas) }, ) @@ -878,59 +540,9 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { const userId = request.user!.id - const { projectId } = request.params - - // Verify project exists and user is a member - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { members: { where: { userId }, take: 1 } }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const isMember = project.members.length > 0 - const isAdmin = request.user!.isAdmin - if (!isMember && !isAdmin) { - throw new ForbiddenError('You must be a project member to access world state') - } - - // Find or create world state for (userId, projectId) - let worldState = await fastify.prisma.worldState.findUnique({ - where: { userId_projectId: { userId, projectId } }, - }) - - if (!worldState) { - worldState = await fastify.prisma.worldState.create({ - data: { - userId, - projectId, - entities: [], - events: [], - times: [], - entityCollections: [], - eventCollections: [], - timeCollections: [], - relations: [], - }, - }) - } - - return reply.send({ - id: worldState.id, - userId: worldState.userId, - projectId: worldState.projectId, - entities: worldState.entities || [], - events: worldState.events || [], - times: worldState.times || [], - entityCollections: worldState.entityCollections || [], - eventCollections: worldState.eventCollections || [], - timeCollections: worldState.timeCollections || [], - relations: worldState.relations || [], - createdAt: worldState.createdAt.toISOString(), - updatedAt: worldState.updatedAt.toISOString(), - }) + const service = serviceFor(request) + const worldState = await service.getWorldState(request.params.projectId, userId) + return reply.send(worldState) }, ) @@ -959,114 +571,11 @@ const projectsRoute: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { const userId = request.user!.id - const { projectId } = request.params - const updateData = request.body - - // Verify project exists and user is a member - const project = await fastify.prisma.project.findUnique({ - where: { id: projectId }, - include: { members: { where: { userId }, take: 1 } }, - }) - - if (!project) { - throw new NotFoundError('Project', projectId) - } - - const isMember = project.members.length > 0 - const isAdmin = request.user!.isAdmin - if (!isMember && !isAdmin) { - throw new ForbiddenError('You must be a project member to update world state') - } - - // Find or create, then update - const existing = await fastify.prisma.worldState.findUnique({ - where: { userId_projectId: { userId, projectId } }, - }) - - let worldState - if (existing) { - worldState = await fastify.prisma.worldState.update({ - where: { userId_projectId: { userId, projectId } }, - data: { - entities: updateData.entities !== undefined ? toJson(updateData.entities) : undefined, - events: updateData.events !== undefined ? toJson(updateData.events) : undefined, - times: updateData.times !== undefined ? toJson(updateData.times) : undefined, - entityCollections: updateData.entityCollections !== undefined ? toJson(updateData.entityCollections) : undefined, - eventCollections: updateData.eventCollections !== undefined ? toJson(updateData.eventCollections) : undefined, - timeCollections: updateData.timeCollections !== undefined ? toJson(updateData.timeCollections) : undefined, - relations: updateData.relations !== undefined ? toJson(updateData.relations) : undefined, - }, - }) - } else { - worldState = await fastify.prisma.worldState.create({ - data: { - userId, - projectId, - entities: toJson(updateData.entities || []), - events: toJson(updateData.events || []), - times: toJson(updateData.times || []), - entityCollections: toJson(updateData.entityCollections || []), - eventCollections: toJson(updateData.eventCollections || []), - timeCollections: toJson(updateData.timeCollections || []), - relations: toJson(updateData.relations || []), - }, - }) - } - - return reply.send({ - id: worldState.id, - userId: worldState.userId, - projectId: worldState.projectId, - entities: worldState.entities || [], - events: worldState.events || [], - times: worldState.times || [], - entityCollections: worldState.entityCollections || [], - eventCollections: worldState.eventCollections || [], - timeCollections: worldState.timeCollections || [], - relations: worldState.relations || [], - createdAt: worldState.createdAt.toISOString(), - updatedAt: worldState.updatedAt.toISOString(), - }) + const service = serviceFor(request) + const worldState = await service.updateWorldState(request.params.projectId, userId, request.body) + return reply.send(worldState) }, ) } -// --------------------------------------------------------------------------- -// Body schemas (defined after the plugin function to keep route definitions -// close to their handler, per the codebase convention). -// --------------------------------------------------------------------------- - -const CreateProjectBody = Type.Object({ - name: Type.String({ minLength: 1 }), - description: Type.Optional(Type.String()), - slug: Type.String({ minLength: 1, pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' }), - ownerGroupId: Type.Optional(Type.String({ format: 'uuid' })), -}) - -const UpdateProjectBody = Type.Object({ - name: Type.Optional(Type.String({ minLength: 1 })), - description: Type.Optional(Type.String()), - settings: Type.Optional(Type.Unknown()), - isArchived: Type.Optional(Type.Boolean()), -}) - -const AddMemberBody = Type.Object({ - userId: Type.String({ format: 'uuid' }), - role: Type.String(), -}) - -const ChangeRoleBody = Type.Object({ - role: Type.String(), -}) - -const UpdateWorldBody = Type.Object({ - entities: Type.Optional(Type.Array(Type.Unknown())), - events: Type.Optional(Type.Array(Type.Unknown())), - times: Type.Optional(Type.Array(Type.Unknown())), - entityCollections: Type.Optional(Type.Array(Type.Unknown())), - eventCollections: Type.Optional(Type.Array(Type.Unknown())), - timeCollections: Type.Optional(Type.Array(Type.Unknown())), - relations: Type.Optional(Type.Array(Type.Unknown())), -}) - export default projectsRoute diff --git a/server/src/services/project-service.ts b/server/src/services/project-service.ts new file mode 100644 index 00000000..af5f61bf --- /dev/null +++ b/server/src/services/project-service.ts @@ -0,0 +1,789 @@ +import { Persona, Prisma } from '@prisma/client' +import { subject } from '@casl/ability' +import type { AppAbility } from '../lib/abilities.js' +import { + NotFoundError, + ValidationError, + ForbiddenError, + ConflictError, +} from '../lib/errors.js' +import { invalidateUserAbilities } from '../middleware/abilities.js' +import { + ProjectRepository, + AssignableUser, +} from '../repositories/ProjectRepository.js' + +/** Convert a value to Prisma JSON without type assertions. */ +function toJson(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) +} + +/** + * Membership roles assignable through the add-member and change-role + * endpoints. `project_owner` is excluded: it is assigned only during project + * creation and is protected by the last-owner rule on removal. + */ +const ASSIGNABLE_ROLES = ['project_manager', 'annotator', 'reviewer', 'viewer'] as const + +/** A role that may be assigned through the membership endpoints. */ +type AssignableRole = typeof ASSIGNABLE_ROLES[number] + +/** + * Returns true if the value is a role assignable through the membership + * endpoints. + * + * @param value - string to check + * @returns whether the value is an assignable role + */ +function isAssignableRole(value: string): value is AssignableRole { + return (ASSIGNABLE_ROLES as readonly string[]).includes(value) +} + +/** Validated fields for creating a project. */ +export interface CreateProjectInput { + name: string + description?: string + slug: string + ownerGroupId?: string +} + +/** Validated fields for updating a project (all optional). */ +export interface UpdateProjectInput { + name?: string + description?: string + settings?: unknown + isArchived?: boolean +} + +/** Validated fields for the world-state update body (all optional). */ +export interface UpdateWorldInput { + entities?: unknown[] + events?: unknown[] + times?: unknown[] + entityCollections?: unknown[] + eventCollections?: unknown[] + timeCollections?: unknown[] + relations?: unknown[] +} + +/** The list scope filter for GET /api/projects. */ +export type ListScope = 'personal' | 'group' | 'all' + +/** Project response shape (ISO date strings, raw `settings` JSON). */ +export interface ProjectResponse { + id: string + name: string + description: string | null + slug: string + ownerUserId: string | null + ownerGroupId: string | null + settings: unknown + isArchived: boolean + createdBy: string + createdAt: string + updatedAt: string +} + +/** Project list-item response: project plus `_count.members` and `myRole`. */ +export interface ProjectListItem extends ProjectResponse { + _count: { members: number } + myRole: string | null +} + +/** Public user projection nested in a membership response. */ +export interface MembershipUser { + id: string + username: string + displayName: string + email: string | null +} + +/** Membership response (ISO date strings, public user projection). */ +export interface MembershipResponse { + id: string + userId: string + projectId: string + role: string + joinedAt: string + user: MembershipUser +} + +/** Project-detail response: project plus members and the assignment count. */ +export interface ProjectDetailResponse extends ProjectResponse { + members: MembershipResponse[] + videoAssignmentCount: number +} + +/** World-state response (ISO date strings, JSON arrays defaulted to []). */ +export interface WorldStateResponse { + id: string + userId: string + projectId: string | null + entities: unknown[] + events: unknown[] + times: unknown[] + entityCollections: unknown[] + eventCollections: unknown[] + timeCollections: unknown[] + relations: unknown[] + createdAt: string + updatedAt: string +} + +/** Assignable-user response: the picker fields for a non-member user. */ +export interface AssignableUserResponse { + id: string + username: string + displayName: string + email: string | null +} + +/** + * Owns project business rules and authorization, delegating all data access to + * a ProjectRepository. Construct one per request from the request-scoped CASL + * ability and the authenticated user's id and system role. + * + * Per-resource authorization fetches the project row first, then checks + * `ability.can(action, subject('Project', row))`. The ability already encodes + * the caller's project memberships, so the service does not fetch the caller's + * membership for an authorization decision; it fetches membership data only + * where the response needs it (for example the list's `myRole`). + * + * @example + * ```typescript + * const service = new ProjectService(repository, request.ability ?? null, request.user?.id, request.user?.systemRole) + * const projects = await service.list(userId, 'all') + * ``` + */ +export class ProjectService { + constructor( + private readonly repository: ProjectRepository, + private readonly ability: AppAbility | null, + private readonly userId: string | undefined, + /** + * The caller's system role. CASL already grants `manage all` to + * system_admin, so the service needs no separate admin branch; the + * parameter is accepted to keep the construction signature uniform across + * the domain services. + */ + _systemRole: string | undefined + ) {} + + /** + * Asserts that a CASL ability is present, returning it narrowed. + * + * Mirrors the per-request `if (!request.ability) throw new ForbiddenError(...)` + * guard the route used on every authenticated handler. + */ + private requireAbility(): AppAbility { + if (!this.ability) { + throw new ForbiddenError('No abilities defined') + } + return this.ability + } + + /** + * Creates a project and the creator's `project_owner` membership atomically. + * + * Authorizes against a candidate project carrying the resolved ownership + * fields: a personal project sets `ownerUserId` to the creator, a group + * project sets `ownerGroupId`. The creator's cached abilities are invalidated + * since they are now a `project_owner`. + * + * @param input - validated create fields + * @returns the created project in response shape + * @throws {ForbiddenError} when no ability is present or the create is denied + * @throws {ConflictError} when the slug is already taken + */ + async create(input: CreateProjectInput): Promise { + const ability = this.requireAbility() + const userId = this.userId! + const ownerGroupId = input.ownerGroupId ?? null + + const existing = await this.repository.findBySlug(input.slug) + if (existing) { + throw new ConflictError(`Project slug "${input.slug}" is already taken`) + } + + const candidate = { + ownerUserId: ownerGroupId ? null : userId, + ownerGroupId, + } + if (!ability.can('create', subject('Project', candidate))) { + throw new ForbiddenError('You do not have permission to create this project') + } + + const created = await this.repository.createWithOwnerMembership( + { + name: input.name, + description: input.description ?? null, + slug: input.slug, + ownerUserId: ownerGroupId ? null : userId, + ownerGroupId, + createdBy: userId, + }, + userId + ) + + // Creator is now a project_owner; their abilities have changed. + invalidateUserAbilities(userId) + + return this.mapProject(created) + } + + /** + * Lists the projects related to the caller, scoped by `scope`. + * + * Relationship scoping (owner, group-owned, direct membership) selects the + * candidate set; it is not a CASL gate. Results are deduplicated, ordered + * newest first, and each carries `_count.members` and the caller's `myRole`. + * + * @param userId - the caller + * @param scope - 'personal' (owned), 'group' (group-owned), or 'all' + * @returns the related projects as list items + */ + async list(userId: string, scope: ListScope): Promise { + const conditions: Prisma.ProjectWhereInput[] = [] + + if (scope === 'personal' || scope === 'all') { + conditions.push({ ownerUserId: userId }) + } + + if (scope === 'group' || scope === 'all') { + const groupIds = await this.repository.findGroupIdsForUser(userId) + if (groupIds.length > 0) { + conditions.push({ ownerGroupId: { in: groupIds } }) + } + } + + if (scope === 'all') { + conditions.push({ members: { some: { userId } } }) + } + + if (conditions.length === 0) { + return [] + } + + const projects = await this.repository.findManyForList({ OR: conditions }, userId) + + // Deduplicate (a project can match multiple conditions). + const seen = new Set() + const unique = projects.filter((p) => { + if (seen.has(p.id)) return false + seen.add(p.id) + return true + }) + + return unique.map((p) => ({ + ...this.mapProject(p), + _count: p._count, + myRole: p.members[0]?.role ?? null, + })) + } + + /** + * Gets a project's details, including members and the video-assignment count. + * + * @param projectId - Project UUID + * @returns the project detail in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + async getById(projectId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findByIdWithMembersAndCounts(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('read', subject('Project', project))) { + throw new ForbiddenError('You must be a project member to view this project') + } + + return { + ...this.mapProject(project), + members: project.members.map((m) => this.mapMembership(m)), + videoAssignmentCount: project._count.videoAssignments, + } + } + + /** + * Updates a project's mutable fields. + * + * @param projectId - Project UUID + * @param input - validated update fields + * @returns the updated project in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or update access is denied + */ + async update(projectId: string, input: UpdateProjectInput): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('update', subject('Project', project))) { + throw new ForbiddenError('Only project owners and managers can update the project') + } + + const updated = await this.repository.update(projectId, { + ...(input.name !== undefined && { name: input.name }), + ...(input.description !== undefined && { description: input.description }), + ...(input.settings !== undefined && { settings: toJson(input.settings) }), + ...(input.isArchived !== undefined && { isArchived: input.isArchived }), + }) + + return this.mapProject(updated) + } + + /** + * Deletes a project and invalidates every former member's cached abilities. + * + * Cascade deletes (memberships, assignments) are handled by the schema. The + * member IDs are snapshotted before the delete so each can be invalidated. + * + * @param projectId - Project UUID + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or delete access is denied + */ + async delete(projectId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('delete', subject('Project', project))) { + throw new ForbiddenError('Only the project owner or a system admin can delete a project') + } + + // Snapshot member ids before the cascade deletes project memberships. + const memberIds = await this.repository.findMemberUserIds(projectId) + + await this.repository.delete(projectId) + + // Every former member loses project-scope access. + for (const memberId of memberIds) { + invalidateUserAbilities(memberId) + } + } + + /** + * Adds a member to a project. + * + * @param projectId - Project UUID + * @param targetUserId - the user to add + * @param role - the membership role (must be assignable) + * @returns the created membership in response shape + * @throws {ValidationError} when the role is not assignable + * @throws {NotFoundError} when the project or target user does not exist + * @throws {ForbiddenError} when no ability is present or member management is denied + * @throws {ConflictError} when the user is already a member + */ + async addMember(projectId: string, targetUserId: string, role: string): Promise { + const ability = this.requireAbility() + + if (!isAssignableRole(role)) { + throw new ValidationError( + `Invalid role "${role}". Must be one of: ${ASSIGNABLE_ROLES.join(', ')}` + ) + } + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('manage_members', subject('Project', project))) { + throw new ForbiddenError('Only project owners and managers can add members') + } + + const targetUser = await this.repository.findUserById(targetUserId) + if (!targetUser) { + throw new NotFoundError('User', targetUserId) + } + + const existingMembership = await this.repository.findMembership(targetUserId, projectId) + if (existingMembership) { + throw new ConflictError('User is already a member of this project') + } + + const membership = await this.repository.createMembership(targetUserId, projectId, role) + + // Newly added member picks up project-scope role permissions. + invalidateUserAbilities(targetUserId) + + return this.mapMembership(membership) + } + + /** + * Lists a project's members. + * + * @param projectId - Project UUID + * @returns the project's memberships in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + async listMembers(projectId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findByIdWithMembers(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('read', subject('Project', project))) { + throw new ForbiddenError('You must be a project member to list members') + } + + return project.members.map((m) => this.mapMembership(m)) + } + + /** + * Lists users who are not yet members of a project, projected to the picker + * fields. Authorized to the same callers who may add members. + * + * @param projectId - Project UUID + * @returns non-member users in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or member management is denied + */ + async listAssignableUsers(projectId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('manage_members', subject('Project', project))) { + throw new ForbiddenError('Only project owners and managers can view assignable users') + } + + const users = await this.repository.findAssignableUsers(projectId) + return users.map((u) => this.mapAssignableUser(u)) + } + + /** + * Changes a member's role. + * + * @param projectId - Project UUID + * @param targetUserId - the member whose role changes + * @param callerUserId - the caller (cannot change their own role) + * @param role - the new role (must be assignable) + * @returns the updated membership in response shape + * @throws {ValidationError} when the role is not assignable or the caller targets themselves + * @throws {NotFoundError} when the project or target membership does not exist + * @throws {ForbiddenError} when no ability is present or member management is denied + */ + async changeMemberRole( + projectId: string, + targetUserId: string, + callerUserId: string, + role: string + ): Promise { + const ability = this.requireAbility() + + if (!isAssignableRole(role)) { + throw new ValidationError( + `Invalid role "${role}". Must be one of: ${ASSIGNABLE_ROLES.join(', ')}` + ) + } + + if (callerUserId === targetUserId) { + throw new ValidationError('You cannot change your own role') + } + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('manage_members', subject('Project', project))) { + throw new ForbiddenError('Only project owners and managers can change member roles') + } + + const targetMembership = await this.repository.findMembership(targetUserId, projectId) + if (!targetMembership) { + throw new NotFoundError('ProjectMembership', targetUserId) + } + + const updated = await this.repository.updateMembershipRole(targetUserId, projectId, role) + + // Role change alters the member's effective permissions. + invalidateUserAbilities(targetUserId) + + return this.mapMembership(updated) + } + + /** + * Removes a member, or lets a member leave the project. + * + * Self-removal needs no member-management permission; removing another member + * does. The last `project_owner` cannot be removed. + * + * @param projectId - Project UUID + * @param targetUserId - the member to remove + * @param callerUserId - the caller (self-removal is always permitted) + * @throws {NotFoundError} when the project or target membership does not exist + * @throws {ForbiddenError} when no ability is present or member management is denied + * @throws {ValidationError} when removing the last project owner + */ + async removeMember(projectId: string, targetUserId: string, callerUserId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + const targetMembership = await this.repository.findMembership(targetUserId, projectId) + if (!targetMembership) { + throw new NotFoundError('ProjectMembership', targetUserId) + } + + // Removing someone else requires member-management permission; self-leave + // does not. + if (callerUserId !== targetUserId) { + if (!ability.can('manage_members', subject('Project', project))) { + throw new ForbiddenError('Only project owners and managers can remove members') + } + } + + // Cannot remove the last project_owner. + if (targetMembership.role === 'project_owner') { + const ownerCount = await this.repository.countMembershipsWithRole(projectId, 'project_owner') + if (ownerCount <= 1) { + throw new ValidationError('Cannot remove the last project owner') + } + } + + await this.repository.deleteMembership(targetUserId, projectId) + + // Removed member loses project-scope permissions immediately. + invalidateUserAbilities(targetUserId) + } + + /** + * Lists the personas scoped to a project, newest first. + * + * @param projectId - Project UUID + * @returns project-scoped personas + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + async listProjectPersonas(projectId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('read', subject('Project', project))) { + throw new ForbiddenError('You must be a project member to view personas') + } + + return this.repository.findProjectPersonas(projectId) + } + + /** + * Gets the caller's world state for a project, creating an empty one on first + * access. + * + * @param projectId - Project UUID + * @param userId - the caller + * @returns the world state in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + async getWorldState(projectId: string, userId: string): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('read', subject('Project', project))) { + throw new ForbiddenError('You must be a project member to access world state') + } + + let worldState = await this.repository.findWorldState(userId, projectId) + if (!worldState) { + worldState = await this.repository.createWorldState({ + userId, + projectId, + entities: [], + events: [], + times: [], + entityCollections: [], + eventCollections: [], + timeCollections: [], + relations: [], + }) + } + + return this.mapWorldState(worldState) + } + + /** + * Updates the caller's world state for a project, creating it if absent. + * + * Only the provided fields are written; omitted fields are preserved. + * + * @param projectId - Project UUID + * @param userId - the caller + * @param input - the world-state update body + * @returns the updated world state in response shape + * @throws {NotFoundError} when the project does not exist + * @throws {ForbiddenError} when no ability is present or read access is denied + */ + async updateWorldState( + projectId: string, + userId: string, + input: UpdateWorldInput + ): Promise { + const ability = this.requireAbility() + + const project = await this.repository.findById(projectId) + if (!project) { + throw new NotFoundError('Project', projectId) + } + + if (!ability.can('read', subject('Project', project))) { + throw new ForbiddenError('You must be a project member to update world state') + } + + const existing = await this.repository.findWorldState(userId, projectId) + + let worldState + if (existing) { + worldState = await this.repository.updateWorldState(userId, projectId, { + entities: input.entities !== undefined ? toJson(input.entities) : undefined, + events: input.events !== undefined ? toJson(input.events) : undefined, + times: input.times !== undefined ? toJson(input.times) : undefined, + entityCollections: input.entityCollections !== undefined ? toJson(input.entityCollections) : undefined, + eventCollections: input.eventCollections !== undefined ? toJson(input.eventCollections) : undefined, + timeCollections: input.timeCollections !== undefined ? toJson(input.timeCollections) : undefined, + relations: input.relations !== undefined ? toJson(input.relations) : undefined, + }) + } else { + worldState = await this.repository.createWorldState({ + userId, + projectId, + entities: toJson(input.entities || []), + events: toJson(input.events || []), + times: toJson(input.times || []), + entityCollections: toJson(input.entityCollections || []), + eventCollections: toJson(input.eventCollections || []), + timeCollections: toJson(input.timeCollections || []), + relations: toJson(input.relations || []), + }) + } + + return this.mapWorldState(worldState) + } + + /** + * Maps a Prisma project row to the response shape: ISO date strings, raw + * `settings` JSON passed through unchanged. + */ + private mapProject(project: { + id: string + name: string + description: string | null + slug: string + ownerUserId: string | null + ownerGroupId: string | null + settings: Prisma.JsonValue + isArchived: boolean + createdBy: string + createdAt: Date + updatedAt: Date + }): ProjectResponse { + return { + id: project.id, + name: project.name, + description: project.description, + slug: project.slug, + ownerUserId: project.ownerUserId, + ownerGroupId: project.ownerGroupId, + settings: project.settings, + isArchived: project.isArchived, + createdBy: project.createdBy, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + } + } + + /** + * Maps a Prisma membership row (with its user projection) to the response + * shape: ISO `joinedAt`, public user fields only. + */ + private mapMembership(membership: { + id: string + userId: string + projectId: string + role: string + joinedAt: Date + user: MembershipUser + }): MembershipResponse { + return { + id: membership.id, + userId: membership.userId, + projectId: membership.projectId, + role: membership.role, + joinedAt: membership.joinedAt.toISOString(), + user: membership.user, + } + } + + /** Maps a Prisma user projection to the assignable-user response shape. */ + private mapAssignableUser(user: AssignableUser): AssignableUserResponse { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + email: user.email, + } + } + + /** + * Maps a Prisma world-state row to the response shape: ISO date strings, + * JSON arrays defaulted to [] when null. + */ + private mapWorldState(worldState: { + id: string + userId: string + projectId: string | null + entities: Prisma.JsonValue + events: Prisma.JsonValue + times: Prisma.JsonValue + entityCollections: Prisma.JsonValue + eventCollections: Prisma.JsonValue + timeCollections: Prisma.JsonValue + relations: Prisma.JsonValue + createdAt: Date + updatedAt: Date + }): WorldStateResponse { + return { + id: worldState.id, + userId: worldState.userId, + projectId: worldState.projectId, + entities: (worldState.entities as unknown[]) || [], + events: (worldState.events as unknown[]) || [], + times: (worldState.times as unknown[]) || [], + entityCollections: (worldState.entityCollections as unknown[]) || [], + eventCollections: (worldState.eventCollections as unknown[]) || [], + timeCollections: (worldState.timeCollections as unknown[]) || [], + relations: (worldState.relations as unknown[]) || [], + createdAt: worldState.createdAt.toISOString(), + updatedAt: worldState.updatedAt.toISOString(), + } + } +} diff --git a/server/test/helpers/rbac-test-setup.ts b/server/test/helpers/rbac-test-setup.ts index 2ad512dd..9a71cc28 100644 --- a/server/test/helpers/rbac-test-setup.ts +++ b/server/test/helpers/rbac-test-setup.ts @@ -29,6 +29,25 @@ export async function seedBaselinePermissions(prisma: PrismaClient): Promise nonDestructive.map(action => ({ @@ -48,6 +67,7 @@ export async function seedBaselinePermissions(prisma: PrismaClient): Promise { expect(serialized.length).toBeGreaterThan(0) }) }) + +/** + * Project subject authorization. + * + * Projects are governed through CASL like every other domain: the Project + * model itself is a CASL subject, so project-scoped role permissions + * (read / update / delete / manage_members) and the personal-owner baseline + * resolve against a subject('Project', row). These cover the policy that the + * projects routes previously enforced with inline role-string checks. + */ +describe('defineAbilitiesFor — Project subject', () => { + const projectPermissions: RolePermissionRow[] = [ + // project_owner: read/update/delete/manage_members on their projects + { scope: 'project', role: 'project_owner', resourceType: 'project', action: 'read', ownOnly: false }, + { scope: 'project', role: 'project_owner', resourceType: 'project', action: 'update', ownOnly: false }, + { scope: 'project', role: 'project_owner', resourceType: 'project', action: 'delete', ownOnly: false }, + { scope: 'project', role: 'project_owner', resourceType: 'project', action: 'manage_members', ownOnly: false }, + // project_manager: read/update/manage_members (no delete) + { scope: 'project', role: 'project_manager', resourceType: 'project', action: 'read', ownOnly: false }, + { scope: 'project', role: 'project_manager', resourceType: 'project', action: 'update', ownOnly: false }, + { scope: 'project', role: 'project_manager', resourceType: 'project', action: 'manage_members', ownOnly: false }, + // annotator / reviewer / viewer: read only + { scope: 'project', role: 'annotator', resourceType: 'project', action: 'read', ownOnly: false }, + { scope: 'project', role: 'reviewer', resourceType: 'project', action: 'read', ownOnly: false }, + { scope: 'project', role: 'viewer', resourceType: 'project', action: 'read', ownOnly: false }, + // group_owner / group_admin: create projects for their group + { scope: 'group', role: 'group_owner', resourceType: 'project', action: 'create', ownOnly: false }, + { scope: 'group', role: 'group_admin', resourceType: 'project', action: 'create', ownOnly: false }, + ] + + const projectRow = (overrides: Partial<{ id: string; ownerUserId: string | null; ownerGroupId: string | null }> = {}) => + subject('Project', { id: 'proj-1', ownerUserId: null, ownerGroupId: null, ...overrides }) + + it('project_owner can read/update/delete/manage_members their own project only', () => { + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [], projectRoles: [{ projectId: 'proj-1', role: 'project_owner' }], + }, projectPermissions) + + const mine = projectRow({ id: 'proj-1', ownerUserId: 'user-1' }) + expect(ability.can('read', mine)).toBe(true) + expect(ability.can('update', mine)).toBe(true) + expect(ability.can('delete', mine)).toBe(true) + expect(ability.can('manage_members', mine)).toBe(true) + + // A different project the user has no membership in is off-limits. + const other = projectRow({ id: 'proj-2', ownerUserId: 'someone' }) + expect(ability.can('read', other)).toBe(false) + expect(ability.can('update', other)).toBe(false) + expect(ability.can('delete', other)).toBe(false) + expect(ability.can('manage_members', other)).toBe(false) + }) + + it('project_manager can read/update/manage_members but not delete', () => { + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [], projectRoles: [{ projectId: 'proj-1', role: 'project_manager' }], + }, projectPermissions) + + // ownerUserId is someone else: a manager manages a project they don't own. + const proj = projectRow({ id: 'proj-1', ownerUserId: 'other' }) + expect(ability.can('read', proj)).toBe(true) + expect(ability.can('update', proj)).toBe(true) + expect(ability.can('manage_members', proj)).toBe(true) + expect(ability.can('delete', proj)).toBe(false) + }) + + it('viewer/annotator/reviewer can read but not write or manage members', () => { + for (const role of ['viewer', 'annotator', 'reviewer']) { + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [], projectRoles: [{ projectId: 'proj-1', role }], + }, projectPermissions) + const proj = projectRow({ id: 'proj-1', ownerUserId: 'other' }) + expect(ability.can('read', proj)).toBe(true) + expect(ability.can('update', proj)).toBe(false) + expect(ability.can('delete', proj)).toBe(false) + expect(ability.can('manage_members', proj)).toBe(false) + } + }) + + it('a non-member cannot read a project', () => { + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [], projectRoles: [], + }, projectPermissions) + const proj = projectRow({ id: 'proj-1', ownerUserId: 'other' }) + expect(ability.can('read', proj)).toBe(false) + expect(ability.can('update', proj)).toBe(false) + }) + + it('baseline: a user can fully manage a personal project they own with no memberships', () => { + // No permissions and no roles at all — only the ownerUserId baseline. + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [], projectRoles: [], + }, []) + const personal = projectRow({ id: 'new', ownerUserId: 'user-1' }) + expect(ability.can('create', personal)).toBe(true) + expect(ability.can('read', personal)).toBe(true) + expect(ability.can('update', personal)).toBe(true) + expect(ability.can('delete', personal)).toBe(true) + expect(ability.can('manage_members', personal)).toBe(true) + + // Cannot create a personal project owned by someone else (forged owner). + const forged = projectRow({ id: 'new', ownerUserId: 'other' }) + expect(ability.can('create', forged)).toBe(false) + }) + + it('group create is scoped to groups the user administers', () => { + const ability = defineAbilitiesFor('user-1', { + systemRole: 'user', groupRoles: [{ groupId: 'group-1', role: 'group_admin' }], projectRoles: [], + }, projectPermissions) + + const forGroup1 = projectRow({ id: 'x', ownerUserId: null, ownerGroupId: 'group-1' }) + expect(ability.can('create', forGroup1)).toBe(true) + + // A group the user does not administer is off-limits. + const forGroup2 = projectRow({ id: 'x', ownerUserId: null, ownerGroupId: 'group-2' }) + expect(ability.can('create', forGroup2)).toBe(false) + }) + + it('system_admin can manage any project', () => { + const ability = defineAbilitiesFor('admin-1', { + systemRole: 'system_admin', groupRoles: [], projectRoles: [], + }, []) + const proj = projectRow({ id: 'p', ownerUserId: 'x', ownerGroupId: 'g' }) + expect(ability.can('read', proj)).toBe(true) + expect(ability.can('delete', proj)).toBe(true) + expect(ability.can('manage_members', proj)).toBe(true) + }) +}) diff --git a/server/test/routes/projects.test.ts b/server/test/routes/projects.test.ts index bde07eec..ac5d30c1 100644 --- a/server/test/routes/projects.test.ts +++ b/server/test/routes/projects.test.ts @@ -82,6 +82,7 @@ describe('Projects API', () => { passwordHash: adminHash, displayName: 'Admin User', isAdmin: true, + systemRole: 'system_admin', }, }) adminUserId = admin.id @@ -946,4 +947,189 @@ describe('Projects API', () => { expect(response.statusCode).toBe(404) }) }) + + // ========================================================================= + // CASL role outcomes for project_manager + // ========================================================================= + + describe('project_manager authorization', () => { + it('allows a manager to update a project they do not own', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Manager Update', slug: 'manager-update' }, + }) + const projectId = createRes.json().id + + await prisma.projectMembership.create({ + data: { userId: otherUserId, projectId, role: 'project_manager' }, + }) + + const response = await app.inject({ + method: 'PUT', + url: `/api/projects/${projectId}`, + cookies: { session_token: otherSessionToken }, + payload: { name: 'Renamed By Manager' }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toBe('Renamed By Manager') + }) + + it('allows a manager to add members', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Manager Adds', slug: 'manager-adds' }, + }) + const projectId = createRes.json().id + + await prisma.projectMembership.create({ + data: { userId: otherUserId, projectId, role: 'project_manager' }, + }) + + const response = await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/members`, + cookies: { session_token: otherSessionToken }, + payload: { userId: adminUserId, role: 'annotator' }, + }) + + expect(response.statusCode).toBe(201) + }) + + it('denies a manager deleting a project they do not own', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Manager No Delete', slug: 'manager-no-delete' }, + }) + const projectId = createRes.json().id + + await prisma.projectMembership.create({ + data: { userId: otherUserId, projectId, role: 'project_manager' }, + }) + + const response = await app.inject({ + method: 'DELETE', + url: `/api/projects/${projectId}`, + cookies: { session_token: otherSessionToken }, + }) + + expect(response.statusCode).toBe(403) + }) + }) + + // ========================================================================= + // GET /api/projects/:projectId/assignable-users + // ========================================================================= + + describe('GET /api/projects/:projectId/assignable-users', () => { + it('returns users who are not yet members', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Assignable', slug: 'assignable' }, + }) + const projectId = createRes.json().id + + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}/assignable-users`, + cookies: { session_token: testSessionToken }, + }) + + expect(response.statusCode).toBe(200) + const users = response.json() + const ids = users.map((u: { id: string }) => u.id) + // The owner (testUser) is already a member and must be excluded. + expect(ids).not.toContain(testUserId) + // Non-members are listed. + expect(ids).toContain(otherUserId) + expect(users[0]).toHaveProperty('username') + expect(users[0]).toHaveProperty('displayName') + expect(users[0]).not.toHaveProperty('passwordHash') + }) + + it('excludes a user once they become a member', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Assignable Filter', slug: 'assignable-filter' }, + }) + const projectId = createRes.json().id + + await app.inject({ + method: 'POST', + url: `/api/projects/${projectId}/members`, + cookies: { session_token: testSessionToken }, + payload: { userId: otherUserId, role: 'annotator' }, + }) + + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}/assignable-users`, + cookies: { session_token: testSessionToken }, + }) + + expect(response.statusCode).toBe(200) + const ids = response.json().map((u: { id: string }) => u.id) + expect(ids).not.toContain(otherUserId) + }) + + it('returns 403 for a viewer (cannot manage members)', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Assignable Forbidden', slug: 'assignable-forbidden' }, + }) + const projectId = createRes.json().id + + await prisma.projectMembership.create({ + data: { userId: otherUserId, projectId, role: 'viewer' }, + }) + + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}/assignable-users`, + cookies: { session_token: otherSessionToken }, + }) + + expect(response.statusCode).toBe(403) + }) + + it('allows a system admin to list assignable users', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/projects', + cookies: { session_token: testSessionToken }, + payload: { name: 'Assignable Admin', slug: 'assignable-admin' }, + }) + const projectId = createRes.json().id + + const response = await app.inject({ + method: 'GET', + url: `/api/projects/${projectId}/assignable-users`, + cookies: { session_token: adminSessionToken }, + }) + + expect(response.statusCode).toBe(200) + }) + + it('returns 404 for a non-existent project', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/projects/00000000-0000-0000-0000-000000000000/assignable-users', + cookies: { session_token: testSessionToken }, + }) + + expect(response.statusCode).toBe(404) + }) + }) }) From 6950e35c09eee40e9cf1a40c08173a01a51421aa Mon Sep 17 00:00:00 2001 From: Aaron Steven White Date: Thu, 18 Jun 2026 14:46:01 -0400 Subject: [PATCH 16/42] Extract the claims domain into a ClaimRepository and ClaimService, reducing routes/claims.ts from 1559 lines with 33 direct prisma calls to 783 thin lines with none, by relocating the existing CASL authorization, the claimsJson rebuild, the claim-extraction and synthesis queue jobs, the auto-created empty summary, and the subclaim cascade verbatim with no behavior change. --- CHANGELOG.md | 1 + docs/docs/project/changelog.md | 1 + server/src/repositories/ClaimRepository.ts | 389 ++++++++ server/src/routes/claims.ts | 942 ++---------------- server/src/services/claim-service.ts | 1042 ++++++++++++++++++++ server/test/routes/claims.test.ts | 119 +++ 6 files changed, 1635 insertions(+), 859 deletions(-) create mode 100644 server/src/repositories/ClaimRepository.ts create mode 100644 server/src/services/claim-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3faca477..070d4a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Extracted the **projects** domain into a `ProjectRepository` (pure Prisma data access, including the create-project-with-owner-membership transaction) and a `ProjectService` (orchestration, authorization, and response mapping), reducing `server/src/routes/projects.ts` from 1072 lines with 32 direct `prisma.*` calls to 581 thin lines with zero, following the personas pattern. - Migrated project authorization onto CASL, the same engine every other domain already uses. The projects routes previously enforced access with bespoke inline checks (`['project_owner','project_manager'].includes(myRole)` role-string comparisons and a `request.user.isAdmin` boolean); `Project` is now a first-class CASL subject in `server/src/lib/abilities.ts`, so reads, updates, deletes, member management, and project creation resolve through the same `RolePermission` matrix and request-scoped `ability` as personas, claims, world state, and video. The database already granted these project permissions (`project_owner` → update/delete/manage_members, `project_manager` → update/manage_members, every project role → read, `group_owner`/`group_admin` → create) and `manage_members` was an already-defined-but-unused CASL action, so the policy is unchanged - this removes a duplicated, divergent authorization path rather than changing who may do what. The group-scoped create permission is now correctly limited to the group a candidate project belongs to (a group administrator could previously be granted creation against any group). One deliberate consistency change: a project administrator is now recognized through the CASL system role (`systemRole === 'system_admin'`), matching every other domain, rather than the separate `isAdmin` boolean column; seeded and provisioned administrators carry both, so no real administrator is affected. - Added `GET /api/projects/:projectId/assignable-users` (authorized by `manage_members`), returning the users who are not already members of a project. The "Add member" picker on the project detail page previously called the admin-only `/api/admin/users` endpoint, so it returned a 403 and showed an empty list to project owners and managers who were not system administrators; it now reads from the scoped endpoint and works for anyone who can manage the project's members. +- Extracted the **claims** domain into a `ClaimRepository` (pure Prisma data access) and a `ClaimService` (orchestration, the relocated CASL authorization, and response mapping), reducing `server/src/routes/claims.ts` from 1559 lines with 33 direct `prisma.*` calls to 783 thin lines with zero. The claims routes already authorized through CASL, so every `can(...)` / `accessibleBy(...)` decision is preserved verbatim, along with the denormalized `claimsJson` rebuild, the claim-extraction and claim-synthesis queue jobs, the auto-created empty summary, and the subclaim cascade - a structural refactor with no behavior change. ### Fixed diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md index ef6e78af..7e4171aa 100644 --- a/docs/docs/project/changelog.md +++ b/docs/docs/project/changelog.md @@ -65,6 +65,7 @@ The 0.5.0 cycle delivers the architecture-modularization roadmap (`notes/archite - Extracted the **projects** domain into a `ProjectRepository` (pure Prisma data access, including the create-project-with-owner-membership transaction) and a `ProjectService` (orchestration, authorization, and response mapping), reducing `server/src/routes/projects.ts` from 1072 lines with 32 direct `prisma.*` calls to 581 thin lines with zero, following the personas pattern. - Migrated project authorization onto CASL, the same engine every other domain already uses. The projects routes previously enforced access with bespoke inline checks (`['project_owner','project_manager'].includes(myRole)` role-string comparisons and a `request.user.isAdmin` boolean); `Project` is now a first-class CASL subject in `server/src/lib/abilities.ts`, so reads, updates, deletes, member management, and project creation resolve through the same `RolePermission` matrix and request-scoped `ability` as personas, claims, world state, and video. The database already granted these project permissions (`project_owner` → update/delete/manage_members, `project_manager` → update/manage_members, every project role → read, `group_owner`/`group_admin` → create) and `manage_members` was an already-defined-but-unused CASL action, so the policy is unchanged - this removes a duplicated, divergent authorization path rather than changing who may do what. The group-scoped create permission is now correctly limited to the group a candidate project belongs to (a group administrator could previously be granted creation against any group). One deliberate consistency change: a project administrator is now recognized through the CASL system role (`systemRole === 'system_admin'`), matching every other domain, rather than the separate `isAdmin` boolean column; seeded and provisioned administrators carry both, so no real administrator is affected. - Added `GET /api/projects/:projectId/assignable-users` (authorized by `manage_members`), returning the users who are not already members of a project. The "Add member" picker on the project detail page previously called the admin-only `/api/admin/users` endpoint, so it returned a 403 and showed an empty list to project owners and managers who were not system administrators; it now reads from the scoped endpoint and works for anyone who can manage the project's members. +- Extracted the **claims** domain into a `ClaimRepository` (pure Prisma data access) and a `ClaimService` (orchestration, the relocated CASL authorization, and response mapping), reducing `server/src/routes/claims.ts` from 1559 lines with 33 direct `prisma.*` calls to 783 thin lines with zero. The claims routes already authorized through CASL, so every `can(...)` / `accessibleBy(...)` decision is preserved verbatim, along with the denormalized `claimsJson` rebuild, the claim-extraction and claim-synthesis queue jobs, the auto-created empty summary, and the subclaim cascade - a structural refactor with no behavior change. ### Fixed diff --git a/server/src/repositories/ClaimRepository.ts b/server/src/repositories/ClaimRepository.ts new file mode 100644 index 00000000..6dc6631e --- /dev/null +++ b/server/src/repositories/ClaimRepository.ts @@ -0,0 +1,389 @@ +import { PrismaClient, Claim, ClaimRelation, VideoSummary, Video, Prisma } from '@prisma/client' + +/** + * Claim row joined with its nested subclaims (up to three levels deep). This is + * the shape returned by the claim-tree read paths (list, create, update) and is + * what the denormalized `claimsJson` field stores. + */ +export type ClaimWithSubclaimTree = Prisma.ClaimGetPayload<{ + include: { + subclaims: { + include: { + subclaims: { + include: { + subclaims: true + } + } + } + } + } +}> + +/** + * Claim row joined with its subclaim tree and its parent claim. Returned by the + * single-claim read path. + */ +export type ClaimWithSubclaimsAndParent = Prisma.ClaimGetPayload<{ + include: { + subclaims: { + include: { + subclaims: { + include: { + subclaims: true + } + } + } + } + parentClaim: true + } +}> + +/** + * VideoSummary joined with its persona and that persona's ontology. Used by the + * relation-create path to validate a relation type against the ontology. + */ +export type SummaryWithPersonaOntology = Prisma.VideoSummaryGetPayload<{ + include: { + persona: { + include: { + ontology: true + } + } + } +}> + +/** + * ClaimRelation joined with both endpoint claims. Used by the relation-delete + * path to authorize against both endpoints. + */ +export type ClaimRelationWithEndpoints = Prisma.ClaimRelationGetPayload<{ + include: { + sourceClaim: true + targetClaim: true + } +}> + +/** Standard three-level subclaim include used by every claim-tree read. */ +const SUBCLAIM_TREE_INCLUDE = { + subclaims: { + include: { + subclaims: { + include: { + subclaims: true, + }, + }, + }, + }, +} as const + +/** + * Repository for all Claim, ClaimRelation, VideoSummary, Video, and Persona + * database access in the claims domain. + * + * This class owns every Prisma call the claims routes used. It performs no + * authorization: callers (the ClaimService) decide who may invoke a method. + * Methods return raw Prisma model types and propagate Prisma errors (for + * example P2025 on a missing delete target) to their callers. + * + * @example + * ```typescript + * const repo = new ClaimRepository(fastify.prisma) + * const claim = await repo.findClaimById(id) + * if (!claim) { + * throw new NotFoundError('Claim', id) + * } + * ``` + */ +export class ClaimRepository { + /** + * Creates a new ClaimRepository instance. + * + * @param prisma - Prisma client instance for database access + */ + constructor(private readonly prisma: PrismaClient) {} + + /** + * Finds a video summary by ID. + * + * @param id - VideoSummary UUID + * @returns the summary, or null if not found + */ + async findVideoSummaryById(id: string): Promise { + return this.prisma.videoSummary.findUnique({ where: { id } }) + } + + /** + * Finds a video summary by ID together with a single root claim (if any). + * + * Used by the synthesis path to verify the summary has at least one claim to + * synthesize. + * + * @param id - VideoSummary UUID + * @returns the summary with at most one root claim, or null if not found + */ + async findVideoSummaryWithRootClaim(id: string): Promise | null> { + return this.prisma.videoSummary.findUnique({ + where: { id }, + include: { + claims: { + where: { parentClaimId: null }, + take: 1, + }, + }, + }) + } + + /** + * Finds a video summary by ID together with its persona and that persona's + * ontology. + * + * @param id - VideoSummary UUID + * @returns the summary with persona and ontology, or null if not found + */ + async findVideoSummaryWithPersonaOntology(id: string): Promise { + return this.prisma.videoSummary.findUnique({ + where: { id }, + include: { + persona: { + include: { + ontology: true, + }, + }, + }, + }) + } + + /** + * Finds a video summary by its (videoId, personaId) unique pair. + * + * @param videoId - the video ID + * @param personaId - the persona UUID + * @returns the summary, or null if none exists yet + */ + async findVideoSummaryByVideoPersona(videoId: string, personaId: string): Promise { + return this.prisma.videoSummary.findUnique({ + where: { videoId_personaId: { videoId, personaId } }, + }) + } + + /** + * Upserts a video summary for a (videoId, personaId) pair, creating an empty + * one if absent and leaving an existing one untouched. + * + * @param videoId - the video ID + * @param personaId - the persona UUID + * @returns the existing or newly created summary + */ + async upsertEmptyVideoSummary(videoId: string, personaId: string): Promise { + return this.prisma.videoSummary.upsert({ + where: { + videoId_personaId: { + videoId, + personaId, + }, + }, + create: { + videoId, + personaId, + summary: [], + }, + update: {}, + }) + } + + /** + * Updates a video summary's denormalized `claimsJson` and `claimsExtractedAt` + * fields. + * + * @param summaryId - VideoSummary UUID + * @param claimsJson - the denormalized claim tree payload + * @param claimsExtractedAt - the extraction timestamp + */ + async updateVideoSummaryClaimsJson( + summaryId: string, + claimsJson: Prisma.InputJsonValue, + claimsExtractedAt: Date + ): Promise { + await this.prisma.videoSummary.update({ + where: { id: summaryId }, + data: { + claimsJson, + claimsExtractedAt, + }, + }) + } + + /** + * Finds a video by ID. + * + * @param id - the video ID + * @returns the video, or null if not found + */ + async findVideoById(id: string): Promise