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:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user