feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { resolveRole } from './role';
describe('resolveRole', () => {
it('defaults per entry point outside production', () => {
expect(resolveRole('main', {})).toBe('all');
expect(resolveRole('worker', {})).toBe('worker');
expect(resolveRole('cli', {})).toBe('job');
});
it('treats a blank APICORE_ROLE as unset', () => {
expect(resolveRole('main', { APICORE_ROLE: ' ' })).toBe('all');
});
it('requires APICORE_ROLE in production', () => {
expect(() => resolveRole('main', { NODE_ENV: 'production' })).toThrow(
'APICORE_ROLE is required in production',
);
});
it('rejects all in production', () => {
expect(() =>
resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'all' }),
).toThrow('APICORE_ROLE=all is for local development and tests only');
});
it('rejects an unknown role', () => {
expect(() => resolveRole('main', { APICORE_ROLE: 'admin' })).toThrow(
'APICORE_ROLE must be one of api, worker, job, all',
);
});
it('rejects a role that does not belong to the entry point', () => {
expect(() => resolveRole('main', { APICORE_ROLE: 'worker' })).toThrow(
'APICORE_ROLE=worker cannot start dist/main.js',
);
expect(() => resolveRole('worker', { APICORE_ROLE: 'api' })).toThrow(
'APICORE_ROLE=api cannot start dist/worker.js',
);
expect(() => resolveRole('cli', { APICORE_ROLE: 'all' })).toThrow(
'APICORE_ROLE=all cannot start dist/cli.js',
);
});
it('accepts api for main in production', () => {
expect(
resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'api' }),
).toBe('api');
});
it('accepts worker and job for their entry points in production', () => {
expect(
resolveRole('worker', { NODE_ENV: 'production', APICORE_ROLE: 'worker' }),
).toBe('worker');
expect(
resolveRole('cli', { NODE_ENV: 'production', APICORE_ROLE: 'job' }),
).toBe('job');
});
});
+57
View File
@@ -0,0 +1,57 @@
export const APICORE_ROLES = ['api', 'worker', 'job', 'all'] as const;
export type ApicoreRole = (typeof APICORE_ROLES)[number];
export type EntryPoint = 'main' | 'worker' | 'cli';
interface EntryPointRules {
readonly file: string;
readonly defaultRole: ApicoreRole;
readonly allowed: readonly ApicoreRole[];
}
const ENTRY_POINTS: Readonly<Record<EntryPoint, EntryPointRules>> = {
main: { file: 'dist/main.js', defaultRole: 'all', allowed: ['api', 'all'] },
worker: {
file: 'dist/worker.js',
defaultRole: 'worker',
allowed: ['worker'],
},
cli: { file: 'dist/cli.js', defaultRole: 'job', allowed: ['job'] },
};
function isApicoreRole(value: string): value is ApicoreRole {
return (APICORE_ROLES as readonly string[]).includes(value);
}
/**
* Resolves the role this process runs as from APICORE_ROLE and the entry
* point it was started from. Outside production the entry point's default
* applies when the variable is unset; production deployments must name the
* role explicitly (and never "all", which bundles API and worker in one
* process) so a misconfigured pod fails loudly instead of running the wrong
* workload.
*/
export function resolveRole(
entry: EntryPoint,
env: NodeJS.ProcessEnv = process.env,
): ApicoreRole {
const rules = ENTRY_POINTS[entry];
const isProduction = env.NODE_ENV === 'production';
const raw = env.APICORE_ROLE?.trim();
if (!raw) {
if (isProduction) {
throw new Error('APICORE_ROLE is required in production');
}
return rules.defaultRole;
}
if (!isApicoreRole(raw)) {
throw new Error(`APICORE_ROLE must be one of ${APICORE_ROLES.join(', ')}`);
}
if (isProduction && raw === 'all') {
throw new Error('APICORE_ROLE=all is for local development and tests only');
}
if (!rules.allowed.includes(raw)) {
throw new Error(`APICORE_ROLE=${raw} cannot start ${rules.file}`);
}
return raw;
}
@@ -0,0 +1,109 @@
import { forwardRef, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { AllInOneModule } from '../all-in-one.module';
import { AppModule } from '../app.module';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { WorkerModule } from '../worker/worker.module';
import { WorkerRuntimeModule } from '../worker/worker-runtime.module';
import { isHttpRole, rootModuleFor } from './root-module';
const importsOf = (module: object): unknown[] =>
(Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? [];
/**
* Every module class reachable from `root` through imports - including
* the imports of dynamic modules (also async ones like
* ConfigModule.forRoot) and forwardRef()s.
*/
async function reachableModules(root: unknown): Promise<Set<unknown>> {
const seen = new Set<unknown>();
async function visit(entry: unknown): Promise<void> {
const resolved: unknown = await entry;
// A circular file import can leave an undefined slot behind.
if (typeof resolved !== 'object' && typeof resolved !== 'function') {
return;
}
if (resolved === null) {
return;
}
if ('forwardRef' in resolved) {
return visit((resolved as { forwardRef: () => unknown }).forwardRef());
}
if ('module' in resolved) {
const dynamic = resolved as { module: unknown; imports?: unknown[] };
await visit(dynamic.module);
for (const imported of dynamic.imports ?? []) {
await visit(imported);
}
return;
}
if (seen.has(resolved)) {
return;
}
seen.add(resolved);
for (const imported of importsOf(resolved)) {
await visit(imported);
}
}
await visit(root);
return seen;
}
// The API role never starts background loops: only the worker and "all"
// (local development and tests) load the worker runtime.
describe('rootModuleFor', () => {
it('the api module never imports the worker runtime, not even transitively', async () => {
const reachable = await reachableModules(AppModule);
expect(reachable).not.toContain(WorkerRuntimeModule);
// The walk really goes deep: into feature modules and dynamic modules.
expect(reachable).toContain(DiscoveryModule);
expect(reachable).toContain(ThrottlerStorageModule);
});
it('api starts the API module alone', () => {
expect(rootModuleFor('api')).toBe(AppModule);
});
it('all adds the worker runtime to the API', () => {
expect(rootModuleFor('all')).toBe(AllInOneModule);
expect(importsOf(AllInOneModule)).toEqual([AppModule, WorkerRuntimeModule]);
});
it('the worker runs the runtime without the HTTP API', async () => {
const reachable = await reachableModules(WorkerModule);
expect(reachable).toContain(WorkerRuntimeModule);
expect(reachable).not.toContain(AppModule);
});
});
describe('reachableModules', () => {
@Module({ imports: [WorkerRuntimeModule] })
class InnerModule {}
@Module({ imports: [forwardRef(() => InnerModule)] })
class ViaForwardRefModule {}
@Module({
imports: [Promise.resolve({ module: class {}, imports: [InnerModule] })],
})
class ViaAsyncDynamicModule {}
it.each([
['a plain import chain', InnerModule],
['a forwardRef', ViaForwardRefModule],
['the imports of an async dynamic module', ViaAsyncDynamicModule],
])('finds the worker runtime behind %s', async (_case, root) => {
expect(await reachableModules(root)).toContain(WorkerRuntimeModule);
});
});
describe('isHttpRole', () => {
it('admits exactly the roles dist/main.js starts', () => {
expect(isHttpRole('api')).toBe(true);
expect(isHttpRole('all')).toBe(true);
expect(isHttpRole('worker')).toBe(false);
expect(isHttpRole('job')).toBe(false);
});
});
@@ -0,0 +1,20 @@
import type { Type } from '@nestjs/common';
import { AllInOneModule } from '../all-in-one.module';
import { AppModule } from '../app.module';
import type { ApicoreRole } from './role';
/** The roles dist/main.js starts; resolveRole('main') admits no other. */
export type HttpRole = Extract<ApicoreRole, 'api' | 'all'>;
export function isHttpRole(role: ApicoreRole): role is HttpRole {
return role === 'api' || role === 'all';
}
/**
* The root module of dist/main.js. "api" is the HTTP API alone and never
* starts background loops, so API pods scale freely; "all" adds the worker
* runtime for local development and tests.
*/
export function rootModuleFor(role: HttpRole): Type<unknown> {
return role === 'all' ? AllInOneModule : AppModule;
}
@@ -0,0 +1,324 @@
import { assertStartupEnvironment } from './startup-checks';
const SECRET = 'x'.repeat(32);
const BASE_ENV = { DATABASE_URL: 'postgres://db', JWT_ACCESS_SECRET: SECRET };
const KEYS = {
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
};
const PROD_ENV = {
...BASE_ENV,
...KEYS,
JWT_REFRESH_SECRET: 'r'.repeat(32),
NODE_ENV: 'production',
FRONTEND_ORIGIN: 'https://app.example.com',
};
describe('assertStartupEnvironment', () => {
it('accepts a complete development environment', () => {
expect(() => assertStartupEnvironment('api', BASE_ENV)).not.toThrow();
});
it('lists missing DATABASE_URL', () => {
expect(() =>
assertStartupEnvironment('api', { JWT_ACCESS_SECRET: SECRET }),
).toThrow('Missing required environment variable(s): DATABASE_URL');
});
it('lists every missing variable', () => {
expect(() => assertStartupEnvironment('worker', {})).toThrow(
'Missing required environment variable(s): DATABASE_URL, JWT_ACCESS_SECRET',
);
});
it('rejects a JWT secret shorter than 32 characters', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
JWT_ACCESS_SECRET: 'short',
}),
).toThrow('JWT_ACCESS_SECRET must be at least 32 characters (got 5)');
});
it('accepts a complete production environment', () => {
expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow();
});
it('requires an https FRONTEND_ORIGIN in production', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
FRONTEND_ORIGIN: 'http://app.example.com',
}),
).toThrow(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
FRONTEND_ORIGIN: undefined,
}),
).toThrow(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
});
it('rejects COOKIE_SECURE=false in production', () => {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, COOKIE_SECURE: 'false' }),
).toThrow('COOKIE_SECURE=false is for local HTTP development only');
});
it('allows an http FRONTEND_ORIGIN and COOKIE_SECURE=false outside production', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
FRONTEND_ORIGIN: 'http://localhost:3000',
COOKIE_SECURE: 'false',
}),
).not.toThrow();
});
describe('encryption keys', () => {
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => {
expect(() =>
assertStartupEnvironment('worker', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: undefined,
WEBHOOK_ENCRYPTION_KEY: undefined,
BACKUP_ENCRYPTION_KEY: undefined,
}),
).toThrow(
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY',
);
expect(() =>
assertStartupEnvironment('job', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY: '',
}),
).toThrow(
'Missing required environment variable(s): WEBHOOK_ENCRYPTION_KEY',
);
});
it('refuses keys shorter than 32 characters, *_PREVIOUS included', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: 'short',
}),
).toThrow('MFA_ENCRYPTION_KEY must be at least 32 characters');
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
BACKUP_ENCRYPTION_KEY_PREVIOUS: 'short',
}),
).toThrow(
'BACKUP_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters',
);
});
it('refuses two purposes sharing one key or a key equal to JWT_ACCESS_SECRET', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
}),
).toThrow(
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different',
);
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
BACKUP_ENCRYPTION_KEY: SECRET,
}),
).toThrow(
'BACKUP_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('refuses a key equal to JWT_REFRESH_SECRET', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET,
}),
).toThrow(
'MFA_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('refuses a *_PREVIOUS key equal to a JWT secret, like a current key', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: SECRET,
BACKUP_ENCRYPTION_KEY_PREVIOUS: PROD_ENV.JWT_REFRESH_SECRET,
}),
).toThrow(
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS, BACKUP_ENCRYPTION_KEY_PREVIOUS must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
);
});
it('accepts *_PREVIOUS keys of their own', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY_PREVIOUS: 'p'.repeat(32),
}),
).not.toThrow();
});
it('never puts a key into its errors', () => {
const shared = 'shared-secret-value-0123456789abcdef';
let message = '';
try {
assertStartupEnvironment('api', {
...PROD_ENV,
MFA_ENCRYPTION_KEY: shared,
WEBHOOK_ENCRYPTION_KEY: shared,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain(shared);
});
});
describe('example placeholders', () => {
const PLACEHOLDER =
'change-me-to-a-random-value-at-least-32-characters-long';
it.each([
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
'MFA_ENCRYPTION_KEY',
'WEBHOOK_ENCRYPTION_KEY',
'BACKUP_ENCRYPTION_KEY',
'MFA_ENCRYPTION_KEY_PREVIOUS',
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
])('production refuses a change-me placeholder in %s', (name) => {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
).toThrow(
`Secret(s) still set to an example placeholder: ${name} - generate real values, e.g. "openssl rand -hex 32"`,
);
});
it('matches the placeholder case-insensitively and lists every hit', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
JWT_REFRESH_SECRET: 'Change-Me-refresh-secret',
MFA_ENCRYPTION_KEY: 'CHANGE-ME-to-a-third-random-value-at-least-32',
}),
).toThrow(
'Secret(s) still set to an example placeholder: JWT_REFRESH_SECRET, MFA_ENCRYPTION_KEY',
);
});
it('names the variable, never the value', () => {
let message = '';
try {
assertStartupEnvironment('api', {
...PROD_ENV,
BACKUP_ENCRYPTION_KEY: PLACEHOLDER,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('BACKUP_ENCRYPTION_KEY');
expect(message).not.toContain(PLACEHOLDER);
});
it('production refuses a change-me OIDC_CLIENT_SECRET when SSO is configured', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
}),
).toThrow(
'Secret(s) still set to an example placeholder: OIDC_CLIENT_SECRET',
);
});
it('accepts production without SSO (OIDC_CLIENT_SECRET unset)', () => {
expect(PROD_ENV).not.toHaveProperty('OIDC_CLIENT_SECRET');
expect(() => assertStartupEnvironment('api', PROD_ENV)).not.toThrow();
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
OIDC_CLIENT_SECRET: 'a-real-entra-client-secret-value',
}),
).not.toThrow();
});
it('production refuses a change-me password inside DATABASE_URL, without echoing the URL', () => {
const url =
'postgresql://apicore:Change-Me-postgres-password@apicore-postgres:5432/apicore?schema=public';
let message = '';
try {
assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL: url });
} catch (error) {
message = (error as Error).message;
}
expect(message).toBe(
'Secret(s) still set to an example placeholder: DATABASE_URL - generate real values, e.g. "openssl rand -hex 32"',
);
expect(message).not.toContain('postgres-password');
expect(message).not.toContain('apicore-postgres');
});
it('finds a percent-encoded change-me password in DATABASE_URL', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
DATABASE_URL: 'postgresql://u:%63hange-me-pw@db:5432/apicore',
}),
).toThrow('Secret(s) still set to an example placeholder: DATABASE_URL');
});
it('accepts a DATABASE_URL with a real password, none, or one it cannot parse', () => {
for (const DATABASE_URL of [
'postgresql://apicore:s3cr3t-generated-value@db:5432/apicore',
'postgresql://change-me-user@db:5432/apicore',
'postgresql://db/apicore',
'not a url',
]) {
expect(() =>
assertStartupEnvironment('api', { ...PROD_ENV, DATABASE_URL }),
).not.toThrow();
}
});
it('lists DATABASE_URL together with other placeholder secrets', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore',
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
}),
).toThrow(
'Secret(s) still set to an example placeholder: DATABASE_URL, OIDC_CLIENT_SECRET',
);
});
it('leaves placeholders alone outside production', () => {
expect(() =>
assertStartupEnvironment('api', {
...BASE_ENV,
JWT_ACCESS_SECRET: PLACEHOLDER,
MFA_ENCRYPTION_KEY: `${PLACEHOLDER}-mfa`,
OIDC_CLIENT_SECRET: 'change-me-entra-client-secret',
DATABASE_URL: 'postgresql://u:change-me@db:5432/apicore',
}),
).not.toThrow();
});
});
});
@@ -0,0 +1,145 @@
import {
ENCRYPTION_KEY_VARIABLES,
PREVIOUS_KEY_SUFFIX,
readRingSecrets,
} from '../common/crypto/key-ring';
import type { ApicoreRole } from './role';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// Production must not fall back to keys derived from JWT_ACCESS_SECRET:
// rotating the JWT secret would then make stored secrets unreadable, and a
// leaked JWT secret would expose them too (common/crypto/key-ring.ts).
const ENCRYPTION_KEYS = Object.values(ENCRYPTION_KEY_VARIABLES);
const PREVIOUS_ENCRYPTION_KEYS = ENCRYPTION_KEYS.map(
(name) => `${name}${PREVIOUS_KEY_SUFFIX}`,
);
// The example files (.env.example, secret.example.yaml) ship "change-me…"
// values long enough to pass every length rule; production must not run on
// them. OIDC_CLIENT_SECRET only counts when set (SSO is optional).
const PLACEHOLDER_PATTERN = /^change-me/i;
const SECRET_VARIABLES = [
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
...ENCRYPTION_KEYS,
...PREVIOUS_ENCRYPTION_KEYS,
'OIDC_CLIENT_SECRET',
];
// JWT_ACCESS_SECRET signs every access/MFA-challenge/SSO-completion token -
// a short or guessable value would let an attacker forge tokens for any
// user. It also still opens MFA and webhook secrets stored before the key
// ring (until the rekey job reseals them). 32 chars matches the output of
// "openssl rand -hex 32" / "-base64 32", the standard way to generate one.
const MIN_JWT_SECRET_LENGTH = 32;
function requiredEnvVars(env: NodeJS.ProcessEnv): readonly string[] {
return env.NODE_ENV === 'production'
? [...REQUIRED_ENV_VARS, ...ENCRYPTION_KEYS]
: REQUIRED_ENV_VARS;
}
function assertProductionSettings(env: NodeJS.ProcessEnv): void {
if (env.NODE_ENV !== 'production') {
return;
}
// Without it CORS and SSO redirects silently fall back to localhost.
if (!env.FRONTEND_ORIGIN?.startsWith('https://')) {
throw new Error(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
}
if (env.COOKIE_SECURE === 'false') {
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
}
}
/**
* The password inside DATABASE_URL. An unparsable URL counts as "no
* password" here - Prisma reports it - and nothing of the URL is echoed.
*/
function databasePassword(env: NodeJS.ProcessEnv): string {
try {
return decodeURIComponent(new URL(env.DATABASE_URL ?? '').password);
} catch {
return '';
}
}
function assertNoPlaceholderSecrets(env: NodeJS.ProcessEnv): void {
if (env.NODE_ENV !== 'production') {
return;
}
const placeholders = [
...(PLACEHOLDER_PATTERN.test(databasePassword(env))
? ['DATABASE_URL']
: []),
...SECRET_VARIABLES.filter((name) =>
PLACEHOLDER_PATTERN.test(env[name] ?? ''),
),
];
if (placeholders.length > 0) {
throw new Error(
`Secret(s) still set to an example placeholder: ${placeholders.join(', ')} - generate real values, e.g. "openssl rand -hex 32"`,
);
}
}
function assertRequiredEnvVars(env: NodeJS.ProcessEnv): void {
const missing = requiredEnvVars(env).filter((name) => !env[name]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
const jwtSecret = env.JWT_ACCESS_SECRET as string;
if (jwtSecret.length < MIN_JWT_SECRET_LENGTH) {
throw new Error(
`JWT_ACCESS_SECRET must be at least ${MIN_JWT_SECRET_LENGTH} characters (got ${jwtSecret.length}) - generate one with e.g. "openssl rand -hex 32"`,
);
}
}
/**
* Lengths are checked like at runtime (incl. *_PREVIOUS), only earlier. In
* production every purpose needs a key of its own, so one leaked or rotated
* secret never touches another purpose. Errors name variables, never values.
*/
function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
for (const variable of ENCRYPTION_KEYS) {
readRingSecrets(variable, env);
}
if (env.NODE_ENV !== 'production') {
return;
}
const keys = ENCRYPTION_KEYS.map((name) => env[name]);
if (new Set(keys).size !== keys.length) {
throw new Error(
`${ENCRYPTION_KEYS.slice(0, -1).join(', ')} and ${ENCRYPTION_KEYS.at(-1)} must be pairwise different`,
);
}
const jwtSecrets = [env.JWT_ACCESS_SECRET, env.JWT_REFRESH_SECRET];
// *_PREVIOUS keys still open data, so the same rule applies to them.
const reused = [...ENCRYPTION_KEYS, ...PREVIOUS_ENCRYPTION_KEYS].filter(
(name) => env[name] && jwtSecrets.includes(env[name]),
);
if (reused.length > 0) {
throw new Error(
`${reused.join(', ')} must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET`,
);
}
}
/**
* Startup checks shared by every entry point (API, worker, CLI job). All
* roles load the same modules and secrets today, so the checks do not differ
* per role yet; the role is part of the signature so role-specific checks can
* be added without touching the callers.
*/
export function assertStartupEnvironment(
_role: ApicoreRole,
env: NodeJS.ProcessEnv = process.env,
): void {
assertRequiredEnvVars(env);
assertProductionSettings(env);
assertNoPlaceholderSecrets(env);
assertEncryptionKeys(env);
}