feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
import { randomBytes } from 'node:crypto';
import { Prisma } from '../generated/prisma';
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../src/auth/mfa/mfa-crypto.util';
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
import { gcmSeal, legacyJwtKey } from '../src/common/crypto/key-ring';
import { RekeyJob } from '../src/jobs/rekey.job';
import { runCli } from '../src/jobs/run-cli';
import { PrismaService } from '../src/prisma/prisma.service';
import {
openWebhookSecret,
sealWebhookSecret,
} from '../src/webhooks/webhook-secret';
type Form = 'legacy' | 'previous' | 'current' | 'plaintext';
type MfaForm = Exclude<Form, 'plaintext'>;
const MFA_FORMS: readonly MfaForm[] = ['legacy', 'previous', 'current'];
const WEBHOOK_FORMS: readonly Form[] = [
'legacy',
'plaintext',
'previous',
'current',
];
const PREVIOUS_KEYS = {
MFA_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-mfa-${'p'.repeat(32)}`,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-webhook-${'q'.repeat(32)}`,
} as const;
/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */
function legacyCiphertext(plaintext: string): string {
const { iv, tag, ciphertext } = gcmSeal(
legacyJwtKey(),
Buffer.from(plaintext, 'utf8'),
);
return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.');
}
/** Seals with the key in `<variable>_PREVIOUS`, as if before the rotation. */
function sealedWithPrevious(variable: string, seal: () => string): string {
const current = process.env[variable];
process.env[variable] = process.env[`${variable}_PREVIOUS`];
try {
return seal();
} finally {
if (current === undefined) {
delete process.env[variable];
} else {
process.env[variable] = current;
}
}
}
function sealMfa(form: MfaForm, plaintext: string): string {
if (form === 'legacy') {
return legacyCiphertext(plaintext);
}
if (form === 'previous') {
return sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret(plaintext),
);
}
return encryptMfaSecret(plaintext);
}
function sealWebhook(form: Form, plaintext: string): string {
if (form === 'legacy') {
return `enc:v1:${legacyCiphertext(plaintext)}`;
}
if (form === 'plaintext') {
return plaintext;
}
if (form === 'previous') {
return sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () =>
sealWebhookSecret(plaintext),
);
}
return sealWebhookSecret(plaintext);
}
const keyIdOf = (stored: string): string => stored.split(':')[0];
/**
* The job as `node dist/cli.js rekey [--dry-run]` runs it: runCli with the
* real JobsModule against the shared test database. The job visits every
* row there, also those of e2e files running in parallel. So this file
* sets only *_PREVIOUS: the current keys stay the ones every e2e process
* has (the development keys from JWT_ACCESS_SECRET in .env.test, unless the
* shell sets real ones), and whatever this job reseals still opens there.
* Assertions on single rows use this file's rows; counts over the whole
* database are lower bounds.
*/
describe('rekey job (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const testStart = new Date(Date.now() - 1000);
const savedEnv = Object.fromEntries(
Object.keys(PREVIOUS_KEYS).map((name) => [name, process.env[name]]),
);
let prisma: PrismaService;
const mfa = {} as Record<MfaForm, { id: string; plaintext: string }>;
const webhook = {} as Record<Form, { id: string; plaintext: string }>;
const created = { mfa: {}, webhook: {} } as {
mfa: Record<MfaForm, string>;
webhook: Record<Form, string>;
};
const extraUserIds: string[] = [];
const createUser = async (name: string, mfaSecret: string) => {
const user = await prisma.user.create({
data: {
email: `rekey-e2e-${name}-${suffix}@example.com`,
passwordHash: 'not-used',
name: `Rekey ${name}`,
mfaEnabled: true,
mfaSecret,
role: { connect: { key: 'READONLY' } },
},
});
return user.id;
};
/** This file's rows as stored now. */
const storedValues = async () => {
const users = await prisma.user.findMany({
where: { id: { in: MFA_FORMS.map((form) => mfa[form].id) } },
select: { id: true, mfaSecret: true },
});
const subscriptions = await prisma.webhookSubscription.findMany({
where: { id: { in: WEBHOOK_FORMS.map((form) => webhook[form].id) } },
select: { id: true, secret: true },
});
const userById = new Map(users.map((row) => [row.id, row.mfaSecret]));
const subscriptionById = new Map(
subscriptions.map((row) => [row.id, row.secret]),
);
return {
mfa: Object.fromEntries(
MFA_FORMS.map((form) => [form, userById.get(mfa[form].id)]),
) as Record<MfaForm, string>,
webhook: Object.fromEntries(
WEBHOOK_FORMS.map((form) => [
form,
subscriptionById.get(webhook[form].id),
]),
) as Record<Form, string>,
};
};
const rekeyRuns = () =>
prisma.jobRun.findMany({
where: { job: 'rekey', startedAt: { gte: testStart } },
orderBy: { startedAt: 'asc' },
});
beforeAll(async () => {
Object.assign(process.env, PREVIOUS_KEYS);
prisma = new PrismaService();
await prisma.$connect();
for (const form of MFA_FORMS) {
const plaintext = generateTotpSecret();
created.mfa[form] = sealMfa(form, plaintext);
mfa[form] = {
id: await createUser(form, created.mfa[form]),
plaintext,
};
}
for (const form of WEBHOOK_FORMS) {
const plaintext = randomBytes(32).toString('hex');
created.webhook[form] = sealWebhook(form, plaintext);
const subscription = await prisma.webhookSubscription.create({
data: {
url: 'https://receiver.example.com/rekey-e2e',
events: ['task.created'],
secret: created.webhook[form],
active: false,
},
});
webhook[form] = { id: subscription.id, plaintext };
}
});
afterAll(async () => {
const runs = await rekeyRuns();
const runIds = runs.map((run) => run.id);
await prisma.notification.deleteMany({
where: { relatedType: 'JobRun', relatedId: { in: runIds } },
});
await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } });
const userIds = [
...MFA_FORMS.flatMap((form) => (mfa[form] ? [mfa[form].id] : [])),
...extraUserIds,
];
await prisma.notification.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.webhookSubscription.deleteMany({
where: {
id: {
in: WEBHOOK_FORMS.flatMap((form) =>
webhook[form] ? [webhook[form].id] : [],
),
},
},
});
await prisma.$disconnect();
for (const [name, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
});
it('a second run rekeys nothing; dry-run writes nothing', async () => {
const currentMfaKeyId = keyIdOf(encryptMfaSecret('key-id-probe'));
const currentWebhookKeyId = keyIdOf(sealWebhookSecret('key-id-probe'));
// Dry-run: counts, opens every value, writes none.
expect(await runCli(['rekey', '--dry-run'])).toBe(0);
expect(await storedValues()).toEqual(created);
// Run: everything not sealed with the current key is resealed.
expect(await runCli(['rekey'])).toBe(0);
const afterFirst = await storedValues();
for (const form of ['legacy', 'previous'] as const) {
expect(keyIdOf(afterFirst.mfa[form])).toBe(currentMfaKeyId);
expect(afterFirst.mfa[form]).not.toBe(created.mfa[form]);
}
for (const form of ['legacy', 'plaintext', 'previous'] as const) {
expect(keyIdOf(afterFirst.webhook[form])).toBe(currentWebhookKeyId);
expect(afterFirst.webhook[form]).not.toBe(created.webhook[form]);
}
expect(afterFirst.mfa.current).toBe(created.mfa.current);
expect(afterFirst.webhook.current).toBe(created.webhook.current);
// Second run: a reseal would draw a fresh IV, so unchanged bytes prove
// that nothing was written.
expect(await runCli(['rekey'])).toBe(0);
expect(await storedValues()).toEqual(afterFirst);
const [dryRun, first, second, ...others] = await rekeyRuns();
expect(others).toHaveLength(0);
for (const run of [dryRun, first, second]) {
expect(run).toMatchObject({ status: 'SUCCEEDED', error: null });
}
const counts = (run: typeof first) => run.result as Record<string, number>;
expect(counts(dryRun)).toMatchObject({ dryRun: true, 'mfa.failed': 0 });
expect(counts(first)).toMatchObject({ dryRun: false, 'mfa.failed': 0 });
for (const run of [dryRun, first]) {
expect(counts(run)['mfa.rekeyed']).toBeGreaterThanOrEqual(2);
expect(counts(run)['webhook.rekeyed']).toBeGreaterThanOrEqual(2);
expect(counts(run)['webhook.plaintextSealed']).toBeGreaterThanOrEqual(1);
}
// No e2e file writes old-format MFA or sealed webhook secrets; others
// may add plaintext webhook secrets meanwhile, so that count stays open.
expect(counts(second)).toMatchObject({
dryRun: false,
'mfa.rekeyed': 0,
'mfa.plaintextSealed': 0,
'mfa.changedMeanwhile': 0,
'mfa.failed': 0,
'webhook.rekeyed': 0,
'webhook.failed': 0,
});
expect(counts(second)['mfa.alreadyCurrent']).toBeGreaterThanOrEqual(3);
expect(counts(second)['webhook.alreadyCurrent']).toBeGreaterThanOrEqual(4);
});
it('rekeyed values open to the original plaintext', async () => {
// The previous keys can go now. (JWT_ACCESS_SECRET cannot change here:
// it is also the source of the development keys.)
for (const name of Object.keys(PREVIOUS_KEYS)) {
delete process.env[name];
}
const stored = await storedValues();
for (const form of MFA_FORMS) {
expect(decryptMfaSecret(stored.mfa[form])).toBe(mfa[form].plaintext);
}
for (const form of WEBHOOK_FORMS) {
expect(openWebhookSecret(stored.webhook[form])).toBe(
webhook[form].plaintext,
);
}
});
it('skips a row changed since it was read and keeps the new secret', async () => {
const userId = await createUser(
'raced',
legacyCiphertext(generateTotpSecret()),
);
extraUserIds.push(userId);
const reenrolled = encryptMfaSecret(generateTotpSecret());
// Between the job's read and its write, the user sets MFA up anew -
// through the real database, so the SQL compare-and-swap is what has
// to keep the new secret.
const racing = {
user: {
findMany: (args: Prisma.UserFindManyArgs) => prisma.user.findMany(args),
updateMany: async (args: Prisma.UserUpdateManyArgs) => {
if (args.where?.id === userId) {
await prisma.user.update({
where: { id: userId },
data: { mfaSecret: reenrolled },
});
}
return prisma.user.updateMany(args);
},
},
webhookSubscription: prisma.webhookSubscription,
} as unknown as PrismaService;
const result = await new RekeyJob(racing).run([]);
expect(result['mfa.changedMeanwhile']).toBeGreaterThanOrEqual(1);
expect(result['mfa.failed']).toBe(0);
const after = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { mfaSecret: true },
});
expect(after.mfaSecret).toBe(reenrolled);
});
});