Neues README im Root mit Zweck, Aufbau, Start, Prüfbefehlen, Betrieb und Projektstand (Issues #44, #43). Backend- und Frontend-README ersetzen die Framework-Vorlagen. CLAUDE.md: Ordnerstruktur und echte Arbeitsweise (main, gebündelt pushen, Rollout nur mit Freigabe). ARCHITECTURE §7/§8: Live-ConfigMap, ensure-secrets.sh, korrektes Admin-Seed-Verhalten. .env.example um BACKUP_MAX_IMPORT_BYTES und SEED_ADMIN_* ergänzt. Statuszeilen in Plänen Rückgrat, T3–T6 und Specs Fahrplan, T7. Refs #42 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
124 KiB
Plattform-Rückgrat Implementation Plan
Status (2026-10-16): Alle 26 Tasks umgesetzt und gemergt (Commits c01a388, cdd30cf, Merge 7097fcd). Nicht ausgerollt; das Rollout-Runbook unten ist noch bei keinem Schritt angewendet. Issues: #13 (Paket), #28 (Rollout).
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: ApiCore läuft aus einem Image in drei Rollen (api, worker, job). Fachliche Folgen hängen nicht mehr an direkten Service-Aufrufen nach dem Commit, sondern an einer dauerhaften Outbox (DomainEvent), die der Worker genau einmal je Handler zustellt. Zeitgesteuerte Arbeit läuft als Kubernetes-CronJob. Rate-Limits zählen über alle Pods. Schlüssel sind je Zweck getrennt. Die öffentliche Herkunftsseite verrät keinen Standort. Die Manifeste in deploy/k8s werden wieder zur Quelle der Wahrheit.
Architecture: NestJS-Backend mit drei Einstiegspunkten: dist/main.js (api/all), dist/worker.js (worker) und dist/cli.js <job> (job). Fachservices schreiben Audit-Eintrag und DomainEvent in derselben Prisma-Transaktion und wecken den Worker per pg_notify. Der Worker übernimmt Ereignisse mit FOR UPDATE SKIP LOCKED und ruft per @OnDomainEvent registrierte Handler auf. Jeder Handler läuft in einer eigenen Transaktion, die seine DomainEventDelivery-Zeile sperrt. Handler schreiben nur in die DB; Netzwerkarbeit liegt in eigenen Outboxen (CalendarSyncOutboxEntry, WebhookDelivery), die Worker-Schleifen abarbeiten. Die Jobs schreiben JobRun. Die Seite „Verwaltung > System“ zeigt Läufe, Warteschlange und Fehler. Kustomize mit base, Komponenten je Rollout-Schritt und Overlay encry.
Tech Stack: NestJS 11, Prisma 7.9 (@prisma/adapter-pg, pg 8), PostgreSQL 18, @nestjs/throttler 6.5, cron 4.4, Jest 30 (Unit + e2e gegen Test-DB), Next.js App Router + TanStack Query + Vitest/RTL, Kustomize, kubeconform, Gitea Actions.
Spec: docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md, Abschnitte 2–9. Abschnitt 10 ist nicht Teil dieses Plans.
Global Constraints
Rollen und Prozesse
APICORE_ROLE∈api | worker | job | all. Ohne Wert gilt außerhalb von Produktion je Einstieg:main→all,worker→worker,cli→job.- Bei
NODE_ENV=production:APICORE_ROLEist Pflicht,allwird abgelehnt, eine Rolle, die nicht zum Einstiegspunkt passt, wird abgelehnt. - Einstiege:
node dist/main.js(api/all),node dist/worker.js(worker),node dist/cli.js <job> [args](job). Exit-Codes von cli:0erfolgreich,1Job gescheitert,2falscher Aufruf. - Jobs:
daily-evaluation,task-templates,cleanup,app-backup,rekey(dazurekey --dry-run).
Worker
- Health:
GET /healthaufWORKER_HEALTH_PORT(Standard3002).200 {"status":"ok"}, wennSELECT 1in ≤ 5 s antwortet und der letzte Dispatcher-Durchlauf ≤ 120 000 ms her ist. Sonst503 {"status":"unhealthy","reason":"database"|"dispatcher-stale"}. - Schleifen:
- Ereignisse: alle 5 000 ms, Weckkanal
domain_event. - Webhook-Zustellung: alle 5 000 ms, Weckkanal
webhook_delivery. - Kalender-Outbox: alle 60 000 ms, Weckkanal
calendar_sync.
- Ereignisse: alle 5 000 ms, Weckkanal
pg_notify-Payload ist immer'', also keine Daten im Kanal.- Wiederverbinden des LISTEN-Clients: 1 s, verdoppelt, höchstens 30 s.
Dispatcher und Outbox
- Batch 50, Claim per
UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED LIMIT 50) RETURNING *. - Wiederholabstand nach Fehlversuch n: 30 s, 2 min, 10 min, 1 h, ab n=5 jeweils 6 h (
RETRY_DELAYS_MS = [30_000, 120_000, 600_000, 3_600_000, 21_600_000]). - Nach 8 Versuchen (
MAX_ATTEMPTS = 8) →FAILED. - Liegengebliebener Claim:
claimedAtälter als 5 min (STALE_CLAIM_MS = 300_000). Er wird mitattempts + 1sofort wiederPENDING, ab 8 VersuchenFAILED. - Handler-Transaktion: Timeout 10 000 ms.
drain()macht höchstens 20 Runden je Takt. - Alle Zeitvergleiche und Zeitrechnungen für Claims, Backoff und Throttler laufen in SQL mit
now()(DB-Uhr), nie mit einem JS-Dategegen DB-Zeitstempel. - Handler sind reine DB-Schreiber: nur über den übergebenen
txschreiben, keine HTTP- oder Graph-Aufrufe. Lesen übertxoderPrismaServiceist erlaubt. - Zeitbasierte Ereignisse:
dedupeKey = "<type>:<aggregateId>:<YYYY-MM-DD>"(UTC-Tag des fachlichen Ereignisses), eindeutig. Nachholfenster 7 Tage.
Webhooks
- Gleiche Wiederholregel wie der Dispatcher, Batch 50, höchstens 5 gleichzeitig, 5 s Timeout (wie bisher).
- Nur
2xxgilt als zugestellt. X-ApiCore-Deliveryist dieWebhookDelivery.idund bleibt über alle Wiederholungen gleich.- Signatur wie bisher über
"<timestamp>.<deliveryId>.<body>".
Kalender-Outbox: unverändert MAX_ATTEMPTS = 5, BATCH_SIZE = 20. Sie läuft im Worker.
Throttler: UNLOGGED-Tabelle ThrottlerHit, PK (key, throttlerName), eine einzige Upsert-Anweisung mit now(). Grenzen unverändert: Standard 100/min, sensible Aktionen 10/min, öffentliche Chargen 30/min.
Schlüssel
MFA_ENCRYPTION_KEY,WEBHOOK_ENCRYPTION_KEY,BACKUP_ENCRYPTION_KEYsind in Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden vonJWT_ACCESS_SECRET/JWT_REFRESH_SECRET.- Optional der vorige Schlüssel als
*_PREVIOUS. - Chiffrat-Format
k<8 hex>:<iv>.<tag>.<data>(Base64-Teile), AES-256-GCM, Schlüssel per HKDF-SHA256 je Zweck.
Öffentliche Herkunftsseite: Cache-Control: public, max-age=300, s-maxage=3600 nur auf 200 von GET /api/v1/public/batches/:lotNumber (Route public/batches/:lotNumber, siehe public.controller.ts). Alle anderen Antworten no-store.
Ressourcen (Spec §7)
| Pod | CPU request | CPU limit | RAM request | RAM limit |
|---|---|---|---|---|
| api | 50m | 500m | 192Mi | 512Mi |
| worker | 20m | 200m | 128Mi | 256Mi |
| frontend | 30m | 300m | 128Mi | 256Mi |
| CronJob-Pods und initContainer | 50m | 500m | 192Mi | 512Mi |
- HPA
api: 1–2 Pods bei 70 % CPU.
CronJobs
- Alle mit
timeZone: Europe/Berlin,concurrencyPolicy: Forbid,startingDeadlineSeconds: 600,successfulJobsHistoryLimit: 3,failedJobsHistoryLimit: 3,ttlSecondsAfterFinished: 86400. - Zeitpläne:
cleanup:0 4 * * *daily-evaluation:30 4 * * *task-templates:0 5 * * *app-backup:0 * * * *(stündliche Prüfung, ob fällig)rekeyundrekey-dry-run:suspend: true,0 0 31 2 *
backoffLimit: 1fürcleanup,daily-evaluation,task-templates;0fürapp-backupundrekey.activeDeadlineSeconds1800, fürapp-backup3600.
Aufbewahrung (cleanup): DONE-Ereignisse und DELIVERED-Zustellungen 30 Tage, FAILED 90 Tage, JobRun 90 Tage. RUNNING-Läufe älter als 6 h → FAILED („abgebrochen“).
Cluster
- Namespace
apicore,ingressClassName: traefik, Annotationtraefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd. - Labels und Selektoren entsprechen dem Live-Stand (read-only geprüft; z. B. Postgres
app.kubernetes.io/name: apicore-postgres, Service-Portpostgres) – Ruling 2026-10-13 nach Review Task 26, StatefulSet-Selektoren sind unveränderlich. Neu:app: apicore-worker,app: apicore-job+apicore.io/job: <name>. - DB-Zugriff nur für Pods mit
db-client: "true".
Projektregeln
- Sicherheit hat Priorität 1.
- Konsistenter Code ist oberstes Gesetz: ein Muster, gemeinsame Helfer:
retry-policy.ts,outbox-claims.ts,notifyWorker,withTimeout,loggableError,DbClient.- Pflicht-
txsteht als erster Parameter, optionalertxals letzter.
- Jedes Feld ist bearbeitbar (Null-Regel, ARCHITECTURE Annahme 20,
update-dto-null-handling.spec.ts). - TDD: zuerst der fehlschlagende Test.
- UI-Texte auf Deutsch; Code-Bezeichner, Log- und Fehlermeldungen auf Englisch wie im Bestand.
lastErrorund Logs nur überloggableError(), nie Payload, Secret oder Antwortkörper.- Kein
prisma db push. Migrationen als SQL inprisma/migrations/<YYYYMMDDHHMMSS>_<name>/migration.sql. Erzeugt werden sie mitprisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --scriptgegen die Test-DB und danach von Hand nachgearbeitet.migrate devnicht gegen geteilte DBs. - Agents committen nicht. Der Orchestrator macht am Ende einen zentralen Commit. Darum gibt es in den Tasks keinen Commit-Schritt, sondern am Ende jedes Tasks einen Schritt „Verifikation“.
- e2e-Tests laufen gegen die lokale Test-DB aus
.env.test(localhost:5433).- Test-DB migrieren (Git Bash, in
ApiCore-Backend/):export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate - Einzelne e2e-Datei:
npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand - Unit:
npx jest <pfad>. Lint wie im Image-Gate:npx eslint "{src,test}/**/*.ts". Typecheck:npm run typecheck.
- Test-DB migrieren (Git Bash, in
- Frontend: vor Next.js-Code
node_modules/next/dist/docs/lesen (sieheApiCore-Frontend/AGENTS.md). Tests mitnpx vitest run "<pfad>", dazunpm run lintundnpm run typecheck. - Neue Abhängigkeiten nur:
pg+@types/pg(Task 5) undcrondirekt statt@nestjs/schedule(Task 17). ARCHITECTURE.mdist eine geteilte Datei. Laufen mehrere Tasks einer Welle parallel, führt der Orchestrator deren Doku-Schritte nacheinander aus.
Review Focus
- Worker fällt stundenlang aus → Ereignisse stauen sich. Die API arbeitet weiter, der Rückstau muss in Reihenfolge und ohne Verhungern abfließen und in „Verwaltung > System“ sichtbar sein.
- Tests:
- Task 4 e2e
drains a backlog of 120 events in batches of at most 50 - Task 24 unit
reports the oldest pending age from the database clock - Task 18 unit
catches up occurrences of the last 7 days exactly once - Task 25 vitest
warns when the oldest open event is older than 10 minutes
- Task 4 e2e
- Tests:
- Handler nicht idempotent bei erneuter Zustellung (Stale-Release, zweiter Worker, Retry nach Teilfehler).
- Gegenmittel: Delivery-Zeile
FOR UPDATEplus Handler in derselben Transaktion, und Handler schreiben nur in die DB. - Tests:
- Task 4 e2e
two dispatchers in parallel produce exactly one effect per event and handler - Task 4 e2e
a stale-released event does not repeat a handler that already committed - Task 4 Wächter
handler files never import network clients - Task 11 e2e
creates exactly one automation task when the handler fails once and is retried
- Task 4 e2e
- Gegenmittel: Delivery-Zeile
rekeybricht mittendrin ab. Der Job muss wiederaufnehmbar sein, darf parallele Änderungen (neue MFA-Einrichtung) nicht überschreiben, und Altchiffrate bleiben lesbar, bis die JWT-Secrets rotiert sind.- Tests:
- Task 21 unit
resumes after an interruption: current rows are skipped, the rest are rekeyed - Task 21 unit
skips a row changed since it was read and reports it - Task 21 e2e
a second run rekeys nothing; dry-run writes nothing
- Task 21 unit
- Tests:
- NetworkPolicy sperrt DNS, Probes oder Postgres-Zugriffe.
- Tests: Task 26
manifest-policy.test.mjs:- jeder Pod hat DNS-Egress zu
k8s-app: kube-dnsUDP/TCP 53 - Postgres-Ingress nur von
db-client: "true" - Frontend ohne Weg zu 5432
- 443-Egress schließt private Bereiche aus
- Default-Deny vorhanden
- jeder Pod hat DNS-Egress zu
- Dazu Prüfbefehle im Runbook, Schritt 2: Probes,
getent, Frontend→Postgres blockiert.
- Tests: Task 26
- Zwei API-Pods plus Throttler bei Uhrenabweichung. Die Zähler müssen über beide Instanzen gelten, nur mit der DB-Uhr rechnen, und parallele Treffer dürfen nicht verloren gehen. e2e-Dateien dürfen sich nicht gegenseitig drosseln.
- Tests:
- Task 19 unit
uses only the database clock (no JS dates in the statement) - Task 19 e2e
counts across two app instances sharing one namespace - Task 19 e2e
40 parallel requests over two instances yield exactly 10 × 429 - Task 19 e2e
separate namespaces never share counters
- Task 19 unit
- Tests:
Abhängigkeiten und Parallelisierung
| Welle | Tasks (parallel, verschiedene Dateien) | wartet auf |
|---|---|---|
| A | 1, 2, 23, 26 | – |
| B | 3, 5, 20, 22 | 3 → 2; 20 → 1; 22 → 2 |
| C | 4 | 3 |
| D | 6, 19 | 6 → 1, 4, 5; 19 → 2, 4 |
| E | 7, 8 | 6 |
| F | 9 | 3, 7, 8 |
| G | 10 | 9 |
| H | 11, 12, 13, 14, 15 | 10 (15 → 3, 9) |
| I | 16, 18, 21, 24 | 15 (21 → 20; 24 → 4, 7) |
| J | 17, 25 | 17 → 11–16; 25 → 24, 7 |
Bekannte geteilte Dateien, deshalb jeweils nur ein Task pro Welle:
src/app.module.ts: 3 (B), 19 (D), 15 (H), 17 (J)package.json: 5 (B), 6 (D), 17 (J)test/utils/create-test-app.ts: nur 19src/worker/worker-runtime.service.ts: 6 (D), 7 (E)src/automation/automation.service.ts: 10 (G), 17 (J). Die Producer-Tasks 11–14 fassen die Datei nicht an; ihre Alt-Wrapper entfernt Task 17.
Tasks
Task 1: Laufrollen und gemeinsame Startprüfungen
Files
- Create:
ApiCore-Backend/src/runtime/role.ts,ApiCore-Backend/src/runtime/startup-checks.ts - Test:
ApiCore-Backend/src/runtime/role.spec.ts,ApiCore-Backend/src/runtime/startup-checks.spec.ts - Modify:
ApiCore-Backend/src/main.ts:REQUIRED_ENV_VARS,MIN_JWT_SECRET_LENGTH,assertRequiredEnvVars,assertProductionSettingswandern nachstartup-checks.ts.ApiCore-Backend/.env.example:APICORE_ROLEundWORKER_HEALTH_PORTdokumentiert, auskommentiert.
Interfaces
- Consumes: –
- Produces:
export const APICORE_ROLES = ['api', 'worker', 'job', 'all'] as const;
export type ApicoreRole = (typeof APICORE_ROLES)[number];
export type EntryPoint = 'main' | 'worker' | 'cli';
export function resolveRole(entry: EntryPoint, env?: NodeJS.ProcessEnv): ApicoreRole;
export function assertStartupEnvironment(role: ApicoreRole, env?: NodeJS.ProcessEnv): void;
- Schritt 1: Failing Tests schreiben (
role.spec.ts,startup-checks.spec.ts)
it('defaults per entry point outside production', () => {
expect(resolveRole('main', {})).toBe('all');
expect(resolveRole('worker', {})).toBe('worker');
expect(resolveRole('cli', {})).toBe('job');
});
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'));
it('accepts api for main in production', () =>
expect(resolveRole('main', { NODE_ENV: 'production', APICORE_ROLE: 'api' })).toBe('api'));
// startup-checks.spec.ts – Verhalten 1:1 aus main.ts übernommen
it('lists missing DATABASE_URL', () =>
expect(() => assertStartupEnvironment('api', { JWT_ACCESS_SECRET: 'x'.repeat(32) })).toThrow('Missing required environment variable(s): DATABASE_URL'));
it('rejects a JWT secret shorter than 32 characters', ...);
it('requires an https FRONTEND_ORIGIN in production', ...);
it('rejects COOKIE_SECURE=false in production', ...);
- Schritt 2:
npx jest src/runtime→ FAIL („Cannot find module './role'“). - Schritt 3: Implementieren
- Erlaubte Rollen je Einstieg:
main: ['api','all'],worker: ['worker'],cli: ['job']. - Fehlermeldungen exakt wie in den Tests.
main.ts:const role = resolveRole('main'); assertStartupEnvironment(role);vorNestFactory.create. Die Modulwahl je Rolle folgt in Task 6.
- Erlaubte Rollen je Einstieg:
- Schritt 4:
npx jest src/runtime→ PASS. - Schritt 5: Verifikation
npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci- Kein Commit. Geänderte Dateien an den Orchestrator melden.
Task 2: Schema und Migration des Rückgrats
Files
- Modify:
ApiCore-Backend/prisma/schema.prisma - Create:
ApiCore-Backend/prisma/migrations/20261013100000_platform_backbone/migration.sql - Modify:
ApiCore-Backend/src/backup/backup.service.ts(MODELS_EXCLUDED_FROM_BACKUP) - Test:
ApiCore-Backend/src/backup/backup.service.spec.ts(bestehender Wächter), neuApiCore-Backend/test/platform-schema.e2e-spec.ts - Docs:
ARCHITECTURE.md§8, Bullet „Export/Import“: Ausnahmeliste um die fünf Modelle ergänzen.
Interfaces
- Produces (Prisma, ladungstragend):
enum DomainEventStatus { PENDING PROCESSING DONE FAILED }
model DomainEvent {
id String @id @default(cuid())
type String
aggregateType String
aggregateId String
payload Json
actorUserId String?
correlationId String
dedupeKey String? @unique
occurredAt DateTime @default(now())
status DomainEventStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
processedAt DateTime?
lastError String?
deliveries DomainEventDelivery[]
@@index([status, availableAt])
@@index([correlationId])
@@index([aggregateType, aggregateId])
}
enum DomainEventDeliveryStatus { PENDING DONE FAILED }
model DomainEventDelivery {
id String @id @default(cuid())
eventId String
event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
handler String
status DomainEventDeliveryStatus @default(PENDING)
attempts Int @default(0)
lastError String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, handler])
}
enum WebhookDeliveryStatus { PENDING PROCESSING DELIVERED FAILED }
model WebhookDelivery {
id String @id @default(cuid())
subscriptionId String
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
event String
body String
status WebhookDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([status, availableAt])
@@index([subscriptionId, createdAt])
}
enum JobRunStatus { RUNNING SUCCEEDED FAILED }
model JobRun {
id String @id @default(cuid())
job String
status JobRunStatus @default(RUNNING)
startedAt DateTime @default(now())
finishedAt DateTime?
result Json?
error String?
@@index([job, startedAt])
@@index([status])
}
// UNLOGGED in der Migration: kein WAL, nach Absturz geleert – nur Zähler gehen verloren.
model ThrottlerHit {
key String
throttlerName String
hits Int
expiresAt DateTime
blockedUntil DateTime?
@@id([key, throttlerName])
@@index([expiresAt])
}
-
Dazu:
WebhookSubscription.deliveries WebhookDelivery[]Apiary.publicLocationLabel String?SiteSettings.originCountry String @default("Deutschland")
-
dedupeKeyals nullbares@uniquestatt partiellem Index. Das umgeht die Drift, vor der Spec §3.5 warnt. -
Schritt 1: Failing Tests
- Erst die Modelle in
schema.prismaeintragen undnpx prisma generatelaufen lassen. Danach muss der bestehende Wächter scheitern. - Neu
test/platform-schema.e2e-spec.ts:
- Erst die Modelle in
it('creates ThrottlerHit as an UNLOGGED table', async () => {
const [row] = await prisma.$queryRaw<{ relpersistence: string }[]>`SELECT relpersistence FROM pg_class WHERE relname = 'ThrottlerHit'`;
expect(row.relpersistence).toBe('u');
});
it('keeps at most one event per dedupe key', async () => {
const data = { type: 'TaskDue', aggregateType: 'Task', aggregateId: `t-${suffix}`, payload: {}, correlationId: 'c', dedupeKey: `TaskDue:t-${suffix}:2026-10-13` };
expect((await prisma.domainEvent.createMany({ data: [data], skipDuplicates: true })).count).toBe(1);
expect((await prisma.domainEvent.createMany({ data: [data], skipDuplicates: true })).count).toBe(0);
});
- Schritt 2:
npx jest src/backup/backup.service.spec.ts→ FAIL: der Wächter listet DomainEvent, DomainEventDelivery, WebhookDelivery, JobRun und ThrottlerHit als „weder gesichert noch ausgenommen“.- e2e → FAIL: Tabelle fehlt.
- Schritt 3: Implementieren
- Migration erzeugen (Test-DB, siehe Global Constraints):
npx prisma migrate deploy, dannnpx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --scriptin die neuemigration.sql. - Von Hand nachbearbeiten:
CREATE TABLE "ThrottlerHit"→CREATE UNLOGGED TABLE "ThrottlerHit"- Kopfkommentar im Stil von
20261012100000_calendar_sync_claims
- Danach
npx prisma migrate deploy && npx prisma generate. MODELS_EXCLUDED_FROM_BACKUPergänzen (englische Begründungen):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.'
- Migration erzeugen (Test-DB, siehe Global Constraints):
- Schritt 4:
- Backup-Spec → PASS.
npx jest --config ./test/jest-e2e.json test/platform-schema.e2e-spec.ts --runInBand→ PASS.- Drift-Check
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code→ Exit 0.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - ARCHITECTURE §8 ergänzen
- kein Commit
- typecheck, lint,
Task 3: Schreibbausteine in der Transaktion – Audit, Benachrichtigungen, Ereignis-Kern
Files
- Create:
src/prisma/db-client.tssrc/common/worker-wakeup.tssrc/domain-events/domain-event-types.tssrc/domain-events/domain-event-context.tssrc/domain-events/domain-events.service.tssrc/domain-events/on-domain-event.decorator.tssrc/domain-events/domain-event-registry.tssrc/domain-events/domain-events.module.ts(@Global, importiertDiscoveryModule)
- Modify:
src/audit/audit-log.service.tssrc/notifications/notifications.service.tssrc/winter-checks/winter-checks.service.ts:tx.auditLog.create→auditLog.record(…, tx)src/app.module.ts: ImportDomainEventsModule
- Test:
src/audit/audit-log.service.spec.tssrc/notifications/notifications.service.spec.tssrc/domain-events/domain-events.service.spec.tssrc/domain-events/domain-event-registry.spec.tssrc/winter-checks/winter-checks.service.spec.ts
Alle Pfade relativ zu ApiCore-Backend/.
Interfaces
- Consumes: Prisma-Modelle aus Task 2.
- Produces:
// src/prisma/db-client.ts
export type DbClient = PrismaService | Prisma.TransactionClient;
// src/common/worker-wakeup.ts
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];
export function notifyWorker(db: DbClient, channel: WakeChannel): Promise<void>; // SELECT pg_notify(channel, '')
// Audit / Benachrichtigungen
AuditLogService.record(input: RecordAuditLogInput, tx?: Prisma.TransactionClient): Promise<void>;
NotificationsService.record(input: CreateNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification>;
NotificationsService.recordForPermission(permission: PermissionKey, input: Omit<CreateNotificationInput, 'userId'>, tx?: Prisma.TransactionClient): Promise<number>;
// Ereignis-Katalog (vollständig, wird später nicht mehr erweitert)
export interface ColonyStateSnapshot { status: ColonyStatus; healthStatus: ColonyCondition | null }
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;
export const DOMAIN_EVENT_TYPES: readonly 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];
actorUserId?: string | null; dedupeKey?: string;
}
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;
}
export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
(event: DomainEventEnvelope<T>, tx: Prisma.TransactionClient) => Promise<void>;
export function dailyDedupeKey(type: DomainEventType, aggregateId: string, day: string): string; // `${type}:${aggregateId}:${day}`
export const domainEventContext: AsyncLocalStorage<{ correlationId: string }>;
@Injectable() class DomainEventsService {
record<T extends DomainEventType>(tx: Prisma.TransactionClient, event: NewDomainEvent<T>): Promise<void>;
recordAll(tx: Prisma.TransactionClient, events: readonly NewDomainEvent[]): Promise<number>;
}
export function OnDomainEvent(types: DomainEventType | readonly DomainEventType[], handlerId: string): MethodDecorator;
export const HANDLER_ID_PATTERN = /^[a-z0-9-]+\.[a-z0-9-]+$/;
export interface RegisteredDomainEventHandler { id: string; invoke: DomainEventHandlerFn }
@Injectable() class DomainEventRegistry implements OnModuleInit {
handlersFor(type: DomainEventType): readonly RegisteredDomainEventHandler[];
handlerIds(): string[];
}
- Schritt 1: Failing Tests
// audit-log.service.spec.ts
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();
});
// notifications.service.spec.ts
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);
expect(prisma.notification.createMany.mock.calls[0][0].data.map((n) => n.userId)).toEqual(['u1', 'u2']);
});
// domain-events.service.spec.ts
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx, { 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) })], skipDuplicates: true });
expect(tx.$executeRaw).toHaveBeenCalledTimes(1); // pg_notify('domain_event','')
});
it('refuses the root PrismaService (must run inside the business transaction)', () =>
expect(service.record(prismaServiceMock as never, event)).rejects.toThrow('DomainEventsService.record needs the transaction client'));
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () => service.record(tx, event));
expect(tx.domainEvent.createMany.mock.calls[0][0].data[0].correlationId).toBe('corr-1');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
// domain-event-registry.spec.ts
it('finds handlers by type, also when one method serves several types', ...);
it('refuses two handlers with the same id for one type', () => expect(() => registry.onModuleInit()).toThrow('Duplicate domain event handler'));
it('rejects a handler id that is not "<consumer>.<name>"', ...);
- Schritt 2:
npx jest src/audit src/notifications src/domain-events src/winter-checks→ FAIL (Module fehlen,recordignorierttx). - Schritt 3: Implementieren
recorddelegiert anrecordAll([event]).recordAllmachttx.domainEvent.createMany({ data, skipDuplicates: true })und beicount > 0notifyWorker(tx, 'domain_event').correlationId = domainEventContext.getStore()?.correlationId ?? randomUUID().- Prüfung „nicht der Root-Client“ über
'$connect' in tx(Prisma 7.9: der Transaktions-Client hat ebenfalls$transaction; geteilter HelferassertTransactionClientinsrc/prisma/db-client.ts). - Payload-JSON enthält nur IDs und Werte aus
DomainEventPayloads, Datumswerte alsYYYY-MM-DD. - Registry:
DiscoveryService.getProviders()+MetadataScanner. Der Decorator hängt Metadaten an ein Array an, damit Stapeln möglich ist. Bei doppelten IDs oder einem ungültigenhandlerIdbricht der Boot ab. recordForPermission:user.findMany({ where: { isActive: true, role: { status: 'ACTIVE', permissions: { some: { permission: { key } } } } } }), danachcreateMany.
- Schritt 4: gleiche Befehle → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/winter-checks.e2e-spec.ts --runInBand→ PASS (Audit jetzt über den Service)- kein Commit
- typecheck, lint,
Task 4: Dispatcher
Files
- Create:
src/common/retry-policy.ts(+.spec.ts)src/common/outbox/outbox-claims.tssrc/domain-events/domain-event-dispatcher.ts(+.spec.ts)src/domain-events/handler-contract.spec.ts(Wächter)test/utils/domain-events.tstest/domain-event-dispatcher.e2e-spec.ts
- Modify:
src/domain-events/domain-events.module.ts(ProviderDomainEventDispatcher) - Docs:
ARCHITECTURE.md, neuer Abschnitt „## 10. Ereignis-Rückgrat und Laufrollen“, Unterabschnitt „Ereignisse“: Outbox, Claim, Backoff,FAILED, Delivery-Sperre, Handler-Vertrag.
Interfaces
- Consumes: Task 3 (
DomainEventRegistry,domainEventContext, Typen). - Produces:
// retry-policy.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;
export function retryDelayMs(attempts: number): number;
// outbox-claims.ts (Tabellenname nur aus geschlossener Union -> Prisma.raw sicher)
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export function claimBatch<Row>(db: PrismaService, table: OutboxTable, options: { limit: number; filter?: Prisma.Sql; orderBy: 'occurredAt' | 'createdAt' }): Promise<Row[]>;
export function releaseStaleClaims(db: PrismaService, table: OutboxTable): Promise<number>;
export function settleSuccess(db: DbClient, table: OutboxTable, id: string, claimedAt: Date, status: 'DONE' | 'DELIVERED'): Promise<boolean>;
export function settleFailure(db: DbClient, table: OutboxTable, id: string, claimedAt: Date, attemptsBefore: number, lastError: string): Promise<'retry' | 'failed'>;
// domain-event-dispatcher.ts
export const DOMAIN_EVENT_BATCH_SIZE = 50;
export const HANDLER_TX_TIMEOUT_MS = 10_000;
export const DRAIN_MAX_ROUNDS = 20;
export interface DispatchFilter { correlationIds?: readonly string[] }
@Injectable() class DomainEventDispatcher {
get lastTickAt(): Date | null;
runOnce(filter?: DispatchFilter): Promise<number>; // Anzahl übernommener Ereignisse
drain(filter?: DispatchFilter, maxRounds?: number): Promise<number>;
releaseStaleClaims(): Promise<number>;
}
// test/utils/domain-events.ts
export function drainDomainEvents(app: INestApplicationContext, rootAggregateIds: readonly string[]): Promise<void>;
-
Gepinnte SQL-Kerne:
- Claim:
UPDATE "<T>" SET "status"='PROCESSING', "claimedAt"=now() WHERE "id" IN (SELECT "id" FROM "<T>" WHERE "status"='PENDING' AND "availableAt" <= now() [AND filter] ORDER BY "<orderBy>" LIMIT ${limit} FOR UPDATE SKIP LOCKED) RETURNING * - Stale:
… SET "status" = CASE WHEN "attempts"+1 >= 8 THEN 'FAILED' ELSE 'PENDING' END, "attempts"="attempts"+1, "claimedAt"=NULL, "availableAt"=now(), "lastError"='claim expired (worker restarted?)' WHERE "status"='PROCESSING' AND "claimedAt" < now() - interval '5 minutes' - Fehler:
"availableAt" = now() + (${retryDelayMs(n)} * interval '1 millisecond'), CAS überid+claimedAt.
- Claim:
-
Ablauf je Ereignis und Handler:
prisma.$transaction(tx => …, { timeout: 10_000 })domainEventDelivery.createMany({ skipDuplicates })SELECT "status" … FOR UPDATE, beiDONEüberspringen- Handler in
domainEventContext.run({ correlationId }) - Delivery
DONE,attempts + 1
-
Fehler (außerhalb der Transaktion): Delivery
attempts + 1,lastError = loggableError(e).message. -
Abschluss je Ereignis:
- alle
DONE→ EreignisDONE(processedAt = now()) - sonst
settleFailuremitlastError = "<handlerId>: <message>"; beifailedwerden offene DeliveriesFAILED
- alle
-
Ereignisse ohne Handler →
DONE. -
Schritt 1: Failing Tests
// retry-policy.spec.ts
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]);
expect(MAX_ATTEMPTS).toBe(8);
// domain-event-dispatcher.spec.ts (Prisma/Registry gemockt)
it('marks an event DONE when every handler succeeded', ...);
it('retries only the failed handler and schedules the event 30 s ahead after the first failure', ...); // SQL-Parameter enthält 30000
it('marks the event and its open deliveries FAILED after the 8th failed attempt', ...);
it('skips a handler whose delivery is already DONE', ...);
it('runs the handler inside the delivery transaction with the event correlation id', ...);
it('updates lastTickAt even when nothing was claimed', ...);
it('stores lastError without the Prisma message', ...); // PrismaClientKnownRequestError mit "secret-host" -> nicht im lastError
// handler-contract.spec.ts (Wächter, Review Focus 2)
it('handler files never import network clients', () => {
for (const file of glob('src/**/*.handlers.ts')) {
expect(read(file)).not.toMatch(/graph-client|webhook-delivery(?!\.worker)|node:https?|\bfetch\(/);
}
});
// test/domain-event-dispatcher.e2e-spec.ts – eigenes Testmodul mit CountingHandler
// @OnDomainEvent('TaskDue', 'test.counter') -> tx.auditLog.create({ entityType: 'DispatcherE2E', entityId: e.aggregateId, action: 'CREATE' })
// @OnDomainEvent('QueenAged', 'test.failing') -> throw new Error('boom') (beide Typen haben im Plan keine echten Handler)
it('two dispatchers in parallel produce exactly one effect per event and handler', async () => {
await recordTaskDue(20);
await Promise.all([appA.get(DomainEventDispatcher).drain(filter), appB.get(DomainEventDispatcher).drain(filter)]);
expect(await prisma.auditLog.count({ where: { entityType: 'DispatcherE2E', entityId: { startsWith: suffix } } })).toBe(20);
});
it('drains a backlog of 120 events in batches of at most 50', ...); // runOnce-Ergebnisse [50, 50, 20, 0]
it('a stale-released event does not repeat a handler that already committed', ...); // claimedAt per SQL auf now()-6 min
it('schedules a failed event about 30 s ahead by the database clock', async () => {
const [{ s }] = await prisma.$queryRaw<{ s: number }[]>`SELECT EXTRACT(EPOCH FROM "availableAt" - now())::float AS s FROM "DomainEvent" WHERE id = ${id}`;
expect(s).toBeGreaterThan(25); expect(s).toBeLessThanOrEqual(30.5);
});
- Schritt 2:
npx jest src/common/retry-policy.spec.ts src/domain-eventsund die e2e-Datei → FAIL. - Schritt 3: Implementieren wie oben beschrieben.
drainDomainEventssucht diecorrelationIds der Wurzel-Aggregate und ruftdispatcher.drain({ correlationIds })auf.- Der Filter verhindert, dass parallele e2e-Dateien fremde Ereignisse abarbeiten.
- Schritt 4: gleiche Befehle → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - ARCHITECTURE §10 „Ereignisse“
- kein Commit
- typecheck, lint,
Task 5: Worker-Bausteine (Takt, LISTEN, Health)
Files
- Create:
src/common/with-timeout.ts(+.spec.ts)src/worker/polling-loop.ts(+.spec.ts)src/worker/pg-listener.ts(+.spec.ts)src/worker/worker-health.ts(+.spec.ts)
- Modify:
src/health/health.controller.ts: lokaleswithTimeoutdurch den gemeinsamen Helfer ersetzenpackage.json:npm install pg@^8.23.0 && npm install -D @types/pg
Interfaces
- Produces:
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T>;
export class PollingLoop {
constructor(options: { name: string; intervalMs: number; tick: () => Promise<unknown>; logger: Logger });
start(): void; wake(): void; stop(): Promise<void>;
}
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 class PgListener {
constructor(options: { connectionString: string; channels: readonly WakeChannel[]; onNotify: (channel: WakeChannel) => void; logger: Logger; clientFactory?: (connectionString: string) => ListenClient });
start(): Promise<void>; stop(): Promise<void>;
}
export const HEALTH_STALE_MS = 120_000;
export const DEFAULT_WORKER_HEALTH_PORT = 3002;
export interface WorkerHealth { ok: boolean; reason?: 'database' | 'dispatcher-stale' }
export function workerHealthPort(env?: NodeJS.ProcessEnv): number;
export function startWorkerHealthServer(options: { port: number; check: () => Promise<WorkerHealth> }): Promise<http.Server>;
- Schritt 1: Failing Tests (Jest Fake Timers)
// polling-loop.spec.ts
it('ticks every intervalMs', ...);
it('never runs two ticks at once', ...);
it('wake() during a tick runs exactly one more tick right after', ...);
it('logs a throwing tick via loggableError and keeps going', ...);
it('stop() waits for the running tick and schedules nothing more', ...);
// pg-listener.spec.ts (Fake-Client)
it('LISTENs on every channel after connecting', () => expect(client.query.mock.calls.map((c) => c[0])).toEqual(['LISTEN "domain_event"', 'LISTEN "calendar_sync"', 'LISTEN "webhook_delivery"']));
it('reconnects after an error with 1 s, 2 s, 4 s … at most 30 s', ...);
it('stop() ends the client and does not reconnect', ...);
// worker-health.spec.ts (echter Server auf Port 0)
it('answers 200 {"status":"ok"} on GET /health when healthy', ...);
it('answers 503 with only status and reason when unhealthy', async () => {
expect(await getJson('/health')).toEqual({ status: 503, body: { status: 'unhealthy', reason: 'dispatcher-stale' } });
});
it('answers 404 for any other path or method', ...);
it('treats a throwing check as 503 database', ...);
- Schritt 2:
npx jest src/worker src/common/with-timeout.spec.ts src/health→ FAIL. - Schritt 3: Implementieren
PollingLoopmitsetTimeout, keinsetInterval, damit der Wächter aus Task 17 greift.- Single-Flight mit Flag
rerunRequested. - Kanalnamen in
LISTENmit doppelten Anführungszeichen; Werte kommen nur ausWAKE_CHANNELS. - Health-Server mit
node:http, ohne Express.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/health.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 6: Worker-Prozess und Einstiegspunkte
Files
- Create:
src/worker/worker-runtime.service.ts(+.spec.ts)src/worker/worker-runtime.module.tssrc/worker/worker-health-server.service.tssrc/worker/worker.module.tssrc/all-in-one.module.tssrc/runtime/root-module.ts(+.spec.ts)src/worker.tstest/worker-runtime.e2e-spec.ts
- Modify:
src/main.ts: Modul je Rollesrc/calendar-sync/calendar-sync.service.ts:@CronscheduledRun,startRun,activeRunundonModuleDestroyentfernen; neurequestRunsrc/calendar-sync/calendar-sync.controller.tssrc/calendar-sync/calendar-sync.service.spec.tstest/calendar-sync.e2e-spec.ts: 202-Testpackage.json: Skripte"worker": "node dist/worker.js","job": "node dist/cli.js"
- Docs:
ARCHITECTURE.md§10, Unterabschnitt „Laufrollen“ (Tabelle aus Spec §2). Annahme 9: Outbox-Takt im Worker statt@Cron, „Jetzt synchronisieren“ weckt den Worker.
Interfaces
- Consumes: Task 1, 4, 5.
- Produces:
export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
@Injectable() class WorkerRuntime implements OnApplicationBootstrap, OnApplicationShutdown { health(): Promise<WorkerHealth> }
@Module({ providers: [WorkerRuntime], exports: [WorkerRuntime] }) class WorkerRuntimeModule {}
// WorkerModule imports: ConfigModule.forRoot({ isGlobal: true }), PrismaModule, AuditModule, NotificationsModule,
// DomainEventsModule, WebhooksModule, CalendarSyncModule, TasksModule, AutomationModule, WorkerRuntimeModule
// WorkerModule providers: WorkerHealthServer (nur hier, nicht in AllInOneModule)
@Module({ imports: [AppModule, WorkerRuntimeModule] }) class AllInOneModule {}
export function rootModuleFor(role: 'api' | 'all'): Type<unknown>;
CalendarSyncService.requestRun(): Promise<void>; // FAILED -> PENDING, notifyWorker(prisma, 'calendar_sync')
// getStatus().running = Anzahl gültiger Claims > 0
-
Schleifen in
WorkerRuntime(Tabelle im Code, Task 7 ergänzt eine Zeile):domain-events: 5 000 ms,domain_event,releaseStaleClaims()und danndrain()calendar-outbox: 60 000 ms,calendar_sync,processOutbox()
-
Schritt 1: Failing Tests
// root-module.spec.ts – „API-Rolle startet nie Hintergrundschleifen“
it('the api module never imports the worker runtime', () =>
expect(Reflect.getMetadata('imports', AppModule)).not.toContain(WorkerRuntimeModule));
it('all adds the worker runtime to the API', () => expect(rootModuleFor('all')).toBe(AllInOneModule));
// worker-runtime.service.spec.ts
it('starts the listener and every loop at bootstrap', ...);
it('wakes the domain-events loop on a domain_event notification', ...);
it('is healthy after a recent tick and dispatcher-stale after 120 s without one', ...);
it('reports database when SELECT 1 fails or exceeds 5 s', ...);
it('stops loops and listener on shutdown', ...);
// calendar-sync.service.spec.ts
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
await service.requestRun();
expect(graph.createEvent).not.toHaveBeenCalled();
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
});
// test/worker-runtime.e2e-spec.ts (WorkerModule + CountingHandler auf 'TaskDue')
it('wakes up on NOTIFY and processes a new event well before the 5 s poll', async () => {
await recordTaskDueInTx();
await expectWithin(1_500, () => countAuditRows('DispatcherE2E'), 1);
});
// test/calendar-sync.e2e-spec.ts: „Jetzt synchronisieren“ -> 202, FAILED wird PENDING, kein Graph-Aufruf aus der API
- Schritt 2:
npx jest src/runtime src/worker src/calendar-syncund beide e2e-Dateien → FAIL. - Schritt 3: Implementieren
worker.ts:resolveRole('worker')→assertStartupEnvironment→NestFactory.createApplicationContext(WorkerModule)→enableShutdownHooks().main.ts:NestFactory.create(rootModuleFor(role)).WorkerHealthServerstartet inonApplicationBootstrapaufworkerHealthPort()und schließt inonApplicationShutdown.
- Schritt 4: → PASS.
npm run build && ls dist/main.js dist/worker.js- Optional, mit lokaler Dev-DB:
APICORE_ROLE=worker node dist/worker.jsundcurl -s localhost:3002/health→{"status":"ok"}
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Carry-forward (Orchestrator): worker.ts ruft wie main.ts zuerst resolveRole('worker') und assertStartupEnvironment; Health-Check = withTimeout(SELECT 1, 5000) → database, Alter des letzten Dispatcher-Durchlaufs (lastTickAt, wird je Ereignis aktualisiert) ≤ HEALTH_STALE_MS → sonst dispatcher-stale, gemessen mit monotoner Uhr (performance.now()).
Task 7: Webhook-Zustellung mit Wiederholung und Zustellprotokoll
Files
- Create:
src/webhooks/webhook-delivery.worker.ts(+.spec.ts)src/webhooks/webhook.handlers.ts(+.spec.ts)src/webhooks/dto/list-webhook-deliveries.dto.tstest/utils/webhooks.ts
- Modify:
src/webhooks/webhooks.service.ts:enqueueDeliveries;dispatch(Altpfad) nutzt sie;deliver()entfälltsrc/webhooks/webhook-events.ts: +varroa.measured,treatment.created,feeding.createdsrc/webhooks/webhooks.controller.ts,src/webhooks/webhooks.module.ts,src/worker/worker-runtime.service.ts(Schleife)src/webhooks/webhooks.service.spec.ts,test/webhooks.e2e-spec.ts- Frontend
ApiCore-Frontend/src/lib/webhooks/types.tsundlabels.ts: +stock.changed„Bestand einer Charge geändert“,varroa.measured„Varroamessung erfasst“,treatment.created„Behandlung erfasst“,feeding.created„Fütterung erfasst“
- Docs:
ARCHITECTURE.md§9, Zeile „Ausgehende Aufrufe“: Zustelltabelle, Wiederholung, stabile Delivery-ID, nur 2xx. §10: Webhook-Zustellung.
Interfaces
- Consumes: Task 3, 4, 6.
- Produces:
WebhooksService.enqueueDeliveries(db: DbClient, event: WebhookEvent, payload: unknown): Promise<number>; // + notifyWorker('webhook_delivery')
WebhooksService.dispatch(event: WebhookEvent, payload: unknown): Promise<void>; // Altpfad, runAfterCommit
WebhooksService.findDeliveries(subscriptionId: string, limit: number): Promise<WebhookDeliveryView[]>;
export interface WebhookDeliveryView { id: string; event: string; status: WebhookDeliveryStatus; attempts: number; lastStatusCode: number | null; lastError: string | null; createdAt: Date; deliveredAt: Date | null }
export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
export const WEBHOOK_DELIVERY_CONCURRENCY = 5;
@Injectable() class WebhookDeliveryWorker {
runOnce(filter?: { subscriptionIds?: readonly string[] }): Promise<number>;
drain(filter?: { subscriptionIds?: readonly string[] }, maxRounds?: number): Promise<number>;
releaseStaleClaims(): Promise<number>;
}
export const DOMAIN_EVENT_WEBHOOKS: Readonly<Partial<Record<DomainEventType, WebhookEvent>>> = {
InspectionRecorded: 'inspection.created', VarroaMeasured: 'varroa.measured', TreatmentRecorded: 'treatment.created',
FeedingRecorded: 'feeding.created', HarvestRecorded: 'harvest.created', BatchBottled: 'batch.updated',
BottlingCorrected: 'batch.updated', StockChanged: 'stock.changed', TaskCreated: 'task.created', TaskCompleted: 'task.completed',
};
class WebhookEventHandlers {
@OnDomainEvent(Object.keys(DOMAIN_EVENT_WEBHOOKS) as DomainEventType[], 'webhooks.forward')
forward(event: DomainEventEnvelope, tx: Prisma.TransactionClient): Promise<void>;
}
// GET /webhooks/:id/deliveries?limit=1..100 (Standard 50), @RequirePermission('webhooks:read'), ohne body
-
Inhalt (
data) wie heute: der Datensatz, geladen übertx.- Harvest mit
colonies: { include: { colony: true } } batch.updated→honeyBatchstock.changed→{ bottlingBatchIds, productIds }, und nur wenn eines davon nicht leer ist- Fehlt der Datensatz → nichts einreihen.
- Harvest mit
-
body = JSON.stringify({ event, data })wird beim Einreihen festgehalten, damit jede Wiederholung dieselben Bytes sendet. -
Schritt 1: Failing Tests
// webhook-delivery.worker.spec.ts
it('signs "<t>.<delivery id>.<body>" and sends the delivery id as X-ApiCore-Delivery', ...);
it('counts only 2xx as delivered and stores the status code', ...);
it('schedules a retry 30 s ahead after a 500 and keeps the same id', ...);
it('marks FAILED after the 8th attempt', ...);
it('fails a delivery for an inactive subscription without sending ("subscription inactive")', ...);
it('never stores the response body or the secret in lastError', ...);
it('sends at most 5 at once', ...);
// webhook.handlers.spec.ts
it('enqueues inspection.created with the inspection as data', ...);
it('enqueues nothing for StockChanged that only touches raw material', ...);
it('skips a record that no longer exists', ...);
// webhooks.service.spec.ts
it('dispatch never throws when enqueuing fails (legacy path)', ...);
it('enqueueDeliveries writes one row per active subscription for the event and wakes the worker', ...);
// test/webhooks.e2e-spec.ts (Altpfad colony.created, die Producer folgen später)
it('delivers colony.created through the queue with a stable delivery id', async () => {
await post('/api/v1/colonies', body);
await deliverWebhooks(app, [subId]);
expect(received[0].headers['x-apicore-delivery']).toBe((await prisma.webhookDelivery.findFirstOrThrow({ where: { subscriptionId: subId } })).id);
});
it('retries a 500 with the same delivery id and then marks it DELIVERED', ...); // availableAt per SQL auf now()
it('lists deliveries for admins and refuses staff', ...); // 200 / 403
- Schritt 2:
npx jest src/webhooksundtest/webhooks.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
- Claims und Settle über
outbox-claims.ts(orderBy: 'createdAt'). postWebhookbleibt.- Worker-Schleife
webhook-deliveries: 5 000 ms, Kanalwebhook_delivery,releaseStaleClaims()und danndrain(). test/utils/webhooks.ts:deliverWebhooks(app, subscriptionIds).
- Claims und Settle über
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Frontend:
npm run lint && npm run typecheck - Docs
- kein Commit
- typecheck, lint,
Carry-forward (Orchestrator, aus Review Task 6): Ein Test stellt sicher, dass die unter WorkerModule entdeckten Handler-IDs denen unter AppModule entsprechen (sonst läuft ein Handler, der in einem nicht vom Worker importierten Modul liegt, in Produktion nie). Neue Schleifen nur als Zeile in der Loop-Tabelle von WorkerRuntime; sie respektieren das Stop-Signal aus onModuleDestroy.
Task 8: Kalender-Handler und „Quelle weg“-Semantik
Files
- Create:
src/calendar-sync/calendar-sync.handlers.ts(+.spec.ts) - Modify:
src/calendar-sync/calendar-sync.service.ts:enqueueInals Kern;enqueueals Altpfad für Königinnencanceldelegiert vorläufig anenqueue; Task 9 entfernt esbuildEventInput('TASK')liefertnullbeiDONE/CANCELLEDoder ohnedueDateprocessEntry: beinullund vorhandenemgraphEventIddas Graph-Event löschen, dannSKIPPED
src/calendar-sync/calendar-sync.module.ts,src/calendar-sync/calendar-sync.service.spec.ts,test/calendar-sync.e2e-spec.ts
- Docs:
ARCHITECTURE.mdAnnahme 9: Abmelden über erneutes Einreihen, der Worker löscht das Event.
Interfaces
- Consumes: Task 3, 6.
- Produces:
CalendarSyncService.enqueueIn(db: DbClient, sourceType: CalendarSyncSourceType, sourceId: string): Promise<void>; // wirft weiter
CalendarSyncService.enqueue(sourceType: CalendarSyncSourceType, sourceId: string): Promise<void>; // Altpfad, wirft nie
class CalendarSyncEventHandlers {
@OnDomainEvent(['InspectionRecorded', 'InspectionCorrected'], 'calendar-sync.inspection') inspection(e, tx): Promise<void>;
@OnDomainEvent(['TreatmentRecorded', 'TreatmentCorrected'], 'calendar-sync.treatment') treatment(e, tx): Promise<void>;
@OnDomainEvent(['FeedingRecorded', 'FeedingCorrected'], 'calendar-sync.feeding') feeding(e, tx): Promise<void>;
@OnDomainEvent(['HarvestRecorded', 'HarvestCorrected'], 'calendar-sync.harvest') harvest(e, tx): Promise<void>;
@OnDomainEvent(['TaskCreated', 'TaskUpdated', 'TaskCompleted'], 'calendar-sync.task') task(e, tx): Promise<void>; // TaskCreated ohne dueDate: nichts
}
- Schritt 1: Failing Tests
it('enqueueIn writes through the given client and lets errors propagate (handler retry)', ...);
it('enqueue (legacy) never throws', ...);
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', ...);
it('treats 404/410 on delete as already deleted', ...);
it('counts any other delete error as a failed attempt', ...);
it('TaskCreated without a due date enqueues nothing', ...); // handlers.spec
// e2e: Aufgabe mit Event erledigen -> Eintrag neu eingereiht -> processOutbox() ruft deleteEvent und setzt SKIPPED
- Schritt 2:
npx jest src/calendar-syncundtest/calendar-sync.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren wie in Files beschrieben.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Task 9: Aufgaben über TasksService mit Ereignissen
Files
- Modify:
src/tasks/tasks.service.ts(+.spec.ts)src/tasks/tasks.module.ts: ImportTaskTemplatesModuleentfälltsrc/task-templates/task-templates.service.ts(+.spec.ts):generateDueTasksund dieWebhooksService-Abhängigkeit entfallensrc/inspections/inspections.service.ts:createTaskFromInspectionübercreateInTxsrc/inspections/inspections.module.ts:TasksModuleimportierensrc/calendar-sync/calendar-sync.service.ts:cancelentfernen
- Test:
test/tasks.e2e-spec.ts,test/task-templates.e2e-spec.tstest/data-integrity.e2e-spec.ts(„gives a task generated from a recurring template its due date“)test/webhooks.e2e-spec.ts:task.created/task.completedüber Drain + Zustellung
Interfaces
- Consumes: Task 3, 7, 8.
- Produces:
export interface NewTaskInput {
title: string; description?: string | null; priority?: TaskPriority; dueDate?: Date | null;
assignedUserId?: string | null; relatedType?: TaskRelatedType; relatedId?: string | null;
sourceInspectionId?: string | null; sourceTemplateId?: string | null; sourceAutomationTrigger?: AutomationTriggerType | null;
}
TasksService.createInTx(tx: Prisma.TransactionClient, input: NewTaskInput, actingUserId: string | null): Promise<Task>; // Task + Audit + TaskCreated
TasksService.create(dto: CreateTaskDto, actingUserId: string): Promise<Task>;
TasksService.update(id: string, dto: UpdateTaskDto, actingUserId: string): Promise<Task>; // + TaskUpdated
TasksService.complete(id: string, actingUserId: string): Promise<Task>; // + TaskCompleted, bei Wiederholung nichts
TasksService.generateFromTemplates(now?: Date): Promise<number>; // je Vorlage: CAS-Claim + createInTx in EINER Transaktion
- Schritt 1: Failing Tests
it('createInTx writes task, audit entry and TaskCreated on the same client', async () => {
await service.createInTx(tx, { title: 'T', dueDate: new Date('2027-05-01T00:00:00Z') }, 'u1');
expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ entityType: 'Task', action: 'CREATE' }), tx);
expect(events.record).toHaveBeenCalledWith(tx, expect.objectContaining({ type: 'TaskCreated', payload: { relatedType: 'GENERAL', relatedId: null, dueDate: '2027-05-01' } }));
});
it('complete records TaskCompleted once and nothing on a repeated complete', ...);
it('generateFromTemplates creates no task when another replica already claimed the occurrence', ...);
it('a template task gets an audit entry without a user', ...);
// e2e
it('POST /tasks leaves a TaskCreated event whose calendar and webhook deliveries are DONE after draining', ...);
- Schritt 2:
npx jest src/tasks src/task-templates src/inspectionsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
findAllruft weiter lazygenerateFromTemplates()auf (Spec §4).createdByUserId: actingUserId ?? undefined.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - kein Commit
- typecheck, lint,
Carry-forward (Orchestrator, aus Review Task 8): Sobald die direkten calendarSync.enqueue/cancel-Aufrufe entfernt sind, deckt ein e2e-Fall den echten Weg ab: Aufgabe erledigen → TaskCompleted → Dispatcher → Handler calendar-sync.task → Outbox-Eintrag → processOutbox() löscht das Graph-Event und setzt SKIPPED.
Task 10: Automationen als Handler
Files
- Create:
src/automation/automation.handlers.ts(+.spec.ts) - Modify:
src/automation/automation.service.ts(+.spec.ts)src/automation/automation.module.ts: importiertTasksModule,NotificationsModule,StockModule;WebhooksServiceentfällt
- Docs:
ARCHITECTURE.mdAnnahme 12: Automatik-Aufgaben laufen überTasksServicemit Audit, Kalender und Webhook.
Interfaces
- Consumes: Task 3, 9.
- Produces:
AutomationService.evaluateInspectionIn(tx: Prisma.TransactionClient, inspection: Inspection): Promise<void>;
AutomationService.evaluateVarroaMeasurementIn(tx, measurement: VarroaMeasurement): Promise<void>;
AutomationService.evaluateTreatmentIn(tx, treatment: Treatment): Promise<void>;
AutomationService.evaluateFeedingIn(tx, feeding: Feeding): Promise<void>;
AutomationService.evaluateColonyHealthIn(tx, colonyId: string, from: ColonyStateSnapshot, to: ColonyStateSnapshot): Promise<void>;
AutomationService.evaluateRawMaterialLowIn(tx, kind: RawMaterialKind, remainingGrams: number): Promise<void>;
AutomationService.evaluateSwarmControlCheckIn(tx, check: SwarmControlCheck): Promise<void>;
AutomationService.evaluateStockItemIn(tx, item: StockItem): Promise<void>;
// Altpfade bleiben bis Task 17: evaluateInspection, evaluateVarroaMeasurement, evaluateTreatment, evaluateFeeding,
// evaluateColonyHealth, evaluateRawMaterialLow, evaluateSwarmControlCheck, evaluateStockItem
// = runAfterCommit(() => prisma.$transaction((tx) => xIn(tx, …)))
// privat: triggerActionIn(tx, rule, relatedType, relatedId, sourceInspectionId?, options?) – Sperre auf die Regel,
// Dublettenprüfung, tasks.createInTx(tx, …, null), notifications.record(…, tx), auditLog.record(AutomationRule CREATE, tx)
class AutomationEventHandlers {
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
@OnDomainEvent(['VarroaMeasured', 'VarroaCorrected'], 'automation.varroa-threshold')
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
@OnDomainEvent('StockChanged', 'automation.raw-material-low') // je rawMaterialKind: StockService.remainingByRawMaterialKind
}
- Schritt 1: Failing Tests
it('triggerActionIn creates the task through TasksService.createInTx on the handler transaction', ...);
it('leaves no task, notification or audit entry when the notification write fails (one rollback)', ...);
it('still creates at most one open task per rule and object', ...);
it('handlers load the record via tx and skip a missing one', ...);
it('colony-health fires only on the transition into CRITICAL', ...);
it('legacy wrappers never throw', ...);
- Schritt 2:
npx jest src/automation→ FAIL. - Schritt 3: Implementieren wie beschrieben.
- Gemeinsamer Kern: Altpfade und Handler rufen dieselben
…In-Funktionen auf. ensureDefaultsläuft über den übergebenen Client.
- Gemeinsamer Kern: Altpfade und Handler rufen dieselben
- Schritt 4:
npx jest src/automation→ PASSnpx jest --config ./test/jest-e2e.json test/automation.e2e-spec.ts test/follow-up-controls.e2e-spec.ts --runInBand→ PASS (Altpfade noch aktiv)
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Carry-forward (Orchestrator, aus T3 Task 2): Der VARROA_THRESHOLD-Handler nutzt varroaThresholdOf aus src/automation/varroa-threshold.ts (einzige Quelle für den Standardwert 10); das inline ?? 10 in automation.service.ts entfällt. Das ist T3-Plan Task 2 Schritt 9.
Task 11: Erzeuger Durchsichten und Varroa
Files
- Modify:
src/inspections/inspections.service.ts(+.spec.ts),src/inspections/inspections.module.ts(AutomationModule-Import entfällt)src/varroa/varroa.service.ts(+.spec.ts),src/varroa/varroa.module.ts
- Test:
test/post-commit-side-effects.e2e-spec.ts(alleiniger Besitzer dieser Datei),test/automation.e2e-spec.ts,test/inspections.e2e-spec.ts,test/varroa.e2e-spec.ts
Interfaces
-
Consumes: Task 3, 10.
-
Produces, je Schreibmethode
prisma.$transaction(async (tx) => { write; auditLog.record(…, tx); events.record(tx, …) }):InspectionsService.create→InspectionRecorded { colonyId }InspectionsService.update→InspectionCorrected { colonyId }. Bewusst wie heute ohne Automatik: eine Korrektur löst keine Regel aus.VarroaService.create→VarroaMeasured { colonyId },update→VarroaCorrected { colonyId }
-
Direkte Aufrufe
webhooks.dispatch,automation.evaluate*undcalendarSync.enqueueentfallen. -
Schritt 1: Failing Tests
// unit
it('create writes inspection, audit entry and InspectionRecorded in one transaction and calls no side effect directly', ...);
// test/post-commit-side-effects.e2e-spec.ts (Review Focus 2, Spec §9)
it('creates exactly one automation task when the handler fails once and is retried', async () => {
jest.spyOn(app.get(NotificationsService), 'record').mockRejectedValueOnce(new Error('notifications down'));
const { body } = await post('/api/v1/inspections', { colonyId, date: '2026-07-01T00:00:00.000Z', queenSeen: false, eggsPresent: false }).expect(201);
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
const deliveries = await prisma.domainEventDelivery.findMany({ where: { event: { aggregateId: body.id } } });
expect(deliveries.find((d) => d.handler === 'automation.inspection-rules')).toMatchObject({ status: 'DONE', attempts: 2 });
expect(deliveries.find((d) => d.handler === 'webhooks.forward')).toMatchObject({ status: 'DONE', attempts: 1 });
});
it('still answers 201 and replays on retry with the same Idempotency-Key (no side effect in the request)', ...);
it('leaves one open task when several triggers for the same colony arrive at once', ...); // nach Drain
- Schritt 2: → FAIL.
- Schritt 3: Implementieren
automation.e2e: die Assertions auf Aufgaben und Benachrichtigungen kommen nachdrainDomainEvents.
- Schritt 4:
npx jest src/inspections src/varroaund die vier e2e-Dateien → PASS. - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - kein Commit
- typecheck, lint,
Task 12: Erzeuger Behandlungen, Fütterungen und Ernten
Files
- Modify:
src/treatments/treatments.service.ts,src/feedings/feedings.service.ts,src/harvests/harvests.service.tsmit Specs und Modulen - Test:
test/follow-up-controls.e2e-spec.ts,test/treatments.e2e-spec.ts,test/feedings.e2e-spec.ts,test/harvests.e2e-spec.ts
Interfaces
-
Consumes: Task 3, 8, 10.
-
Produces:
TreatmentRecorded/TreatmentCorrected { colonyId }FeedingRecorded/FeedingCorrected { colonyId }HarvestRecorded/HarvestCorrected { apiaryId, colonyIds }- jeweils mit Audit in der Transaktion
-
Webhook
harvest.createdund der Kalender kommen jetzt aus den Handlern.HarvestsService.createbraucht keinfindByIdmehr für den Webhook. -
Schritt 1: Failing Tests
it('create writes treatment, audit entry and TreatmentRecorded in one transaction', ...);
it('update records TreatmentCorrected and no automation', ...);
it('harvest create records HarvestRecorded with the colony ids', ...);
// follow-up-controls.e2e: Folgekontrolle erst nach drainDomainEvents, Fälligkeit wie bisher ab Maßnahme-Datum
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4:
npx jest src/treatments src/feedings src/harvestsund die e2e-Dateien → PASS.test/post-commit-side-effects.e2e-spec.tsbleibt ohne Änderung grün; der Behandlungs-Test prüft nur 201 und Replay.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - kein Commit
- typecheck, lint,
Task 13: Erzeuger Völker und Überwinterung
Files
- Modify:
src/colonies/colonies.service.ts(+.spec.ts),src/colonies/colonies.module.tssrc/winter-checks/winter-checks.service.ts(+.spec.ts)
- Test:
test/colonies.e2e-spec.ts,test/winter-checks.e2e-spec.ts
Interfaces
-
Consumes: Task 3, 10.
-
Produces:
update,merge(für jedes vereinigte Volk),dissolve,reactivateundmarkDeceasedIfRequestedschreibenColonyStatusChanged { from, to }, sobald sichstatusoderhealthStatusändert.- Beim Übergang von lebend zu beendet (
DISSOLVED,SOLD,DECEASED,MERGED, sieheisLivingColonyStatus) zusätzlichColonyEnded { status }. - Audit überall in der Transaktion, auch bei
create,moveundsplit. colony.createdbleibt im Altpfadwebhooks.dispatch; dafür gibt es in dieser Stufe kein Ereignis.
-
Schritt 1: Failing Tests
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', ...);
it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', ...);
it('merge records ColonyEnded "MERGED" for every merged colony', ...);
it('a winter loss with markColonyDeceased records ColonyEnded "DECEASED" in the same transaction', ...);
// e2e: COLONY_HEALTH_CRITICAL-Aufgabe erst nach drainDomainEvents
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - kein Commit
- typecheck, lint,
Task 14: Erzeuger Bestand und Abfüllung (mit Herstellung und Rohstoffen)
Files
- Modify:
src/stock/stock.service.ts(+.spec.ts):notify()entfällt, neurecordChange.consumeForProductionundreturnProductionInputsgeben die verbrauchten Sorten zurück.src/bottling/bottling.service.ts(+.spec.ts)src/production-batches/production-batches.service.ts(+.spec.ts):checkRawMaterialLowentfälltsrc/raw-materials/raw-materials.service.ts(+.spec.ts):checkLowStockentfällt- die zugehörigen Module (
AutomationModule- undWebhooksService-Abhängigkeiten entfallen)
- Test:
test/stock.e2e-spec.ts,test/bottling.e2e-spec.ts,test/shop-sync.e2e-spec.ts,test/raw-materials.e2e-spec.ts,test/data-integrity.e2e-spec.ts(Abschnitt „Bottling quantities“ unverändert grün)
Interfaces
- Consumes: Task 3, 7, 10.
- Produces:
export interface StockChange { bottlingBatchIds: string[]; productIds: string[]; rawMaterialKinds: RawMaterialKind[] }
StockService.recordChange(tx: Prisma.TransactionClient, change: StockChange, actingUserId: string | null,
aggregate: { type: 'BatchMovement' | 'BottlingBatch' | 'ProductionBatch' | 'ShopStockRequest'; id: string }): Promise<void>; // nichts bei leerer Änderung
StockService.consumeForProduction(tx, productionBatchId, lotNumber, inputs, actingUserId): Promise<RawMaterialKind[]>;
StockService.returnProductionInputs(tx, productionBatchId, lotNumber, actingUserId): Promise<RawMaterialKind[]>;
-
Wer welches Ereignis schreibt:
withdrawundcorrect(inkl. Rohstoff-Ziel mit der Sorte der Charge) →StockChangedin ihrer Transaktion.- Shop-Buchung → ein
StockChangedje angenommener Anfrage, in deren Transaktion. - Abfüllung:
create→BatchBottled { honeyBatchId }+StockChanged;update/remove→BottlingCorrected { honeyBatchId, removed }+StockChanged. - Herstellung
create/update/remove→StockChangedmitproductIdsund den verbrauchten Sorten.
-
Schritt 1: Failing Tests
it('withdraw books the movement, the audit entry and StockChanged in one transaction', ...);
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', ...);
it('a shop request records one StockChanged per accepted booking', ...);
it('bottling create records BatchBottled and StockChanged together', ...);
// raw-materials.e2e: RAW_MATERIAL_LOW-Aufgabe erst nach drainDomainEvents (minGrams gesetzt)
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4:
npx jest src/stock src/bottling src/production-batches src/raw-materialsund die e2e-Dateien → PASS. - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - kein Commit
- typecheck, lint,
Task 15: Job-Grundgerüst (JobRun, cli.ts, cleanup, task-templates)
Files
- Create:
src/system/system.module.tssrc/system/job-run.service.ts(+.spec.ts)src/jobs/job-names.tssrc/jobs/job.tssrc/jobs/job-registry.ts(+.spec.ts)src/jobs/jobs.module.tssrc/jobs/run-cli.ts(+.spec.ts)src/jobs/cleanup.job.ts(+.spec.ts)src/jobs/task-templates.job.ts- Platzhalter
src/jobs/daily-evaluation.job.ts,src/jobs/app-backup.job.ts,src/jobs/rekey.job.ts(werfenError('job not implemented yet'), werden in 16/18/21 ersetzt) src/cli.tstest/jobs.e2e-spec.ts
- Modify:
src/common/idempotency/idempotency-cleanup.service.ts(+.spec.ts):@CronundscheduledCleanupentfallensrc/app.module.ts:IdempotencyCleanupService-Provider entfällt,SystemModulewird importiert
- Docs:
ARCHITECTURE.md§8 „Automatischer Zeitplan“ (Idempotenz-Aufräumen per CronJob) und §10 „Jobs“.
Interfaces
- Consumes: Task 2, 3, 9.
- Produces:
export const JOB_NAMES = ['daily-evaluation', 'task-templates', 'cleanup', 'app-backup', 'rekey'] as const;
export type JobName = (typeof JOB_NAMES)[number];
export const JOB_LABELS_DE: Record<JobName, string> = { 'daily-evaluation': 'Tägliche Auswertung', 'task-templates': 'Aufgaben aus Vorlagen', cleanup: 'Aufräumen', 'app-backup': 'App-Backup', rekey: 'Schlüssel umstellen' };
export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = { 'daily-evaluation': 'automation:update', 'task-templates': 'automation:update', cleanup: 'system:manage', 'app-backup': 'backup:configure', rekey: 'system:manage' };
export type JobResult = Record<string, number | string | boolean | null>;
export interface Job { readonly name: JobName; run(args: readonly string[]): Promise<JobResult> }
@Injectable() class JobRunService {
run(job: JobName, work: () => Promise<JobResult>): Promise<JobRun>; // RUNNING -> SUCCEEDED|FAILED; bei FAILED: recordForPermission(…, 'Job „<Label>“ fehlgeschlagen')
recent(limit: number, job?: JobName): Promise<JobRun[]>;
latestPerJob(): Promise<Record<JobName, JobRun | null>>;
}
export function runCli(argv: readonly string[], createContext?: () => Promise<INestApplicationContext>): Promise<0 | 1 | 2>;
// JobsModule imports: ConfigModule.forRoot({ isGlobal: true }), PrismaModule, AuditModule, NotificationsModule,
// DomainEventsModule, TasksModule, BackupModule, SystemModule; providers: alle Job-Klassen, JobRegistry, IdempotencyCleanupService
export const DOMAIN_EVENT_RETENTION_DAYS = 30; export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
export const FAILED_RETENTION_DAYS = 90; export const JOB_RUN_RETENTION_DAYS = 90; export const JOB_RUN_STALE_HOURS = 6;
-
cleanuplöscht:- Idempotenz-Einträge und verbrauchte Tokens (wie bisher)
- abgelaufene
ThrottlerHit("expiresAt" < now()und keine aktive Sperre) DONE-Ereignisse undDELIVERED-Zustellungen älter als 30 TageFAILED-Einträge älter als 90 TageJobRunälter als 90 Tage
-
Außerdem setzt
cleanupRUNNING-Läufe, die älter als 6 h sind, aufFAILEDmiterror: 'aborted (pod ended without result)'. -
Schritt 1: Failing Tests
it('the registry covers every JOB_NAME exactly once', ...);
it('rejects an unknown job with exit code 2 and writes no JobRun', async () => expect(await runCli(['nope'], ctx)).toBe(2));
it('returns 1 and stores the error via loggableError when the job fails', ...);
it('passes extra arguments such as --dry-run to the job', ...);
it('notifies holders of the job permission when a run fails', ...);
it('cleanup removes only expired rows and reports counts', ...);
// test/jobs.e2e-spec.ts
it('node dist/cli.js cleanup equivalent: runCli(["cleanup"]) writes a SUCCEEDED JobRun with counts', ...);
it('task-templates creates the due task once even when run twice', ...);
- Schritt 2:
npx jest src/jobs src/system src/common/idempotencyundtest/jobs.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
cli.ts:resolveRole('cli'),assertStartupEnvironment, dannrunCli(process.argv.slice(2)).then((code) => { process.exitCode = code; }).
- Schritt 4: → PASS.
npm run build && ls dist/cli.js dist/worker.js dist/main.js - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Carry-forward (Orchestrator, aus Reviews der Tasks 3, 19):
cleanuplöscht zusätzlich abgelaufeneThrottlerHit-Zeilen (expiresAt < now()UNDblockedUntilist NULL oder< now(), SQL-Uhr – sonst würde eine aktive Fehlversuchs-Sperre der API-Schlüssel aufgehoben; Throttler-Name der Sperre siehe ERP-Branchfailed-attempt-limiter.ts).- Benachrichtigungen über
recordForPermissionbei gescheiterten Jobs werden dedupliziert (z. B. eine je Job und Tag), sonst meldet der stündlicheapp-backup-Check jede Stunde. cli.tsruft wiemain.tszuerstresolveRoleundassertStartupEnvironmentauf.
Task 16: app-backup als CronJob mit Fälligkeitsprüfung
Files
- Modify:
src/backup/backup-scheduler.service.ts(+.spec.ts):SchedulerRegistry,reload(),runScheduled()und der mtime-Guard entfallensrc/backup/backup.controller.ts(+.spec.ts): keinereload()-Aufrufe mehrsrc/backup/backup.module.ts: exportiertBackupSchedulerServicesrc/jobs/app-backup.job.ts: Platzhalter ersetzen- Frontend
ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx(+page.spec.tsx) undsrc/lib/backup/types.ts
- Create:
src/backup/backup-due.ts(+.spec.ts),test/app-backup-job.e2e-spec.ts - Docs:
ARCHITECTURE.md§8 „Automatischer Zeitplan“. Der CronJob prüft stündlich gegen den Zeitplan aus Env oder GUI. Es gibt keinen mtime-Guard mehr; „Jetzt sichern“ bleibt in der API.
Interfaces
- Consumes: Task 15.
- Produces:
export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin';
export function isBackupDue(cronSchedule: string, lastBackupAt: Date | null, now: Date, timeZone?: string): boolean; // null -> true; sonst nächster Termin nach lastBackupAt <= now
export type AppBackupResult = { written: true; fileName: string; sizeBytes: number; pruned: number } | { written: false; reason: 'no-path' | 'not-due' };
BackupSchedulerService.runIfDue(now?: Date): Promise<AppBackupResult>; // lastBackupAt = mtime der neuesten Backup-Datei
// BackupScheduleStatus += lastJobRun: { status: JobRunStatus; startedAt: string; finishedAt: string | null; error: string | null } | null
- Schritt 1: Failing Tests
it('is not due again on the same day for "0 3 * * *"', () =>
expect(isBackupDue('0 3 * * *', new Date('2026-10-12T01:00:00Z'), new Date('2026-10-13T00:30:00Z'))).toBe(false));
it('is due after the next 03:00 Europe/Berlin', () =>
expect(isBackupDue('0 3 * * *', new Date('2026-10-12T01:00:00Z'), new Date('2026-10-13T01:05:00Z'))).toBe(true));
it('runIfDue writes nothing without a path', ...);
// e2e: Job zweimal in derselben Stunde -> eine Datei; JobRun.result.written true, dann false
// Frontend: zeigt „Wird stündlich geprüft · letzter Lauf {Datum} · Fehlgeschlagen: {error}“
- Schritt 2:
- Backend:
npx jest src/backup src/jobsundtest/app-backup-job.e2e-spec.ts→ FAIL - Frontend:
npx vitest run "src/app/(app)/settings/backup/page.spec.tsx"→ FAIL
- Backend:
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- Backend typecheck, lint,
npm test -- --ci - Frontend
npm run lint && npm run typecheck - Docs
- kein Commit
- Backend typecheck, lint,
Task 17: In-Prozess-Pfade abbauen und Wächtertests
Files
- Modify:
src/app.module.ts:ScheduleModule.forRoot()entfälltpackage.json:npm uninstall @nestjs/schedule && npm install cron@^4.4.0src/automation/automation.service.ts(+.spec.ts): ungenutzte Altpfade entfernen (evaluateInspection,evaluateVarroaMeasurement,evaluateTreatment,evaluateFeeding,evaluateColonyHealth,evaluateRawMaterialLow)
- Create:
src/runtime/no-in-process-schedulers.spec.tssrc/common/after-commit-usage.spec.ts
- Docs:
ARCHITECTURE.mdAnnahme 11 neu fassen. Seiteneffekte laufen über das Ereignis-Rückgrat;runAfterCommitnur noch für die Liste unten. Nachzügler für eine spätere Stufe: Königinnen, Honigchargen, Lager, Schwarmkontrolle.
Interfaces
-
Consumes: Task 11–16.
-
Schritt 1: Failing Tests
// no-in-process-schedulers.spec.ts (Spec §9 Wächter)
it('no @Cron/@Interval/@Timeout, SchedulerRegistry, ScheduleModule or setInterval in src', () => {
expect(offenders(/@(Cron|Interval|Timeout)\(|SchedulerRegistry|ScheduleModule|setInterval\(/)).toEqual([]);
});
it('package.json no longer depends on @nestjs/schedule', () => expect(pkg.dependencies['@nestjs/schedule']).toBeUndefined());
// after-commit-usage.spec.ts
it('runAfterCommit is only used by the remaining legacy paths', () => {
expect(filesCalling('runAfterCommit(')).toEqual(['src/automation/automation.service.ts', 'src/calendar-sync/calendar-sync.service.ts', 'src/webhooks/webhooks.service.ts']);
expect(filesCalling('calendarSync.enqueue(')).toEqual(['src/queens/queens.service.ts']);
expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/honey-batches/honey-batches.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts']);
});
- Schritt 2:
npx jest src/runtime src/common/after-commit-usage.spec.ts→ FAIL (ScheduleModuleund Altaufrufe noch vorhanden). - Schritt 3: Implementieren
- In
jest.transformIgnorePatternsbleibtcron. @nestjs/scheduleaus beiden Jest-Konfigurationen entfernen.
- In
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci,npm run build - Docs
- kein Commit
- typecheck, lint,
Task 18: daily-evaluation – zeitbasierte Ereignisse
Files
- Create:
src/jobs/daily-evaluation.rules.ts(+.spec.ts),test/daily-evaluation.e2e-spec.ts - Modify:
src/jobs/daily-evaluation.job.ts(Platzhalter ersetzen) - Docs:
ARCHITECTURE.md§10, „Zeitbasierte Ereignisse“ (Regeln und Werte; die Handler folgen mit T4 aus Spec §10)
Interfaces
- Consumes: Task 3, 15.
- Produces:
export const CATCH_UP_DAYS = 7;
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
export const ACTIVE_SEASON_MONTHS: readonly number[] = [3, 4, 5, 6, 7, 8, 9, 10];
export function utcDay(date: Date): string; // YYYY-MM-DD
export function withdrawalEndDay(t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>): string | null;
export function latestCycleOccurrence(base: Date, intervalDays: number, today: Date): string | null; // größtes base+k*Intervall <= heute, k>=1
export function inCatchUpWindow(day: string, today: Date): boolean;
DailyEvaluationJob.run(): Promise<JobResult>; // { WithdrawalEnded: n, TaskDue: n, QueenAged: n, VarroaMeasurementDue: n, InspectionOverdue: n }
-
Regeln, jeweils mit Tag im Nachholfenster
[heute − 7, heute]:WithdrawalEnded(Aggregat Treatment):(endDate ?? date) + withdrawalPeriodDaysTaskDue(Task, StatusOPEN/IN_PROGRESS):dueDateQueenAged(QueenACTIVEmitcurrentYear − hatchYear ≥ 2): Tag<YYYY>-01-01, also einmal pro JahrVarroaMeasurementDueundInspectionOverdue(lebende Völker überLIVING_COLONY_WHERE): Basis ist die letzte Messung bzw. Durchsicht, sonstcolony.createdAt. Nur wenn der Monat des Tags inACTIVE_SEASON_MONTHSliegt.
-
Geschrieben wird je Typ in einer Transaktion über
events.recordAll(tx, …)mitdailyDedupeKey. -
Schritt 1: Failing Tests
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(withdrawalEndDay({ date: d('2026-09-28'), endDate: d('2026-10-01'), withdrawalPeriodDays: 14 })).toBe('2026-10-15'));
it('catches up occurrences of the last 7 days exactly once', ...);
it('emits no inspection or varroa event outside March–October', ...);
// e2e
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', ...);
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Task 19: Throttler über die Datenbank
Files
- Create:
src/common/throttler/prisma-throttler.storage.ts(+.spec.ts)src/common/throttler/throttler-storage.module.tstest/throttler.e2e-spec.ts
- Modify:
src/app.module.ts:ThrottlerModule.forRootAsynctest/utils/create-test-app.ts: eigener Namespace je Instanz, optional geteilt
- Docs:
ARCHITECTURE.md§9, Zeile „Rate-Limits“ (über alle Pods, DB-Uhr). Den ersten „Bekannte Restpunkte“-Bullet entfernen.
Interfaces
- Consumes: Task 2, 4.
- Produces:
export const THROTTLER_NAMESPACE = Symbol('THROTTLER_NAMESPACE'); // Produktion: ''
@Injectable() class PrismaThrottlerStorage implements ThrottlerStorage {
increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<ThrottlerStorageRecord>;
}
// ThrottlerModule.forRootAsync({ imports: [ThrottlerStorageModule], inject: [PrismaThrottlerStorage],
// useFactory: (storage) => ({ throttlers: [{ name: 'default', ttl: 60000, limit: 100 }], storage }) })
export function createTestApp(options?: { throttlerNamespace?: string }): Promise<INestApplication<App>>; // Standard: randomUUID()
-
Semantik in einer Anweisung:
INSERT … ON CONFLICT ("key","throttlerName") DO UPDATE … RETURNING.- Gespeichert wird der Schlüssel
${namespace}:${key}(keykommt vom Guard bereits als SHA-256). - Aktive Sperre (
blockedUntil > now()):hitsbleibt. - Abgelaufenes Fenster oder abgelaufene Sperre:
hits = 1,expiresAt = now() + ttl,blockedUntil = NULL. - Sonst
hits + 1. - Werden die neuen
hitsgrößer alslimitund es gibt keine aktive Sperre:blockedUntil = now() + blockDuration. - Rückgabe aus SQL:
totalHitstimeToExpire = ceil(EXTRACT(EPOCH FROM "expiresAt" - now()))isBlocked = "blockedUntil" > now()timeToBlockExpire, sonst 0
- Gespeichert wird der Schlüssel
-
Schritt 1: Failing Tests
it('uses only the database clock (no JS dates in the statement)', () => {
const values = prisma.$queryRaw.mock.calls[0].slice(1);
expect(values.some((v) => v instanceof Date)).toBe(false);
expect(sqlText()).toContain('now()');
});
// test/throttler.e2e-spec.ts
it('counts across two app instances sharing one namespace', async () => {
const ns = randomUUID(); const [a, b] = [await createTestApp({ throttlerNamespace: ns }), await createTestApp({ throttlerNamespace: ns })];
for (let i = 0; i < 30; i++) await request((i % 2 ? a : b).getHttpServer()).get(`/api/v1/public/batches/NONE-${suffix}`).expect(404);
await request(a.getHttpServer()).get(`/api/v1/public/batches/NONE-${suffix}`).expect(429);
});
it('40 parallel requests over two instances yield exactly 10 × 429', ...);
it('separate namespaces never share counters', ...);
it('an expired window starts again at 1', ...); // expiresAt per SQL in die Vergangenheit
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS. Zusätzlich
test/auth-throttle.e2e-spec.tsundtest/account-guard.e2e-spec.ts→ PASS. - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - komplette e2e-Suite
npm run test:e2e(prüft, dass parallele Dateien sich nicht drosseln) - Docs
- kein Commit
- typecheck, lint,
Task 20: Schlüsselbund und getrennte Schlüssel
Files
- Create:
src/common/crypto/key-ring.ts(+.spec.ts) - Modify:
src/auth/mfa/mfa-crypto.util.ts(+ Spec)src/webhooks/webhook-secret.ts(+ Spec)src/backup/backup-crypto.ts(+ Spec)src/runtime/startup-checks.ts(+ Spec).env.exampledeploy/k8s/base/backend/secret.example.yaml
- Docs:
ARCHITECTURE.mdAnnahme 19 anpassen (Schlüssel getrennt). Neue Annahme 22 „Schlüsselbund“: Format, Rotation über*_PREVIOUS, Altchiffrate sind nur lesbar, solangeJWT_ACCESS_SECRETunverändert ist.
Interfaces
- Consumes: Task 1.
- Produces:
export const MIN_ENCRYPTION_KEY_LENGTH = 32;
export const KEY_ID_PATTERN = /^k[0-9a-f]{8}:/;
export type SecretPurpose = 'mfa' | 'webhook';
export interface RingKey { id: string; key: Buffer }
export interface KeyRing { current: RingKey; previous: RingKey | null }
export function loadKeyRing(purpose: SecretPurpose, env?: NodeJS.ProcessEnv): KeyRing; // Prod: Env-Schlüssel Pflicht; Dev-Rückfall: HKDF aus JWT_ACCESS_SECRET je Zweck
export function sealWithRing(ring: KeyRing, plaintext: string): string; // `${id}:${iv}.${tag}.${data}`
export function openWithRing(ring: KeyRing, stored: string): { plaintext: string; keyId: string };
export function legacyJwtKey(env?: NodeJS.ProcessEnv): Buffer; // sha256(JWT_ACCESS_SECRET), nur zum Entschlüsseln
export function encryptMfaSecret(plaintext: string): string;
export function decryptMfaSecret(stored: string): string;
export function mfaSecretNeedsRekey(stored: string): boolean;
export function sealWebhookSecret(secret: string): string;
export function openWebhookSecret(stored: string): string; // k…: | enc:v1: (alt) | Klartext (alt)
export function webhookSecretNeedsRekey(stored: string): boolean;
// backup-crypto: Ring = BACKUP_ENCRYPTION_KEY (current), BACKUP_ENCRYPTION_KEY_PREVIOUS, legacy JWT (nur Entschlüsseln), Treffer über sealed.keyId; Dateiformat v3 unverändert
-
Ableitung:
hkdfSync('sha256', secret, 'apicore-key-ring', 'apicore-<purpose>-v1', 32). Key-ID:'k' + sha256(key).hex.slice(0, 8). -
Startprüfung in Produktion: alle drei Variablen gesetzt, je ≥ 32 Zeichen, paarweise verschieden, keine gleich
JWT_ACCESS_SECREToderJWT_REFRESH_SECRET. -
Schritt 1: Failing Tests
it('seals with the current key and prefixes its id', () => expect(sealWithRing(ring, 's')).toMatch(KEY_ID_PATTERN));
it('opens a value sealed with the previous key', ...);
it('refuses an unknown key id', () => expect(() => openWithRing(ring, 'k00000000:a.b.c')).toThrow('unknown key id'));
it('fails on a tampered ciphertext (GCM)', ...);
it('derives different keys for mfa and webhook from the same dev fallback', ...);
it('still opens a legacy MFA secret encrypted with the JWT-derived key', ...);
it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', ...);
it('opens a backup sealed with BACKUP_ENCRYPTION_KEY_PREVIOUS', ...);
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', ...);
it('refuses two purposes sharing one key or a key equal to JWT_ACCESS_SECRET', ...);
- Schritt 2:
npx jest src/common/crypto src/auth/mfa src/webhooks src/backup src/runtime→ FAIL. - Schritt 3: Implementieren wie beschrieben.
- Liest das System ein Altchiffrat, schreibt es einmal je Prozess
warn„legacy ciphertext read – run the rekey job“, ohne Werte.
- Liest das System ein Altchiffrat, schreibt es einmal je Prozess
- Schritt 4: → PASS.
- Zusätzlich
npx jest --config ./test/jest-e2e.json test/auth.e2e-spec.ts test/webhooks.e2e-spec.ts test/backup.e2e-spec.ts --runInBand→ PASS
- Zusätzlich
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Task 21: rekey-Job
Files
- Modify:
src/jobs/rekey.job.ts(Platzhalter ersetzen, +.spec.ts) - Create:
test/rekey.e2e-spec.ts - Docs:
ARCHITECTURE.mdAnnahme 22, Ablaufrekey(Reihenfolge siehe Runbook Schritt 4).
Interfaces
- Consumes: Task 15, 20.
- Produces:
export interface RekeyCounts { total: number; rekeyed: number; alreadyCurrent: number; plaintextSealed: number; changedMeanwhile: number; failed: number }
RekeyJob.run(args: readonly string[]): Promise<JobResult>;
// args enthält '--dry-run' -> nichts schreiben, nur zählen
// Je Zeile einzeln, kein großer Transaktionsblock; CAS: updateMany({ where: { id, mfaSecret: alterWert } }) bzw. { id, secret: alterWert }
// failed > 0 -> Job wirft nach Abschluss aller Zeilen (JobRun FAILED, Zähler bleiben im Fehlertext)
- Schritt 1: Failing Tests
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', ...);
it('resumes after an interruption: current rows are skipped, the rest are rekeyed', async () => {
updateMany.mockRejectedValueOnce(new Error('connection reset'));
await expect(job.run([])).rejects.toThrow(); // 1 failed
updateMany.mockResolvedValue({ count: 1 });
expect(await job.run([])).toMatchObject({ 'mfa.alreadyCurrent': 2, 'mfa.rekeyed': 1, 'mfa.failed': 0 });
});
it('skips a row changed since it was read and reports it', ...); // CAS count 0 -> changedMeanwhile 1, kein Überschreiben
// e2e
it('a second run rekeys nothing; dry-run writes nothing', ...);
it('rekeyed values open to the original plaintext', ...);
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Task 22: Öffentliche Herkunftsseite ohne genauen Standort
Files
- Backend:
src/public/public.service.ts(+ Spec)src/public/public.controller.tssrc/common/middleware/no-store.middleware.ts(+ neue Spec): die Präfix-Ausnahme entfällt, alles istno-storesrc/apiaries/dto/create-apiary.dto.tssrc/site-settings/dto/update-site-settings.dto.ts,src/site-settings/site-settings.service.tssrc/common/validation/update-dto-null-handling.spec.tstest/public.e2e-spec.ts,test/apiaries.e2e-spec.ts
- Frontend:
src/lib/public-batch/types.ts- neu
src/lib/public-batch/origin-text.ts(+.spec.ts) src/app/herkunft/[lot]/page.tsxsrc/lib/apiaries/types.tssrc/app/(app)/apiaries/[id]/apiary-edit-card.tsx(+ Spec)src/app/(app)/apiaries/new/page.tsx,apiary-form-values.ts(+ Spec)src/lib/site-settings/types.tssrc/app/(app)/settings/automation/social-media-tab.tsx(+ neuesocial-media-tab.spec.tsx)
- Docs:
ARCHITECTURE.md§9 „Transport/Antworten“ (Cache-Kopf nur bei 200). Neue Annahme 23: öffentliche Herkunft nur überpublicLocationLabelund Land, Diebstahlschutz.
Interfaces
- Consumes: Task 2.
- Produces:
export const PUBLIC_BATCH_CACHE_CONTROL = 'public, max-age=300, s-maxage=3600';
export interface PublicOrigin { regions: string[]; country: string } // regions = eindeutige, getrimmte, nicht leere publicLocationLabels, sortiert
// PublicBatch: `apiaries` entfällt, neu `origin: PublicOrigin`. Name, location, address und Koordinaten des Stands verlassen den Server nicht mehr.
// CreateApiaryDto.publicLocationLabel?: string – @IsOptional() @IsString() @MaxLength(100); in UpdateApiaryDto per null leerbar
// UpdateSiteSettingsDto.originCountry?: string – @IsOptionalNotNull() @IsString() @MinLength(2) @MaxLength(80)
// Frontend: export function originText(origin: PublicOrigin): string // "Nordhessen, Deutschland" | "Deutschland"
-
Controller (nach erfolgreicher Suche,
@Res({ passthrough: true })):res.setHeader('Cache-Control', PUBLIC_BATCH_CACHE_CONTROL); res.removeHeader('Pragma'). -
UI-Texte:
- Stand: Feld „Ort für die Herkunftsseite“, Hinweis „Wird öffentlich auf der Herkunftsseite der Honiggläser gezeigt, z. B. „Nordhessen“. Nie die genaue Adresse eintragen. Leer lassen: Es erscheint nur das Land.“
- Website: Feld „Herkunftsland“, Hinweis „Erscheint auf der Herkunftsseite, z. B. „Deutschland“.“
-
Schritt 1: Failing Tests
it('never returns apiary name, location, address or coordinates', async () => {
const json = JSON.stringify(await service.findBatchByLot('WH-1'));
for (const secret of ['Stand Wald', 'Waldweg 3', '51.2']) expect(json).not.toContain(secret);
});
it('returns distinct labels plus the country; only the country when no label is set', ...);
// e2e
it('sends the public cache header on 200 and no-store on 404', async () => {
(await get(`/api/v1/public/batches/${lot}`)).expect(200).expect('Cache-Control', 'public, max-age=300, s-maxage=3600');
(await get('/api/v1/public/batches/NONE')).expect(404).expect('Cache-Control', 'no-store');
});
it('lets publicLocationLabel be set, changed and cleared with null', ...);
// Null-Vertrag: UpdateApiaryDto.clearable += 'publicLocationLabel'; UpdateSiteSettingsDto notNull: ['originCountry']
// Frontend: originText({ regions: [], country: 'Deutschland' }) === 'Deutschland'; Edit-Karte sendet emptyToNull(publicLocationLabel)
- Schritt 2:
- Backend:
npx jest src/public src/commonund die e2e-Dateien → FAIL - Frontend:
npx vitest run src/lib/public-batch "src/app/(app)/apiaries" "src/app/(app)/settings/automation/social-media-tab.spec.tsx"→ FAIL
- Backend:
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- Backend: typecheck, lint,
npm test -- --ci - Frontend:
npm run lint && npm run typecheck - Docs
- kein Commit
- Backend: typecheck, lint,
Task 23: Shop-Schlüssel getrennt nach Lesen und Buchen
Files
- Backend:
src/api-keys/api-keys.service.ts(+ neue.spec.ts),test/api-keys.e2e-spec.ts - Frontend:
src/lib/api-keys/types.ts- neu
src/lib/api-keys/scopes.ts(+.spec.ts) src/app/(app)/settings/automation/api-keys-tab.tsx(+ neueapi-keys-tab.spec.tsx)
- Docs:
ARCHITECTURE.mdAnnahme 14 und §9 „Maschinen-Zugriff“: Lesen und Buchen sind getrennte Schlüssel.
Interfaces
- Produces:
// Backend: ApiKeysService.create wirft BadRequestException('Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen'),
// wenn scopes 'shop-sync:read' UND 'shop-sync:write' enthält. Bestehende Schlüssel bleiben gültig.
// Frontend
export type ApiKeyScope = 'shop-sync:read' | 'shop-sync:write';
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = { 'shop-sync:read': 'Shop-Daten lesen (Wabenhain)', 'shop-sync:write': 'Verkäufe und Rückbuchungen buchen (Wabenhain)' };
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, 'scopes'>): boolean;
-
UI:
- Beim Anlegen eine Auswahl „Berechtigung“ mit dem bestehenden
ChoiceGroup(Lesen / Buchen), immer genau ein Scope. - In der Liste Scope-Badges je Schlüssel.
- Hinweis bei kombinierten Scopes (
role="status"): „Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in Wabenhain eintragen, dann diesen widerrufen.“
- Beim Anlegen eine Auswahl „Berechtigung“ mit dem bestehenden
-
Schritt 1: Failing Tests
it('refuses a new key with both shop scopes', ...); // Backend unit + e2e 400
expect(hasCombinedShopScopes({ scopes: ['shop-sync:read', 'shop-sync:write'] })).toBe(true);
it('shows the separation hint only for a key with both scopes', ...); // vitest
it('creates a write key when "Buchen" is chosen', ...); // createApiKey mit scopes ['shop-sync:write']
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- Backend: typecheck, lint,
npm test -- --ci - Frontend:
npm run lint && npm run typecheck - Docs
- kein Commit
- Backend: typecheck, lint,
Task 24: System-API (Läufe, Warteschlange, Wiederholen)
Files
- Create:
src/system/system.controller.tssrc/system/system-queue.service.ts(+.spec.ts)src/system/dto/system-list-query.dto.tstest/system.e2e-spec.ts
- Modify:
src/system/system.module.ts(Controller und Provider)src/auth/permissions/permission-catalog.ts: neue Gruppetest/authorization-coverage.e2e-spec.ts, falls dort eine Routenliste gepflegt wird
- Docs:
ARCHITECTURE.md§10, „Verwaltung > System“.
Interfaces
- Consumes: Task 4, 7, 15.
- Produces:
// permission-catalog.ts
...group('System', 'system', [['read', 'Hintergrundjobs und Warteschlangen ansehen'], ['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen']], 3500),
export interface JobRunView { id: string; job: JobName; status: JobRunStatus; startedAt: Date; finishedAt: Date | null; error: string | null; result: JobResult | null }
export interface SystemOverview {
jobs: { job: JobName; lastRun: JobRunView | null }[];
domainEvents: { pending: number; processing: number; failed: number; oldestPendingAgeSeconds: number | null };
webhookDeliveries: { pending: number; failed: number };
}
export interface FailedDomainEventView { id: string; type: string; aggregateType: string; aggregateId: string; occurredAt: Date; attempts: number; lastError: string | null; failedHandlers: { handler: string; lastError: string | null }[] }
export interface FailedWebhookDeliveryView { id: string; subscriptionId: string; targetHost: string; event: string; attempts: number; lastStatusCode: number | null; lastError: string | null; createdAt: Date }
SystemQueueService.overview(): Promise<SystemOverview>; // Alter per SQL: EXTRACT(EPOCH FROM now() - MIN("occurredAt"))
SystemQueueService.failedDomainEvents(limit: number): Promise<FailedDomainEventView[]>;
SystemQueueService.retryDomainEvent(id: string, actingUserId: string): Promise<void>; // 404 unbekannt, 409 nicht FAILED; Ereignis + FAILED-Deliveries -> PENDING, attempts 0; Audit (DomainEvent UPDATE); notifyWorker
SystemQueueService.failedWebhookDeliveries(limit: number): Promise<FailedWebhookDeliveryView[]>;
SystemQueueService.retryWebhookDelivery(id: string, actingUserId: string): Promise<void>;
// Routen (@RequirePermission):
// GET /system/overview system:read
// GET /system/job-runs?job=&limit=1..100 system:read (Standard 50)
// GET /system/domain-events/failed?limit= system:read
// POST /system/domain-events/:id/retry system:manage (204)
// GET /system/webhook-deliveries/failed?limit= system:read
// POST /system/webhook-deliveries/:id/retry system:manage (204)
- Schritt 1: Failing Tests
it('reports the oldest pending age from the database clock', ...); // $queryRaw, kein Date.now()
it('retry resets a FAILED event and its failed deliveries, writes an audit entry and wakes the worker', ...);
it('retry of a DONE event is 409', ...);
it('shows only the host of a webhook target', ...);
// e2e
it('staff without system:read gets 403; API keys never reach /system', ...);
it('admin retries a FAILED event and the next drain completes it', ...);
- Schritt 2: → FAIL.
- Schritt 3: Implementieren.
- Schritt 4: → PASS, inklusive
test/authorization-coverage.e2e-spec.ts. - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci - Docs
- kein Commit
- typecheck, lint,
Task 25: Seite „Verwaltung > System“ und Webhook-Zustellprotokoll
Files (alle in ApiCore-Frontend/)
- Create:
src/lib/system/api.ts,src/lib/system/types.tssrc/lib/system/labels.ts(+.spec.ts)src/app/(app)/settings/system/page.tsx(+page.spec.tsx)src/app/(app)/settings/automation/webhook-deliveries-dialog.tsx(+.spec.tsx)
- Modify:
src/app/(app)/sidebar.tsx:ADMIN_GROUP+{ label: "System", href: "/settings/system", icon: Activity, permission: "system:read" }src/app/(app)/settings/layout.tsx:SETTINGS_AREA_PERMISSIONS+"system:read"src/app/(app)/settings/automation/webhooks-tab.tsx: Knopf „Zustellprotokoll“src/lib/webhooks/api.ts:fetchWebhookDeliveries
Interfaces
- Consumes: Task 24 (Routen und Typen), Task 7 (
GET /webhooks/:id/deliveries). - Produces:
export function fetchSystemOverview(): Promise<SystemOverview>;
export function fetchJobRuns(job?: JobName): Promise<JobRunView[]>;
export function fetchFailedDomainEvents(): Promise<FailedDomainEventView[]>;
export function retryDomainEvent(id: string): Promise<void>;
export function fetchFailedWebhookDeliveries(): Promise<FailedWebhookDeliveryView[]>;
export function retryWebhookDelivery(id: string): Promise<void>;
export function fetchWebhookDeliveries(subscriptionId: string): Promise<WebhookDeliveryView[]>;
export const JOB_LABELS: Record<JobName, string>; // wie JOB_LABELS_DE im Backend
export const JOB_RUN_STATUS_LABELS = { RUNNING: 'Läuft', SUCCEEDED: 'Erfolgreich', FAILED: 'Fehlgeschlagen' } as const;
export const DOMAIN_EVENT_LABELS: Record<string, string>; // z. B. InspectionRecorded: 'Durchsicht erfasst'
export const QUEUE_STALE_WARNING_SECONDS = 600;
export function formatAge(seconds: number): string; // "vor 3 Min." / "vor 2 Std."
-
UI-Texte:
- Überschrift „System“, Abschnitte „Hintergrundjobs“, „Ereignis-Warteschlange“ und „Webhook-Zustellungen“.
- „Offen: {n}“, „Ältestes offenes Ereignis: {formatAge}“, „Keine offenen Ereignisse“.
- Warnung ab 600 s: „Der Worker arbeitet die Warteschlange gerade nicht ab.“
- „Keine fehlgeschlagenen Ereignisse.“, Knopf „Erneut versuchen“ (nur mit
system:manage), Toast „Wird erneut versucht“. - Dialog „Zustellprotokoll“ mit Status „Zugestellt / Offen / Fehlgeschlagen“, HTTP-Code und Versuchen.
-
Schritt 1: Failing Tests
it('lists every job with its last run and status label', ...);
it('warns when the oldest open event is older than 10 minutes', ...);
it('retries a failed event and refetches the overview', ...);
it('hides "Erneut versuchen" without system:manage', ...);
it('shows the delivery log of a webhook', ...); // webhook-deliveries-dialog.spec.tsx
expect(formatAge(180)).toBe('vor 3 Min.');
- Schritt 2:
npx vitest run src/lib/system "src/app/(app)/settings/system" "src/app/(app)/settings/automation/webhook-deliveries-dialog.spec.tsx"→ FAIL. - Schritt 3: Implementieren
- Vorher
node_modules/next/dist/docs/lesen. - Bestehende Bausteine nutzen:
Card,Badge,ConfirmDialog,QueryState,EmptyState.
- Vorher
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npm test && npm run build- kein Commit
Task 26: Kubernetes (Kustomize), Dockerfiles und CI
Files
- Move (mit
git mv, Geschichte bleibt erhalten):deploy/k8s/backend/*→deploy/k8s/base/backend/deploy/k8s/frontend/*→deploy/k8s/base/frontend/deploy/k8s/postgres/*→deploy/k8s/base/postgres/ingress.yamlbeider Projekte →deploy/k8s/overlays/encry/ingress-backend.yamlundingress-frontend.yaml
- Create:
deploy/k8s/base/kustomization.yaml. Die Dateien*.example.yamlstehen nicht unterresources.deploy/k8s/components/network-policies/mitkustomization.yaml(kind: Component),default-deny.yaml,allow-dns.yaml,frontend.yaml,api.yaml,worker.yaml,jobs.yaml,postgres.yamldeploy/k8s/components/pod-hardening/mitkustomization.yaml,patch-frontend.yamldeploy/k8s/components/backend-roles/mitkustomization.yaml,patch-api.yaml,worker-deployment.yaml,cronjobs.yamldeploy/k8s/components/api-hpa/mitkustomization.yaml,hpa.yaml,patch-api-replicas.yaml(JSON6902remove /spec/replicas)deploy/k8s/overlays/encry/mitkustomization.yaml,configmap-backend.yaml,configmap-frontend.yamldeploy/k8s/tests/manifest-policy.test.mjs.gitea/workflows/manifests.yml
- Modify:
ApiCore-Backend/Dockerfile:FROM node:22-alpine@sha256:<digest>in beiden StagesCMD ["node", "dist/main.js"]EXPOSE 3001 3002- Kommentar zum initContainer
ApiCore-Frontend/Dockerfile: Digest-Pin.gitea/workflows/backend-image.ymlundfrontend-image.yml: Zeiletype=raw,value=latest,enable={{is_default_branch}}entfernen
- Docs:
ARCHITECTURE.md§7 neu (Struktur base/components/overlay, Rollen-Deployments, CronJobs, NetworkPolicy-Tabelle) und §8 „Schema-Migrationen“ (initContainer statt CMD, Seed nur api).
Interfaces (YAML-Eckdaten, gepinnt)
-
api (base
Deployment apicore-backend, Selektor unverändert):- base
replicas: 1,strategy: RollingUpdate (maxSurge 1, maxUnavailable 0) - Image ohne Tag; den Digest setzt das Overlay.
- base
-
backend-roles-Patch für api:- env
APICORE_ROLE=api,HOME=/tmp - Pod-Label
db-client: "true",automountServiceAccountToken: false - Container
readOnlyRootFilesystem: true,emptyDirauf/tmp, Ressourcen laut Tabelle - initContainers:
migrate:["node", "node_modules/prisma/build/index.js", "migrate", "deploy"], envHOME=/tmp,CHECKPOINT_DISABLE=1seed:["node", "dist/seed.js"]- beide mit RORFS,
/tmpund den Job-Ressourcen
- Probes wie bisher.
- env
-
worker (
Deployment apicore-worker):replicas: 1, Labelsapp: apicore-worker,db-client: "true"- env
APICORE_ROLE=worker,WORKER_HEALTH_PORT=3002,HOME=/tmp command: ["node", "dist/worker.js"], initContainermigrate(ohne Seed)- Port
3002(health);startupProbe(/health, failureThreshold 30, periodSeconds 10),livenessProbe(periodSeconds 30, failureThreshold 3),readinessProbe(periodSeconds 15) terminationGracePeriodSeconds: 30, kein Service, kein IngressenvFromderselben ConfigMap und desselben Secrets
-
CronJobs
apicore-job-<name>:command: ["node", "dist/cli.js", "<name>"], Labelsapp: apicore-job,apicore.io/job: <name>,db-client: "true"restartPolicy: Never, Werte laut Global Constraintsapp-backupbindetapicore-dataauf/dataeinapicore-job-rekey-dry-runmit["node", "dist/cli.js", "rekey", "--dry-run"]
-
frontend (
pod-hardening): RORFS,emptyDirauf/tmpund/app/.next/cache,automountServiceAccountToken: false, Ressourcen laut Tabelle. -
Postgres bleibt in diesem Task unverändert. Die Live-Härtung übernimmt Runbook Schritt 1.
-
HPA:
autoscaling/v2,minReplicas: 1,maxReplicas: 2, CPUaverageUtilization: 70. -
NetworkPolicies (Spec §5.2):
- Default-Deny für Ingress und Egress.
- DNS-Egress für alle Pods zu
namespaceSelector kubernetes.io/metadata.name: kube-system+podSelector k8s-app: kube-dns, UDP und TCP 53. - Traefik-Quelle:
namespaceSelector kube-system+podSelector app.kubernetes.io/name: rke2-traefik. Wird im Runbook Schritt 1 gegen die Live-Policies geprüft. - frontend: Ingress von Traefik auf 3000; Egress zu
app: apicore-backendauf 3001. - api: Ingress von Traefik und vom Frontend auf 3001; Egress zu Postgres 5432 und 443 öffentlich.
- worker: kein Ingress; Egress zu Postgres 5432 und 443 öffentlich.
- jobs (
app in (apicore-job, apicore-db-backup)): Egress zu Postgres 5432. - postgres: Ingress nur von
db-client: "true"auf 5432, kein Egress. - 443 öffentlich bedeutet:
- IPv4
0.0.0.0/0ohne10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16, 127.0.0.0/8 - IPv6
::/0ohnefc00::/7, fe80::/10, ::1/128
- IPv4
-
Overlay
encry:namespace: apicorecomponents:alle vier (Zielstand)images:Backend, Frontend undpostgresmitdigest: sha256:0000…0000als Platzhalter (wird im Runbook Schritt 0 ersetzt)- Ingress-Hosts und ConfigMap-Werte als Platzhalter (Runbook Schritt 1);
ingressClassName: traefikund Middleware-Annotation wie live
-
CI
manifests.yml:- Trigger:
pushaufmainundpull_requestmitpaths: ['deploy/k8s/**', '.gitea/workflows/manifests.yml'], dazuworkflow_dispatch runs-on: docker-builder,actions/setup-node@v4(22)kubectl,kubeconformundyqals Binär-Downloads mit festen Versionen und festen SHA-256-Werten (sha256sum -c)- Ablauf:
kubectl kustomize deploy/k8s/overlays/encry > "$RUNNER_TEMP/rendered.yaml"→kubeconform -strict -summary "$RUNNER_TEMP/rendered.yaml"→yq ea -o=json '[.]' … > rendered.json→RENDERED_MANIFESTS=… node --test deploy/k8s/tests/manifest-policy.test.mjs
- Trigger:
-
Schritt 1: Failing Test
deploy/k8s/tests/manifest-policy.test.mjs(node:test, liest das JSON ausRENDERED_MANIFESTS):
test('default deny for ingress and egress exists', ...);
test('every pod template can resolve DNS (UDP+TCP 53 to kube-dns)', ...);
test('postgres accepts 5432 only from db-client pods; only api, worker, jobs and db-backup carry db-client', ...);
test('frontend has no path to 5432 and no ipBlock egress', ...);
test('worker and job pods accept no ingress', ...);
test('every 0.0.0.0/0 and ::/0 egress excludes private ranges and allows only TCP 443', ...);
test('all app pods: automountServiceAccountToken false, readOnlyRootFilesystem true, /tmp emptyDir', ...);
test('cronjobs: Forbid, Europe/Berlin, command node dist/cli.js <known job>', ...);
test('resources match the spec table; HPA api 1–2 at 70 % CPU', ...);
test('api and worker run prisma migrate deploy as initContainer; only api seeds', ...);
- Schritt 2:
kubectl kustomize deploy/k8s/overlays/encry→ FAIL (Overlay fehlt). Falls lokal keinkubectloderyqvorhanden ist, übernimmt der Orchestrator oder die CI diesen Lauf. - Schritt 3: Implementieren
- Manifeste wie oben.
- Base-Image-Digest auflösen mit
docker buildx imagetools inspect node:22-alpine --format '{{json .Manifest.Digest}}'. Ohne Docker:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/node:pull" | sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -sI -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json" \
https://registry-1.docker.io/v2/library/node/manifests/22-alpine | grep -i docker-content-digest
- Versionen und SHA-256 der CI-Werkzeuge von den offiziellen Release-Seiten eintragen:
https://dl.k8s.io/release/<v>/bin/linux/amd64/kubectl.sha256,CHECKSUMSbei kubeconform,checksumsbei yq. - Blocker vor dem Commit: Weder der Dockerfile-Digest noch die CI-Checksummen dürfen Platzhalter bleiben. Die Overlay-Digests und Hosts sind dagegen gewollte Platzhalter (Runbook).
- Schritt 4:
kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary -→ 0 Fehlernode --test deploy/k8s/tests/manifest-policy.test.mjs→ PASSdocker build ApiCore-Backend(falls Docker vorhanden) → Image startet mitCMD node dist/main.js
- Schritt 5: Verifikation
git statuszeigt die Verschiebungen als Renames.- Docs.
- Kein Apply gegen den Cluster, kein Commit.
Abschluss (Orchestrator)
- Backend:
npm run lint && npm run typecheck && npm test && npm run build- Test-DB migrieren, dann
npm run test:e2e, komplett ls dist/main.js dist/worker.js dist/cli.js dist/seed.js
- Frontend:
npm run lint && npm run typecheck && npm test && npm run build - Manifeste:
kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary -undnode --test deploy/k8s/tests/manifest-policy.test.mjs - Drift-Check gegen die Test-DB:
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code→ 0 - Platzhalter-Kontrolle:
grep -rn "REPLACE\|sha256:0000" ApiCore-Backend/Dockerfile ApiCore-Frontend/Dockerfile .gitea/workflows→ leer. Treffer nur im Overlay sind erlaubt. ARCHITECTURE.mdauf Widersprüche lesen: §7, §8, §9, §10, Annahmen 9, 11, 12, 14, 19, 22, 23.- Ein zentraler Commit, Nachricht endet mit
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>.
Rollout-Runbook (nicht Teil der Tasks; jeder Schritt braucht die Freigabe des Inhabers)
Schritt 0 – Vorbereitung (Freigabe)
- Im Overlay zunächst die Live-Digests eintragen (aktuell laufende Images), nicht die neuen: Das neue Backend-Image verlangt
APICORE_ROLEund migriert nicht mehr selbst – es geht erst in Schritt 3 zusammen mitbackend-roleslive. Neue Digests ermitteln (docker buildx imagetools inspect git.encry.de/<owner>/apicore-backend:sha-<commit>) und für Schritt 3 bereithalten; Postgres-Digest = live. - Kubernetes-Version prüfen (
kubectl version) und optional in kubeconform pinnen (-kubernetes-version).
Schritt 1 – Overlay auf den Ist-Stand bringen (Freigabe)
- In
overlays/encry/kustomization.yamlallecomponentsvorübergehend auskommentieren. - Live-Stand exportieren:
kubectl -n apicore get deploy,sts,cronjob,ingress,cm,networkpolicy -o yaml. - Übernehmen:
- Hosts, TLS-Secrets und ConfigMap-Werte ins Overlay.
- Die Postgres-Härtung wörtlich in
base/postgres/statefulset.yaml. - Die Selektoren der 3 Live-NetworkPolicies (Traefik-Labels,
db-client) incomponents/network-policies.
kubectl diff -k deploy/k8s/overlays/encrywiederholen, bis der Diff leer ist.- Prüfen:
kubectl -n kube-system get pods -l k8s-app=kube-dnsund das Traefik-Label. - Rückweg: keiner nötig, es wird nichts angewendet.
- Achtung:
base/postgres/data-pvc.yamlstellt das Live-PVC unterkubectl apply/delete -k. Niekubectl delete -kauf das Overlay ausführen – das PV istRetain, die Daten bleiben, aber das PVC müsste von Hand neu gebunden werden.
Schritt 2 – NetworkPolicies schärfen (Freigabe)
- Hinweis: Die drei Policies aus dem rke2-cluster-Repo (
allow-from-traefik,allow-same-namespace,default-deny-ingress, nur Ingress) bleiben bis zum Ende dieses Schritts parallel aktiv und lockern Ingress (Traefik erreicht bis dahin jeden Port, auch Postgres). - Reihenfolge: (a)
components/network-policiesaktivieren – enthält die ACME-HTTP01-Solver-Policy und das Labeldb-client: "true"am api-Pod –,kubectl diff -k …,kubectl apply -k …; (b) prüfen (unten); (c) erst danach im rke2-cluster-Repo dieapicore-Kopien vonallow-from-traefikundallow-same-namespaceentfernen und erneut prüfen; (d) vor dem nächsten Zertifikats-Renewal (frontend 2026-10-25, backend 2026-10-26) mitkubectl -n apicore get certificatebestätigen, dass ein Renewal durchläuft (oder percmctl renewtesten). - Prüfen:
- Alle Pods
Ready, keine Probe-Fehler inkubectl get events. - DNS:
kubectl -n apicore exec deploy/apicore-backend -- getent hosts apicore-postgres. - Frontend → Postgres blockiert:
kubectl -n apicore exec deploy/apicore-frontend -- node -e "require('net').connect(5432,'apicore-postgres').on('connect',()=>console.log('OPEN')).on('error',e=>console.log(e.code)).setTimeout(3000,()=>{console.log('BLOCKED');process.exit()})"→BLOCKED. - Login funktioniert, SSO und Have-I-Been-Pwned sind erreichbar.
- Alle Pods
- Rückweg:
kubectl -n apicore delete networkpolicy <neue Namen>, alte Policies aus dem Export wieder anwenden.
Schritt 3 – Rollen, Worker, CronJobs, Pod-Härtung (Freigabe)
- Vorab prüfen (Inhaber):
SHOW TIME ZONE;auf der Live-DB (App pinnt jede Sitzung auf UTC; optionalALTER DATABASE apicore SET timezone TO 'UTC';) und dassDATABASE_URLkeinoptions=enthält – sonst verweigert die App den Start. - Vorab prüfen (Inhaber, ohne Werte auszugeben): Das Live-Secret
apicore-backend-secretenthält keinen Wert, der mitchange-mebeginnt – besondersJWT_REFRESH_SECRET(alter Beispielwert war kurz und wurde nie geprüft). Sonst verweigert jede Rolle den Start. - Secret ergänzen, außerhalb des Repos:
MFA_ENCRYPTION_KEYundWEBHOOK_ENCRYPTION_KEYneu erzeugen (openssl rand -hex 32, alle verschieden).- Ein vorhandenes
BACKUP_ENCRYPTION_KEYunverändert lassen. Fehlt es, neu setzen; alte JWT-abgeleitete Backups bleiben bis Schritt 4 lesbar.
components/pod-hardeningundcomponents/backend-rolesaktivieren, neue Image-Digests, diff, apply.- Prüfen:
- initContainer
migrateerfolgreich; im API-LogAPICORE_ROLE=api. - Worker
Ready,kubectl -n apicore exec deploy/apicore-worker -- wget -qO- localhost:3002/health. kubectl -n apicore create job cleanup-test --from=cronjob/apicore-job-cleanup→ in „Verwaltung > System“Erfolgreich.- Testdurchsicht anlegen → „Offen: 0“ nach Sekunden; Webhook-Testziel bekommt die Zustellung.
- initContainer
- Zusatz mit eigener Freigabe: Cloudflare-Cache-Regel und Rate-Limit-Regel für
/api/v1/public/batches/*(Spec §5.4). - Rückweg: vorigen Digest zurücksetzen und die Komponenten deaktivieren. Die Migration ist additiv, das alte Image läuft weiter.
Schritt 4 – Schlüssel trennen (Freigabe)
Voraussetzung: Schritt 3 ist live, api und worker laufen mit MFA_ENCRYPTION_KEY/WEBHOOK_ENCRYPTION_KEY, JWT_ACCESS_SECRET ist noch der alte Wert (nur er öffnet die Altchiffrate).
kubectl -n apicore create job rekey-dry-$(date +%s) --from=cronjob/apicore-job-rekey-dry-run→ in „Verwaltung > System“ bzw.JobRun:Erfolgreich, Zählermfa.failed/webhook.failed= 0;mfa.rekeyed+webhook.rekeyed+webhook.plaintextSealedist die Zahl der umzustellenden Werte. Scheitert der Dry-Run: Pod-Log nennt je Zeile ID und Grund.unknown key id→ fehlenden*_PREVIOUSsetzen;ciphertext failed authenticationbei einem Altwert → JWT-Secret wurde schon geändert, betroffene Nutzer richten MFA neu ein bzw. Webhook neu anlegen. Erst mitfailed: 0weiter.kubectl -n apicore create job rekey-$(date +%s) --from=cronjob/apicore-job-rekey→JobRunErfolgreich.changedMeanwhile> 0 ist ein harter Stopp, kein Erfolg: die App hat diese Zeile inzwischen selbst neu geschrieben, der Job hat sie bewusst nicht angefasst (sie kann noch mit dem alten Schlüssel versiegelt sein). Dry-Run aus 1 wiederholen (und bei Bedarf den Lauf), bis alle Zähler 0 sind, erst dann rotieren. Beifailed> 0 endet der Lauf immerFAILED, die Zähler stehen im Fehlertext; betroffene Zeilen behalten ihren Wert.- Noch einmal den Dry-Run aus 1:
mfa.rekeyed,webhook.rekeyed,webhook.plaintextSealed,changedMeanwhileundfailed= 0. Der Dry-Run öffnet auch Zeilen, die schon den aktuellen Schlüssel tragen (ein beschädigter Wert zählt alsfailed); nur der echte Lauf überspringt sie ungeöffnet. Bricht ein Lauf ab (Pod weg), einfach wiederholen – umgestellte Zeilen werden übersprungen. - MFA-Login testen, Webhook-Testzustellung prüfen.
- „Jetzt sichern“: neues Backup mit den neuen Schlüsseln (ältere, JWT-versiegelte Backups sind nach Punkt 6 nicht mehr einspielbar).
- Zügig danach
JWT_ACCESS_SECRETundJWT_REFRESH_SECRETrotieren undkubectl -n apicore rollout restart deploy/apicore-backend deploy/apicore-worker. Alle Sitzungen enden einmal, das ist gewollt.
- Rückweg vor Punkt 6: alte Werte zurück, Altchiffrate bleiben lesbar (umgestellte Werte öffnen nur mit den neuen Schlüsseln – die neuen Schlüssel darum nie verwerfen, sondern höchstens als
*_PREVIOUSbehalten). Nach Punkt 6 gibt es keinen Rückweg für Altchiffrate, darum die Prüfungen in 1–5. - Später (eigene Freigabe): den Klartext-Rückfall in
openWebhookSecretentfernen, wenn der Dry-Runwebhook.plaintextSealed: 0zeigt.
Schritt 5 – HPA einschalten (Freigabe)
components/api-hpaaktivieren, diff, apply.- Prüfen:
kubectl -n apicore get hpazeigt CPU-Ziel und aktuellen Wert. Der Throttler zählt über 2 Pods (optional ein kurzer Lasttest gegen/api/v1/public/batches/X: nach 30 Anfragen pro Minute kommt 429). - Rückweg: Komponente deaktivieren,
replicas: 1.
Self-Review
Abdeckung Spec → Tasks
| Spec-Abschnitt | Tasks |
|---|---|
§2 Laufrollen, Einstiege, WorkerModule, Health, Jobs, JobRun, Migration als initContainer |
1, 5, 6, 15, 16, 18, 21, 26 |
§3.1 DomainEvent/DomainEventDelivery |
2 |
§3.2 Schreiben in der Transaktion, Audit mit tx |
3, 9, 11–14 |
§3.3 Verteilen (SKIP LOCKED, Backoff, FAILED, Stale, NOTIFY + 5 s) |
4, 5, 6 |
| §3.4 Katalog (Erzeuger und erste Handler) und zeitbasierte Ereignisse | 3, 7, 8, 10, 11–14, 18 |
§3.5 Umstellung, Kalender aus Handlern, WebhookDelivery, Aufgaben über TasksService |
7, 8, 9, 10, 17 |
| §4 Mehrfach-Sicherheit (Backup, Idempotenz, Vorlagen, Throttler) | 15, 16, 17, 19 |
§5.1 Schlüssel, Schlüsselbund, rekey, Startabbruch |
20, 21 |
| §5.2 NetworkPolicies | 26 |
§5.3 Pod-Härtung, Digest, kein latest |
26 |
§5.4 Cache-Kopf, publicLocationLabel |
22 (Cloudflare: Runbook) |
| §5.5 Getrennte Shop-Schlüssel und UI-Hinweis | 23 |
| §6 Kustomize, Overlay, Rollout-Reihenfolge | 26 und Runbook |
| §7 Ressourcen und HPA | 26 |
| §8 Fehlerfälle (Worker aus, Handler wirft, Ziel weg, CronJob scheitert, 2 API-Pods, Migration scheitert) | 4, 7, 15, 19, 24, 25, 26 |
| §9 Tests (Unit, e2e, Wächter, Manifeste) | 1, 3, 4, 11, 17, 19, 20, 21, 26 |
Bewusste Entscheidungen und Abweichungen (bitte beim Review bestätigen)
- Zusätzliche Katalog-Ereignisse:
VarroaCorrected,FeedingCorrected,HarvestCorrected,TaskUpdated,BottlingCorrected. Sie halten das MusterRecorded/Correctedeinheitlich, und Kalender sowie Automatik reagieren heute auch auf Änderungen. - Neue Webhook-Ereignisse:
varroa.measured,treatment.created,feeding.created. Spec §3.4 nennt dafür einen Webhook-Handler. InspectionCorrectedohne Automatik: wie heute keine Regelauswertung bei einer Korrektur.- Handler schreiben nur in die DB:
- Kalender-„Abmelden“ heißt jetzt erneut einreihen; der Worker löscht das Graph-Event.
- „Jetzt synchronisieren“ weckt nur noch den Worker.
- Schlüssel-ID: Fingerabdruck
k<8 hex>statt fortlaufendk1. Zwei verschiedene Schlüssel können so nie dieselbe ID tragen. - Herkunftsland: neues Feld
SiteSettings.originCountry(Standard „Deutschland“, bearbeitbar), weil es kein Land-Feld gab.- Die öffentliche Antwort enthält auch den Stand-Namen nicht mehr, denn das Frontend fiel bisher auf ihn zurück.
- app-backup: Der CronJob läuft stündlich und prüft den Zeitplan aus GUI oder Env. So bleibt der Zeitplan in der GUI bearbeitbar, ohne Kubernetes-API.
- Bestand:
StockChangedträgt zusätzlichrawMaterialKinds. Damit wird auch die Rohstoff-Mindestmenge („Mindestbestand“) zum Handler. - Berechtigungen:
backup:manageundautomation:manageaus Spec §8 gibt es nicht. Sie werden aufbackup:configureundautomation:updateabgebildet.- Neu sind
system:readundsystem:manage.
- Bleiben bewusst auf
runAfterCommit, weil sie nicht im Katalog der ersten Stufe stehen:colony.created, Königinnen, Honigchargen, Lager (StockItem), Schwarmkontrolle. Der Wächter in Task 17 hält diese Liste fest. - Nicht in diesem Plan:
- Spec §4 „Nummernkreise: Jahreswechsel“ und „
NotificationReadje Nutzer“, laut Spec eigene Fixes bzw. T4. - Spec §10 komplett.
- Spec §4 „Nummernkreise: Jahreswechsel“ und „