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

+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AppModule } from './app.module';
import { WorkerRuntimeModule } from './worker/worker-runtime.module';
/**
* Role "all": the API plus the worker loops in one process, for local
* development and tests. Production refuses it (runtime/role.ts) and runs
* api and worker as separate pods. No worker health server here - the
* API's /health endpoints serve the process.
*/
@Module({
imports: [AppModule, WorkerRuntimeModule],
})
export class AllInOneModule {}
@@ -0,0 +1,58 @@
import { BadRequestException } from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
),
},
};
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['shop-sync:read', 'shop-sync:write'])),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
);
});
it('refuses a new key with both shop scopes', async () => {
await expect(
service.create(
{
name: 'Shop',
scopes: ['shop-sync:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it.each(['shop-sync:read', 'shop-sync:write'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
{ name: 'Shop', scopes: [scope] },
'user-1',
);
expect(created.apiKey.scopes).toEqual([scope]);
},
);
});
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
@@ -85,6 +86,16 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book,
// a leaked booking key cannot read. Existing combined keys stay valid.
if (
dto.scopes.includes('shop-sync:read') &&
dto.scopes.includes('shop-sync:write')
) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -15,12 +15,16 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
constructor(
private readonly apiariesService: ApiariesService,
private readonly apiaryFieldService: ApiaryFieldService,
) {}
@RequirePermission('apiaries:read')
@Get()
@@ -31,6 +35,24 @@ export class ApiariesController {
return this.apiariesService.findAll({ status, location });
}
@RequirePermission('colonies:read')
@Get(':id/colony-overview')
colonyOverview(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.colonyOverview(id, actor.id);
}
@RequirePermission('colonies:read')
@Get(':id/offline-pack')
offlinePack(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiaryFieldService.offlinePack(id, actor.id);
}
@RequirePermission('apiaries:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
controllers: [ApiariesController],
providers: [ApiariesService],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -0,0 +1,206 @@
import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
let prisma: {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
code,
apiaryId: 'apiary-1',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
broodStatus: null,
feedStatus: null,
healthStatus: null,
currentQueenId: queenId,
notes: null,
});
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
beforeEach(() => {
prisma = {
apiary: {
findUnique: jest.fn().mockResolvedValue({
id: 'apiary-1',
name: 'Heimstand',
location: 'Garten',
status: 'ACTIVE',
}),
},
colony: {
findMany: jest
.fn()
.mockResolvedValue([
colonyRow('c10', 'V-10', 'q1'),
colonyRow('c2', 'V-2', null),
]),
},
inspection: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
notification: { groupBy: jest.fn().mockResolvedValue([]) },
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
);
});
it('throws NotFoundException for an unknown apiary', async () => {
grant('colonies:read');
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('sorts living colonies by code with numeric collation', async () => {
grant('colonies:read');
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
expect(prisma.colony.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
}),
);
});
it('leaves out inspections, queens and tasks without their read permission', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.inspections).toBeNull();
expect(pack.queens).toBeNull();
expect(pack.tasks).toBeNull();
expect(pack.overview.inspectionsVisible).toBe(false);
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
true,
);
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
expect(prisma.queen.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
prisma.inspection.groupBy.mockResolvedValue([
{ colonyId: 'c2', _max: { date } },
]);
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.overview.inspectionsVisible).toBe(true);
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
expect(prisma.queen.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
);
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
],
},
}),
);
});
it('keeps at most three inspections per colony, newest first', async () => {
grant('colonies:read', 'inspections:read');
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
where: { colonyId: 'c2' },
take: 3,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
});
expect(pack.inspections).toHaveLength(2);
});
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
grant('colonies:read', 'inspections:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(Object.keys(pack.apiary).sort()).toEqual([
'id',
'location',
'name',
'status',
]);
const apiarySelect = (
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
)[0].select;
expect(apiarySelect).not.toHaveProperty('latitude');
expect(apiarySelect).not.toHaveProperty('longitude');
expect(apiarySelect).not.toHaveProperty('address');
expect(apiarySelect).not.toHaveProperty('notes');
const inspectionArgs = (
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
)[0];
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
grant('colonies:read');
prisma.notification.groupBy.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
]);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }],
},
}),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
openCritical: 1,
});
expect(overview.rows[1]).toMatchObject({
openWarnings: 0,
openCritical: 0,
});
});
});
@@ -0,0 +1,230 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import type {
ColonyOverview,
ColonyOverviewRow,
OfflinePackV1,
} from './apiary-field.types';
const INSPECTIONS_PER_COLONY = 3;
type PackColony = OfflinePackV1['colonies'][number];
interface FieldData {
apiary: OfflinePackV1['apiary'];
colonies: PackColony[];
permissions: ReadonlySet<string>;
overview: ColonyOverview;
}
/**
* Read models for work at the apiary: the colony traffic-light overview and
* the offline pack the PWA caches before the beekeeper loses reception.
* Sections beyond the colonies are only included for callers who may read them.
*/
@Injectable()
export class ApiaryFieldService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
async colonyOverview(
apiaryId: string,
userId: string,
): Promise<ColonyOverview> {
return (await this.load(apiaryId, userId)).overview;
}
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load(
apiaryId,
userId,
);
const [queens, inspections, tasks] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read')
? this.loadInspections(colonies)
: null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
]);
return {
version: 1,
generatedAt: new Date().toISOString(),
apiary,
overview,
colonies,
queens,
inspections,
tasks,
};
}
private async load(apiaryId: string, userId: string): Promise<FieldData> {
// Explicit select: coordinates, address and notes of an apiary are
// theft-sensitive and must never reach the field payloads.
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { id: true, name: true, location: true, status: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const permissions = await this.permissionResolver.getPermissions(userId);
const found = await this.prisma.colony.findMany({
where: { ...LIVING_COLONY_WHERE, apiaryId },
select: {
id: true,
code: true,
apiaryId: true,
status: true,
colonyType: true,
hiveType: true,
strength: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
currentQueenId: true,
notes: true,
},
});
const colonies = [...found].sort((a, b) =>
a.code.localeCompare(b.code, 'de', { numeric: true }),
);
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
}));
return {
apiary,
colonies,
permissions,
overview: { apiaryId, inspectionsVisible, rows },
};
}
private async lastInspectionDates(
colonies: PackColony[],
): Promise<Map<string, string>> {
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: colonies.map((colony) => colony.id) } },
_max: { date: true },
});
const dates = new Map<string, string>();
for (const group of groups) {
if (group._max.date) {
dates.set(group.colonyId, group._max.date.toISOString());
}
}
return dates;
}
private async warningCounts(
colonies: PackColony[],
userId: string,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
});
}
}
return counts;
}
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
);
return this.prisma.queen.findMany({
where: { id: { in: queenIds } },
select: {
id: true,
code: true,
hatchYear: true,
breed: true,
markingColor: true,
markingNumber: true,
status: true,
},
});
}
private async loadInspections(
colonies: PackColony[],
): Promise<OfflinePackV1['inspections']> {
const perColony = await Promise.all(
colonies.map((colony) =>
this.prisma.inspection.findMany({
where: { colonyId: colony.id },
take: INSPECTIONS_PER_COLONY,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
}),
),
);
return perColony.flat();
}
private loadTasks(
apiaryId: string,
colonies: PackColony[],
): Promise<OfflinePackV1['tasks']> {
return this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: apiaryId },
{
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
},
],
},
select: {
id: true,
title: true,
description: true,
dueDate: true,
priority: true,
status: true,
relatedType: true,
relatedId: true,
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'asc' }],
});
}
}
@@ -0,0 +1,91 @@
import type {
ApiaryStatus,
Colony,
ColonyCondition,
ColonyStatus,
Inspection,
Queen,
Task,
} from '../../generated/prisma';
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| 'id'
| 'code'
| 'apiaryId'
| 'status'
| 'colonyType'
| 'hiveType'
| 'strength'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
| 'currentQueenId'
| 'notes'
>[];
queens:
| Pick<
Queen,
| 'id'
| 'code'
| 'hatchYear'
| 'breed'
| 'markingColor'
| 'markingNumber'
| 'status'
>[]
| null;
inspections: Omit<Inspection, 'userId'>[] | null;
tasks:
| Pick<
Task,
| 'id'
| 'title'
| 'description'
| 'dueDate'
| 'priority'
| 'status'
| 'relatedType'
| 'relatedId'
>[]
| null;
}
@@ -1,5 +1,11 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@@ -35,4 +41,13 @@ export class CreateApiaryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description:
'Ort für die öffentliche Herkunftsseite, z. B. "Nordhessen". Nie die genaue Adresse.',
})
@IsOptional()
@IsString()
@MaxLength(100)
publicLocationLabel?: string;
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { noStoreMiddleware } from './common/middleware/no-store.middleware';
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
* deploy/k8s/base/backend/configmap.example.yaml). A plain number ("1") is a hop
* count and stays the default when the variable is unset.
*/
export function trustProxySetting(): number | string {
+12 -1
View File
@@ -3,10 +3,13 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { DomainEventsModule } from './domain-events/domain-events.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
@@ -60,10 +63,18 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
ThrottlerModule.forRootAsync({
imports: [ThrottlerStorageModule],
inject: [PrismaThrottlerStorage],
useFactory: (storage: PrismaThrottlerStorage) => ({
throttlers: [{ name: 'default', ttl: 60000, limit: 100 }],
storage,
}),
}),
PrismaModule,
PermissionsModule,
AuditModule,
DomainEventsModule,
HealthModule,
UsersModule,
AuthModule,
@@ -43,6 +43,18 @@ describe('AuditLogService', () => {
expect(createArg.data.userId).toBe('user-1');
});
it('writes through the transaction client when one is given', async () => {
const tx = { auditLog: { create: jest.fn() } };
await service.record(
{ entityType: 'Inspection', entityId: 'i1', action: 'CREATE' },
tx as never,
);
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it('findAll filters by entity type and id', async () => {
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
+13 -3
View File
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
interface RecordAuditLogInput {
export interface RecordAuditLogInput {
entityType: string;
entityId: string;
action: AuditAction;
@@ -20,8 +21,17 @@ export interface AuditLogFilter {
export class AuditLogService {
constructor(private readonly prisma: PrismaService) {}
async record(input: RecordAuditLogInput): Promise<void> {
await this.prisma.auditLog.create({
/**
* With `tx` the entry commits or rolls back together with the change it
* describes - no audited change without its entry, and no entry for a
* change that never happened.
*/
async record(
input: RecordAuditLogInput,
tx?: Prisma.TransactionClient,
): Promise<void> {
const db: DbClient = tx ?? this.prisma;
await db.auditLog.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
@@ -1,37 +1,114 @@
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { Logger } from '@nestjs/common';
import { KEY_ID_PATTERN } from '../../common/crypto/key-ring';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from './mfa-crypto.util';
// Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET),
// no key id) with JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SECRET =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const PLAINTEXT = 'JBSWY3DPEHPK3PXP';
describe('mfa-crypto.util', () => {
const originalSecret = process.env.JWT_ACCESS_SECRET;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env.JWT_ACCESS_SECRET = originalSecret;
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a TOTP secret through encrypt/decrypt', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
const ciphertext = encryptMfaSecret(plaintext);
expect(ciphertext).not.toContain(plaintext);
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
it('round-trips a TOTP secret and prefixes the key id', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
expect(ciphertext).toMatch(KEY_ID_PATTERN);
expect(ciphertext).not.toContain(PLAINTEXT);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('produces a different ciphertext every time (random IV)', () => {
const plaintext = 'JBSWY3DPEHPK3PXP';
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT));
});
it('no longer depends on JWT_ACCESS_SECRET', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
});
it('fails to decrypt with the wrong key', () => {
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
expect(() => decryptMfaSecret(ciphertext)).toThrow();
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id');
});
it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const ciphertext = encryptMfaSecret(PLAINTEXT);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY;
process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40);
expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(ciphertext)).toBe(true);
});
it('does not flag a secret sealed with the current key', () => {
expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false);
});
it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => {
expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT);
expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true);
});
it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication with the legacy key', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it.
const disguised = `${currentKeyId}:${LEGACY_SECRET}`;
expect(() => decryptMfaSecret(disguised)).toThrow(
'ciphertext failed authentication',
);
expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('fails on a tampered ciphertext', () => {
const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':');
const [iv, tag, data] = body.split('.');
const bytes = Buffer.from(data, 'base64');
bytes[0] ^= 0xff;
expect(() =>
decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`),
).toThrow('ciphertext failed authentication');
});
it('rejects a malformed ciphertext', () => {
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
'Invalid MFA secret ciphertext',
'malformed ciphertext',
);
});
});
+21 -43
View File
@@ -1,53 +1,31 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
function getKey(): Buffer {
const secret = process.env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return createHash('sha256').update(secret).digest();
}
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
} from '../../common/crypto/key-ring';
/**
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
* are one-way hashed) so the server can compute the expected code on every
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
* out of the DB in plaintext without introducing a new secret to manage.
* login. They are sealed with the MFA key ring (MFA_ENCRYPTION_KEY, see
* common/crypto/key-ring.ts). Secrets stored before the key ring have no key
* id and were encrypted with sha256(JWT_ACCESS_SECRET); they stay readable
* until the rekey job reseals them.
*/
export function encryptMfaSecret(plaintext: string): string {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted]
.map((buffer) => buffer.toString('base64'))
.join('.');
return sealWithRing(loadKeyRing('mfa'), plaintext);
}
export function decryptMfaSecret(ciphertext: string): string {
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
if (!ivB64 || !authTagB64 || !dataB64) {
throw new Error('Invalid MFA secret ciphertext');
}
const decipher = createDecipheriv(
ALGORITHM,
getKey(),
Buffer.from(ivB64, 'base64'),
);
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
export function decryptMfaSecret(stored: string): string {
return KEY_ID_PATTERN.test(stored)
? openWithRing(loadKeyRing('mfa'), stored).plaintext
: openLegacyCiphertext('mfa', stored);
}
/** True for secrets the rekey job should reseal with the current key. */
export function mfaSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('mfa'), stored);
}
@@ -0,0 +1,41 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -0,0 +1,30 @@
import type { AutomationRule } from '../../generated/prisma';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
@@ -1,5 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
import { BadRequestException, Logger } from '@nestjs/common';
import {
isSealedBackup,
openBackup,
sealBackup,
type SealedBackup,
} from './backup-crypto';
// Sealed by the code before the key ring, JWT_ACCESS_SECRET = 'x' * 40:
// LEGACY_JWT_BACKUP without BACKUP_ENCRYPTION_KEY (key derived from the JWT
// secret), KEY_BACKUP with BACKUP_ENCRYPTION_KEY = 'k' * 40.
const FIXTURE_BUNDLE = {
version: 3,
exportedAt: '2026-10-01T09:00:00.000Z',
data: { user: [{ id: 'u1' }] },
};
const LEGACY_JWT_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '6ac8404ff5ae',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'vD+47m3e5IFJAexz',
tag: 'LOgrmURu9vAMalQmlduDpw==',
ciphertext:
'yvuAGhVALAxXkqZM8FpeiPnGHD9llp/SVtdQ6S58oS4ezussfP7Lqj8ZDuNbxabwjUP4QBJvVdEyhKCFZ9w5YRbCRjDozizJyE6YwjqiCsAXWPyVZ2lZ6Fo8/G+/0HsoQg==',
};
const KEY_BACKUP: SealedBackup = {
format: 'apicore-backup',
version: 3,
keyId: '14b11a29dc33',
exportedAt: '2026-10-01T09:00:00.000Z',
algorithm: 'A256GCM+gzip',
iv: 'NKe1zNJfRPyBqeYL',
tag: 'zU/oVHyV4bj5+wwF0lvGiA==',
ciphertext:
'ArjmmWq18F3OkPQYfx6x9XcjWdqP1SZC9zarUCRfV7bo6KRYx3pi5sNMRro6mg0eqlr4+C4jRKn3y+110kaWEFrY6J/ashF76pWSl1gzfiewkpxdqoyjkXRxrd4AnPrnfw==',
};
describe('backup-crypto', () => {
const originalEnv = { ...process.env };
@@ -12,10 +48,13 @@ describe('backup-crypto', () => {
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
delete process.env.BACKUP_ENCRYPTION_KEY;
delete process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('round-trips a bundle and hides its contents', () => {
@@ -45,7 +84,17 @@ describe('backup-crypto', () => {
).toThrow(BadRequestException);
});
it('rejects a file with a truncated auth tag', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
const tag = Buffer.from(sealed.tag, 'base64').subarray(0, 4);
expect(() =>
openBackup({ ...sealed, tag: tag.toString('base64') }),
).toThrow(BadRequestException);
});
it('rejects a file sealed with another key', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
@@ -64,4 +113,80 @@ describe('backup-crypto', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'short';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
});
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
process.env.BACKUP_ENCRYPTION_KEY_PREVIOUS = 'k'.repeat(40);
expect(openBackup(sealed)).toEqual(bundle);
expect(sealBackup(bundle, bundle.exportedAt).keyId).not.toBe(sealed.keyId);
});
it('still opens files sealed before the key ring (file format v3 unchanged)', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(openBackup(KEY_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
});
it('only reads with the JWT-derived key, never seals with it once BACKUP_ENCRYPTION_KEY is set', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(sealBackup(bundle, bundle.exportedAt).keyId).toBe(KEY_BACKUP.keyId);
});
it('fails closed on a JWT-derived backup once JWT_ACCESS_SECRET changed', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => openBackup(LEGACY_JWT_BACKUP)).toThrow(BadRequestException);
});
it('refuses to seal in production without BACKUP_ENCRYPTION_KEY', () => {
process.env.NODE_ENV = 'production';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow(
'BACKUP_ENCRYPTION_KEY is not set',
);
});
it('warns once that a development key stands in for BACKUP_ENCRYPTION_KEY', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const sealed = fresh.sealBackup(bundle, bundle.exportedAt);
fresh.sealBackup(bundle, bundle.exportedAt);
fresh.openBackup(sealed);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
'BACKUP_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('does not warn when only reading a legacy file next to a configured key', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./backup-crypto')>('./backup-crypto');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
expect(fresh.openBackup(LEGACY_JWT_BACKUP)).toEqual(FIXTURE_BUNDLE);
expect(warn).not.toHaveBeenCalled();
});
});
});
+67 -54
View File
@@ -1,6 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
import {
ENCRYPTION_KEY_VARIABLES,
devFallbackSecret,
findRingKey,
gcmOpen,
gcmSeal,
readRingSecrets,
requireJwtAccessSecret,
type KeyRing,
type RingKey,
} from '../common/crypto/key-ring';
/**
* A backup holds everything - password hashes, encrypted MFA secrets,
@@ -9,17 +20,17 @@ import * as zlib from 'node:zlib';
* keeps it unreadable outside ApiCore AND makes any change to it (or a
* hand-made file) fail authentication before a single row is touched.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
* Keys follow the key ring (common/crypto/key-ring.ts): BACKUP_ENCRYPTION_KEY
* seals, BACKUP_ENCRYPTION_KEY_PREVIOUS still opens, and files sealed before
* the key was mandatory - with a key derived from JWT_ACCESS_SECRET - are
* still read (never written, except outside production without
* BACKUP_ENCRYPTION_KEY). The file names its key in `keyId`. Either way the
* AES key comes from HKDF with a backup-specific label, never the raw
* secret; derivation and file format v3 are unchanged, so older files open.
*/
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
export const SEALED_BACKUP_VERSION = 3;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// Decompressed size cap: a small file must not unpack into gigabytes.
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
@@ -35,25 +46,9 @@ export interface SealedBackup {
ciphertext: string;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
type KeySource = 'backup' | 'jwt';
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
function deriveBackupKey(secret: string, source: KeySource): RingKey {
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
@@ -63,12 +58,35 @@ function deriveKey(): { key: Buffer; keyId: string } {
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
const id = crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
return { id, key };
}
function legacyBackupKey(): RingKey {
return deriveBackupKey(requireJwtAccessSecret(), 'jwt');
}
function loadBackupRing(): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES.backup;
const secrets = readRingSecrets(variable);
return {
current: secrets.current
? deriveBackupKey(secrets.current, 'backup')
: deriveBackupKey(devFallbackSecret(variable), 'jwt'),
previous: secrets.previous
? deriveBackupKey(secrets.previous, 'backup')
: null,
};
}
/** The ring's keys first; the JWT-derived key of older files only as a last resort for reading. */
function keyForReading(keyId: string): RingKey | null {
const ringKey = findRingKey(loadBackupRing(), keyId);
if (ringKey) {
return ringKey;
}
const legacy = legacyBackupKey();
return legacy.id === keyId ? legacy : null;
}
function associatedData(
@@ -80,24 +98,21 @@ function associatedData(
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const { current } = loadBackupRing();
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
keyId: current.id,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const sealed = gcmSeal(current.key, compressed, associatedData(header));
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
iv: sealed.iv.toString('base64'),
tag: sealed.tag.toString('base64'),
ciphertext: sealed.ciphertext.toString('base64'),
};
}
@@ -112,32 +127,30 @@ export function isSealedBackup(value: unknown): value is SealedBackup {
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
/** The bundle inside a sealed backup; throws if no known key made the file or it was changed. */
export function openBackup(sealed: SealedBackup): unknown {
if (sealed.version !== SEALED_BACKUP_VERSION) {
throw new BadRequestException(
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
);
}
const { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
const ringKey = keyForReading(sealed.keyId);
if (!ringKey) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY bzw. BACKUP_ENCRYPTION_KEY_PREVIOUS prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
compressed = gcmOpen(
ringKey.key,
{
iv: Buffer.from(sealed.iv, 'base64'),
tag: Buffer.from(sealed.tag, 'base64'),
ciphertext: Buffer.from(sealed.ciphertext, 'base64'),
},
associatedData(sealed),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} catch {
throw new BadRequestException(
'Backup-Datei ist beschädigt oder wurde verändert',
@@ -216,6 +216,27 @@ describe('BackupService', () => {
);
expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull();
});
it('exports and restores Inspection.broodFrames', async () => {
const row = {
id: 'inspection-1',
colonyId: 'colony-1',
userId: 'known-user',
broodFrames: 5,
};
prisma.inspection.findMany.mockResolvedValue([row]);
const bundle = await service.exportAll();
const exported = bundle.data.inspection as { broodFrames: number }[];
expect(exported[0]?.broodFrames).toBe(5);
await service.importAll(bundle, 'admin-1', TRUSTED);
const [[call]] = tx.inspection.upsert.mock.calls as [
[{ create: { broodFrames: number } }],
];
expect(call.create.broodFrames).toBe(5);
});
});
describe('importAll', () => {
@@ -127,6 +127,14 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
DomainEvent:
'Operational outbox - side effects of restored data must never be replayed after a restore.',
DomainEventDelivery:
'Per-handler progress of DomainEvent rows, excluded with them.',
WebhookDelivery:
'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -12,7 +12,10 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { CalendarSyncService } from './calendar-sync.service';
import {
CalendarSyncService,
CalendarSyncStatusReport,
} from './calendar-sync.service';
import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto';
@ApiTags('calendar-sync')
@@ -45,13 +48,27 @@ export class CalendarSyncController {
return this.calendarSyncService.getStatus();
}
// Starts a pass and answers at once (202) - the pass itself can take
// minutes. The returned status says running: true; GET status follows it.
// Queues a pass and answers at once (202): FAILED entries are reset and
// the worker is woken - the API itself never calls Graph. GET status
// reports running from the worker's live claims; until the worker has
// claimed its first entry, a due pass counts as running, so the client
// starts following it.
@RequirePermission('calendar:configure')
@Post('run-now')
@HttpCode(HttpStatus.ACCEPTED)
runNow() {
this.calendarSyncService.startRun();
return this.calendarSyncService.getStatus();
async runNow(): Promise<CalendarSyncStatusReport> {
await this.calendarSyncService.requestRun();
const status = await this.calendarSyncService.getStatus();
return { ...status, running: status.running || isPassDue(status) };
}
}
/** The woken worker syncs every PENDING entry - if syncing is set up. */
function isPassDue(status: CalendarSyncStatusReport): boolean {
return (
status.configured &&
status.enabled &&
Boolean(status.groupId) &&
status.pendingCount > 0
);
}
@@ -22,6 +22,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.Mock };
harvest: { findUnique: jest.Mock };
queenColonyHistory: { findUnique: jest.Mock };
$executeRaw: jest.Mock;
};
let settingsService: { get: jest.Mock };
let graphClient: {
@@ -93,6 +94,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.fn() },
harvest: { findUnique: jest.fn() },
queenColonyHistory: { findUnique: jest.fn() },
$executeRaw: jest.fn().mockResolvedValue(1),
};
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
graphClient = {
@@ -284,6 +286,44 @@ describe('CalendarSyncService', () => {
);
});
// Worker shutdown: the entry in progress is finished and settled (a cut
// off one is only taken over after the stale-claim window), but no
// further entry is claimed.
it('stops between entries once the signal aborts', async () => {
const stop = new AbortController();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
pendingEntry({ id: 'entry-2', sourceId: 'task-2' }),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockImplementation(() => {
stop.abort();
return Promise.resolve('graph-event-1');
});
await service.processOutbox(stop.signal);
expect(graphClient.createEvent).toHaveBeenCalledTimes(1);
expectSettled({
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
});
expect(updateManyCalls().map((call) => call.where.id)).not.toContain(
'entry-2',
);
});
it('reads nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
await service.processOutbox(stop.signal);
expect(settingsService.get).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('claims a row with a conditional update before working on it', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
@@ -626,22 +666,42 @@ describe('CalendarSyncService', () => {
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
// API request neither talks to Graph nor waits for up to 20 entries.
describe('requestRun', () => {
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
await service.requestRun();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => {
await service.requestRun();
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['calendar_sync']);
});
it('resets before waking, so the woken worker finds the entries PENDING', async () => {
await service.requestRun();
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(wakeOrder);
});
it('leaves FAILED entries alone on a regular worker tick', async () => {
await service.processOutbox();
expect(
@@ -649,127 +709,7 @@ describe('CalendarSyncService', () => {
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -794,8 +734,8 @@ describe('CalendarSyncService', () => {
});
});
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
// one working - its live claims show that across replicas.
// The pass runs in a worker process, never in the API that answers -
// only the worker's live claims can show it, across replicas.
it('reports running while any worker holds an entry', async () => {
prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(0)
@@ -1,9 +1,9 @@
import { createHash } from 'node:crypto';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { loggableError } from '../common/loggable-error';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import {
GraphClientService,
@@ -17,8 +17,8 @@ import {
Prisma,
} from '../../generated/prisma';
// Bounded per tick so a large backlog can't turn one cron run into a
// long-running request that overlaps the next tick.
// Bounded per tick so a large backlog can't turn one worker tick into a
// long-running pass that delays the other wake-ups.
const BATCH_SIZE = 20;
// A row that keeps failing this many times stops retrying automatically
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
@@ -73,7 +73,7 @@ export interface CalendarSyncStatusReport {
pendingCount: number;
failedCount: number;
syncedCount: number;
/** A pass is under way - on this pod, or any pod holding an entry. */
/** A pass is under way: some worker holds an entry (a live claim). */
running: boolean;
}
@@ -82,10 +82,8 @@ function getErrorMessage(error: unknown): string {
}
@Injectable()
export class CalendarSyncService implements OnModuleDestroy {
export class CalendarSyncService {
private readonly logger = new Logger(CalendarSyncService.name);
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
private activeRun: Promise<void> | null = null;
constructor(
private readonly prisma: PrismaService,
@@ -209,20 +207,15 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// The cron tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_MINUTE)
async scheduledRun(): Promise<void> {
try {
await this.processOutbox();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
// One pass, run by the worker's calendar-outbox loop (worker/
// worker-runtime.service.ts): every 60 s and on a calendar_sync wake-up.
// The loop logs a failed pass via loggableError. Once `signal` aborts
// (worker shutdown), no further entry is claimed; the entry in progress
// is finished, since a cut-off one waits for the stale-claim window.
async processOutbox(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
}
async processOutbox(): Promise<void> {
const settings = await this.settingsService.get();
if (
!settings.enabled ||
@@ -232,15 +225,18 @@ export class CalendarSyncService implements OnModuleDestroy {
return;
}
// Several workers may run at once - one cron tick per pod, plus "Jetzt
// synchronisieren". Each entry is claimed right before it is written,
// so exactly one of them talks to Graph about it.
// Several workers may run at once - one loop per worker pod. Each entry
// is claimed right before it is written, so exactly one of them talks
// to Graph about it.
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
where: { status: 'PENDING', ...claimableNow() },
orderBy: { updatedAt: 'asc' },
take: BATCH_SIZE,
});
for (const candidate of candidates) {
if (signal?.aborted) {
return;
}
const claimed = await this.claim(candidate);
if (claimed) {
await this.processEntry(claimed, settings.groupId);
@@ -248,41 +244,19 @@ export class CalendarSyncService implements OnModuleDestroy {
}
}
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
// attempts - besides editing the source record the documented way out of
// FAILED. A FAILED row is never claimed (settle() clears the claim with
// it), so this cannot pull a row from under a running worker. The regular
// cron tick leaves FAILED alone.
async runNow(): Promise<void> {
// "Jetzt synchronisieren": gives FAILED entries a fresh set of attempts -
// besides editing the source record the documented way out of FAILED -
// and wakes the worker, which runs the pass. The API never calls Graph
// for it, so the request returns at once. A FAILED row is never claimed
// (settle() clears the claim with it), so this cannot pull a row from
// under a running worker. The regular tick leaves FAILED alone. Reset
// first: the woken worker must find the entries PENDING.
async requestRun(): Promise<void> {
await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
await this.processOutbox();
}
// What the API's "Jetzt synchronisieren" calls: starts runNow() in the
// background and returns at once - a pass over up to BATCH_SIZE entries
// with several Graph calls each must not hold the HTTP request. One such
// pass per pod at a time; passes on other pods (and the cron tick) stay
// safe through the per-entry claims. getStatus().running follows it.
startRun(): void {
if (!this.activeRun) {
this.activeRun = this.runNow()
.catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync run failed: ${message}`, stack);
})
.finally(() => {
this.activeRun = null;
});
}
}
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
// shutdown lets it finish (each Graph request is bounded by its timeout).
async onModuleDestroy(): Promise<void> {
await this.activeRun;
await notifyWorker(this.prisma, WAKE_CHANNELS.calendarSync);
}
// Compare-and-set on the row as it was read: the update only matches while
@@ -534,7 +508,8 @@ export class CalendarSyncService implements OnModuleDestroy {
this.prisma.calendarSyncOutboxEntry.count({
where: { status: 'SYNCED' },
}),
// Live claims: some worker is writing an entry right now.
// Live claims: some worker is writing an entry right now. The pass
// runs in a worker process, so nothing local can tell.
this.prisma.calendarSyncOutboxEntry.count({
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
}),
@@ -546,7 +521,7 @@ export class CalendarSyncService implements OnModuleDestroy {
pendingCount,
failedCount,
syncedCount,
running: this.activeRun !== null || heldCount > 0,
running: heldCount > 0,
};
}
}
@@ -0,0 +1,370 @@
import { Logger } from '@nestjs/common';
import { createHash, hkdfSync } from 'node:crypto';
import {
KEY_ID_PATTERN,
isSealedWithCurrentKey,
legacyJwtKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
readRingSecrets,
sealWithRing,
} from './key-ring';
const JWT_SECRET = 'j'.repeat(40);
const KEY_A = 'a'.repeat(40);
const KEY_B = 'b'.repeat(40);
// Written by the code before the key ring: AES-256-GCM with
// sha256(JWT_ACCESS_SECRET) as key, no key id. JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_JWT_SECRET = 'x'.repeat(40);
const LEGACY_CIPHERTEXT =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const LEGACY_PLAINTEXT = 'JBSWY3DPEHPK3PXP';
function expectedKeyId(secret: string, purpose: string): string {
const key = Buffer.from(
hkdfSync('sha256', secret, 'apicore-key-ring', `apicore-${purpose}-v1`, 32),
);
return `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
}
/** Applies `change` to one base64 part of `<id>:<iv>.<tag>.<data>`. */
function tamper(
stored: string,
part: 0 | 1 | 2,
change: (bytes: Buffer) => Buffer,
): string {
const [keyId, body] = stored.split(':');
const parts = body.split('.');
parts[part] = change(Buffer.from(parts[part], 'base64')).toString('base64');
return `${keyId}:${parts.join('.')}`;
}
function flipFirstByte(bytes: Buffer): Buffer {
const copy = Buffer.from(bytes);
copy[0] ^= 0xff;
return copy;
}
describe('key-ring', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('loadKeyRing', () => {
it('derives the key per purpose with HKDF and names it by its hash', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(ring.current.id).toBe(expectedKeyId(KEY_A, 'mfa'));
expect(ring.current.key).toHaveLength(32);
expect(ring.previous).toBeNull();
});
it('derives different keys for mfa and webhook from the same dev fallback', () => {
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
const mfa = loadKeyRing('mfa', env);
const webhook = loadKeyRing('webhook', env);
expect(mfa.current.id).toBe(expectedKeyId(JWT_SECRET, 'mfa'));
expect(webhook.current.id).toBe(expectedKeyId(JWT_SECRET, 'webhook'));
expect(mfa.current.key.equals(webhook.current.key)).toBe(false);
expect(mfa.current.key.equals(legacyJwtKey(env))).toBe(false);
});
it('loads the previous key from *_PREVIOUS', () => {
const ring = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_B,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(ring.current.id).toBe(expectedKeyId(KEY_B, 'webhook'));
expect(ring.previous?.id).toBe(expectedKeyId(KEY_A, 'webhook'));
});
it('requires the key in production instead of falling back', () => {
expect(() =>
loadKeyRing('webhook', {
NODE_ENV: 'production',
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('WEBHOOK_ENCRYPTION_KEY is not set');
});
it('refuses keys shorter than 32 characters, the previous one too', () => {
expect(() => loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: 'short' })).toThrow(
'MFA_ENCRYPTION_KEY must be at least 32 characters',
);
expect(() =>
loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_A,
MFA_ENCRYPTION_KEY_PREVIOUS: 'short',
}),
).toThrow('MFA_ENCRYPTION_KEY_PREVIOUS must be at least 32 characters');
});
it('needs JWT_ACCESS_SECRET for the dev fallback', () => {
expect(() => loadKeyRing('mfa', {})).toThrow(
'JWT_ACCESS_SECRET is not set',
);
});
});
describe('readRingSecrets', () => {
it('returns the configured secrets of one variable', () => {
expect(
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: KEY_A,
BACKUP_ENCRYPTION_KEY_PREVIOUS: KEY_B,
}),
).toEqual({ current: KEY_A, previous: KEY_B });
expect(readRingSecrets('BACKUP_ENCRYPTION_KEY', {})).toEqual({
current: null,
previous: null,
});
});
it('never puts the key into its errors', () => {
const shortKey = 'too-short-but-secret';
let message = '';
try {
readRingSecrets('BACKUP_ENCRYPTION_KEY', {
BACKUP_ENCRYPTION_KEY: shortKey,
});
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('BACKUP_ENCRYPTION_KEY');
expect(message).not.toContain(shortKey);
});
});
describe('sealWithRing / openWithRing', () => {
const ring = loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
it('seals with the current key and prefixes its id', () => {
const stored = sealWithRing(ring, 's');
expect(stored).toMatch(KEY_ID_PATTERN);
expect(stored.startsWith(`${ring.current.id}:`)).toBe(true);
});
it('round-trips a value and reports the key it was sealed with', () => {
const stored = sealWithRing(ring, LEGACY_PLAINTEXT);
expect(stored).not.toContain(LEGACY_PLAINTEXT);
expect(openWithRing(ring, stored)).toEqual({
plaintext: LEGACY_PLAINTEXT,
keyId: ring.current.id,
});
});
it('uses a fresh IV for every value', () => {
expect(sealWithRing(ring, 's')).not.toBe(sealWithRing(ring, 's'));
});
it('opens a value sealed with the previous key', () => {
const stored = sealWithRing(ring, 'secret');
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(openWithRing(rotated, stored)).toEqual({
plaintext: 'secret',
keyId: ring.current.id,
});
});
it('tells whether a value is sealed with the current key', () => {
const rotated = loadKeyRing('mfa', {
MFA_ENCRYPTION_KEY: KEY_B,
MFA_ENCRYPTION_KEY_PREVIOUS: KEY_A,
});
expect(isSealedWithCurrentKey(rotated, sealWithRing(ring, 's'))).toBe(
false,
);
expect(isSealedWithCurrentKey(rotated, sealWithRing(rotated, 's'))).toBe(
true,
);
expect(isSealedWithCurrentKey(rotated, LEGACY_CIPHERTEXT)).toBe(false);
});
it('refuses an unknown key id', () => {
expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow(
'unknown key id',
);
});
it('refuses a value sealed for another purpose', () => {
const webhookRing = loadKeyRing('webhook', {
WEBHOOK_ENCRYPTION_KEY: KEY_A,
});
expect(() =>
openWithRing(webhookRing, sealWithRing(ring, 'secret')),
).toThrow('unknown key id');
});
it('fails on a tampered ciphertext (GCM)', () => {
const stored = sealWithRing(ring, 'secret');
for (const part of [0, 1, 2] as const) {
expect(() =>
openWithRing(ring, tamper(stored, part, flipFirstByte)),
).toThrow('ciphertext failed authentication');
}
});
it('refuses a truncated auth tag', () => {
const stored = sealWithRing(ring, 'secret');
const truncated = tamper(stored, 1, (tag) => tag.subarray(0, 4));
expect(() => openWithRing(ring, truncated)).toThrow(
'malformed ciphertext',
);
});
it('fails with the right key id but a different key', () => {
const stored = sealWithRing(ring, 'secret');
const forged = {
current: { id: ring.current.id, key: Buffer.alloc(32, 1) },
previous: null,
};
expect(() => openWithRing(forged, stored)).toThrow(
'ciphertext failed authentication',
);
});
it('refuses values without key id or with a malformed body', () => {
expect(() => openWithRing(ring, LEGACY_CIPHERTEXT)).toThrow(
'missing key id',
);
expect(() => openWithRing(ring, `${ring.current.id}:abc`)).toThrow(
'malformed ciphertext',
);
});
it('never puts key or plaintext into its errors', () => {
const stored = sealWithRing(ring, 'plaintext-value');
const tampered = tamper(stored, 2, flipFirstByte);
let message = '';
try {
openWithRing(ring, tampered);
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain('plaintext-value');
expect(message).not.toContain(KEY_A);
expect(message).not.toContain(ring.current.key.toString('base64'));
});
});
describe('development fallback', () => {
it('warns once per purpose that a development key stands in, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const env = { JWT_ACCESS_SECRET: JWT_SECRET };
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('mfa', env);
fresh.loadKeyRing('webhook', env);
fresh.loadKeyRing('webhook', { ...env, WEBHOOK_ENCRYPTION_KEY: KEY_A });
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'MFA_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'WEBHOOK_ENCRYPTION_KEY not set – using development key derived from JWT_ACCESS_SECRET; never in production',
);
});
});
it('stays silent when the key is configured', () => {
jest.isolateModules(() => {
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.loadKeyRing('mfa', { MFA_ENCRYPTION_KEY: KEY_A });
expect(warn).not.toHaveBeenCalled();
});
});
});
describe('legacy ciphertexts', () => {
const legacyEnv = { JWT_ACCESS_SECRET: LEGACY_JWT_SECRET };
it('takes sha256(JWT_ACCESS_SECRET) as the legacy key', () => {
expect(legacyJwtKey(legacyEnv)).toEqual(
createHash('sha256').update(LEGACY_JWT_SECRET).digest(),
);
});
it('still opens a value sealed before the key ring', () => {
expect(openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv)).toBe(
LEGACY_PLAINTEXT,
);
});
it('fails closed once JWT_ACCESS_SECRET changed', () => {
expect(() =>
openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, {
JWT_ACCESS_SECRET: JWT_SECRET,
}),
).toThrow('ciphertext failed authentication');
});
it('warns once per process and purpose, without values', () => {
jest.isolateModules(() => {
// A fresh module copy, so earlier tests cannot have used up the warning.
const nest =
jest.requireActual<typeof import('@nestjs/common')>('@nestjs/common');
const fresh =
jest.requireActual<typeof import('./key-ring')>('./key-ring');
const warn = jest
.spyOn(nest.Logger.prototype, 'warn')
.mockImplementation(() => undefined);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.openLegacyCiphertext('mfa', LEGACY_CIPHERTEXT, legacyEnv);
fresh.warnLegacyRead('webhook');
fresh.warnLegacyRead('webhook');
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenNthCalledWith(
1,
'legacy ciphertext read – run the rekey job (mfa)',
);
expect(warn).toHaveBeenNthCalledWith(
2,
'legacy ciphertext read – run the rekey job (webhook)',
);
});
});
});
});
@@ -0,0 +1,286 @@
import { Logger } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
hkdfSync,
randomBytes,
} from 'node:crypto';
/**
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
* backup files) are encrypted with a key of their own per purpose, never
* with a JWT secret. Each purpose has a key ring: the current key from
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
* opens, so a key can be rotated without losing data. A sealed value names
* its key: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts, AES-256-GCM).
*
* The AES key comes from HKDF-SHA256 with a per-purpose label, so even the
* same secret never yields the same key for two purposes. Outside
* production a missing variable falls back to JWT_ACCESS_SECRET (still per
* purpose, logged once); production refuses to start without the variables
* (runtime/startup-checks.ts).
*
* Values from before the key ring carry no key id and were encrypted with
* sha256(JWT_ACCESS_SECRET). They stay readable (never written) until the
* rekey job reseals them - and only as long as JWT_ACCESS_SECRET is unchanged.
*/
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export const PREVIOUS_KEY_SUFFIX = '_PREVIOUS';
export const ENCRYPTION_KEY_VARIABLES = {
mfa: 'MFA_ENCRYPTION_KEY',
webhook: 'WEBHOOK_ENCRYPTION_KEY',
backup: 'BACKUP_ENCRYPTION_KEY',
} as const;
export type EncryptionKeyVariable =
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
export type SecretPurpose = 'mfa' | 'webhook';
export interface RingKey {
id: string;
key: Buffer;
}
export interface KeyRing {
current: RingKey;
previous: RingKey | null;
}
/** The configured secrets behind one ring, before key derivation. */
export interface RingSecrets {
current: string | null;
previous: string | null;
}
/** AES-256-GCM output, in parts, so each format chooses its own encoding. */
export interface GcmSealed {
iv: Buffer;
tag: Buffer;
ciphertext: Buffer;
}
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_BYTES = 32;
const HKDF_SALT = 'apicore-key-ring';
const LEGACY_READ_WARNING = 'legacy ciphertext read – run the rekey job';
const DEV_FALLBACK_WARNING =
'not set – using development key derived from JWT_ACCESS_SECRET; never in production';
const logger = new Logger('KeyRing');
const issuedWarnings = new Set<string>();
/** Each warning once per process: they describe configuration, not events. */
function warnOnce(message: string): void {
if (issuedWarnings.has(message)) {
return;
}
issuedWarnings.add(message);
logger.warn(message);
}
function readKey(name: string, env: NodeJS.ProcessEnv): string | null {
const value = env[name];
if (!value) {
return null;
}
if (value.length < MIN_ENCRYPTION_KEY_LENGTH) {
throw new Error(
`${name} must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters - generate one with e.g. "openssl rand -hex 32"`,
);
}
return value;
}
/**
* `<variable>` and `<variable>_PREVIOUS`, each at least 32 characters when
* set. The current one is mandatory in production. Errors name the
* variable, never its value.
*/
export function readRingSecrets(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): RingSecrets {
const current = readKey(variable, env);
const previous = readKey(`${variable}${PREVIOUS_KEY_SUFFIX}`, env);
if (!current && env.NODE_ENV === 'production') {
throw new Error(`${variable} is not set`);
}
return { current, previous };
}
export function requireJwtAccessSecret(
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = env.JWT_ACCESS_SECRET;
if (!secret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return secret;
}
/**
* The stand-in secret for an unset `variable` outside production (in
* production readRingSecrets() has already refused). Logged once per
* variable, without values.
*/
export function devFallbackSecret(
variable: EncryptionKeyVariable,
env: NodeJS.ProcessEnv = process.env,
): string {
const secret = requireJwtAccessSecret(env);
warnOnce(`${variable} ${DEV_FALLBACK_WARNING}`);
return secret;
}
function deriveRingKey(purpose: SecretPurpose, secret: string): RingKey {
const key = Buffer.from(
hkdfSync('sha256', secret, HKDF_SALT, `apicore-${purpose}-v1`, KEY_BYTES),
);
const id = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
return { id, key };
}
/** The ring of one purpose; outside production a missing key falls back to JWT_ACCESS_SECRET. */
export function loadKeyRing(
purpose: SecretPurpose,
env: NodeJS.ProcessEnv = process.env,
): KeyRing {
const variable = ENCRYPTION_KEY_VARIABLES[purpose];
const secrets = readRingSecrets(variable, env);
return {
current: deriveRingKey(
purpose,
secrets.current ?? devFallbackSecret(variable, env),
),
previous: secrets.previous
? deriveRingKey(purpose, secrets.previous)
: null,
};
}
export function findRingKey(ring: KeyRing, keyId: string): RingKey | null {
if (ring.current.id === keyId) {
return ring.current;
}
return ring.previous?.id === keyId ? ring.previous : null;
}
export function gcmSeal(
key: Buffer,
plaintext: Buffer,
associatedData?: Buffer,
): GcmSealed {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
cipher.setAAD(associatedData);
}
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return { iv, tag: cipher.getAuthTag(), ciphertext };
}
/**
* Fails closed: a short tag (which GCM would otherwise accept), a wrong key
* or any changed byte throws, and the error carries no input.
*/
export function gcmOpen(
key: Buffer,
sealed: GcmSealed,
associatedData?: Buffer,
): Buffer {
if (sealed.iv.length !== IV_BYTES || sealed.tag.length !== AUTH_TAG_BYTES) {
throw new Error('malformed ciphertext');
}
try {
const decipher = createDecipheriv(ALGORITHM, key, sealed.iv, {
authTagLength: AUTH_TAG_BYTES,
});
if (associatedData) {
decipher.setAAD(associatedData);
}
decipher.setAuthTag(sealed.tag);
return Buffer.concat([
decipher.update(sealed.ciphertext),
decipher.final(),
]);
} catch {
throw new Error('ciphertext failed authentication');
}
}
function sealPayload(key: Buffer, plaintext: string): string {
const sealed = gcmSeal(key, Buffer.from(plaintext, 'utf8'));
return [sealed.iv, sealed.tag, sealed.ciphertext]
.map((part) => part.toString('base64'))
.join('.');
}
function openPayload(key: Buffer, payload: string): string {
const parts = payload.split('.');
if (parts.length !== 3) {
throw new Error('malformed ciphertext');
}
const [iv, tag, ciphertext] = parts.map((part) =>
Buffer.from(part, 'base64'),
);
return gcmOpen(key, { iv, tag, ciphertext }).toString('utf8');
}
/** `${id}:${iv}.${tag}.${data}`, always with the current key. */
export function sealWithRing(ring: KeyRing, plaintext: string): string {
return `${ring.current.id}:${sealPayload(ring.current.key, plaintext)}`;
}
export function openWithRing(
ring: KeyRing,
stored: string,
): { plaintext: string; keyId: string } {
if (!KEY_ID_PATTERN.test(stored)) {
throw new Error('missing key id');
}
const separator = stored.indexOf(':');
const keyId = stored.slice(0, separator);
const ringKey = findRingKey(ring, keyId);
if (!ringKey) {
throw new Error('unknown key id');
}
return {
plaintext: openPayload(ringKey.key, stored.slice(separator + 1)),
keyId,
};
}
/** False for anything the rekey job should reseal: previous key, legacy or plain values. */
export function isSealedWithCurrentKey(ring: KeyRing, stored: string): boolean {
return stored.startsWith(`${ring.current.id}:`);
}
/** sha256(JWT_ACCESS_SECRET), the key of values from before the key ring - only ever used to read. */
export function legacyJwtKey(env: NodeJS.ProcessEnv = process.env): Buffer {
return createHash('sha256').update(requireJwtAccessSecret(env)).digest();
}
/** Logs once per process and purpose that a pre-key-ring value was read, without the value. */
export function warnLegacyRead(purpose: SecretPurpose): void {
warnOnce(`${LEGACY_READ_WARNING} (${purpose})`);
}
/** Opens a pre-key-ring `<iv>.<tag>.<data>` value sealed with legacyJwtKey(). */
export function openLegacyCiphertext(
purpose: SecretPurpose,
payload: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const plaintext = openPayload(legacyJwtKey(env), payload);
warnLegacyRead(purpose);
return plaintext;
}
@@ -0,0 +1,26 @@
import type { NextFunction, Request, Response } from 'express';
import { noStoreMiddleware } from './no-store.middleware';
function run(path: string) {
const setHeader = jest.fn();
const next = jest.fn() as unknown as NextFunction;
noStoreMiddleware(
{ path } as Request,
{ setHeader } as unknown as Response,
next,
);
return { setHeader, next };
}
describe('noStoreMiddleware', () => {
it.each(['/api/v1/apiaries', '/api/v1/public/batches/WH-1'])(
'marks %s no-store',
(path) => {
const { setHeader, next } = run(path);
expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(setHeader).toHaveBeenCalledWith('Pragma', 'no-cache');
expect(next).toHaveBeenCalledTimes(1);
},
);
});
@@ -1,22 +1,17 @@
import type { NextFunction, Request, Response } from 'express';
// The public origin pages of honey lots are the only answers meant for
// everyone; they may be cached briefly.
const CACHEABLE_PREFIX = '/api/v1/public/';
/**
* Personal data, tokens and exports must not end up in a browser's
* back/forward cache or a shared proxy. Every API answer is no-store unless
* it is one of the public, anonymous ones.
* back/forward cache or a shared proxy. Every API answer starts out no-store;
* the one anonymous origin page overrides this on its 200 only (see
* PublicController).
*/
export function noStoreMiddleware(
req: Request,
_req: Request,
res: Response,
next: NextFunction,
): void {
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
}
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
next();
}
@@ -0,0 +1,204 @@
import { Prisma } from '../../../generated/prisma';
import { DbClient } from '../../prisma/db-client';
import { PrismaService } from '../../prisma/prisma.service';
import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
/**
* Claim, settle and stale release for every outbox table. One worker owns
* an entry from the claim (status PROCESSING, claimedAt = now()) until it
* settles it; settling is a compare-and-set on id + claimedAt, so a worker
* whose claim was released in the meantime changes nothing. Every time
* comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date.
*/
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
export interface ClaimOptions {
limit: number;
/** Extra condition, ANDed to the claim (e.g. a correlation id filter). */
filter?: Prisma.Sql;
orderBy: OutboxOrderColumn;
}
interface OutboxTableSql {
table: Prisma.Sql;
statusType: Prisma.Sql;
settledAtColumn: Prisma.Sql;
successStatus: 'DONE' | 'DELIVERED';
}
/**
* Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what
* makes Prisma.raw safe here.
*/
const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
DomainEvent: {
table: Prisma.raw('"DomainEvent"'),
statusType: Prisma.raw('"DomainEventStatus"'),
settledAtColumn: Prisma.raw('"processedAt"'),
successStatus: 'DONE',
},
WebhookDelivery: {
table: Prisma.raw('"WebhookDelivery"'),
statusType: Prisma.raw('"WebhookDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
};
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
occurredAt: Prisma.raw('"occurredAt"'),
createdAt: Prisma.raw('"createdAt"'),
};
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql {
if (!Object.hasOwn(OUTBOX_TABLES, table)) {
throw new Error(`Unknown outbox table "${String(table)}"`);
}
return OUTBOX_TABLES[table];
}
function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
if (!Object.hasOwn(ORDER_COLUMNS, column)) {
throw new Error(`Unknown outbox order column "${String(column)}"`);
}
return ORDER_COLUMNS[column];
}
/**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
* several workers claim side by side without waiting for or overlapping
* each other. Returns the claimed rows (all columns) in claim order.
* Uses the root client: a claim must commit on its own.
*/
export async function claimBatch<Row>(
db: PrismaService,
table: OutboxTable,
options: ClaimOptions,
): Promise<Row[]> {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError(
`Claim limit must be a positive integer, got ${options.limit}`,
);
}
const { table: name } = outboxTable(table);
const orderBy = orderColumn(options.orderBy);
const filter = options.filter
? Prisma.sql`AND ${options.filter}`
: Prisma.empty;
return db.$queryRaw<Row[]>(Prisma.sql`
WITH claimed AS (
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN (
SELECT "id" FROM ${name}
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
ORDER BY ${orderBy}
LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED
)
RETURNING *
)
SELECT * FROM claimed ORDER BY ${orderBy}`);
}
/** An entry a stale release put back (PENDING) or gave up on (FAILED). */
export interface ReleasedClaim {
id: string;
status: 'PENDING' | 'FAILED';
}
/**
* Puts entries whose claim is older than STALE_CLAIM_MS (their worker died)
* back to PENDING right away, with attempts + 1 - or to FAILED once that
* reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop
* forever. Returns the released entries, for follow-up work on FAILED ones.
*/
export async function releaseStaleClaimRows(
db: PrismaService,
table: OutboxTable,
): Promise<ReleasedClaim[]> {
const { table: name, statusType } = outboxTable(table);
return db.$queryRaw<ReleasedClaim[]>(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"availableAt" = now(),
"lastError" = ${STALE_CLAIM_ERROR}
WHERE "status" = 'PROCESSING'
AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond')
RETURNING "id", "status"`);
}
/** releaseStaleClaimRows for callers that only need the count. */
export async function releaseStaleClaims(
db: PrismaService,
table: OutboxTable,
): Promise<number> {
return (await releaseStaleClaimRows(db, table)).length;
}
/**
* Settles a claimed entry as done. Returns false if the claim was lost
* (released as stale, maybe claimed again) - the current owner settles it.
* lastError stays as history of earlier failures.
*/
export async function settleSuccess(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
status: 'DONE' | 'DELIVERED',
): Promise<boolean> {
const sql = outboxTable(table);
if (status !== sql.successStatus) {
throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`);
}
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${sql.table}
SET "status" = CAST(${status} AS ${sql.statusType}),
"claimedAt" = NULL,
${sql.settledAtColumn} = now()
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry after a failed attempt: back to PENDING after
* retryDelayMs(attempts) by the database clock, or FAILED once attempts
* reaches MAX_ATTEMPTS. `lastError` must come from loggableError().
*
* The outcome also holds if the claim was lost: only a stale release
* changes a claimed row, and it applies the same attempts + 1 >=
* MAX_ATTEMPTS rule to the same attempt count.
*/
export async function settleFailure(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
attemptsBefore: number,
lastError: string,
): Promise<'retry' | 'failed'> {
const { table: name, statusType } = outboxTable(table);
const attempts = attemptsBefore + 1;
const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry';
const nextAttempt =
outcome === 'retry'
? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')`
: Prisma.empty;
await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"lastError" = ${lastError}
${nextAttempt}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome;
}
@@ -0,0 +1,29 @@
import {
MAX_ATTEMPTS,
RETRY_DELAYS_MS,
retryDelayMs,
STALE_CLAIM_MS,
} from './retry-policy';
describe('retry policy', () => {
it('waits 30 s, 2 min, 10 min, 1 h and from the 5th failure on 6 h', () => {
expect([1, 2, 3, 4, 5, 6, 7].map(retryDelayMs)).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000, 21_600_000, 21_600_000,
]);
});
it('gives up after 8 attempts and treats a claim older than 5 min as stale', () => {
expect(MAX_ATTEMPTS).toBe(8);
expect(STALE_CLAIM_MS).toBe(300_000);
expect(RETRY_DELAYS_MS).toEqual([
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
]);
});
it.each([0, -1, 1.5, Number.NaN])(
'refuses %p failed attempts - a delay exists only after a failure',
(attempts) => {
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
},
);
});
@@ -0,0 +1,23 @@
/**
* The one retry rule for every outbox (domain events, webhook deliveries):
* after the n-th failed attempt the entry waits RETRY_DELAYS_MS[n - 1],
* from the 5th failure on 6 h each; the MAX_ATTEMPTS-th failure ends in
* FAILED. A claim older than STALE_CLAIM_MS belongs to a worker that died.
* The delays are applied in SQL against now() (database clock), see
* outbox-claims.ts.
*/
export const RETRY_DELAYS_MS = [
30_000, 120_000, 600_000, 3_600_000, 21_600_000,
] as const;
export const MAX_ATTEMPTS = 8;
export const STALE_CLAIM_MS = 300_000;
/** Delay after `attempts` failed attempts (1 = the first failure). */
export function retryDelayMs(attempts: number): number {
if (!Number.isInteger(attempts) || attempts < 1) {
throw new RangeError(
`Retry delay needs a positive number of failed attempts, got ${attempts}`,
);
}
return RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1];
}
@@ -0,0 +1,108 @@
import { PrismaService } from '../../prisma/prisma.service';
import { PrismaThrottlerStorage } from './prisma-throttler.storage';
describe('PrismaThrottlerStorage', () => {
const queryRaw = jest.fn<
Promise<unknown>,
[readonly string[], ...unknown[]]
>();
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
const row = {
totalHits: 3,
timeToExpire: 42,
isBlocked: false,
timeToBlockExpire: 0,
};
const sqlText = (): string => queryRaw.mock.calls[0][0].join('?');
const sqlValues = (): unknown[] => queryRaw.mock.calls[0].slice(1);
beforeEach(() => {
queryRaw.mockReset();
queryRaw.mockResolvedValue([row]);
});
it('uses only the database clock (no JS dates in the statement)', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(sqlValues().some((v) => v instanceof Date)).toBe(false);
expect(sqlText()).toContain('now()');
});
it('blocks on the very first hit when the limit is below 1', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
0,
60000,
'default',
);
expect(sqlText()).toMatch(/VALUES[\s\S]*?CASE\s+WHEN 1 > /);
});
it('runs exactly one statement as an upsert on the composite key', async () => {
await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(sqlText()).toContain('INSERT INTO "ThrottlerHit"');
expect(sqlText()).toContain('ON CONFLICT ("key", "throttlerName")');
expect(sqlText()).toContain('RETURNING');
});
it('prefixes the stored key with the namespace and passes the limits as parameters', async () => {
await new PrismaThrottlerStorage(prisma, 'ns-1').increment(
'abc',
60000,
10,
120000,
'default',
);
const values = sqlValues();
expect(values).toContain('ns-1:abc');
expect(values).toContain('default');
expect(values).toContain(60000);
expect(values).toContain(10);
expect(values).toContain(120000);
});
it('returns the record computed by the database', async () => {
const result = await new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
);
expect(result).toEqual(row);
});
it('propagates database errors so the request fails closed', async () => {
queryRaw.mockRejectedValue(new Error('db down'));
await expect(
new PrismaThrottlerStorage(prisma, '').increment(
'k',
60000,
10,
60000,
'default',
),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,88 @@
import { Inject, Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import { PrismaService } from '../../prisma/prisma.service';
// The package does not export the record type from its root.
type ThrottlerStorageRecord = Awaited<
ReturnType<ThrottlerStorage['increment']>
>;
/**
* Key prefix that separates counter sets inside the shared table. Production
* uses '' (all pods share one set); tests give every app its own value.
*/
export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE');
/**
* Rate-limit counters in the UNLOGGED table "ThrottlerHit", so the limits hold
* across all api pods. One upsert statement does the whole bookkeeping, and
* every time comparison uses the database clock (now()), never a JS date.
* A database error is not caught here: the guard fails and the request is
* rejected (fail closed).
*/
@Injectable()
export class PrismaThrottlerStorage implements ThrottlerStorage {
constructor(
private readonly prisma: PrismaService,
@Inject(THROTTLER_NAMESPACE) private readonly namespace: string,
) {}
async increment(
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<ThrottlerStorageRecord> {
const storedKey = `${this.namespace}:${key}`;
// ttl and blockDuration are milliseconds. In ON CONFLICT DO UPDATE every
// expression reads the OLD row: "active block" keeps the hits, an expired
// window or block starts again at 1, everything else counts up.
const rows = await this.prisma.$queryRaw<ThrottlerStorageRecord[]>`
INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil")
VALUES (
${storedKey}, ${throttlerName}, 1,
now() + (${ttl}::double precision * interval '1 millisecond'),
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
)
ON CONFLICT ("key", "throttlerName") DO UPDATE SET
"hits" = CASE
WHEN t."blockedUntil" > now() THEN t."hits"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN 1
ELSE t."hits" + 1
END,
"expiresAt" = CASE
WHEN t."blockedUntil" > now() THEN t."expiresAt"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL
THEN now() + (${ttl}::double precision * interval '1 millisecond')
ELSE t."expiresAt"
END,
"blockedUntil" = CASE
WHEN t."blockedUntil" > now() THEN t."blockedUntil"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
WHEN t."hits" + 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
RETURNING
"hits"::int AS "totalHits",
GREATEST(ceil(EXTRACT(EPOCH FROM ("expiresAt" - now()))), 0)::int AS "timeToExpire",
COALESCE("blockedUntil" > now(), false) AS "isBlocked",
CASE
WHEN "blockedUntil" > now()
THEN GREATEST(ceil(EXTRACT(EPOCH FROM ("blockedUntil" - now()))), 0)::int
ELSE 0
END AS "timeToBlockExpire"
`;
return rows[0];
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import {
PrismaThrottlerStorage,
THROTTLER_NAMESPACE,
} from './prisma-throttler.storage';
@Module({
providers: [
// Production: one shared counter set for all pods.
{ provide: THROTTLER_NAMESPACE, useValue: '' },
PrismaThrottlerStorage,
],
exports: [PrismaThrottlerStorage],
})
export class ThrottlerStorageModule {}
@@ -22,6 +22,7 @@ import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluati
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateSiteSettingsDto } from '../../site-settings/dto/update-site-settings.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
@@ -53,6 +54,7 @@ const contracts: Record<string, NullContract> = {
'longitude',
'address',
'notes',
'publicLocationLabel',
],
},
UpdateAutomationRuleDto: {
@@ -152,6 +154,7 @@ const contracts: Record<string, NullContract> = {
'weather',
'temperature',
'colonyStrength',
'broodFrames',
'broodAmount',
'broodPattern',
'openBroodPresent',
@@ -243,6 +246,13 @@ const contracts: Record<string, NullContract> = {
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
// The link fields clear with "" (see the DTO), not null; originCountry is
// NOT NULL with a default.
UpdateSiteSettingsDto: {
dto: UpdateSiteSettingsDto,
notNull: ['originCountry'],
clearable: [],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
@@ -0,0 +1,37 @@
import { withTimeout } from './with-timeout';
describe('withTimeout', () => {
afterEach(() => {
jest.useRealTimers();
});
it('resolves with the value when the promise settles in time', async () => {
await expect(withTimeout(Promise.resolve(42), 1000, 'late')).resolves.toBe(
42,
);
});
it('rejects with the original error when the promise rejects in time', async () => {
await expect(
withTimeout(Promise.reject(new Error('boom')), 1000, 'late'),
).rejects.toThrow('boom');
});
it('rejects with the given message when the promise takes too long', async () => {
jest.useFakeTimers();
const pending = withTimeout(
new Promise<never>(() => undefined),
500,
'too slow',
);
const assertion = expect(pending).rejects.toThrow('too slow');
await jest.advanceTimersByTimeAsync(500);
await assertion;
});
it('clears its timer once the promise settled', async () => {
jest.useFakeTimers();
await withTimeout(Promise.resolve(1), 500, 'late');
expect(jest.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,16 @@
/** Rejects with `message` if `promise` has not settled within `ms`. */
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@@ -0,0 +1,24 @@
import { DbClient } from '../prisma/db-client';
/** LISTEN/NOTIFY channels the worker loops wait on, one per outbox. */
export const WAKE_CHANNELS = {
domainEvents: 'domain_event',
calendarSync: 'calendar_sync',
webhookDeliveries: 'webhook_delivery',
} as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
/**
* Wakes the worker loop behind `channel` before its next poll. The payload
* is always empty: the loop reads its work from the table, so nothing can
* leak through the channel or be slipped in as work. On a transaction
* client Postgres sends the notification only on commit - a rolled-back
* write wakes nobody. A lost notification only delays work until the poll.
*/
export async function notifyWorker(
db: DbClient,
channel: WakeChannel,
): Promise<void> {
await db.$executeRaw`SELECT pg_notify(${channel}, '')`;
}
@@ -24,6 +24,7 @@ import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { UpdateDocumentDto } from './dto/update-document.dto';
import { DocumentsService } from './documents.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -60,6 +61,7 @@ export class DocumentsController {
}
@RequirePermission('documents:upload')
@Idempotent()
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -0,0 +1,10 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* Set by the dispatcher while a handler runs: events the handler records
* inherit the correlation id of the event being handled, so one business
* action and all its follow-up events can be traced together.
*/
export const domainEventContext = new AsyncLocalStorage<{
correlationId: string;
}>();
@@ -0,0 +1,589 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DOMAIN_EVENT_BATCH_SIZE,
DomainEventDispatcher,
DRAIN_MAX_ROUNDS,
HANDLER_TX_TIMEOUT_MS,
} from './domain-event-dispatcher';
import type {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
interface ClaimedRow {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: unknown;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
type DeliveryStatus = 'PENDING' | 'DONE' | 'FAILED';
interface UpsertArgs {
where: unknown;
create: { lastError: string };
update: { lastError: string };
}
const CLAIMED_AT = new Date('2026-10-13T08:00:01.234Z');
function row(overrides: Partial<ClaimedRow> = {}): ClaimedRow {
return {
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
claimedAt: CLAIMED_AT,
...overrides,
};
}
function handler(
id: string,
invoke: RegisteredDomainEventHandler['invoke'] = () => Promise.resolve(),
): RegisteredDomainEventHandler & { invoke: jest.Mock } {
return { id, invoke: jest.fn(invoke) };
}
function text(query: Prisma.Sql): string {
return query.sql.replace(/\s+/g, ' ').trim();
}
/**
* Stands in for Postgres: a queue of claim results, the delivery rows the
* lock query finds, and every statement the dispatcher sent.
*/
class FakeDb {
readonly claims: ClaimedRow[][] = [];
/** Rows the stale release returns, one list per call. */
readonly releases: { id: string; status: 'PENDING' | 'FAILED' }[][] = [];
readonly deliveries = new Map<string, DeliveryStatus>();
readonly tx = {
domainEventDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
$queryRaw: jest.fn((query: Prisma.Sql) => {
const handlerId = query.values[1] as string;
return Promise.resolve([
{
id: `d-${handlerId}`,
status: this.deliveries.get(handlerId) ?? 'PENDING',
},
]);
}),
$executeRaw: jest.fn().mockResolvedValue(1),
};
readonly prisma = {
$queryRaw: jest.fn((query: Prisma.Sql) =>
Promise.resolve(
(query.sql.includes('SKIP LOCKED')
? this.claims.shift()
: this.releases.shift()) ?? [],
),
),
$executeRaw: jest.fn().mockResolvedValue(1),
// Called as (fn, { timeout }); the options only matter to Prisma.
$transaction: jest.fn((fn: (tx: unknown) => Promise<unknown>) =>
fn(this.tx),
),
domainEventDelivery: {
upsert: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
claimQuery(call = 0): Prisma.Sql {
return (this.prisma.$queryRaw.mock.calls as unknown as [Prisma.Sql][])[
call
][0];
}
/** Statements on the root client: settle and stale release. */
rootStatements(): Prisma.Sql[] {
return (this.prisma.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
/** Statements inside handler transactions after the lock: delivery DONE. */
deliveryDoneStatements(): Prisma.Sql[] {
return (this.tx.$executeRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => query,
);
}
upsertArgs(call = 0): UpsertArgs {
return (
this.prisma.domainEventDelivery.upsert.mock.calls as [UpsertArgs][]
)[call][0];
}
}
describe('DomainEventDispatcher', () => {
let db: FakeDb;
let handlers: RegisteredDomainEventHandler[];
let dispatcher: DomainEventDispatcher;
let logWarn: jest.SpyInstance;
beforeEach(() => {
db = new FakeDb();
handlers = [];
const registry = {
handlersFor: jest.fn(() => handlers),
} as unknown as DomainEventRegistry;
dispatcher = new DomainEventDispatcher(
db.prisma as unknown as PrismaService,
registry,
);
logWarn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('pins batch size, handler transaction timeout and drain rounds', () => {
expect(DOMAIN_EVENT_BATCH_SIZE).toBe(50);
expect(HANDLER_TX_TIMEOUT_MS).toBe(10_000);
expect(DRAIN_MAX_ROUNDS).toBe(20);
});
it('claims at most 50 pending events, oldest first, skipping locked rows, by the database clock', async () => {
await dispatcher.runOnce();
const claim = db.claimQuery();
expect(text(claim)).toContain(
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
);
expect(text(claim)).toContain(
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
);
expect(claim.values).toEqual([50]);
});
it('restricts the claim to the given correlation ids', async () => {
await dispatcher.runOnce({ correlationIds: ['c1', 'c2'] });
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['c1', 'c2'], 50]);
});
it('marks an event DONE when every handler succeeded', async () => {
handlers = [handler('a.first'), handler('b.second')];
db.claims.push([row()]);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.first'],
['d-b.second'],
]);
const [settle] = db.rootStatements();
expect(text(settle)).toContain('"processedAt" = now()');
expect(text(settle)).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(settle.values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks an event without handlers DONE', async () => {
db.claims.push([row({ type: 'VarroaMeasured' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).not.toHaveBeenCalled();
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the delivery DONE with attempts + 1 in the handler transaction, by the database clock', async () => {
handlers = [handler('a.first')];
db.claims.push([row()]);
await dispatcher.runOnce();
const [done] = db.deliveryDoneStatements();
expect(text(done)).toBe(
`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ?`,
);
});
it('retries only the failed handler and schedules the event 30 s ahead after the first failure', async () => {
const ok = handler('a.ok');
const flaky = handler('b.flaky');
flaky.invoke.mockRejectedValueOnce(new Error('boom'));
handlers = [ok, flaky];
db.claims.push([row({ attempts: 0 })]);
await dispatcher.runOnce();
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-a.ok'],
]);
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledWith({
where: { eventId_handler: { eventId: 'e1', handler: 'b.flaky' } },
create: {
eventId: 'e1',
handler: 'b.flaky',
attempts: 1,
lastError: 'boom',
},
update: { attempts: { increment: 1 }, lastError: 'boom' },
});
const [settle] = db.rootStatements();
expect(text(settle)).toContain(
`"availableAt" = now() + (? * interval '1 millisecond')`,
);
expect(text(settle)).toContain('"attempts" = "attempts" + 1');
expect(settle.values).toEqual([
'PENDING',
'b.flaky: boom',
30_000,
'e1',
CLAIMED_AT,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
// The next run: a.ok committed its delivery, so only b.flaky runs again.
db.deliveries.set('a.ok', 'DONE');
db.claims.push([row({ attempts: 1 })]);
await dispatcher.runOnce();
expect(ok.invoke).toHaveBeenCalledTimes(1);
expect(flaky.invoke).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[1].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('marks the event and its open deliveries FAILED after the 8th failed attempt', async () => {
handlers = [handler('a.broken', () => Promise.reject(new Error('boom')))];
db.claims.push([row({ attempts: 7 })]);
await dispatcher.runOnce();
const [settle] = db.rootStatements();
expect(settle.values).toEqual([
'FAILED',
'a.broken: boom',
'e1',
CLAIMED_AT,
]);
expect(text(settle)).not.toContain('interval');
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
it('keeps the first failure as lastError when several handlers fail', async () => {
handlers = [
handler('a.first', () => Promise.reject(new Error('one'))),
handler('b.second', () => Promise.reject(new Error('two'))),
];
db.claims.push([row()]);
await dispatcher.runOnce();
expect(db.prisma.domainEventDelivery.upsert).toHaveBeenCalledTimes(2);
expect(db.rootStatements()[0].values).toContain('a.first: one');
});
it('skips a handler whose delivery is already DONE', async () => {
handlers = [handler('a.done'), handler('b.open')];
db.deliveries.set('a.done', 'DONE');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).not.toHaveBeenCalled();
expect(handlers[1].invoke).toHaveBeenCalledTimes(1);
expect(db.deliveryDoneStatements().map((query) => query.values)).toEqual([
['d-b.open'],
]);
expect(db.rootStatements()[0].values).toEqual(['DONE', 'e1', CLAIMED_AT]);
});
it('runs the handler again when its delivery FAILED before (manual retry)', async () => {
handlers = [handler('a.failed-before')];
db.deliveries.set('a.failed-before', 'FAILED');
db.claims.push([row()]);
await dispatcher.runOnce();
expect(handlers[0].invoke).toHaveBeenCalledTimes(1);
});
it('runs the handler inside the delivery transaction with the event correlation id', async () => {
const seen: unknown[] = [];
handlers = [
handler('a.first', (event, tx) => {
seen.push(event, tx, domainEventContext.getStore());
return Promise.resolve();
}),
];
db.claims.push([row({ correlationId: 'corr-42' })]);
await dispatcher.runOnce();
expect(db.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: HANDLER_TX_TIMEOUT_MS,
});
expect(seen[1]).toBe(db.tx);
expect(seen[2]).toEqual({ correlationId: 'corr-42' });
expect(seen[0]).toEqual({
id: 'e1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: 'corr-42',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
});
expect(domainEventContext.getStore()).toBeUndefined();
// Delivery row first (idempotent), then the row lock, then the handler.
expect(db.tx.domainEventDelivery.createMany).toHaveBeenCalledWith({
data: [{ eventId: 'e1', handler: 'a.first' }],
skipDuplicates: true,
});
const [lock] = db.tx.$queryRaw.mock.calls[0];
expect(text(lock)).toBe(
'SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ? AND "handler" = ? FOR UPDATE',
);
expect(lock.values).toEqual(['e1', 'a.first']);
expect(
db.tx.domainEventDelivery.createMany.mock.invocationCallOrder[0],
).toBeLessThan(db.tx.$queryRaw.mock.invocationCallOrder[0]);
expect(db.tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
(handlers[0].invoke as jest.Mock).mock.invocationCallOrder[0],
);
});
it('advances lastTickAt after every dispatched event, so a long batch never looks stale', async () => {
const start = new Date('2026-10-13T08:00:00.000Z');
jest.useFakeTimers({
now: start,
doNotFake: [
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'setImmediate',
'clearImmediate',
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
],
});
const seen: (Date | null)[] = [];
// Every event takes 100 s, e.g. waiting on locks.
handlers = [
handler('a.slow', () => {
seen.push(dispatcher.lastTickAt);
jest.setSystemTime(Date.now() + 100_000);
return Promise.resolve();
}),
];
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' }), row({ id: 'e3' })]);
try {
await dispatcher.runOnce();
} finally {
jest.useRealTimers();
}
expect(seen.map((tick) => tick?.toISOString())).toEqual([
'2026-10-13T08:00:00.000Z',
'2026-10-13T08:01:40.000Z',
'2026-10-13T08:03:20.000Z',
]);
expect(dispatcher.lastTickAt?.toISOString()).toBe(
'2026-10-13T08:05:00.000Z',
);
});
it('updates lastTickAt even when nothing was claimed', async () => {
expect(dispatcher.lastTickAt).toBeNull();
await expect(dispatcher.runOnce()).resolves.toBe(0);
expect(dispatcher.lastTickAt).toBeInstanceOf(Date);
});
it('stores lastError without the Prisma message', async () => {
handlers = [
handler('a.db', () =>
Promise.reject(
new Prisma.PrismaClientKnownRequestError(
"Can't reach database server at secret-host:5432",
{ code: 'P1001', clientVersion: '7.9.1' },
),
),
),
];
db.claims.push([row()]);
await dispatcher.runOnce();
const upsert = db.upsertArgs();
expect(upsert.create.lastError).toBe('PrismaClientKnownRequestError P1001');
expect(upsert.update.lastError).toBe('PrismaClientKnownRequestError P1001');
const settleValues = db.rootStatements()[0].values;
expect(settleValues).toContain('a.db: PrismaClientKnownRequestError P1001');
expect(JSON.stringify(settleValues)).not.toContain('secret-host');
});
it('keeps dispatching the batch when one event cannot be settled', async () => {
db.claims.push([row({ id: 'e1' }), row({ id: 'e2' })]);
db.prisma.$executeRaw.mockRejectedValueOnce(new Error('connection lost'));
await expect(dispatcher.runOnce()).resolves.toBe(2);
expect(db.rootStatements().map((query) => query.values[1])).toEqual([
'e1',
'e2',
]);
});
it('only logs when the claim was lost before settling (stale release)', async () => {
db.claims.push([row()]);
db.prisma.$executeRaw.mockResolvedValueOnce(0);
await expect(dispatcher.runOnce()).resolves.toBe(1);
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('e1'));
});
describe('drain', () => {
it('repeats rounds until a round claims nothing and returns the total', async () => {
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(dispatcher.drain({ correlationIds: ['c1'] })).resolves.toBe(
3,
);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(3);
expect(db.claimQuery(2).values).toEqual([['c1'], 50]);
});
it('stops after maxRounds even if work remains', async () => {
for (let i = 0; i < 5; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain(undefined, 2)).resolves.toBe(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('makes at most 20 rounds by default', async () => {
for (let i = 0; i < 25; i++) {
db.claims.push([row({ id: `e${i}` })]);
}
await expect(dispatcher.drain()).resolves.toBe(DRAIN_MAX_ROUNDS);
});
// Worker shutdown: the claimed batch is finished (its events would
// otherwise sit PROCESSING until the stale-claim release), but no
// further batch is claimed.
it('finishes the claimed batch but claims no further one once the signal aborts', async () => {
const stop = new AbortController();
handlers = [
handler('a.stopper', () => {
stop.abort();
return Promise.resolve();
}),
];
db.claims.push(
[row({ id: 'e1' }), row({ id: 'e2' })],
[row({ id: 'e3' })],
);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(2);
expect(handlers[0].invoke).toHaveBeenCalledTimes(2);
expect(db.prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it('claims nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
db.claims.push([row()]);
await expect(
dispatcher.drain(undefined, DRAIN_MAX_ROUNDS, stop.signal),
).resolves.toBe(0);
expect(db.prisma.$queryRaw).not.toHaveBeenCalled();
});
});
it('releases stale claims with attempts + 1, FAILED from the 8th attempt on', async () => {
db.releases.push([
{ id: 'e1', status: 'PENDING' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'PENDING' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
const [release] = db.prisma.$queryRaw.mock.calls[0];
expect(text(release)).toBe(
`UPDATE "DomainEvent" SET "status" = CAST(CASE WHEN "attempts" + 1 >= ? THEN 'FAILED' ELSE 'PENDING' END AS "DomainEventStatus"), "attempts" = "attempts" + 1, "claimedAt" = NULL, "availableAt" = now(), "lastError" = ? WHERE "status" = 'PROCESSING' AND "claimedAt" < now() - (? * interval '1 millisecond') RETURNING "id", "status"`,
);
expect(release.values).toEqual([
8,
'claim expired (worker restarted?)',
300_000,
]);
expect(db.prisma.domainEventDelivery.updateMany).not.toHaveBeenCalled();
});
it('marks the open deliveries FAILED when a stale release ends an event in FAILED', async () => {
db.releases.push([
{ id: 'e1', status: 'FAILED' },
{ id: 'e2', status: 'PENDING' },
{ id: 'e3', status: 'FAILED' },
]);
await expect(dispatcher.releaseStaleClaims()).resolves.toBe(3);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledTimes(1);
expect(db.prisma.domainEventDelivery.updateMany).toHaveBeenCalledWith({
where: { eventId: { in: ['e1', 'e3'] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
});
});
@@ -0,0 +1,269 @@
import { Injectable, Logger } from '@nestjs/common';
import { DomainEventDeliveryStatus, Prisma } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import {
claimBatch,
releaseStaleClaimRows,
settleFailure,
settleSuccess,
} from '../common/outbox/outbox-claims';
import { PrismaService } from '../prisma/prisma.service';
import { domainEventContext } from './domain-event-context';
import {
DomainEventRegistry,
RegisteredDomainEventHandler,
} from './domain-event-registry';
import { AnyDomainEventEnvelope, DomainEventType } from './domain-event-types';
export const DOMAIN_EVENT_BATCH_SIZE = 50;
export const HANDLER_TX_TIMEOUT_MS = 10_000;
export const DRAIN_MAX_ROUNDS = 20;
export interface DispatchFilter {
/** Only events of these correlations (e2e tests: no foreign events). */
correlationIds?: readonly string[];
}
/** A claimed DomainEvent row, as RETURNING * yields it. */
interface ClaimedDomainEvent {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
payload: Prisma.JsonValue;
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
claimedAt: Date;
}
/**
* Delivers claimed domain events to their registered handlers, exactly once
* per (event, handler): each handler runs in its own transaction that first
* locks its DomainEventDelivery row and marks it DONE on commit. A handler
* whose delivery is DONE is skipped, so a retry, a second worker or a
* stale-released event never repeats an effect that was committed.
*/
@Injectable()
export class DomainEventDispatcher {
private readonly logger = new Logger(DomainEventDispatcher.name);
private tickAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly registry: DomainEventRegistry,
) {}
/**
* When the dispatcher last made progress - a claim (empty or not) or a
* dispatched event - for the worker health. Advancing per event keeps a
* long batch (lock waits, slow handlers) from looking stale.
*/
get lastTickAt(): Date | null {
return this.tickAt;
}
/** One batch: claims up to 50 events and returns how many it claimed. */
async runOnce(filter?: DispatchFilter): Promise<number> {
const events = await claimBatch<ClaimedDomainEvent>(
this.prisma,
'DomainEvent',
{
limit: DOMAIN_EVENT_BATCH_SIZE,
filter: claimFilter(filter),
orderBy: 'occurredAt',
},
);
this.tickAt = new Date();
for (const event of events) {
await this.dispatchSafely(event);
this.tickAt = new Date();
}
return events.length;
}
/**
* Runs batches until one claims nothing - follow-up events that handlers
* record are picked up in the next round - but at most `maxRounds`, so
* one tick never blocks the worker for long. Returns the claimed total.
* Once `signal` aborts (worker shutdown), no further batch is claimed;
* the batch already claimed is finished, since its events would
* otherwise wait for the stale-claim release.
*/
async drain(
filter?: DispatchFilter,
maxRounds = DRAIN_MAX_ROUNDS,
signal?: AbortSignal,
): Promise<number> {
let total = 0;
for (let round = 0; round < maxRounds && !signal?.aborted; round++) {
const claimed = await this.runOnce(filter);
if (claimed === 0) {
break;
}
total += claimed;
}
return total;
}
/**
* Releases stale claims; an event that thereby reaches FAILED gets its
* open deliveries FAILED as well, like a failed settle.
*/
async releaseStaleClaims(): Promise<number> {
const released = await releaseStaleClaimRows(this.prisma, 'DomainEvent');
const failed = released
.filter((claim) => claim.status === 'FAILED')
.map((claim) => claim.id);
if (failed.length > 0) {
await this.failOpenDeliveries(failed);
this.logger.error(
`Domain events FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
);
}
return released.length;
}
private async failOpenDeliveries(eventIds: readonly string[]): Promise<void> {
await this.prisma.domainEventDelivery.updateMany({
where: { eventId: { in: [...eventIds] }, status: 'PENDING' },
data: { status: 'FAILED' },
});
}
/** One event must not stop the batch; an unsettled event goes stale. */
private async dispatchSafely(event: ClaimedDomainEvent): Promise<void> {
try {
await this.dispatch(event);
} catch (error) {
this.logger.error(
`Domain event ${event.id} (${event.type}) not settled: ${loggableError(error).message}`,
);
}
}
private async dispatch(event: ClaimedDomainEvent): Promise<void> {
const envelope = toEnvelope(event);
const failures: string[] = [];
for (const handler of this.registry.handlersFor(
event.type as DomainEventType,
)) {
const failure = await this.deliver(envelope, handler);
if (failure !== null) {
failures.push(`${handler.id}: ${failure}`);
}
}
if (failures.length === 0) {
const settled = await settleSuccess(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
'DONE',
);
if (!settled) {
this.logger.warn(
`Domain event ${event.id} (${event.type}): claim lost before settling, left to its current owner`,
);
}
return;
}
const outcome = await settleFailure(
this.prisma,
'DomainEvent',
event.id,
event.claimedAt,
event.attempts,
failures[0],
);
if (outcome === 'failed') {
await this.failOpenDeliveries([event.id]);
this.logger.error(
`Domain event ${event.id} (${event.type}) FAILED after ${event.attempts + 1} attempts: ${failures.join('; ')}`,
);
return;
}
this.logger.warn(
`Domain event ${event.id} (${event.type}) attempt ${event.attempts + 1} failed, retrying: ${failures.join('; ')}`,
);
}
/**
* Runs one handler in its delivery transaction. Returns null on success
* or skip, else the loggable error message (already recorded on the
* delivery row, outside the rolled-back transaction).
*/
private async deliver(
event: AnyDomainEventEnvelope,
handler: RegisteredDomainEventHandler,
): Promise<string | null> {
try {
await this.prisma.$transaction(
async (tx) => {
await tx.domainEventDelivery.createMany({
data: [{ eventId: event.id, handler: handler.id }],
skipDuplicates: true,
});
const [delivery] = await tx.$queryRaw<
{ id: string; status: DomainEventDeliveryStatus }[]
>(
Prisma.sql`SELECT "id", "status" FROM "DomainEventDelivery" WHERE "eventId" = ${event.id} AND "handler" = ${handler.id} FOR UPDATE`,
);
if (delivery.status === 'DONE') {
return;
}
await domainEventContext.run(
{ correlationId: event.correlationId },
() => handler.invoke(event, tx),
);
await tx.$executeRaw(
Prisma.sql`UPDATE "DomainEventDelivery" SET "status" = 'DONE', "attempts" = "attempts" + 1, "completedAt" = now(), "updatedAt" = now() WHERE "id" = ${delivery.id}`,
);
},
{ timeout: HANDLER_TX_TIMEOUT_MS },
);
return null;
} catch (error) {
const message = loggableError(error).message;
await this.prisma.domainEventDelivery.upsert({
where: { eventId_handler: { eventId: event.id, handler: handler.id } },
create: {
eventId: event.id,
handler: handler.id,
attempts: 1,
lastError: message,
},
update: { attempts: { increment: 1 }, lastError: message },
});
return message;
}
}
}
function claimFilter(filter?: DispatchFilter): Prisma.Sql | undefined {
if (filter?.correlationIds === undefined) {
return undefined;
}
return Prisma.sql`"correlationId" = ANY(${[...filter.correlationIds]})`;
}
/**
* The row as the handler sees it. The producer paired type and payload
* (DomainEventsService only accepts AnyNewDomainEvent), so the cast holds.
*/
function toEnvelope(event: ClaimedDomainEvent): AnyDomainEventEnvelope {
return {
id: event.id,
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload,
actorUserId: event.actorUserId,
correlationId: event.correlationId,
occurredAt: event.occurredAt,
attempts: event.attempts,
} as AnyDomainEventEnvelope;
}
@@ -0,0 +1,456 @@
import { Injectable, Provider, Scope } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '../../generated/prisma';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsModule } from './domain-events.module';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import { HANDLER_ID_PATTERN, OnDomainEvent } from './on-domain-event.decorator';
interface FakeWrapper {
instance: unknown;
isAlias?: boolean;
isStatic?: boolean;
isTransient?: boolean;
}
function registryWith(...wrappers: FakeWrapper[]): DomainEventRegistry {
const discovery = {
getProviders: () =>
wrappers.map(
({
instance,
isAlias = false,
isStatic = true,
isTransient = false,
}) => ({
instance,
isAlias,
isTransient,
isDependencyTreeStatic: () => isStatic,
}),
),
};
return new DomainEventRegistry(
discovery as unknown as DiscoveryService,
new MetadataScanner(),
);
}
function registryFor(...instances: unknown[]): DomainEventRegistry {
return registryWith(...instances.map((instance) => ({ instance })));
}
function envelope<T extends DomainEventType>(
type: T,
payload: DomainEventEnvelope<T>['payload'],
): DomainEventEnvelope<T> {
return {
id: 'e1',
type,
aggregateType: 'Inspection',
aggregateId: 'i1',
payload,
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00Z'),
attempts: 0,
};
}
class CalendarHandlers {
readonly seen: string[] = [];
@OnDomainEvent(['InspectionRecorded', 'TreatmentRecorded'], 'calendar.sync')
sync(event: DomainEventEnvelope): Promise<void> {
this.seen.push(event.type);
return Promise.resolve();
}
notAHandler(): void {}
}
class AutomationHandlers {
@OnDomainEvent('InspectionRecorded', 'automation.queenless')
@OnDomainEvent('InspectionCorrected', 'automation.queenless-corrected')
evaluate(): Promise<void> {
return Promise.resolve();
}
}
describe('DomainEventRegistry', () => {
it('finds handlers by type, also when one method serves several types', async () => {
const calendar = new CalendarHandlers();
const registry = registryFor(calendar, new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['automation.queenless', 'calendar.sync']);
expect(
registry.handlersFor('TreatmentRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
expect(registry.handlersFor('VarroaMeasured')).toEqual([]);
const [handler] = registry.handlersFor('TreatmentRecorded');
await handler.invoke(
envelope('TreatmentRecorded', { colonyId: 'c1' }),
{} as Prisma.TransactionClient,
);
expect(calendar.seen).toEqual(['TreatmentRecorded']);
});
it('registers stacked decorators on one method separately', () => {
const registry = registryFor(new AutomationHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('InspectionCorrected').map((handler) => handler.id),
).toEqual(['automation.queenless-corrected']);
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
]);
});
it('passes event and transaction client to the handler bound to its provider', async () => {
const received: unknown[] = [];
class Recorder {
readonly name = 'recorder';
@OnDomainEvent('TaskDue', 'tasks.reminder')
handle(event: DomainEventEnvelope, tx: Prisma.TransactionClient) {
received.push(this.name, event.id, tx);
return Promise.resolve();
}
}
const registry = registryFor(new Recorder());
registry.onModuleInit();
const tx = { marker: true } as unknown as Prisma.TransactionClient;
await registry
.handlersFor('TaskDue')[0]
.invoke(envelope('TaskDue', { dueOn: '2026-10-13' }), tx);
expect(received).toEqual(['recorder', 'e1', tx]);
});
it('lists every handler id once', () => {
const registry = registryFor(
new CalendarHandlers(),
new AutomationHandlers(),
);
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([
'automation.queenless',
'automation.queenless-corrected',
'calendar.sync',
]);
});
it('ignores providers without an instance or without handlers', () => {
const registry = registryFor(undefined, null, 'value', { plain: true });
registry.onModuleInit();
expect(registry.handlerIds()).toEqual([]);
});
it('refuses two handlers with the same id for one type', () => {
class OtherCalendarHandlers {
@OnDomainEvent('InspectionRecorded', 'calendar.sync')
again(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(
new CalendarHandlers(),
new OtherCalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it('refuses the same provider registered twice', () => {
const registry = registryFor(
new CalendarHandlers(),
new CalendarHandlers(),
);
expect(() => registry.onModuleInit()).toThrow(
'Duplicate domain event handler',
);
});
it.each([
'calendar',
'Calendar.sync',
'calendar.sync.extra',
'calendar.',
'calendar sync',
'.sync',
])(
'rejects a handler id that is not "<consumer>.<name>": %p',
(handlerId) => {
class BadId {
@OnDomainEvent('TaskDue', handlerId)
handle(): Promise<void> {
return Promise.resolve();
}
}
const registry = registryFor(new BadId());
expect(HANDLER_ID_PATTERN.test(handlerId)).toBe(false);
expect(() => registry.onModuleInit()).toThrow(
'Invalid domain event handler id',
);
},
);
it('rejects an event type outside the catalog', () => {
class UnknownType {
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new UnknownType()).onModuleInit()).toThrow(
'Unknown domain event type',
);
});
it('rejects a handler without any event type', () => {
class NoType {
@OnDomainEvent([], 'colonies.nothing')
handle(): Promise<void> {
return Promise.resolve();
}
}
expect(() => registryFor(new NoType()).onModuleInit()).toThrow(
'serves no domain event type',
);
});
it('skips alias providers - the aliased provider is registered once', () => {
const calendar = new CalendarHandlers();
const registry = registryWith(
{ instance: calendar },
{ instance: calendar, isAlias: true },
);
registry.onModuleInit();
expect(
registry.handlersFor('InspectionRecorded').map((handler) => handler.id),
).toEqual(['calendar.sync']);
});
it('refuses a handler on a request-scoped provider (dependency tree not static)', () => {
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: false,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('refuses a handler on a transient provider even with a static dependency tree', () => {
// Nest reports isDependencyTreeStatic() === true here, but the
// discovered instance is an unconstructed Object.create(prototype).
const registry = registryWith({
instance: new CalendarHandlers(),
isStatic: true,
isTransient: true,
});
expect(() => registry.onModuleInit()).toThrow(
'must be on a static (singleton) provider',
);
});
it('ignores a transient provider without handlers', () => {
class TransientService {
run(): void {}
}
const registry = registryWith({
instance: new TransientService(),
isTransient: true,
});
expect(() => registry.onModuleInit()).not.toThrow();
});
it('ignores a non-static provider without handlers', () => {
class RequestScopedService {
run(): void {}
}
const registry = registryWith({
instance: new RequestScopedService(),
isStatic: false,
});
expect(() => registry.onModuleInit()).not.toThrow();
expect(registry.handlerIds()).toEqual([]);
});
describe('order', () => {
class ManyHandlers {
@OnDomainEvent('TaskDue', 'calendar1.a')
@OnDomainEvent('TaskDue', 'calendar.sync')
@OnDomainEvent('TaskDue', 'calendar-sync.a')
@OnDomainEvent('TaskDue', 'calendar.a')
handle(): Promise<void> {
return Promise.resolve();
}
}
const CODE_POINT_ORDER = [
'calendar-sync.a',
'calendar.a',
'calendar.sync',
'calendar1.a',
];
afterEach(() => jest.restoreAllMocks());
it('sorts by code point in handlersFor and handlerIds, whatever the locale collation says', () => {
// A collation that reverses everything: the order must not change.
jest
.spyOn(String.prototype, 'localeCompare')
.mockImplementation(function (this: string, other: string) {
if (this > other) return -1;
return this < other ? 1 : 0;
});
const registry = registryFor(new ManyHandlers());
registry.onModuleInit();
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(CODE_POINT_ORDER);
expect(registry.handlerIds()).toEqual(CODE_POINT_ORDER);
});
});
});
describe('handler envelope typing', () => {
const harvest = envelope('HarvestRecorded', {
apiaryId: 'a1',
colonyIds: ['c1', 'c2'],
});
const inspection = envelope('InspectionRecorded', { colonyId: 'c3' });
it('ties every event type to its payload, so a multi-type handler narrows by type', async () => {
const seen: string[] = [];
// Compiles only because the envelope is a distributive union.
const handler: DomainEventHandlerFn<
'InspectionRecorded' | 'HarvestRecorded'
> = (event) => {
seen.push(
...(event.type === 'HarvestRecorded'
? event.payload.colonyIds
: [event.payload.colonyId]),
);
return Promise.resolve();
};
await handler(harvest, {} as Prisma.TransactionClient);
await handler(inspection, {} as Prisma.TransactionClient);
expect(seen).toEqual(['c1', 'c2', 'c3']);
});
it('refuses an envelope whose payload belongs to another type', () => {
const misfit = { ...harvest, type: 'TaskDue' } as const;
// @ts-expect-error - TaskDue carries { dueOn }, not a harvest payload
const wrong: AnyDomainEventEnvelope = misfit;
expect(wrong.type).toBe('TaskDue');
});
});
describe('DomainEventRegistry with Nest DI', () => {
@Injectable()
class Clock {
today(): string {
return '2026-10-13';
}
}
@Injectable()
class SingletonHandlers {
readonly seen: string[] = [];
constructor(private readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.singleton')
handle(): Promise<void> {
this.seen.push(this.clock.today());
return Promise.resolve();
}
}
@Injectable({ scope: Scope.TRANSIENT })
class TransientHandlers {
constructor(readonly clock: Clock) {}
@OnDomainEvent('TaskDue', 'di.transient')
handle(): Promise<void> {
return Promise.resolve();
}
}
@Injectable({ scope: Scope.REQUEST })
class RequestHandlers {
@OnDomainEvent('TaskDue', 'di.request')
handle(): Promise<void> {
return Promise.resolve();
}
}
async function compile(...providers: Provider[]): Promise<TestingModule> {
return Test.createTestingModule({
imports: [DomainEventsModule],
providers: [Clock, ...providers],
}).compile();
}
it('registers a singleton handler once, also behind an alias, bound to the DI instance', async () => {
const moduleRef = await compile(SingletonHandlers, {
provide: 'HANDLERS_ALIAS',
useExisting: SingletonHandlers,
});
await moduleRef.init();
const registry = moduleRef.get(DomainEventRegistry);
expect(
registry.handlersFor('TaskDue').map((handler) => handler.id),
).toEqual(['di.singleton']);
await registry
.handlersFor('TaskDue')[0]
.invoke(
envelope('TaskDue', { dueOn: '2026-10-13' }),
{} as Prisma.TransactionClient,
);
expect(moduleRef.get(SingletonHandlers).seen).toEqual(['2026-10-13']);
await moduleRef.close();
});
it.each([
['transient', TransientHandlers],
['request-scoped', RequestHandlers],
])('stops the boot for a handler on a %s provider', async (_scope, type) => {
const moduleRef = await compile(type);
// No close(): it re-throws the failed init, and nothing holds resources.
await expect(moduleRef.init()).rejects.toThrow(
'must be on a static (singleton) provider',
);
});
});
@@ -0,0 +1,174 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import {
DOMAIN_EVENT_TYPES,
DomainEventHandlerFn,
DomainEventType,
} from './domain-event-types';
import {
domainEventHandlerMetadata,
DomainEventHandlerMetadata,
HANDLER_ID_PATTERN,
} from './on-domain-event.decorator';
export interface RegisteredDomainEventHandler {
id: string;
invoke: DomainEventHandlerFn;
}
interface DiscoveredHandler extends RegisteredDomainEventHandler {
types: readonly DomainEventType[];
/** "<Class>.<method>" for boot error messages. */
source: string;
}
const NO_HANDLERS: readonly RegisteredDomainEventHandler[] = Object.freeze([]);
/**
* Collects every @OnDomainEvent method of every provider at boot. A wrong
* declaration - invalid id, unknown type, the same id twice for one type -
* stops the boot: a handler that silently never runs, or runs under
* another handler's delivery record, would lose or swallow effects.
*/
@Injectable()
export class DomainEventRegistry implements OnModuleInit {
private handlers = new Map<
DomainEventType,
readonly RegisteredDomainEventHandler[]
>();
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
) {}
onModuleInit(): void {
this.handlers = groupByType(this.discover());
}
/** Sorted by handler id (compareIds), so handlers run in a stable order. */
handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[] {
return this.handlers.get(type) ?? NO_HANDLERS;
}
handlerIds(): string[] {
const ids = new Set<string>();
for (const handlers of this.handlers.values()) {
handlers.forEach((handler) => ids.add(handler.id));
}
return [...ids].sort(compareIds);
}
/**
* Aliases are skipped - they point at a provider that is scanned anyway.
* A handler must sit on a static (singleton) provider: the worker calls
* it outside any request. For a request-scoped provider (dependency tree
* not static) and for a transient one (Nest reports its tree as static)
* the discovered instance is only an unconstructed Object.create of the
* prototype, without dependencies - the handler would fail at dispatch.
*/
private discover(): DiscoveredHandler[] {
return this.discovery.getProviders().flatMap((wrapper) => {
const instance: unknown = wrapper.instance;
if (wrapper.isAlias || !instance || typeof instance !== 'object') {
return [];
}
const handlers = this.handlersOf(instance);
const isSingleton =
!wrapper.isTransient && wrapper.isDependencyTreeStatic();
if (handlers.length > 0 && !isSingleton) {
const [first] = handlers;
throw new Error(
`Domain event handler "${first.id}" on ${first.source} must be on a static (singleton) provider: the worker calls handlers outside any request`,
);
}
return handlers;
});
}
private handlersOf(instance: object): DiscoveredHandler[] {
const prototype = Object.getPrototypeOf(instance) as object | null;
return this.scanner.getAllMethodNames(prototype).flatMap((methodName) => {
const method: unknown = (prototype as Record<string, unknown>)[
methodName
];
if (typeof method !== 'function') {
return [];
}
const source = `${instance.constructor.name}.${methodName}`;
return domainEventHandlerMetadata(method).map((entry) => {
assertValid(entry, source);
return {
id: entry.handlerId,
types: entry.types,
source,
invoke: (method as DomainEventHandlerFn).bind(
instance,
) as DomainEventHandlerFn,
};
});
});
}
}
function assertValid(entry: DomainEventHandlerMetadata, source: string): void {
if (!HANDLER_ID_PATTERN.test(entry.handlerId)) {
throw new Error(
`Invalid domain event handler id "${entry.handlerId}" on ${source}: expected "<consumer>.<name>" in lowercase letters, digits and hyphens`,
);
}
if (entry.types.length === 0) {
throw new Error(
`Domain event handler "${entry.handlerId}" on ${source} serves no domain event type`,
);
}
const unknown = entry.types.find(
(type) => !DOMAIN_EVENT_TYPES.includes(type),
);
if (unknown !== undefined) {
throw new Error(
`Unknown domain event type "${unknown}" for handler "${entry.handlerId}" on ${source}`,
);
}
}
function groupByType(
handlers: readonly DiscoveredHandler[],
): Map<DomainEventType, readonly RegisteredDomainEventHandler[]> {
const byType = new Map<DomainEventType, DiscoveredHandler[]>();
for (const handler of handlers) {
for (const type of handler.types) {
const existing = byType.get(type) ?? [];
const duplicate = existing.find((other) => other.id === handler.id);
if (duplicate) {
throw new Error(
`Duplicate domain event handler "${handler.id}" for ${type} (${duplicate.source} and ${handler.source})`,
);
}
byType.set(type, [...existing, handler]);
}
}
return new Map(
[...byType].map(([type, list]) => [
type,
Object.freeze(
[...list]
.sort((a, b) => compareIds(a.id, b.id))
.map(({ id, invoke }) => ({ id, invoke })),
),
]),
);
}
/**
* Ordinal comparison by UTF-16 code unit - for handler ids, which
* HANDLER_ID_PATTERN restricts to ASCII, that is code-point order. Unlike
* localeCompare it does not depend on the runtime's locale or ICU data, so
* every pod runs handlers in the same order.
*/
function compareIds(a: string, b: string): number {
if (a < b) {
return -1;
}
return a > b ? 1 : 0;
}
@@ -0,0 +1,173 @@
import {
ColonyCondition,
ColonyStatus,
Prisma,
RawMaterialKind,
TaskRelatedType,
TaskStatus,
} from '../../generated/prisma';
export interface ColonyStateSnapshot {
status: ColonyStatus;
healthStatus: ColonyCondition | null;
}
/**
* The complete event catalog. A payload carries only ids and the values a
* handler needs - never personal data or secrets; dates are 'YYYY-MM-DD'
* strings. Handlers read anything else from the database.
*/
export interface DomainEventPayloads {
InspectionRecorded: { colonyId: string };
InspectionCorrected: { colonyId: string };
VarroaMeasured: { colonyId: string };
VarroaCorrected: { colonyId: string };
TreatmentRecorded: { colonyId: string };
TreatmentCorrected: { colonyId: string };
FeedingRecorded: { colonyId: string };
FeedingCorrected: { colonyId: string };
HarvestRecorded: { apiaryId: string; colonyIds: string[] };
HarvestCorrected: { apiaryId: string; colonyIds: string[] };
ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot };
ColonyEnded: { status: ColonyStatus };
BatchBottled: { honeyBatchId: string };
BottlingCorrected: { honeyBatchId: string; removed: boolean };
StockChanged: {
bottlingBatchIds: string[];
productIds: string[];
rawMaterialKinds: RawMaterialKind[];
};
TaskCreated: {
relatedType: TaskRelatedType;
relatedId: string | null;
dueDate: string | null;
};
TaskUpdated: { status: TaskStatus; dueDate: string | null };
TaskCompleted: { relatedType: TaskRelatedType; relatedId: string | null };
WithdrawalEnded: { colonyId: string; endedOn: string };
TaskDue: { dueOn: string };
QueenAged: { hatchYear: number; colonyId: string | null };
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
}
export type DomainEventType = keyof DomainEventPayloads;
// A Record over the union: the compiler refuses a missing or unknown type.
const CATALOG: Record<DomainEventType, true> = {
InspectionRecorded: true,
InspectionCorrected: true,
VarroaMeasured: true,
VarroaCorrected: true,
TreatmentRecorded: true,
TreatmentCorrected: true,
FeedingRecorded: true,
FeedingCorrected: true,
HarvestRecorded: true,
HarvestCorrected: true,
ColonyStatusChanged: true,
ColonyEnded: true,
BatchBottled: true,
BottlingCorrected: true,
StockChanged: true,
TaskCreated: true,
TaskUpdated: true,
TaskCompleted: true,
WithdrawalEnded: true,
TaskDue: true,
QueenAged: true,
VarroaMeasurementDue: true,
InspectionOverdue: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
Object.keys(CATALOG) as DomainEventType[],
);
export type AggregateType =
| 'Inspection'
| 'VarroaMeasurement'
| 'Treatment'
| 'Feeding'
| 'Harvest'
| 'Colony'
| 'BottlingBatch'
| 'ProductionBatch'
| 'BatchMovement'
| 'ShopStockRequest'
| 'Task'
| 'Queen';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
/** The acting user; null or absent for the system or a job. */
actorUserId?: string | null;
/** Time-based events only, see dailyDedupeKey. */
dedupeKey?: string;
}
/**
* One NewDomainEvent per type in T, so `type` and `payload` stay paired: a
* TaskDue with an InspectionRecorded payload does not compile.
* NewDomainEvent<A | B> alone would accept either payload with either type.
*/
export type AnyNewDomainEvent<T extends DomainEventType = DomainEventType> = {
[K in T]: NewDomainEvent<K>;
}[T];
/** What a handler receives: the stored event. */
export interface DomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> {
id: string;
type: T;
aggregateType: AggregateType;
aggregateId: string;
payload: DomainEventPayloads[T];
actorUserId: string | null;
correlationId: string;
occurredAt: Date;
attempts: number;
}
/**
* One DomainEventEnvelope per type in T, paired like AnyNewDomainEvent: a
* handler for several types narrows the payload by checking `event.type`.
*/
export type AnyDomainEventEnvelope<
T extends DomainEventType = DomainEventType,
> = {
[K in T]: DomainEventEnvelope<K>;
}[T];
/**
* A handler writes only through `tx` (its delivery transaction) and makes
* no network calls; reading through `tx` or PrismaService is fine.
*/
export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
(
event: AnyDomainEventEnvelope<T>,
tx: Prisma.TransactionClient,
) => Promise<void>;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Unique key of a time-based event: at most one per type, aggregate and
* (UTC) day of the business event, however often the job runs. A full
* timestamp instead of the day would create a new event on every run, so
* it is refused.
*/
export function dailyDedupeKey(
type: DomainEventType,
aggregateId: string,
day: string,
): string {
if (!DAY_PATTERN.test(day)) {
throw new Error('Daily dedupe key needs a YYYY-MM-DD day');
}
return `${type}:${aggregateId}:${day}`;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { PrismaModule } from '../prisma/prisma.module';
import { DomainEventDispatcher } from './domain-event-dispatcher';
import { DomainEventRegistry } from './domain-event-registry';
import { DomainEventsService } from './domain-events.service';
@Global()
@Module({
imports: [DiscoveryModule, PrismaModule],
providers: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
exports: [DomainEventsService, DomainEventRegistry, DomainEventDispatcher],
})
export class DomainEventsModule {}
@@ -0,0 +1,265 @@
import { DomainEventsService } from './domain-events.service';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
} from './domain-event-types';
type CreateManyArg = {
data: {
type: string;
aggregateId: string;
actorUserId: string | null;
correlationId: string;
dedupeKey: string | null;
payload: unknown;
}[];
skipDuplicates: boolean;
};
describe('DomainEventsService', () => {
let service: DomainEventsService;
let tx: {
domainEvent: { createMany: jest.Mock };
$executeRaw: jest.Mock;
};
const event: NewDomainEvent<'InspectionRecorded'> = {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
};
const daily: NewDomainEvent<'TaskDue'> = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'),
};
function createManyArg(call = 0): CreateManyArg {
const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][];
return calls[call][0];
}
beforeEach(() => {
tx = {
domainEvent: {
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
service = new DomainEventsService();
});
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx as never, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
actorUserId: 'u1',
});
expect(tx.domainEvent.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
type: 'InspectionRecorded',
aggregateId: 'i1',
actorUserId: 'u1',
correlationId: expect.any(String) as unknown,
}),
],
skipDuplicates: true,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => {
await service.record(tx as never, event);
const [[strings, ...values]] = tx.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['domain_event']);
});
it('refuses the root PrismaService (must run inside the business transaction)', async () => {
const prismaServiceMock = {
...tx,
$connect: jest.fn(),
$transaction: jest.fn(),
};
await expect(
service.record(prismaServiceMock as never, event),
).rejects.toThrow(
'DomainEventsService.record needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('names recordAll when recordAll gets the root client', async () => {
const prismaServiceMock = { ...tx, $connect: jest.fn() };
await expect(
service.recordAll(prismaServiceMock as never, [event]),
).rejects.toThrow(
'DomainEventsService.recordAll needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('records through a transaction client that can open nested transactions (Prisma 7)', async () => {
const nestingTx = { ...tx, $transaction: jest.fn() };
await service.record(nestingTx as never, event);
expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1);
});
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () =>
service.record(tx as never, event),
);
expect(createManyArg().data[0].correlationId).toBe('corr-1');
});
it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => {
await service.recordAll(tx as never, [event, daily]);
await service.record(tx as never, event);
const [first, second] = createManyArg(0).data;
expect(first.correlationId).toBe(second.correlationId);
expect(createManyArg(1).data[0].correlationId).not.toBe(
first.correlationId,
);
});
it('stores missing actor and dedupe key as null and keeps the payload as given', async () => {
await service.recordAll(tx as never, [event, daily]);
const [plain, withKey] = createManyArg().data;
expect(plain.actorUserId).toBeNull();
expect(plain.dedupeKey).toBeNull();
expect(plain.payload).toEqual({ colonyId: 'c1' });
expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('returns the number of inserted events', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 1 });
await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe(
1,
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it('does nothing for an empty list', async () => {
await expect(service.recordAll(tx as never, [])).resolves.toBe(0);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
'InspectionRecorded',
'StockChanged',
'TaskDue',
'InspectionOverdue',
]),
);
});
it('builds the daily dedupe key as "<type>:<aggregateId>:<YYYY-MM-DD>"', () => {
expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe(
'QueenAged:q1:2026-10-13',
);
});
it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])(
'refuses %p as day - a timestamp would make every run a new event',
(day) => {
expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow(
'Daily dedupe key needs a YYYY-MM-DD day',
);
},
);
});
// The @ts-expect-error lines are checked by `npm run typecheck`: if the
// type stopped refusing the mismatch, the unused directive fails it.
describe('domain event typing', () => {
const tx = {
domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
const service = new DomainEventsService();
it('ties every event type to its payload in recordAll', async () => {
const recorded = service.recordAll(tx as never, [
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
},
]);
await expect(recorded).resolves.toBe(0);
});
it('refuses a stored event value whose payload belongs to another type', () => {
const misfit = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { colonyId: 'c1' },
} as const;
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
const wrong: AnyNewDomainEvent = misfit;
expect(wrong.type).toBe('TaskDue');
});
it('keeps record<T> checking the payload against the literal type', async () => {
await service.record(tx as never, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
});
expect(tx.domainEvent.createMany).toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { assertTransactionClient } from '../prisma/db-client';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DomainEventType,
NewDomainEvent,
} from './domain-event-types';
/**
* Writes domain events into the outbox. Always inside the business
* transaction: no commit, no event - and no commit without its event.
*/
@Injectable()
export class DomainEventsService {
async record<T extends DomainEventType>(
tx: Prisma.TransactionClient,
event: NewDomainEvent<T>,
): Promise<void> {
assertTransactionClient(tx, 'DomainEventsService.record');
await this.insert(tx, [event]);
}
/**
* Returns how many events were inserted - an event whose dedupeKey
* already exists is skipped. The worker is woken only if something new
* was written; Postgres delivers the wake-up on commit.
*/
async recordAll(
tx: Prisma.TransactionClient,
events: readonly AnyNewDomainEvent[],
): Promise<number> {
assertTransactionClient(tx, 'DomainEventsService.recordAll');
return this.insert(tx, events);
}
private async insert(
tx: Prisma.TransactionClient,
events: readonly NewDomainEvent[],
): Promise<number> {
if (events.length === 0) {
return 0;
}
const correlationId =
domainEventContext.getStore()?.correlationId ?? randomUUID();
const { count } = await tx.domainEvent.createMany({
data: events.map((event) => ({
type: event.type,
aggregateType: event.aggregateType,
aggregateId: event.aggregateId,
payload: event.payload as Prisma.InputJsonValue,
actorUserId: event.actorUserId ?? null,
correlationId,
dedupeKey: event.dedupeKey ?? null,
})),
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(tx, WAKE_CHANNELS.domainEvents);
}
return count;
}
}
@@ -0,0 +1,209 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
/**
* Guard for the handler contract (Review Focus 2): a domain event handler
* runs inside its delivery transaction and may be repeated after a crash,
* so it only writes to the database. Network work belongs to an outbox of
* its own (calendar sync, WebhooksService.enqueueDeliveries) that a worker
* loop drains. A static source scan:
* - every @OnDomainEvent handler lives in a `*.handlers.ts` file, and
* - a handler file imports no network client: no http/https/fetch library,
* no `fetch(` call, nothing from graph-client or webhook-delivery, and no
* src module that itself makes network calls (a fetch wrapper).
*/
const SRC = join(__dirname, '..');
const DECORATOR_FILE = join(
SRC,
'domain-events',
'on-domain-event.decorator.ts',
);
interface SourceFile {
path: string;
source: string;
}
const NETWORK_PACKAGES = new Set([
'http',
'https',
'http2',
'net',
'tls',
'dgram',
'undici',
'axios',
'node-fetch',
'cross-fetch',
'got',
'ky',
'superagent',
]);
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
const FETCH_CALL = /\bfetch\s*\(/;
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
function importsOf(source: string): string[] {
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
}
function isNetworkPackage(specifier: string): boolean {
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
}
/** A module that talks to the network itself. */
function makesNetworkCalls(file: SourceFile): boolean {
return (
FETCH_CALL.test(file.source) ||
importsOf(file.source).some(isNetworkPackage)
);
}
function resolveImport(from: string, specifier: string): string | undefined {
if (!specifier.startsWith('.')) {
return undefined;
}
const base = resolve(dirname(from), specifier);
return [`${base}.ts`, join(base, 'index.ts')].find(existsSync) ?? base;
}
/** Files declaring @OnDomainEvent handlers outside a `*.handlers.ts` file. */
function misplacedHandlerFiles(files: readonly SourceFile[]): string[] {
return files
.filter(
(file) =>
file.path !== DECORATOR_FILE &&
!file.path.endsWith('.spec.ts') &&
file.source.includes('@OnDomainEvent(') &&
!file.path.endsWith('.handlers.ts'),
)
.map((file) => file.path);
}
/** What a handler file must not use; empty when it keeps the contract. */
function networkViolations(
handlerFile: SourceFile,
networkModules: ReadonlySet<string>,
): string[] {
const violations = importsOf(handlerFile.source).filter((specifier) => {
const resolved = resolveImport(handlerFile.path, specifier);
return (
isNetworkPackage(specifier) ||
NETWORK_MODULE_NAME.test(specifier) ||
(resolved !== undefined && networkModules.has(resolved))
);
});
return FETCH_CALL.test(handlerFile.source)
? [...violations, 'fetch(']
: violations;
}
function sourceFiles(): SourceFile[] {
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts'))
.map((file) => join(SRC, file))
.map((path) => ({ path, source: readFileSync(path, 'utf8') }));
}
function networkModulesOf(files: readonly SourceFile[]): Set<string> {
return new Set(
files
.filter((file) => !file.path.endsWith('.spec.ts'))
.filter(makesNetworkCalls)
.map((file) => file.path),
);
}
describe('domain event handler contract', () => {
const files = sourceFiles();
const networkModules = networkModulesOf(files);
it('every @OnDomainEvent handler lives in a *.handlers.ts file', () => {
expect(misplacedHandlerFiles(files)).toEqual([]);
});
it('handler files never import network clients', () => {
for (const file of files.filter((f) => f.path.endsWith('.handlers.ts'))) {
expect({
file: file.path,
violations: networkViolations(file, networkModules),
}).toEqual({
file: file.path,
violations: [],
});
}
});
it('knows the network modules of the code base (fetch wrappers included)', () => {
const relative = [...networkModules].map((path) =>
path
.slice(SRC.length + 1)
.split(sep)
.join('/'),
);
expect(relative).toEqual(
expect.arrayContaining([
'calendar-sync/graph-client.service.ts',
'webhooks/webhook-delivery.ts',
'weather/weather.service.ts',
'common/password/password-policy.ts',
]),
);
expect(relative).not.toContain('webhooks/webhooks.service.ts');
});
describe('with planted violations', () => {
const handlerPath = join(SRC, 'colonies', 'colonies.handlers.ts');
it('flags a handler declared outside a *.handlers.ts file', () => {
const planted = {
path: join(SRC, 'colonies', 'colonies.service.ts'),
source: "@OnDomainEvent('TaskDue', 'colonies.due')\n",
};
expect(misplacedHandlerFiles([planted])).toEqual([planted.path]);
expect(
misplacedHandlerFiles([
{ path: handlerPath, source: planted.source },
{
path: `${planted.path.slice(0, -3)}.spec.ts`,
source: planted.source,
},
{ path: DECORATOR_FILE, source: planted.source },
]),
).toEqual([]);
});
it.each([
"import https from 'https';",
"import { request } from 'http';",
"import { get } from 'node:https';",
"const http = require('node:http');",
"import { fetch as undiciFetch } from 'undici';",
"import axios from 'axios';",
'const response = await fetch(url);',
"import { GraphClientService } from '../calendar-sync/graph-client.service';",
"import { postWebhook } from '../webhooks/webhook-delivery';",
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
"import { WeatherService } from '../weather/weather.service';",
"const { checkPassword } = await import('../common/password/password-policy');",
])('flags %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).not.toEqual([]);
});
it.each([
"import { WebhooksService } from '../webhooks/webhooks.service';",
"import { Prisma } from '../../generated/prisma';",
"import type { DomainEventEnvelope } from '../domain-events/domain-event-types';",
'const prefetched = await tx.task.findMany();',
"const scheme = 'https';",
])('lets a database-only line pass: %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).toEqual([]);
});
});
});
@@ -0,0 +1,50 @@
import { DomainEventType } from './domain-event-types';
/**
* "<consumer>.<name>", e.g. "calendar.sync-source". The id is stored per
* delivery, so it must stay stable once events were delivered to it.
*/
export const HANDLER_ID_PATTERN = /^[a-z0-9-]+\.[a-z0-9-]+$/;
const DOMAIN_EVENT_HANDLER_METADATA = 'domain-event-handler';
export interface DomainEventHandlerMetadata {
types: readonly DomainEventType[];
handlerId: string;
}
/**
* Marks a provider method as handler for one or more event types; the
* DomainEventRegistry finds it at boot and validates the id. Decorators
* stack - each adds its own entry.
*/
export function OnDomainEvent(
types: DomainEventType | readonly DomainEventType[],
handlerId: string,
): MethodDecorator {
const entry: DomainEventHandlerMetadata = {
types: typeof types === 'string' ? [types] : [...types],
handlerId,
};
return (_target, _propertyKey, descriptor) => {
const method = descriptor.value as object;
Reflect.defineMetadata(
DOMAIN_EVENT_HANDLER_METADATA,
[...domainEventHandlerMetadata(method), entry],
method,
);
return descriptor;
};
}
export function domainEventHandlerMetadata(
method: object,
): readonly DomainEventHandlerMetadata[] {
const entries: unknown = Reflect.getMetadata(
DOMAIN_EVENT_HANDLER_METADATA,
method,
);
return Array.isArray(entries)
? (entries as DomainEventHandlerMetadata[])
: [];
}
@@ -6,27 +6,14 @@ import {
VERSION_NEUTRAL,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { PrismaService } from '../prisma/prisma.service';
import { Public } from '../auth/decorators/public.decorator';
import { loggableError } from '../common/loggable-error';
import { withTimeout } from '../common/with-timeout';
const READINESS_QUERY_TIMEOUT_MS = 5000;
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Readiness query timed out')),
ms,
);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@ApiTags('health')
@Public()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
@@ -35,6 +22,10 @@ export class HealthController {
constructor(private readonly prisma: PrismaService) {}
// Liveness must not touch the throttler: it needs the database, and a database
// outage must not turn this probe red (kubelet would restart all pods).
// Readiness stays throttled and may fail with the database.
@SkipThrottle()
@Get()
liveness(): { status: 'ok' } {
return { status: 'ok' };
@@ -46,6 +37,7 @@ export class HealthController {
await withTimeout(
this.prisma.$queryRaw`SELECT 1`,
READINESS_QUERY_TIMEOUT_MS,
'Readiness query timed out',
);
return { status: 'ok' };
} catch (error: unknown) {
@@ -35,6 +35,12 @@ export class CreateInspectionDto {
@Min(0)
colonyStrength?: number;
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
@IsOptional()
@IsInt()
@Min(0)
broodFrames?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
+12 -45
View File
@@ -1,55 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { configureApp } from './app.config';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
// JWT_ACCESS_SECRET is the root of trust for both authentication (every
// access/MFA-challenge/SSO-completion token is signed with it) and MFA
// secret confidentiality (mfa-crypto.util.ts derives the AES key that
// encrypts stored TOTP secrets from it) - a short or guessable value here
// would let an attacker forge tokens for any user AND decrypt everyone's
// stored TOTP secret, not just guess a password. 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 assertProductionSettings(): void {
if (process.env.NODE_ENV !== 'production') {
return;
}
// Without it CORS and SSO redirects silently fall back to localhost.
if (!process.env.FRONTEND_ORIGIN?.startsWith('https://')) {
throw new Error(
'FRONTEND_ORIGIN must be set to an https:// origin in production',
);
}
if (process.env.COOKIE_SECURE === 'false') {
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
}
}
function assertRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
const jwtSecret = process.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"`,
);
}
}
import { resolveRole } from './runtime/role';
import { isHttpRole, rootModuleFor } from './runtime/root-module';
import { assertStartupEnvironment } from './runtime/startup-checks';
async function bootstrap() {
assertRequiredEnvVars();
assertProductionSettings();
const role = resolveRole('main');
// resolveRole('main') admits no other role; this only narrows the type.
if (!isHttpRole(role)) {
throw new Error(`APICORE_ROLE=${role} cannot start dist/main.js`);
}
assertStartupEnvironment(role);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const app = await NestFactory.create<NestExpressApplication>(
rootModuleFor(role),
);
app.enableShutdownHooks();
configureApp(app);
@@ -2,25 +2,35 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { PrismaService } from '../prisma/prisma.service';
type CreateManyArg = { data: { userId: string; message: string }[] };
describe('NotificationsService', () => {
let service: NotificationsService;
let prisma: {
notification: {
create: jest.Mock;
createMany: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
user: { findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
notification: {
create: jest.fn(),
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new NotificationsService(prisma as unknown as PrismaService);
});
@@ -44,6 +54,93 @@ describe('NotificationsService', () => {
});
});
it('records through the transaction client when one is given', async () => {
const tx = { notification: { create: jest.fn() } };
await service.record(
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
tx as never,
);
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(prisma.notification.create).not.toHaveBeenCalled();
});
describe('recordForPermission', () => {
it('notifies every active user whose active role holds the permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
}),
).resolves.toBe(2);
const [[createArg]] = prisma.notification.createMany.mock.calls as [
[CreateManyArg],
];
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
expect(createArg.data[0]).toEqual({
userId: 'u1',
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
});
});
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
await service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
});
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: {
some: { permission: { key: 'backup:configure' } },
},
},
},
select: { id: true },
});
});
it('writes nothing and returns 0 when nobody holds the permission', async () => {
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
}),
).resolves.toBe(0);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
await expect(
service.recordForPermission(
'backup:configure',
{ priority: 'WARNUNG', message: 'x' },
tx as never,
),
).resolves.toBe(1);
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).not.toHaveBeenCalled();
expect(prisma.notification.createMany).not.toHaveBeenCalled();
});
});
it('lists notifications for a user including broadcast notifications', async () => {
await service.findAll({ userId: 'user-1' });
@@ -4,7 +4,13 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Notification, NotificationPriority } from '../../generated/prisma';
import { DbClient } from '../prisma/db-client';
import { PermissionKey } from '../auth/permissions/permission-catalog';
import {
Notification,
NotificationPriority,
Prisma,
} from '../../generated/prisma';
export interface CreateNotificationInput {
userId?: string;
@@ -23,8 +29,42 @@ export interface NotificationFilter {
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
async record(input: CreateNotificationInput): Promise<Notification> {
return this.prisma.notification.create({ data: { ...input } });
async record(
input: CreateNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification> {
const db: DbClient = tx ?? this.prisma;
return db.notification.create({ data: { ...input } });
}
/**
* One notification per user who may act on it: active account, active
* role, permission granted - the same rule the PermissionsGuard applies.
* Returns how many were written.
*/
async recordForPermission(
permission: PermissionKey,
input: Omit<CreateNotificationInput, 'userId'>,
tx?: Prisma.TransactionClient,
): Promise<number> {
const db: DbClient = tx ?? this.prisma;
const recipients = await db.user.findMany({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: permission } } },
},
},
select: { id: true },
});
if (recipients.length === 0) {
return 0;
}
const { count } = await db.notification.createMany({
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
});
return count;
}
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> {
@@ -0,0 +1,25 @@
import { assertTransactionClient } from './db-client';
describe('assertTransactionClient', () => {
it('refuses the root client and names the caller', () => {
const root = { $connect: jest.fn(), $transaction: jest.fn() };
expect(() =>
assertTransactionClient(root as never, 'DomainEventsService.recordAll'),
).toThrow('DomainEventsService.recordAll needs the transaction client');
});
it('accepts a transaction client that can open nested transactions (Prisma 7)', () => {
const nestingTx = { $transaction: jest.fn(), $executeRaw: jest.fn() };
expect(() =>
assertTransactionClient(nestingTx as never, 'caller'),
).not.toThrow();
});
it('accepts a plain transaction client', () => {
expect(() =>
assertTransactionClient({ $executeRaw: jest.fn() } as never, 'caller'),
).not.toThrow();
});
});
+23
View File
@@ -0,0 +1,23 @@
import { Prisma } from '../../generated/prisma';
import { PrismaService } from './prisma.service';
/**
* The root client or the client of an interactive transaction, for helpers
* that work on either (e.g. notifyWorker). A helper that must run inside the
* caller's business transaction takes Prisma.TransactionClient instead and
* checks it with assertTransactionClient.
*/
export type DbClient = PrismaService | Prisma.TransactionClient;
/**
* Throws if `db` is the root client: a write through it would commit on its
* own, even if the caller's business transaction rolls back. `$connect` is
* withheld from transaction clients; `$transaction` does not tell the two
* apart, because a Prisma 7 transaction client can open nested transactions.
* `caller` names the method in the error, e.g. "DomainEventsService.recordAll".
*/
export function assertTransactionClient(db: DbClient, caller: string): void {
if ('$connect' in db) {
throw new Error(`${caller} needs the transaction client`);
}
}
@@ -0,0 +1,130 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
DB_SESSION_OPTIONS,
DB_SESSION_TIME_ZONE,
dbSessionConfig,
} from './db-session';
describe('dbSessionConfig', () => {
const url = 'postgresql://apicore:s3cret@db:5432/apicore?schema=public';
it('pins every session to UTC through the startup options', () => {
expect(DB_SESSION_TIME_ZONE).toBe('UTC');
expect(DB_SESSION_OPTIONS).toBe('-c TimeZone=UTC');
expect(dbSessionConfig(url)).toEqual({
connectionString: url,
options: '-c TimeZone=UTC',
});
});
it('keeps a missing connection string for pg to report', () => {
expect(dbSessionConfig(undefined)).toEqual({
connectionString: undefined,
options: '-c TimeZone=UTC',
});
});
it.each([
'postgresql://apicore:s3cret@db/apicore?options=-c%20TimeZone%3DEurope%2FBerlin',
'postgresql://apicore:s3cret@db/apicore?schema=public&OPTIONS=-c%20x%3Dy',
'postgresql://apicore:s3cret@db/apicore?options',
'postgresql://apicore:s3cret@db/apicore?options&schema=public',
])(
'refuses a URL with its own options - pg would let them override the pin: %s',
(withOptions) => {
expect(() => dbSessionConfig(withOptions)).toThrow(
'DATABASE_URL must not set "options"',
);
},
);
it.each([
'postgresql://apicore:s3cret@db/apicore?schema=public&optionsx=1',
'postgresql://apicore:s3cret@db/options?schema=public',
])('accepts a URL that only looks similar: %s', (similar) => {
expect(dbSessionConfig(similar).options).toBe('-c TimeZone=UTC');
});
it('never echoes the connection string (password) in the error', () => {
let message = '';
try {
dbSessionConfig('postgresql://apicore:s3cret@db/apicore?options=x');
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain('DATABASE_URL must not set "options"');
expect(message).not.toContain('s3cret');
});
});
/**
* Guard: every Postgres client in src gets its settings from
* dbSessionConfig, so no session escapes the UTC pin. prisma/seed.ts is the
* documented exception: it lives outside src (its build cannot import from
* src) and writes only through Prisma, whose timestamps do not depend on
* the session time zone.
*/
describe('database clients in src', () => {
const SRC = join(__dirname, '..');
const CLIENT_CONSTRUCTION =
/new\s+(PrismaPg|Pool|Client)\s*\(([\s\S]{0,120})/g;
const FROM_SESSION_CONFIG = /^\s*(?:\{\s*\.\.\.)?\s*dbSessionConfig\(/;
const IMPORTS_PG = /from\s+['"]pg['"]/;
/** `new X(` calls that do not start from dbSessionConfig(...). */
function clientsWithoutSessionConfig(source: string): string[] {
const usesPg = IMPORTS_PG.test(source);
return [...source.matchAll(CLIENT_CONSTRUCTION)]
.filter(([, type]) => type === 'PrismaPg' || usesPg)
.filter(([, , args]) => !FROM_SESSION_CONFIG.test(args))
.map(([, type]) => type);
}
it('builds every PrismaPg, pg Pool and pg Client from dbSessionConfig', () => {
const offenders = readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
.flatMap((file) =>
clientsWithoutSessionConfig(readFileSync(join(SRC, file), 'utf8')).map(
(client) => `${file}: new ${client}`,
),
);
expect(offenders).toEqual([]);
});
it.each([
[
'a pg Pool',
"import { Pool } from 'pg';\nconst pool = new Pool({ connectionString: url });",
],
[
'a pg Client',
"import { Client } from 'pg';\nconst client = new Client({ connectionString, keepAlive: true });",
],
[
'a PrismaPg adapter',
'const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });',
],
])('flags %s built without the session config', (_name, source) => {
expect(clientsWithoutSessionConfig(source)).toHaveLength(1);
});
it.each([
[
'the Prisma adapter',
'const adapter = new PrismaPg(dbSessionConfig(process.env.DATABASE_URL));',
],
[
'the LISTEN client',
"import { Client } from 'pg';\nreturn new Client({\n ...dbSessionConfig(connectionString),\n keepAlive: true,\n});",
],
[
'a Client that is not pg',
"import { Client } from 'openid-client';\nconst oidc = new Client(metadata);",
],
])('accepts %s', (_name, source) => {
expect(clientsWithoutSessionConfig(source)).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Every database session runs in UTC. Columns are `timestamp without time
* zone`: Prisma fills @default(now()) and writes Dates as UTC wall clock,
* while SQL now() (claims, backoff, throttler) is stored in the session's
* time zone. Only a UTC session keeps both on one clock, whatever the
* server or database default is.
*/
export const DB_SESSION_TIME_ZONE = 'UTC';
export const DB_SESSION_OPTIONS = `-c TimeZone=${DB_SESSION_TIME_ZONE}`;
export interface DbSessionConfig {
connectionString: string | undefined;
/** Startup options sent with every new connection. */
options: string;
}
/**
* Connection settings for every Postgres client of the app (Prisma pool,
* LISTEN client). pg lets an `options` parameter in the URL override the
* config, which would silently drop the UTC pin - such a URL is refused.
*/
export function dbSessionConfig(
connectionString: string | undefined,
): DbSessionConfig {
if (
connectionString !== undefined &&
/[?&]options(?:=|&|$)/i.test(connectionString)
) {
throw new Error(
'DATABASE_URL must not set "options": the session time zone is pinned to UTC in db-session.ts',
);
}
return { connectionString, options: DB_SESSION_OPTIONS };
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../../generated/prisma';
import { dbSessionConfig } from './db-session';
@Injectable()
export class PrismaService
@@ -9,7 +10,8 @@ export class PrismaService
{
constructor() {
super({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
// Every pooled connection runs in UTC, see db-session.ts.
adapter: new PrismaPg(dbSessionConfig(process.env.DATABASE_URL)),
});
}
@@ -1,4 +1,5 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { Throttle } from '@nestjs/throttler';
import { ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator';
@@ -7,6 +8,11 @@ import { PublicBatch, PublicService } from './public.service';
// What a printed lot number can look like - anything else is not a lot.
const LOT_NUMBER_PATTERN = /^[A-Za-z0-9._/-]{1,64}$/;
// Nothing on this page is personal or stand-specific (see PublicOrigin), so
// a successful answer may sit in the browser and at the Cloudflare edge. Only
// the 200 gets this header - errors keep the global no-store.
export const PUBLIC_BATCH_CACHE_CONTROL = 'public, max-age=300, s-maxage=3600';
@ApiTags('public')
@Public()
@Controller('public/batches')
@@ -17,10 +23,16 @@ export class PublicController {
// so it gets a tighter per-IP limit than logged-in traffic.
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Get(':lotNumber')
findByLot(@Param('lotNumber') lotNumber: string): Promise<PublicBatch> {
async findByLot(
@Param('lotNumber') lotNumber: string,
@Res({ passthrough: true }) res: Response,
): Promise<PublicBatch> {
if (!LOT_NUMBER_PATTERN.test(lotNumber)) {
throw new NotFoundException();
}
return this.publicService.findBatchByLot(lotNumber);
const batch = await this.publicService.findBatchByLot(lotNumber);
res.setHeader('Cache-Control', PUBLIC_BATCH_CACHE_CONTROL);
res.removeHeader('Pragma');
return batch;
}
}
@@ -3,6 +3,26 @@ import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
function lotWithApiaries(apiaries: Record<string, unknown>[]) {
return {
lotNumber: 'WH-1',
date: new Date('2026-08-20'),
bestBeforeDate: new Date('2028-08-20'),
jarSize: 500,
product: null,
honeyBatch: {
honeyType: null,
waterContent: null,
extraction: {
date: null,
harvests: apiaries.map((apiary) => ({
harvest: { date: new Date('2026-08-12'), apiary },
})),
},
},
};
}
describe('PublicService', () => {
let service: PublicService;
let prisma: { bottlingBatch: { findUnique: jest.Mock } };
@@ -15,6 +35,7 @@ describe('PublicService', () => {
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
originCountry: 'Deutschland',
}),
};
service = new PublicService(
@@ -30,7 +51,7 @@ describe('PublicService', () => {
);
});
it('shapes the traceability chain into public-safe fields, deduplicating apiaries', async () => {
it('shapes the traceability chain into public-safe fields, deduplicating regions', async () => {
const harvestDate1 = new Date('2026-08-05');
const harvestDate2 = new Date('2026-08-12');
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
@@ -51,7 +72,11 @@ describe('PublicService', () => {
apiary: {
id: 'apiary-1',
name: 'Stand Wald',
location: 'Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: ' Nordhessen ',
},
},
},
@@ -61,7 +86,11 @@ describe('PublicService', () => {
apiary: {
id: 'apiary-1',
name: 'Stand Wald',
location: 'Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: ' Nordhessen ',
},
},
},
@@ -75,7 +104,10 @@ describe('PublicService', () => {
expect(result.lotNumber).toBe('WH-2026-001');
expect(result.productName).toBe('Sommerhonig 500g');
expect(result.waterContent).toBe(16.8);
expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]);
expect(result.origin).toEqual({
regions: ['Nordhessen'],
country: 'Deutschland',
});
expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]);
});
@@ -135,4 +167,56 @@ describe('PublicService', () => {
expect(serialized).not.toContain('Nick Muster');
expect(serialized).not.toContain('Musterweg');
});
it('never returns apiary name, location, address or coordinates', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([
{
id: 'a1',
name: 'Stand Wald',
location: 'Waldweg 3',
address: 'Waldweg 3, 12345 Ort',
latitude: 51.2,
longitude: 9.4,
publicLocationLabel: 'Nordhessen',
},
]),
);
const json = JSON.stringify(await service.findBatchByLot('WH-1'));
for (const secret of ['Stand Wald', 'Waldweg 3', '51.2', '9.4', 'Ort']) {
expect(json).not.toContain(secret);
}
expect(json).not.toContain('apiaries');
});
it('returns distinct sorted labels plus the country', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([
{ id: 'a1', publicLocationLabel: 'Südhessen' },
{ id: 'a2', publicLocationLabel: ' Nordhessen' },
{ id: 'a3', publicLocationLabel: 'Südhessen ' },
{ id: 'a4', publicLocationLabel: ' ' },
{ id: 'a5', publicLocationLabel: null },
]),
);
const result = await service.findBatchByLot('WH-1');
expect(result.origin).toEqual({
regions: ['Nordhessen', 'Südhessen'],
country: 'Deutschland',
});
});
it('returns only the country when no label is set', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(
lotWithApiaries([{ id: 'a1', publicLocationLabel: null }]),
);
const result = await service.findBatchByLot('WH-1');
expect(result.origin).toEqual({ regions: [], country: 'Deutschland' });
});
});
+15 -12
View File
@@ -2,9 +2,12 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
export interface PublicApiaryOrigin {
name: string;
location: string | null;
// Honey apiaries are a theft target: the public page names a region
// (publicLocationLabel) and a country - never a stand's name, address or
// coordinates.
export interface PublicOrigin {
regions: string[];
country: string;
}
export interface PublicSocialLinks {
@@ -25,7 +28,7 @@ export interface PublicBatch {
waterContent: number | null;
extractedDate: Date | null;
harvestDates: Date[];
apiaries: PublicApiaryOrigin[];
origin: PublicOrigin;
socialLinks: PublicSocialLinks;
}
@@ -61,13 +64,13 @@ export class PublicService {
const harvests = batch.honeyBatch.extraction.harvests.map(
(entry) => entry.harvest,
);
const apiaryByName = new Map<string, PublicApiaryOrigin>();
for (const harvest of harvests) {
apiaryByName.set(harvest.apiary.id, {
name: harvest.apiary.name,
location: harvest.apiary.location,
});
}
const regions = [
...new Set(
harvests
.map((harvest) => harvest.apiary.publicLocationLabel?.trim() ?? '')
.filter((label) => label !== ''),
),
].sort((a, b) => a.localeCompare(b, 'de'));
const harvestDates = [...new Set(harvests.map((h) => h.date.getTime()))]
.sort((a, b) => a - b)
.map((time) => new Date(time));
@@ -85,7 +88,7 @@ export class PublicService {
waterContent: batch.honeyBatch.waterContent,
extractedDate: batch.honeyBatch.extraction.date,
harvestDates,
apiaries: [...apiaryByName.values()],
origin: { regions, country: settings.originCountry },
socialLinks: {
youtube: settings.youtubeUrl,
instagram: settings.instagramUrl,
+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);
}
@@ -3,9 +3,12 @@ import {
IsOptional,
IsString,
IsUrl,
Matches,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
// An empty string clears the link (stored as null); anything else must be a
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
@@ -52,4 +55,15 @@ export class UpdateSiteSettingsDto {
@IsString()
@MaxLength(300)
holderAddress?: string;
@ApiPropertyOptional({
description: 'Herkunftsland auf der öffentlichen Herkunftsseite',
})
@IsOptionalNotNull()
@IsString()
@MinLength(2)
@MaxLength(80)
// At least two visible characters - the value is trimmed before saving.
@Matches(/^\s*\S.*\S\s*$/s)
originCountry?: string;
}
@@ -60,6 +60,7 @@ export class SiteSettingsService {
dto.holderAddress !== undefined
? emptyToNull(dto.holderAddress.trim())
: undefined,
originCountry: dto.originCountry?.trim(),
},
});
await this.auditLog.record({
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export const MAX_PRODUCT_LENGTH = 200;
function trimString({ value }: TransformFnParams): unknown {
return typeof value === 'string' ? value.trim() : (value as unknown);
}
export class LastByProductQueryDto {
@ApiProperty({
description: 'Präparatename (ohne Beachtung der Groß-/Kleinschreibung)',
example: 'Oxuvar',
maxLength: MAX_PRODUCT_LENGTH,
})
@Transform(trimString)
@IsString({ message: 'product muss ein Text sein' })
@IsNotEmpty({ message: 'product darf nicht leer sein' })
@MaxLength(MAX_PRODUCT_LENGTH, {
message: `product darf höchstens ${MAX_PRODUCT_LENGTH} Zeichen lang sein`,
})
product!: string;
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
/** Fields of a previous treatment that a new one of the same product reuses. */
export interface TreatmentPrefill {
sourceTreatmentId: string;
sourceDate: string;
product: string;
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
}
@Injectable()
export class TreatmentPrefillService {
constructor(private readonly prisma: PrismaService) {}
/**
* The caller's own newest treatment with this product (case-insensitive).
* Strictly scoped to `userId`: supplier, proof and vet are the caller's
* own purchase data and must never leak across users.
*/
async lastByProduct(
userId: string,
product: string,
): Promise<TreatmentPrefill | null> {
const treatment = await this.prisma.treatment.findFirst({
where: {
userId,
product: { equals: product.trim(), mode: 'insensitive' },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
select: {
id: true,
date: true,
product: true,
activeIngredient: true,
withdrawalPeriodDays: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
},
});
if (!treatment) {
return null;
}
const { id, date, ...rest } = treatment;
return { sourceTreatmentId: id, sourceDate: date.toISOString(), ...rest };
}
}
@@ -14,6 +14,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto';
import { LastByProductQueryDto } from './dto/last-by-product-query.dto';
import { TreatmentPrefillService } from './treatment-prefill.service';
import { TreatmentsService } from './treatments.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@@ -21,7 +23,10 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiBearerAuth()
@Controller('treatments')
export class TreatmentsController {
constructor(private readonly treatmentsService: TreatmentsService) {}
constructor(
private readonly treatmentsService: TreatmentsService,
private readonly treatmentPrefillService: TreatmentPrefillService,
) {}
@RequirePermission('treatments:read')
@Get()
@@ -39,6 +44,21 @@ export class TreatmentsController {
);
}
/** The caller's newest treatment with this product, to prefill a new one. */
@RequirePermission('treatments:create')
@Get('last-by-product')
async getLastByProduct(
@Query() query: LastByProductQueryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return {
treatment: await this.treatmentPrefillService.lastByProduct(
actor.id,
query.product,
),
};
}
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { TreatmentsController } from './treatments.controller';
import { ColonyTreatmentsController } from './colony-treatments.controller';
import { TreatmentPrefillService } from './treatment-prefill.service';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [AutomationModule],
controllers: [TreatmentsController, ColonyTreatmentsController],
providers: [TreatmentsService],
providers: [TreatmentsService, TreatmentPrefillService],
exports: [TreatmentsService],
})
export class TreatmentsModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { varroaThresholdOf } from '../automation/varroa-threshold';
import { PrismaService } from '../prisma/prisma.service';
export interface VarroaThreshold {
// null when the VARROA_THRESHOLD rule is switched off.
thresholdMitesPerDay: number | null;
}
@Injectable()
export class VarroaThresholdService {
constructor(private readonly prisma: PrismaService) {}
async current(): Promise<VarroaThreshold> {
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
select: { active: true, config: true },
});
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
}
}
@@ -6,13 +6,17 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('varroa-measurements')
@ApiBearerAuth()
@Controller('varroa-measurements')
export class VarroaController {
constructor(private readonly varroaService: VarroaService) {}
constructor(
private readonly varroaService: VarroaService,
private readonly varroaThresholdService: VarroaThresholdService,
) {}
@RequirePermission('varroa:read')
@Get()
@@ -20,6 +24,13 @@ export class VarroaController {
return this.varroaService.findAll();
}
/** Effective alarm threshold; declared before :id so it is not shadowed. */
@RequirePermission('varroa:read', 'varroa:create')
@Get('threshold')
getThreshold() {
return this.varroaThresholdService.current();
}
@RequirePermission('varroa:read')
@Get(':id')
findOne(@Param('id') id: string) {
+2 -1
View File
@@ -3,11 +3,12 @@ import { AutomationModule } from '../automation/automation.module';
import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
@Module({
imports: [AutomationModule],
controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService],
providers: [VarroaService, VarroaThresholdService],
exports: [VarroaService],
})
export class VarroaModule {}
@@ -9,10 +9,15 @@ function openMeteoResponse(
tempMin: number | null;
precipitation: number | null;
wind?: number | null;
weatherCode?: number | null;
}[],
): unknown {
const withCodes = days.some((day) => day.weatherCode !== undefined);
return {
daily: {
...(withCodes
? { weather_code: days.map((day) => day.weatherCode ?? null) }
: {}),
time: days.map((day) => day.date),
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
@@ -91,6 +96,7 @@ describe('WeatherService', () => {
tempMinC: 12,
precipitationMm: 0,
windSpeedMaxKmh: 10,
weatherCode: null,
inspectionSuitable: true,
},
]);
@@ -99,6 +105,61 @@ describe('WeatherService', () => {
expect(url.toString()).toContain('longitude=13.4');
});
it('maps daily weather_code to weatherCode', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
weatherCode: 61,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].weatherCode).toBe(61);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.searchParams.get('daily')).toContain('weather_code');
});
it('weatherCode is null when Open-Meteo omits it', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily[0].weatherCode).toBeNull();
});
it.each([
['too cold', { tempMax: 10, tempMin: 4, precipitation: 0, wind: 5 }],
['raining', { tempMax: 20, tempMin: 12, precipitation: 3, wind: 5 }],
@@ -30,6 +30,9 @@ export interface DailyWeather {
tempMinC: number | null;
precipitationMm: number | null;
windSpeedMaxKmh: number | null;
// WMO weather interpretation code (Open-Meteo `weather_code`); null when
// the provider omits it.
weatherCode: number | null;
// null when a required value is missing (rather than a misleading guess).
inspectionSuitable: boolean | null;
}
@@ -54,6 +57,7 @@ interface OpenMeteoDaily {
temperature_2m_min: (number | null)[];
precipitation_sum: (number | null)[];
wind_speed_10m_max: (number | null)[];
weather_code?: (number | null)[];
}
interface OpenMeteoResponse {
@@ -108,7 +112,7 @@ export class WeatherService {
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code',
);
url.searchParams.set('wind_speed_unit', 'kmh');
url.searchParams.set('past_days', String(PAST_DAYS));
@@ -136,6 +140,7 @@ export class WeatherService {
temperature_2m_min,
precipitation_sum,
wind_speed_10m_max,
weather_code,
} = body.daily;
return time.map((date, index) => {
const tempMaxC = temperature_2m_max[index] ?? null;
@@ -147,6 +152,7 @@ export class WeatherService {
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm,
windSpeedMaxKmh,
weatherCode: weather_code?.[index] ?? null,
inspectionSuitable:
tempMaxC === null ||
precipitationMm === null ||
@@ -0,0 +1,91 @@
import { Logger } from '@nestjs/common';
import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util';
import { KEY_ID_PATTERN } from '../common/crypto/key-ring';
import {
openWebhookSecret,
sealWebhookSecret,
webhookSecretNeedsRekey,
} from './webhook-secret';
// Written by the code before the key ring: "enc:v1:" + AES-256-GCM with
// sha256(JWT_ACCESS_SECRET), JWT_ACCESS_SECRET = 'x' * 40.
const LEGACY_SEALED =
'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ';
// Rows from before webhook secrets were encrypted at all.
const LEGACY_PLAIN = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64';
describe('webhook-secret', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS;
delete process.env.MFA_ENCRYPTION_KEY;
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
it('seals with the webhook key and a key id', () => {
const stored = sealWebhookSecret('topsecret');
expect(stored).toMatch(KEY_ID_PATTERN);
expect(stored).not.toContain('topsecret');
expect(openWebhookSecret(stored)).toBe('topsecret');
expect(webhookSecretNeedsRekey(stored)).toBe(false);
});
it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', () => {
expect(openWebhookSecret(LEGACY_SEALED)).toBe('topsecret');
expect(openWebhookSecret(LEGACY_PLAIN)).toBe(LEGACY_PLAIN);
expect(webhookSecretNeedsRekey(LEGACY_SEALED)).toBe(true);
expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true);
});
it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => {
const stored = sealWebhookSecret('topsecret');
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS =
process.env.WEBHOOK_ENCRYPTION_KEY;
process.env.WEBHOOK_ENCRYPTION_KEY = 'v'.repeat(40);
expect(openWebhookSecret(stored)).toBe('topsecret');
expect(webhookSecretNeedsRekey(stored)).toBe(true);
});
it('fails closed on a legacy enc:v1 secret once JWT_ACCESS_SECRET changed', () => {
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(() => openWebhookSecret(LEGACY_SEALED)).toThrow(
'ciphertext failed authentication',
);
});
it('never retries a k…: value that fails authentication as legacy or plain secret', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
const currentKeyId = sealWebhookSecret('topsecret').split(':')[0];
// A valid legacy ciphertext behind the current key id: only a retry with
// sha256(JWT_ACCESS_SECRET) could open it, and only the plaintext
// fallback would hand the stored string back.
const legacyPayload = LEGACY_SEALED.slice('enc:v1:'.length);
expect(() => openWebhookSecret(`${currentKeyId}:${legacyPayload}`)).toThrow(
'ciphertext failed authentication',
);
expect(() => openWebhookSecret(`k00000000:${legacyPayload}`)).toThrow(
'unknown key id',
);
expect(warn).not.toHaveBeenCalled();
});
it('keeps MFA and webhook keys apart, even on the shared dev fallback', () => {
delete process.env.WEBHOOK_ENCRYPTION_KEY;
expect(() => openWebhookSecret(encryptMfaSecret('topsecret'))).toThrow(
'unknown key id',
);
});
});
+27 -10
View File
@@ -1,19 +1,36 @@
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../auth/mfa/mfa-crypto.util';
KEY_ID_PATTERN,
isSealedWithCurrentKey,
loadKeyRing,
openLegacyCiphertext,
openWithRing,
sealWithRing,
warnLegacyRead,
} from '../common/crypto/key-ring';
// Webhook signing secrets must stay reversible (the server signs with them),
// so they are stored AES-256-GCM-encrypted like TOTP secrets. Rows written
// before this carry the plain secret and no prefix - still readable.
const PREFIX = 'enc:v1:';
// so they are sealed with the webhook key ring (WEBHOOK_ENCRYPTION_KEY, see
// common/crypto/key-ring.ts). Two older forms stay readable until the rekey
// job reseals them: "enc:v1:" + a ciphertext under sha256(JWT_ACCESS_SECRET),
// and, from before encryption, the plain secret without any prefix.
const LEGACY_PREFIX = 'enc:v1:';
export function sealWebhookSecret(secret: string): string {
return `${PREFIX}${encryptMfaSecret(secret)}`;
return sealWithRing(loadKeyRing('webhook'), secret);
}
export function openWebhookSecret(stored: string): string {
return stored.startsWith(PREFIX)
? decryptMfaSecret(stored.slice(PREFIX.length))
: stored;
if (KEY_ID_PATTERN.test(stored)) {
return openWithRing(loadKeyRing('webhook'), stored).plaintext;
}
if (stored.startsWith(LEGACY_PREFIX)) {
return openLegacyCiphertext('webhook', stored.slice(LEGACY_PREFIX.length));
}
warnLegacyRead('webhook');
return stored;
}
/** True for secrets the rekey job should reseal with the current key. */
export function webhookSecretNeedsRekey(stored: string): boolean {
return !isSealedWithCurrentKey(loadKeyRing('webhook'), stored);
}
@@ -0,0 +1,86 @@
import { WinterChecksService } from './winter-checks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
describe('WinterChecksService', () => {
const livingColony = { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' };
const lostCheck: CreateWinterCheckDto = {
colonyId: 'c1',
season: 'SPRING',
date: '2027-03-20',
outcome: 'LOST',
lossCause: 'DEAD_OR_EMPTY',
markColonyDeceased: true,
};
let service: WinterChecksService;
let tx: {
winterCheck: { create: jest.Mock };
colony: { update: jest.Mock };
auditLog: { create: jest.Mock };
};
let prisma: {
colony: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {
winterCheck: {
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ id: 'w1', ...data }),
),
},
colony: {
update: jest
.fn()
.mockResolvedValue({ ...livingColony, status: 'DECEASED' }),
},
auditLog: { create: jest.fn() },
};
prisma = {
colony: { findUnique: jest.fn().mockResolvedValue(livingColony) },
$transaction: jest
.fn()
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
auditLog = { record: jest.fn() };
service = new WinterChecksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('audits a colony marked deceased through the audit service inside the transaction', async () => {
await service.create(lostCheck, 'u1');
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'Colony',
entityId: 'c1',
action: 'UPDATE',
userId: 'u1',
beforeData: livingColony,
afterData: { ...livingColony, status: 'DECEASED' },
},
tx,
);
expect(tx.auditLog.create).not.toHaveBeenCalled();
});
it('writes no colony audit when the colony is not to be marked deceased', async () => {
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
expect(tx.colony.update).not.toHaveBeenCalled();
const auditedTypes = (
auditLog.record.mock.calls as [{ entityType: string }][]
).map(([input]) => input.entityType);
expect(auditedTypes).toEqual(['WinterCheck']);
});
});
@@ -226,8 +226,8 @@ export class WinterChecksService {
where: { id: colony.id },
data: { status: 'DECEASED', deactivatedAt: check.date },
});
await tx.auditLog.create({
data: {
await this.auditLog.record(
{
entityType: 'Colony',
entityId: colony.id,
action: 'UPDATE',
@@ -235,7 +235,8 @@ export class WinterChecksService {
beforeData: colony,
afterData: after,
},
});
tx,
);
}
private async withDuplicateMessage<T>(run: () => Promise<T>): Promise<T> {
+66
View File
@@ -0,0 +1,66 @@
import { INestApplicationContext } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { bootstrapWorker } from './worker';
import { WorkerModule } from './worker/worker.module';
const VALID_ENV: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://worker:secret@db/app',
JWT_ACCESS_SECRET: 'a'.repeat(32),
};
describe('bootstrapWorker', () => {
let createContext: jest.SpyInstance;
let app: { enableShutdownHooks: jest.Mock };
beforeEach(() => {
app = { enableShutdownHooks: jest.fn() };
createContext = jest
.spyOn(NestFactory, 'createApplicationContext')
.mockResolvedValue(app as unknown as INestApplicationContext);
});
afterEach(() => jest.restoreAllMocks());
// A misconfigured pod must fail before any module connects anywhere.
it.each([
[
'a role of another entry point',
{ ...VALID_ENV, APICORE_ROLE: 'api' },
'APICORE_ROLE=api cannot start dist/worker.js',
],
[
'"all" in production',
{ ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' },
'APICORE_ROLE=all is for local development and tests only',
],
[
'no role in production',
{ ...VALID_ENV, NODE_ENV: 'production' },
'APICORE_ROLE is required in production',
],
[
'a missing DATABASE_URL',
{ JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET },
'Missing required environment variable(s): DATABASE_URL',
],
[
'a short JWT secret',
{ ...VALID_ENV, JWT_ACCESS_SECRET: 'too-short' },
'JWT_ACCESS_SECRET must be at least 32 characters',
],
])(
'refuses %s before creating the context',
async (_case, env: NodeJS.ProcessEnv, message: string) => {
await expect(bootstrapWorker(env)).rejects.toThrow(message);
expect(createContext).not.toHaveBeenCalled();
},
);
it('starts WorkerModule as an application context with shutdown hooks', async () => {
await expect(bootstrapWorker(VALID_ENV)).resolves.toBe(app);
expect(createContext).toHaveBeenCalledWith(WorkerModule);
expect(app.enableShutdownHooks).toHaveBeenCalledTimes(1);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { INestApplicationContext } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { resolveRole } from './runtime/role';
import { assertStartupEnvironment } from './runtime/startup-checks';
import { WorkerModule } from './worker/worker.module';
/**
* Entry point of the worker role: domain events, calendar outbox and their
* wake-ups, plus GET /health on WORKER_HEALTH_PORT - no HTTP API. Role and
* environment are checked before any module is created, so a misconfigured
* pod fails before it connects anywhere. `env` is for tests; the modules
* themselves read process.env.
*/
export async function bootstrapWorker(
env: NodeJS.ProcessEnv = process.env,
): Promise<INestApplicationContext> {
const role = resolveRole('worker', env);
assertStartupEnvironment(role, env);
const app = await NestFactory.createApplicationContext(WorkerModule);
app.enableShutdownHooks();
return app;
}
// Started as `node dist/worker.js`; importing this file starts nothing.
if (require.main === module) {
void bootstrapWorker();
}
@@ -0,0 +1,254 @@
import { Logger } from '@nestjs/common';
import { Client } from 'pg';
import { ListenClient, PgListener } from './pg-listener';
import type { WakeChannel } from '../common/worker-wakeup';
jest.mock('pg', () => ({ Client: jest.fn() }));
const CONNECTION_STRING = 'postgresql://user:secret@db/app';
const CHANNELS: readonly WakeChannel[] = [
'domain_event',
'calendar_sync',
'webhook_delivery',
];
class FakeClient implements ListenClient {
connect = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
query = jest.fn<Promise<unknown>, [string]>().mockResolvedValue(undefined);
end = jest.fn<Promise<void>, []>().mockResolvedValue(undefined);
private readonly handlers = new Map<string, (arg: unknown) => void>();
on(
event: 'notification' | 'error' | 'end',
cb: (arg: unknown) => void,
): void {
this.handlers.set(event, cb);
}
emit(event: 'notification' | 'error' | 'end', arg?: unknown): void {
this.handlers.get(event)?.(arg);
}
}
describe('PgListener', () => {
let clients: FakeClient[];
let connectFails: boolean;
let logger: {
error: jest.Mock<void, unknown[]>;
warn: jest.Mock<void, unknown[]>;
log: jest.Mock<void, unknown[]>;
};
let onNotify: jest.Mock;
let listener: PgListener;
function createListener(
clientFactory: (connectionString: string) => ListenClient,
): PgListener {
return new PgListener({
connectionString: CONNECTION_STRING,
channels: CHANNELS,
onNotify,
logger: logger as unknown as Logger,
clientFactory,
});
}
beforeEach(() => {
jest.useFakeTimers();
clients = [];
connectFails = false;
logger = {
error: jest.fn<void, unknown[]>(),
warn: jest.fn<void, unknown[]>(),
log: jest.fn<void, unknown[]>(),
};
onNotify = jest.fn();
listener = createListener(() => {
const client = new FakeClient();
if (connectFails) {
client.connect.mockRejectedValue(new Error('ECONNREFUSED'));
}
clients.push(client);
return client;
});
});
afterEach(async () => {
await listener.stop();
jest.useRealTimers();
});
it('LISTENs on every channel after connecting', async () => {
await listener.start();
expect(clients).toHaveLength(1);
expect(clients[0].connect).toHaveBeenCalledTimes(1);
expect(clients[0].query.mock.calls.map((c) => c[0])).toEqual([
'LISTEN "domain_event"',
'LISTEN "calendar_sync"',
'LISTEN "webhook_delivery"',
]);
});
it('passes the connection string to the client factory', async () => {
const factory = jest.fn<ListenClient, [string]>(() => new FakeClient());
const other = createListener(factory);
await other.start();
expect(factory).toHaveBeenCalledWith(CONNECTION_STRING);
await other.stop();
});
it('forwards notifications of known channels and ignores others', async () => {
await listener.start();
clients[0].emit('notification', { channel: 'calendar_sync', payload: '' });
clients[0].emit('notification', { channel: 'something_else' });
clients[0].emit('notification', 'garbage');
expect(onNotify).toHaveBeenCalledTimes(1);
expect(onNotify).toHaveBeenCalledWith('calendar_sync');
});
it('reconnects after an error with 1 s, 2 s, 4 s … at most 30 s', async () => {
await listener.start();
connectFails = true;
clients[0].emit('error', new Error('connection lost'));
for (const delay of [1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
const before = clients.length;
await jest.advanceTimersByTimeAsync(delay - 1);
expect(clients).toHaveLength(before);
await jest.advanceTimersByTimeAsync(1);
expect(clients).toHaveLength(before + 1);
}
});
it('resets the backoff after a successful reconnect', async () => {
await listener.start();
clients[0].emit('error', new Error('lost'));
await jest.advanceTimersByTimeAsync(1000);
expect(clients).toHaveLength(2);
clients[1].emit('end');
await jest.advanceTimersByTimeAsync(999);
expect(clients).toHaveLength(2);
await jest.advanceTimersByTimeAsync(1);
expect(clients).toHaveLength(3);
});
it('reconnects only once when error and end both fire', async () => {
await listener.start();
clients[0].emit('error', new Error('lost'));
clients[0].emit('end');
await jest.advanceTimersByTimeAsync(1000);
expect(clients).toHaveLength(2);
});
it('retries when the initial connect fails and logs no credentials', async () => {
connectFails = true;
await listener.start();
expect(clients).toHaveLength(1);
await jest.advanceTimersByTimeAsync(1000);
expect(clients).toHaveLength(2);
const logged = JSON.stringify([
...logger.error.mock.calls,
...logger.warn.mock.calls,
]);
expect(logged).not.toContain('secret');
});
it('stop() ends the client and does not reconnect', async () => {
await listener.start();
await listener.stop();
expect(clients[0].end).toHaveBeenCalledTimes(1);
clients[0].emit('end');
await jest.advanceTimersByTimeAsync(60_000);
expect(clients).toHaveLength(1);
});
it('flushes a backlog by notifying every channel after a reconnect only', async () => {
await listener.start();
expect(onNotify).not.toHaveBeenCalled();
clients[0].emit('error', new Error('lost'));
await jest.advanceTimersByTimeAsync(1000);
expect(
(onNotify.mock.calls as [WakeChannel][]).map(([channel]) => channel),
).toEqual([...CHANNELS]);
});
it('stop() does not hang on a client whose end() never settles', async () => {
await listener.start();
clients[0].end.mockReturnValue(new Promise<void>(() => undefined));
const stopping = listener.stop();
await jest.advanceTimersByTimeAsync(5000);
await expect(stopping).resolves.toBeUndefined();
});
it('does not recycle the connection when onNotify throws', async () => {
onNotify.mockImplementation(() => {
throw new Error('consumer failed');
});
await listener.start();
clients[0].emit('notification', { channel: 'domain_event' });
await jest.advanceTimersByTimeAsync(60_000);
expect(clients).toHaveLength(1);
expect(clients[0].end).not.toHaveBeenCalled();
clients[0].emit('error', new Error('lost'));
await jest.advanceTimersByTimeAsync(1000);
expect(clients).toHaveLength(2);
// Flush calls all threw, yet the new connection stays.
await jest.advanceTimersByTimeAsync(60_000);
expect(onNotify).toHaveBeenCalledTimes(1 + CHANNELS.length);
expect(clients).toHaveLength(2);
expect(clients[1].end).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
});
it('creates the default pg client with keep-alive settings', async () => {
const created = new FakeClient();
(Client as unknown as jest.Mock).mockReturnValue(created);
const defaulted = new PgListener({
connectionString: CONNECTION_STRING,
channels: CHANNELS,
onNotify,
logger: logger as unknown as Logger,
});
await defaulted.start();
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
connectionString: CONNECTION_STRING,
keepAlive: true,
keepAliveInitialDelayMillis: 10_000,
}),
);
await defaulted.stop();
});
it('pins the LISTEN session to UTC like every Prisma connection', async () => {
(Client as unknown as jest.Mock).mockReturnValue(new FakeClient());
const defaulted = new PgListener({
connectionString: CONNECTION_STRING,
channels: CHANNELS,
onNotify,
logger: logger as unknown as Logger,
});
await defaulted.start();
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
connectionString: CONNECTION_STRING,
options: '-c TimeZone=UTC',
}),
);
await defaulted.stop();
});
});
+149
View File
@@ -0,0 +1,149 @@
import { Logger } from '@nestjs/common';
import { Client } from 'pg';
import { loggableError } from '../common/loggable-error';
import { withTimeout } from '../common/with-timeout';
import type { WakeChannel } from '../common/worker-wakeup';
import { dbSessionConfig } from '../prisma/db-session';
const RECONNECT_INITIAL_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const CONNECTION_TIMEOUT_MS = 10_000;
const KEEP_ALIVE_INITIAL_DELAY_MS = 10_000;
const END_TIMEOUT_MS = 5000;
export interface ListenClient {
connect(): Promise<void>;
query(sql: string): Promise<unknown>;
on(event: 'notification' | 'error' | 'end', cb: (arg: unknown) => void): void;
end(): Promise<void>;
}
export interface PgListenerOptions {
connectionString: string;
channels: readonly WakeChannel[];
onNotify: (channel: WakeChannel) => void;
logger: Logger;
clientFactory?: (connectionString: string) => ListenClient;
}
/**
* Same connection string and session settings (UTC) as Prisma, so host,
* TLS and time zone match.
*/
function createPgClient(connectionString: string): ListenClient {
return new Client({
...dbSessionConfig(connectionString),
keepAlive: true,
keepAliveInitialDelayMillis: KEEP_ALIVE_INITIAL_DELAY_MS,
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
}) as unknown as ListenClient;
}
function notificationChannel(arg: unknown): string | undefined {
if (typeof arg !== 'object' || arg === null) return undefined;
const { channel } = arg as { channel?: unknown };
return typeof channel === 'string' ? channel : undefined;
}
/**
* Dedicated Postgres connection that LISTENs on the wake-up channels and
* reconnects with exponential backoff (1 s, doubled, at most 30 s). A missed
* notification is harmless: the polling loops catch up on their next tick.
*/
export class PgListener {
private readonly createClient: (connectionString: string) => ListenClient;
private client: ListenClient | undefined;
private reconnectTimer: NodeJS.Timeout | undefined;
private reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS;
private stopped = true;
constructor(private readonly options: PgListenerOptions) {
this.createClient = options.clientFactory ?? createPgClient;
}
async start(): Promise<void> {
if (!this.stopped) return;
this.stopped = false;
await this.connect(false);
}
async stop(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
const client = this.client;
this.client = undefined;
if (client) await this.endQuietly(client);
}
private async connect(isReconnect: boolean): Promise<void> {
const client = this.createClient(this.options.connectionString);
this.client = client;
client.on('notification', (arg) => this.handleNotification(arg));
client.on('error', (error) => this.handleLoss(client, error));
client.on('end', () => this.handleLoss(client));
try {
await client.connect();
for (const channel of this.options.channels) {
await client.query(`LISTEN "${channel}"`);
}
this.reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS;
// Notifications sent while disconnected are gone; wake everything.
if (isReconnect) {
this.options.channels.forEach((channel) => this.notify(channel));
}
} catch (error: unknown) {
this.handleLoss(client, error);
}
}
/** A failing consumer must never be mistaken for a lost connection. */
private notify(channel: WakeChannel): void {
try {
this.options.onNotify(channel);
} catch (error: unknown) {
const { message } = loggableError(error);
this.options.logger.error(`onNotify failed for ${channel}: ${message}`);
}
}
private handleNotification(arg: unknown): void {
const channel = notificationChannel(arg);
const known = this.options.channels.find((c) => c === channel);
if (known) this.notify(known);
}
private handleLoss(client: ListenClient, error?: unknown): void {
// error and end fire for the same loss; only the current client counts.
if (this.client !== client) return;
this.client = undefined;
if (error !== undefined) {
const { message } = loggableError(error);
this.options.logger.warn(`LISTEN connection lost: ${message}`);
}
void this.endQuietly(client);
if (this.stopped) return;
const delay = this.reconnectDelayMs;
this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS);
this.options.logger.log(`Reconnecting LISTEN connection in ${delay} ms`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
void this.connect(true);
}, delay);
}
private async endQuietly(client: ListenClient): Promise<void> {
try {
await withTimeout(
client.end(),
END_TIMEOUT_MS,
'LISTEN client end timed out',
);
} catch (error: unknown) {
const { message } = loggableError(error);
this.options.logger.warn(`Closing LISTEN connection failed: ${message}`);
}
}
}
@@ -0,0 +1,215 @@
import { Logger } from '@nestjs/common';
import { PollingLoop } from './polling-loop';
const INTERVAL_MS = 5000;
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('PollingLoop', () => {
let logger: { error: jest.Mock };
function createLoop(tick: () => Promise<unknown>): PollingLoop {
return new PollingLoop({
name: 'test-loop',
intervalMs: INTERVAL_MS,
tick,
logger: logger as unknown as Logger,
});
}
beforeEach(() => {
jest.useFakeTimers();
logger = { error: jest.fn() };
});
afterEach(() => {
jest.useRealTimers();
});
it('ticks every intervalMs', async () => {
const tick = jest.fn().mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
expect(tick).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(INTERVAL_MS - 1);
expect(tick).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(tick).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(INTERVAL_MS);
expect(tick).toHaveBeenCalledTimes(3);
await loop.stop();
});
it('never runs two ticks at once', async () => {
const gate = deferred();
const tick = jest.fn().mockReturnValue(gate.promise);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(INTERVAL_MS * 3);
expect(tick).toHaveBeenCalledTimes(1);
gate.resolve();
await loop.stop();
});
it('wake() during a tick runs exactly one more tick right after', async () => {
const gate = deferred();
const tick = jest
.fn()
.mockReturnValueOnce(gate.promise)
.mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
loop.wake();
loop.wake();
loop.wake();
expect(tick).toHaveBeenCalledTimes(1);
gate.resolve();
await jest.advanceTimersByTimeAsync(0);
expect(tick).toHaveBeenCalledTimes(2);
await loop.stop();
});
it('wake() while waiting runs the next tick immediately', async () => {
const tick = jest.fn().mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
expect(tick).toHaveBeenCalledTimes(1);
loop.wake();
await jest.advanceTimersByTimeAsync(0);
expect(tick).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(INTERVAL_MS - 1);
expect(tick).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
expect(tick).toHaveBeenCalledTimes(3);
await loop.stop();
});
it('logs a throwing tick via loggableError and keeps going', async () => {
const tick = jest
.fn()
.mockRejectedValueOnce(new Error('tick exploded'))
.mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('tick exploded'),
expect.any(String),
);
await jest.advanceTimersByTimeAsync(INTERVAL_MS);
expect(tick).toHaveBeenCalledTimes(2);
await loop.stop();
});
it('stop() waits for the running tick and schedules nothing more', async () => {
const gate = deferred();
const tick = jest.fn().mockReturnValue(gate.promise);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
let stopped = false;
const stopping = loop.stop().then(() => {
stopped = true;
});
await jest.advanceTimersByTimeAsync(0);
expect(stopped).toBe(false);
gate.resolve();
await stopping;
expect(stopped).toBe(true);
await jest.advanceTimersByTimeAsync(INTERVAL_MS * 3);
expect(tick).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('wake() after stop() does nothing', async () => {
const tick = jest.fn().mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
await loop.stop();
loop.wake();
await jest.advanceTimersByTimeAsync(INTERVAL_MS);
expect(tick).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
});
it('does not lose a wake() fired right at the end of a tick', async () => {
const tick = jest
.fn()
.mockImplementationOnce(async () => {
// Fires after the tick body, before the loop bookkeeping finished.
await Promise.resolve();
queueMicrotask(() => loop.wake());
})
.mockResolvedValue(undefined);
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(1);
expect(tick).toHaveBeenCalledTimes(2);
await loop.stop();
});
it('never runs two ticks at once across stop() and start()', async () => {
const gate = deferred();
let active = 0;
let maxActive = 0;
const tick = jest
.fn()
.mockImplementationOnce(async () => {
active++;
maxActive = Math.max(maxActive, active);
await gate.promise;
active--;
})
.mockImplementation(() => {
active++;
maxActive = Math.max(maxActive, active);
active--;
return Promise.resolve();
});
const loop = createLoop(tick);
loop.start();
await jest.advanceTimersByTimeAsync(0);
void loop.stop();
loop.start();
await jest.advanceTimersByTimeAsync(INTERVAL_MS);
expect(tick).toHaveBeenCalledTimes(1);
gate.resolve();
await jest.advanceTimersByTimeAsync(1);
expect(tick).toHaveBeenCalledTimes(2);
expect(maxActive).toBe(1);
await loop.stop();
});
});
@@ -0,0 +1,81 @@
import { Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
export interface PollingLoopOptions {
name: string;
intervalMs: number;
tick: () => Promise<unknown>;
logger: Logger;
}
/**
* Single-flight polling loop. The next tick is scheduled with setTimeout
* only after the previous one finished (no setInterval), so ticks never
* overlap. `wake()` skips the wait; a wake during a running tick leads to
* exactly one more tick right after it.
*/
export class PollingLoop {
private timer: NodeJS.Timeout | undefined;
private running: Promise<void> | undefined;
private rerunRequested = false;
private stopped = true;
constructor(private readonly options: PollingLoopOptions) {}
start(): void {
if (!this.stopped) return;
this.stopped = false;
// A tick of the previous run may still be finishing; its bookkeeping
// schedules the next one, so ticks never overlap across stop()/start().
// The first tick after start() runs immediately, also in that case.
if (this.running) this.rerunRequested = true;
else this.schedule(0);
}
wake(): void {
if (this.stopped) return;
if (this.running) {
this.rerunRequested = true;
return;
}
this.schedule(0);
}
async stop(): Promise<void> {
this.stopped = true;
this.rerunRequested = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
await this.running;
}
private schedule(delayMs: number): void {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = undefined;
this.running = this.runTick().finally(() => {
this.running = undefined;
// Decided after `running` is cleared: a wake() before this point
// set rerunRequested, one after it schedules itself.
if (!this.stopped) {
this.schedule(this.rerunRequested ? 0 : this.options.intervalMs);
}
});
}, delayMs);
}
private async runTick(): Promise<void> {
this.rerunRequested = false;
try {
await this.options.tick();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.options.logger.error(
`${this.options.name} tick failed: ${message}`,
stack,
);
}
}
}
@@ -0,0 +1,44 @@
import type { Server } from 'node:http';
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { startWorkerHealthServer, workerHealthPort } from './worker-health';
import { WorkerRuntime } from './worker-runtime.service';
/**
* GET /health of the worker process on WORKER_HEALTH_PORT (default 3002),
* for the kubelet only - no Service, no Ingress. Only WorkerModule provides
* it; in "all" the API's own /health endpoints apply.
*/
@Injectable()
export class WorkerHealthServer
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(WorkerHealthServer.name);
private server: Server | undefined;
constructor(private readonly runtime: WorkerRuntime) {}
async onApplicationBootstrap(): Promise<void> {
const port = workerHealthPort();
this.server = await startWorkerHealthServer({
port,
check: () => this.runtime.health(),
});
this.logger.log(`Worker health endpoint listening on port ${port}`);
}
async onApplicationShutdown(): Promise<void> {
const server = this.server;
this.server = undefined;
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
@@ -0,0 +1,135 @@
import * as http from 'node:http';
import { AddressInfo } from 'node:net';
import { Logger } from '@nestjs/common';
import {
DEFAULT_WORKER_HEALTH_PORT,
HEALTH_STALE_MS,
startWorkerHealthServer,
WorkerHealth,
workerHealthPort,
} from './worker-health';
interface JsonResponse {
status: number;
body: unknown;
}
describe('worker health', () => {
let server: http.Server | undefined;
afterEach(async () => {
jest.restoreAllMocks();
const running = server;
server = undefined;
if (running) {
await new Promise((resolve) => running.close(resolve));
}
});
async function start(check: () => Promise<WorkerHealth>): Promise<number> {
server = await startWorkerHealthServer({ port: 0, check });
return (server.address() as AddressInfo).port;
}
function request(
port: number,
path: string,
method = 'GET',
): Promise<JsonResponse> {
return new Promise((resolve, reject) => {
const req = http.request({ port, path, method }, (res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: raw ? (JSON.parse(raw) as unknown) : undefined,
}),
);
});
req.on('error', reject);
req.end();
});
}
it('exposes the documented constants', () => {
expect(HEALTH_STALE_MS).toBe(120_000);
expect(DEFAULT_WORKER_HEALTH_PORT).toBe(3002);
});
it('answers 200 {"status":"ok"} on GET /health when healthy', async () => {
const port = await start(() => Promise.resolve({ ok: true }));
expect(await request(port, '/health')).toEqual({
status: 200,
body: { status: 'ok' },
});
});
it('answers 503 with only status and reason when unhealthy', async () => {
const port = await start(() =>
Promise.resolve({ ok: false, reason: 'dispatcher-stale' }),
);
expect(await request(port, '/health')).toEqual({
status: 503,
body: { status: 'unhealthy', reason: 'dispatcher-stale' },
});
});
it('answers 404 for any other path or method', async () => {
const port = await start(() => Promise.resolve({ ok: true }));
expect((await request(port, '/')).status).toBe(404);
expect((await request(port, '/health/ready')).status).toBe(404);
expect((await request(port, '/health', 'POST')).status).toBe(404);
});
it('sends no JSON content type with a 404', async () => {
const port = await start(() => Promise.resolve({ ok: true }));
const headers = await new Promise<http.IncomingHttpHeaders>((resolve) => {
http.get({ port, path: '/nope' }, (res) => {
res.resume();
resolve(res.headers);
});
});
expect(headers['content-type']).toBeUndefined();
});
it('treats a throwing check as 503 database', async () => {
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const port = await start(() => Promise.reject(new Error('pw=hunter2')));
expect(await request(port, '/health')).toEqual({
status: 503,
body: { status: 'unhealthy', reason: 'database' },
});
expect(error).toHaveBeenCalled();
});
describe('workerHealthPort', () => {
it('defaults to 3002', () => {
expect(workerHealthPort({})).toBe(3002);
});
it('reads WORKER_HEALTH_PORT', () => {
expect(workerHealthPort({ WORKER_HEALTH_PORT: '4100' })).toBe(4100);
});
it.each([
'abc',
'0',
'-1',
'70000',
'30.5',
'',
'0x10',
'1e3',
' 3002',
'3002 ',
'+3002',
])('rejects invalid value %p', (value) => {
expect(() => workerHealthPort({ WORKER_HEALTH_PORT: value })).toThrow(
'WORKER_HEALTH_PORT',
);
});
});
});
@@ -0,0 +1,94 @@
import * as http from 'node:http';
import { Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
/** The dispatcher must have completed a run within this window. */
export const HEALTH_STALE_MS = 120_000;
export const DEFAULT_WORKER_HEALTH_PORT = 3002;
const MAX_PORT = 65_535;
const HEALTH_PATH = '/health';
export interface WorkerHealth {
ok: boolean;
reason?: 'database' | 'dispatcher-stale';
}
export interface WorkerHealthServerOptions {
port: number;
check: () => Promise<WorkerHealth>;
}
const logger = new Logger('WorkerHealth');
export function workerHealthPort(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.WORKER_HEALTH_PORT;
if (raw === undefined) return DEFAULT_WORKER_HEALTH_PORT;
const port = /^\d+$/.test(raw) ? Number(raw) : 0;
if (port < 1 || port > MAX_PORT) {
throw new Error(
`WORKER_HEALTH_PORT must be an integer from 1 to ${MAX_PORT}`,
);
}
return port;
}
async function evaluate(
check: () => Promise<WorkerHealth>,
): Promise<WorkerHealth> {
try {
return await check();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Worker health check failed: ${message}`, stack);
return { ok: false, reason: 'database' };
}
}
function respond(
res: http.ServerResponse,
status: number,
body?: Record<string, string>,
): void {
const headers: http.OutgoingHttpHeaders = { 'Cache-Control': 'no-store' };
if (body) headers['Content-Type'] = 'application/json';
res.writeHead(status, headers);
res.end(body ? JSON.stringify(body) : undefined);
}
/** Minimal probe endpoint (node:http, no Express): GET /health only. */
export async function startWorkerHealthServer(
options: WorkerHealthServerOptions,
): Promise<http.Server> {
const server = http.createServer((req, res) => {
const path = (req.url ?? '').split('?')[0];
if (req.method !== 'GET' || path !== HEALTH_PATH) {
respond(res, 404);
return;
}
evaluate(options.check)
.then((health) => {
if (health.ok) {
respond(res, 200, { status: 'ok' });
} else {
respond(res, 503, {
status: 'unhealthy',
reason: health.reason ?? 'database',
});
}
})
.catch((error: unknown) => {
const { message, stack } = loggableError(error);
logger.error(`Worker health response failed: ${message}`, stack);
res.destroy();
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(options.port, () => {
server.off('error', reject);
resolve();
});
});
return server;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { WorkerRuntime } from './worker-runtime.service';
/**
* The worker loops. Imported by WorkerModule (dist/worker.js) and
* AllInOneModule ("all"), never by AppModule: API pods run no background
* loops. Its dependencies come from the global Prisma, DomainEvents and
* CalendarSync modules of the importing root module.
*/
@Module({
providers: [WorkerRuntime],
exports: [WorkerRuntime],
})
export class WorkerRuntimeModule {}
@@ -0,0 +1,330 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import type { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import type { WakeChannel } from '../common/worker-wakeup';
import {
DomainEventDispatcher,
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import type { PrismaService } from '../prisma/prisma.service';
import { PgListener, PgListenerOptions } from './pg-listener';
import { HEALTH_STALE_MS } from './worker-health';
import {
CALENDAR_OUTBOX_POLL_MS,
DOMAIN_EVENT_POLL_MS,
HEALTH_QUERY_TIMEOUT_MS,
WorkerRuntime,
} from './worker-runtime.service';
interface FakeListener {
start: jest.Mock<Promise<void>, []>;
stop: jest.Mock<Promise<void>, []>;
}
jest.mock('./pg-listener', () => ({ PgListener: jest.fn() }));
const DATABASE_URL = 'postgresql://worker:secret@db/app';
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
/** Advances lastTickAt like the real dispatcher: a new stamp per run. */
class FakeDispatcher {
lastTickAt: Date | null = null;
releaseStaleClaims = jest.fn<Promise<number>, []>().mockResolvedValue(0);
drain = jest.fn<Promise<number>, unknown[]>(() => {
this.tick();
return Promise.resolve(0);
});
tick(): void {
this.lastTickAt = new Date();
}
}
describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL;
let listener: FakeListener;
let prisma: { $queryRaw: jest.Mock };
let dispatcher: FakeDispatcher;
let calendarSync: {
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
};
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
const listenerOptions = (): PgListenerOptions =>
(ListenerMock.mock.calls[0] as [PgListenerOptions])[0];
const notify = (channel: WakeChannel) => listenerOptions().onNotify(channel);
/** Bootstraps the runtime and lets the first ticks run. */
async function bootstrap(): Promise<void> {
await runtime.onApplicationBootstrap();
await jest.advanceTimersByTimeAsync(0);
}
beforeEach(() => {
jest.useFakeTimers();
process.env.DATABASE_URL = DATABASE_URL;
listener = {
start: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
stop: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
};
ListenerMock.mockReset().mockImplementation(() => listener);
prisma = { $queryRaw: jest.fn().mockResolvedValue([{ ok: 1 }]) };
dispatcher = new FakeDispatcher();
calendarSync = {
processOutbox: jest
.fn<Promise<void>, [AbortSignal?]>()
.mockResolvedValue(undefined),
};
logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
runtime = new WorkerRuntime(
prisma as unknown as PrismaService,
dispatcher as unknown as DomainEventDispatcher,
calendarSync as unknown as CalendarSyncService,
);
});
afterEach(async () => {
await runtime.onModuleDestroy();
jest.restoreAllMocks();
jest.useRealTimers();
// Assigning undefined would store the string "undefined".
if (originalDatabaseUrl === undefined) {
delete process.env.DATABASE_URL;
} else {
process.env.DATABASE_URL = originalDatabaseUrl;
}
});
it('exposes the documented poll intervals', () => {
expect(DOMAIN_EVENT_POLL_MS).toBe(5_000);
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
});
it('starts the listener and every loop at bootstrap', async () => {
await runtime.onApplicationBootstrap();
expect(ListenerMock).toHaveBeenCalledWith(
expect.objectContaining({
connectionString: DATABASE_URL,
channels: ['domain_event', 'calendar_sync'],
}),
);
expect(listener.start).toHaveBeenCalledTimes(1);
// LISTEN is up before the first tick: nothing committed in between
// can be missed until the next poll.
expect(dispatcher.drain).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(0);
expect(dispatcher.releaseStaleClaims).toHaveBeenCalledTimes(1);
expect(dispatcher.drain).toHaveBeenCalledTimes(1);
// Every event of the database (no filter), stoppable by shutdown.
expect(dispatcher.drain).toHaveBeenCalledWith(
undefined,
DRAIN_MAX_ROUNDS,
expect.any(AbortSignal),
);
const [releaseOrder] =
dispatcher.releaseStaleClaims.mock.invocationCallOrder;
const [drainOrder] = dispatcher.drain.mock.invocationCallOrder;
expect(releaseOrder).toBeLessThan(drainOrder);
expect(calendarSync.processOutbox).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
});
it('polls domain events every 5 s and the calendar outbox every 60 s', async () => {
await bootstrap();
await jest.advanceTimersByTimeAsync(DOMAIN_EVENT_POLL_MS);
expect(dispatcher.drain).toHaveBeenCalledTimes(2);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(
CALENDAR_OUTBOX_POLL_MS - DOMAIN_EVENT_POLL_MS,
);
expect(dispatcher.drain).toHaveBeenCalledTimes(
CALENDAR_OUTBOX_POLL_MS / DOMAIN_EVENT_POLL_MS + 1,
);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(2);
});
it('wakes the domain-events loop on a domain_event notification', async () => {
await bootstrap();
notify('domain_event');
await jest.advanceTimersByTimeAsync(0);
expect(dispatcher.drain).toHaveBeenCalledTimes(2);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1);
});
it('wakes the calendar-outbox loop on a calendar_sync notification', async () => {
await bootstrap();
notify('calendar_sync');
await jest.advanceTimersByTimeAsync(0);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(2);
expect(dispatcher.drain).toHaveBeenCalledTimes(1);
});
it('is healthy after a recent tick and dispatcher-stale after 120 s without one', async () => {
await bootstrap();
expect(await runtime.health()).toEqual({ ok: true });
// From the next tick on the dispatcher hangs and stamps nothing more.
const stuck = deferred();
dispatcher.drain.mockReturnValue(stuck.promise.then(() => 0));
await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS);
expect(await runtime.health()).toEqual({ ok: true });
await jest.advanceTimersByTimeAsync(1);
expect(await runtime.health()).toEqual({
ok: false,
reason: 'dispatcher-stale',
});
stuck.resolve();
});
// lastTickAt advances per dispatched event: a long batch is progress.
it('stays healthy while a long batch keeps dispatching events', async () => {
await bootstrap();
const batch = deferred();
dispatcher.drain.mockReturnValue(batch.promise.then(() => 0));
await jest.advanceTimersByTimeAsync(DOMAIN_EVENT_POLL_MS);
await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS - 1_000);
dispatcher.tick(); // one more event of the running batch dispatched
expect(await runtime.health()).toEqual({ ok: true });
await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS);
expect(await runtime.health()).toEqual({ ok: true });
await jest.advanceTimersByTimeAsync(1);
expect(await runtime.health()).toEqual({
ok: false,
reason: 'dispatcher-stale',
});
batch.resolve();
});
// A wall-clock step must neither fake nor hide a stall.
it('measures the dispatcher age on the monotonic clock', async () => {
await bootstrap();
const stuck = deferred();
dispatcher.drain.mockReturnValue(stuck.promise.then(() => 0));
jest.setSystemTime(Date.now() + 10 * HEALTH_STALE_MS);
expect(await runtime.health()).toEqual({ ok: true });
stuck.resolve();
});
it('counts the time since bootstrap while the dispatcher never ran', async () => {
dispatcher.drain.mockRejectedValue(new Error('database down'));
await bootstrap();
await jest.advanceTimersByTimeAsync(HEALTH_STALE_MS);
expect(await runtime.health()).toEqual({ ok: true });
await jest.advanceTimersByTimeAsync(1);
expect(await runtime.health()).toEqual({
ok: false,
reason: 'dispatcher-stale',
});
});
it('reports database when SELECT 1 fails or exceeds 5 s', async () => {
await bootstrap();
prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused'));
expect(await runtime.health()).toEqual({ ok: false, reason: 'database' });
prisma.$queryRaw.mockReturnValueOnce(new Promise(() => undefined));
const slow = runtime.health();
await jest.advanceTimersByTimeAsync(HEALTH_QUERY_TIMEOUT_MS);
await expect(slow).resolves.toEqual({ ok: false, reason: 'database' });
});
it('logs a failed health query without the Prisma message', async () => {
await bootstrap();
prisma.$queryRaw.mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError('password "hunter2"', {
code: 'P1001',
clientVersion: '7.9.1',
}),
);
await runtime.health();
const [message, stack] = logError.mock.calls[0] as [string, unknown];
expect(message).toContain('P1001');
expect(message).not.toContain('hunter2');
expect(stack).toBeUndefined();
});
// A running tick must not claim up to 19 more batches of 50 events or
// 19 more calendar entries while the pod is being stopped.
it('hands the running work a stop signal that shutdown aborts', async () => {
await bootstrap();
const [, , drainSignal] = dispatcher.drain.mock.calls[0] as [
unknown,
unknown,
AbortSignal,
];
const [outboxSignal] = calendarSync.processOutbox.mock.calls[0];
expect(drainSignal.aborted).toBe(false);
expect(outboxSignal).toBe(drainSignal);
await runtime.onModuleDestroy();
expect(drainSignal.aborted).toBe(true);
});
it('stops loops and listener on shutdown', async () => {
await bootstrap();
await runtime.onModuleDestroy();
expect(listener.stop).toHaveBeenCalledTimes(1);
notify('domain_event');
await jest.advanceTimersByTimeAsync(CALENDAR_OUTBOX_POLL_MS * 2);
expect(dispatcher.drain).toHaveBeenCalledTimes(1);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1);
});
// A pass cut off mid-entry would only be taken over after the 5 min
// stale-claim window - and Prisma disconnects right after this hook.
it('lets shutdown wait for a running tick', async () => {
const pass = deferred();
calendarSync.processOutbox.mockReturnValueOnce(pass.promise);
await bootstrap();
let stopped = false;
const shutdown = runtime.onModuleDestroy().then(() => {
stopped = true;
});
await jest.advanceTimersByTimeAsync(0);
expect(stopped).toBe(false);
// The running pass has been told to stop after its current entry.
expect(calendarSync.processOutbox.mock.calls[0][0]?.aborted).toBe(true);
pass.resolve();
await shutdown;
expect(stopped).toBe(true);
});
});
@@ -0,0 +1,175 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnModuleDestroy,
} from '@nestjs/common';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { loggableError } from '../common/loggable-error';
import { withTimeout } from '../common/with-timeout';
import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup';
import {
DomainEventDispatcher,
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import { PrismaService } from '../prisma/prisma.service';
import { PgListener } from './pg-listener';
import { PollingLoop } from './polling-loop';
import { HEALTH_STALE_MS, WorkerHealth } from './worker-health';
export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
/** The health check's SELECT 1 must answer within this. */
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
/** One background loop: its poll interval and the channel that wakes it. */
interface WorkerLoop {
name: string;
intervalMs: number;
channel: WakeChannel;
tick: () => Promise<unknown>;
}
function databaseUrl(): string {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is required for the LISTEN connection');
}
return url;
}
/**
* The background work of the worker role (dist/worker.js; also "all" for
* local development and tests): one single-flight polling loop per outbox,
* woken early through LISTEN/NOTIFY. Every loop claims its rows, so more
* worker pods are safe. The API role never loads this module
* (runtime/root-module.ts).
*/
@Injectable()
export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(WorkerRuntime.name);
private readonly loops: ReadonlyMap<WakeChannel, PollingLoop>;
private listener: PgListener | undefined;
/** Aborted at shutdown: running ticks claim no further work. */
private readonly stopping = new AbortController();
/** Monotonic time (performance.now) of the last dispatcher progress. */
private progressAt = performance.now();
/** The dispatcher's lastTickAt as last seen; a new value is progress. */
private seenTickAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly dispatcher: DomainEventDispatcher,
private readonly calendarSync: CalendarSyncService,
) {
this.loops = new Map(
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
channel,
new PollingLoop({ name, intervalMs, tick, logger: this.logger }),
]),
);
}
/** One row per outbox the worker serves. */
private workerLoops(): readonly WorkerLoop[] {
return [
{
name: 'domain-events',
intervalMs: DOMAIN_EVENT_POLL_MS,
channel: WAKE_CHANNELS.domainEvents,
tick: () => this.dispatchDomainEvents(),
},
{
name: 'calendar-outbox',
intervalMs: CALENDAR_OUTBOX_POLL_MS,
channel: WAKE_CHANNELS.calendarSync,
tick: () => this.calendarSync.processOutbox(this.stopping.signal),
},
];
}
async onApplicationBootstrap(): Promise<void> {
this.progressAt = performance.now();
this.listener = new PgListener({
connectionString: databaseUrl(),
channels: [...this.loops.keys()],
onNotify: (channel) => this.loops.get(channel)?.wake(),
logger: this.logger,
});
// LISTEN first: work committed from now on wakes a loop, and work
// committed before is found by the first ticks, which run right away.
await this.listener.start();
this.loops.forEach((loop) => loop.start());
}
// Not onApplicationShutdown: Nest runs every onModuleDestroy first, and
// PrismaService disconnects there. A running tick finishes the entry or
// batch it holds but claims nothing more; stopping waits for it - a tick
// cut off mid-entry leaves a claim that is only taken over after the
// 5 min stale-claim window.
async onModuleDestroy(): Promise<void> {
this.stopping.abort();
const listener = this.listener;
this.listener = undefined;
await listener?.stop();
await Promise.all([...this.loops.values()].map((loop) => loop.stop()));
}
/**
* Unhealthy if the database does not answer SELECT 1 within 5 s, or if
* the dispatcher has made no progress for longer than HEALTH_STALE_MS.
*/
async health(): Promise<WorkerHealth> {
try {
await withTimeout(
this.prisma.$queryRaw`SELECT 1`,
HEALTH_QUERY_TIMEOUT_MS,
'Worker health query timed out',
);
} catch (error: unknown) {
// Prisma errors by name/code/model only (see loggableError).
const { message, stack } = loggableError(error);
this.logger.error(`Worker health query failed: ${message}`, stack);
return { ok: false, reason: 'database' };
}
const idleMs = performance.now() - this.noteDispatcherProgress();
if (idleMs > HEALTH_STALE_MS) {
this.logger.warn(
`Domain event dispatcher made no progress for ${Math.round(idleMs)} ms`,
);
return { ok: false, reason: 'dispatcher-stale' };
}
return { ok: true };
}
/**
* The dispatcher stamps lastTickAt with the wall clock on every claim and
* every dispatched event. Its age is taken on the monotonic clock instead,
* so a clock step can neither fake nor hide a stall: a new stamp counts
* from the moment it is first seen - after every tick, and on every
* health check while a long batch is still running. Before the first
* stamp, the time since bootstrap counts.
*/
private noteDispatcherProgress(): number {
const tickAt = this.dispatcher.lastTickAt;
if (tickAt !== this.seenTickAt) {
this.seenTickAt = tickAt;
this.progressAt = performance.now();
}
return this.progressAt;
}
private async dispatchDomainEvents(): Promise<void> {
try {
await this.dispatcher.releaseStaleClaims();
// No filter: every event of the database.
await this.dispatcher.drain(
undefined,
DRAIN_MAX_ROUNDS,
this.stopping.signal,
);
} finally {
this.noteDispatcherProgress();
}
}
}
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { AutomationModule } from '../automation/automation.module';
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { TasksModule } from '../tasks/tasks.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { WorkerHealthServer } from './worker-health-server.service';
import { WorkerRuntimeModule } from './worker-runtime.module';
/**
* Root module of dist/worker.js, started with
* NestFactory.createApplicationContext: the modules the worker loops and
* the domain event handlers need, without the HTTP layer (no guards,
* interceptors, throttler or routes - controllers of the imported modules
* are never served). The only port is WorkerHealthServer's GET /health.
*/
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
NotificationsModule,
DomainEventsModule,
WebhooksModule,
CalendarSyncModule,
TasksModule,
AutomationModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],
})
export class WorkerModule {}