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:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,265 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
decryptMfaSecret,
|
||||
encryptMfaSecret,
|
||||
mfaSecretNeedsRekey,
|
||||
} from '../auth/mfa/mfa-crypto.util';
|
||||
import { loadKeyRing, SecretPurpose } from '../common/crypto/key-ring';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
isPlaintextWebhookSecret,
|
||||
openWebhookSecret,
|
||||
sealWebhookSecret,
|
||||
webhookSecretNeedsRekey,
|
||||
} from '../webhooks/webhook-secret';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
/** `rekey --dry-run`: open and count every secret, write none. */
|
||||
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
||||
|
||||
/** What one run did with the secrets of one purpose (mfa, webhook). */
|
||||
export interface RekeyCounts {
|
||||
/** Rows with a stored secret. */
|
||||
total: number;
|
||||
/** Sealed with the previous key or before the key ring, resealed. */
|
||||
rekeyed: number;
|
||||
/**
|
||||
* Already sealed with the current key, left as they are. Only a dry-run
|
||||
* opens them (a value that does not open counts as `failed`).
|
||||
*/
|
||||
alreadyCurrent: number;
|
||||
/** Webhook secrets stored in plain text, now sealed. */
|
||||
plaintextSealed: number;
|
||||
/** Written by someone else since this run read them; not overwritten. */
|
||||
changedMeanwhile: number;
|
||||
/** Could not be opened or written; they keep their old value. */
|
||||
failed: number;
|
||||
}
|
||||
|
||||
type RowOutcome = Exclude<keyof RekeyCounts, 'total'>;
|
||||
|
||||
interface StoredSecret {
|
||||
id: string;
|
||||
stored: string;
|
||||
}
|
||||
|
||||
/** One column of sealed secrets, with the helpers of its purpose. */
|
||||
interface SecretColumn {
|
||||
purpose: SecretPurpose;
|
||||
/** How log lines name a row: "<entity> <id>". */
|
||||
entity: string;
|
||||
read(): Promise<StoredSecret[]>;
|
||||
/**
|
||||
* Writes `next` only while the row still holds `previous` - one UPDATE
|
||||
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
||||
* another run) is never overwritten. False if nothing matched.
|
||||
*/
|
||||
swap(id: string, previous: string, next: string): Promise<boolean>;
|
||||
needsRekey(stored: string): boolean;
|
||||
isPlaintext(stored: string): boolean;
|
||||
open(stored: string): string;
|
||||
seal(plaintext: string): string;
|
||||
}
|
||||
|
||||
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
||||
function isDryRun(args: readonly string[]): boolean {
|
||||
const refused = args.filter((arg) => arg !== REKEY_DRY_RUN_ARG);
|
||||
if (refused.length > 0) {
|
||||
throw new Error(
|
||||
`rekey does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return args.length > 0;
|
||||
}
|
||||
|
||||
function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
|
||||
const count = (outcome: RowOutcome): number =>
|
||||
outcomes.filter((each) => each === outcome).length;
|
||||
return {
|
||||
total: outcomes.length,
|
||||
rekeyed: count('rekeyed'),
|
||||
alreadyCurrent: count('alreadyCurrent'),
|
||||
plaintextSealed: count('plaintextSealed'),
|
||||
changedMeanwhile: count('changedMeanwhile'),
|
||||
failed: count('failed'),
|
||||
};
|
||||
}
|
||||
|
||||
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
||||
function toJobResult(
|
||||
dryRun: boolean,
|
||||
counts: readonly (readonly [SecretPurpose, RekeyCounts])[],
|
||||
): JobResult {
|
||||
return {
|
||||
dryRun,
|
||||
...Object.fromEntries(
|
||||
counts.flatMap(([purpose, perPurpose]) =>
|
||||
(Object.keys(perPurpose) as (keyof RekeyCounts)[]).map((key) => [
|
||||
`${purpose}.${key}`,
|
||||
perPurpose[key],
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function describeResult(result: JobResult): string {
|
||||
return Object.entries(result)
|
||||
.map(([key, value]) => `${key}=${String(value)}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reseals every stored MFA (TOTP) and webhook signing secret with the
|
||||
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
||||
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
||||
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
||||
* still stored in plain text. Run it after setting new keys, before
|
||||
* removing `*_PREVIOUS` and before rotating the JWT secrets.
|
||||
*
|
||||
* One row at a time, each write its own compare-and-swap statement, no
|
||||
* transaction around the run: an interrupted run keeps what it resealed and
|
||||
* the next run skips those rows. A row that cannot be opened or written
|
||||
* keeps its old value; the run goes on and fails at the end, with the
|
||||
* counts in its error. Logs name rows by id, never a secret or ciphertext.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RekeyJob implements Job {
|
||||
readonly name: JobName = 'rekey';
|
||||
|
||||
private readonly logger = new Logger(RekeyJob.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run(args: readonly string[]): Promise<JobResult> {
|
||||
const dryRun = isDryRun(args);
|
||||
const columns = [this.mfaColumn(), this.webhookColumn()];
|
||||
// A key that is set but unusable fails the run before any row is read.
|
||||
for (const column of columns) {
|
||||
loadKeyRing(column.purpose);
|
||||
}
|
||||
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||
for (const column of columns) {
|
||||
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||
}
|
||||
const result = toJobResult(dryRun, counts);
|
||||
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`rekey: ${failed} secret(s) could not be rekeyed - ${describeResult(result)}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async rekeyColumn(
|
||||
column: SecretColumn,
|
||||
dryRun: boolean,
|
||||
): Promise<RekeyCounts> {
|
||||
const rows = await column.read();
|
||||
const outcomes: RowOutcome[] = [];
|
||||
for (const row of rows) {
|
||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||
}
|
||||
return countOutcomes(outcomes);
|
||||
}
|
||||
|
||||
private async rekeyRow(
|
||||
column: SecretColumn,
|
||||
row: StoredSecret,
|
||||
dryRun: boolean,
|
||||
): Promise<RowOutcome> {
|
||||
const current = !column.needsRekey(row.stored);
|
||||
// A real run skips current rows unopened; a dry-run opens them too, so
|
||||
// the closing all-zero check also catches a corrupted current value.
|
||||
if (current && !dryRun) {
|
||||
return 'alreadyCurrent';
|
||||
}
|
||||
const outcome: RowOutcome = current
|
||||
? 'alreadyCurrent'
|
||||
: column.isPlaintext(row.stored)
|
||||
? 'plaintextSealed'
|
||||
: 'rekeyed';
|
||||
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
||||
try {
|
||||
// Opened in a dry-run too: it reports what a real run could not reseal.
|
||||
const plaintext = column.open(row.stored);
|
||||
if (dryRun) {
|
||||
return outcome;
|
||||
}
|
||||
const resealed = column.seal(plaintext);
|
||||
// The swap replaces the only copy of the old value.
|
||||
if (column.open(resealed) !== plaintext) {
|
||||
throw new Error('resealed secret does not open to the same value');
|
||||
}
|
||||
if (await column.swap(row.id, row.stored, resealed)) {
|
||||
return outcome;
|
||||
}
|
||||
this.logger.warn(
|
||||
`${subject} changed since it was read, left as it is (run rekey --dry-run to check)`,
|
||||
);
|
||||
return 'changedMeanwhile';
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`${subject} not rekeyed: ${loggableError(error).message}`,
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
private mfaColumn(): SecretColumn {
|
||||
return {
|
||||
purpose: 'mfa',
|
||||
entity: 'User',
|
||||
read: async () => {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { mfaSecret: { not: null } },
|
||||
select: { id: true, mfaSecret: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return users.flatMap(({ id, mfaSecret }) =>
|
||||
mfaSecret === null ? [] : [{ id, stored: mfaSecret }],
|
||||
);
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.user.updateMany({
|
||||
where: { id, mfaSecret: previous },
|
||||
data: { mfaSecret: next },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: mfaSecretNeedsRekey,
|
||||
// TOTP secrets were never stored in plain text.
|
||||
isPlaintext: () => false,
|
||||
open: decryptMfaSecret,
|
||||
seal: encryptMfaSecret,
|
||||
};
|
||||
}
|
||||
|
||||
private webhookColumn(): SecretColumn {
|
||||
return {
|
||||
purpose: 'webhook',
|
||||
entity: 'WebhookSubscription',
|
||||
read: async () => {
|
||||
const subscriptions = await this.prisma.webhookSubscription.findMany({
|
||||
select: { id: true, secret: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return subscriptions.map(({ id, secret }) => ({ id, stored: secret }));
|
||||
},
|
||||
swap: async (id, previous, next) => {
|
||||
const { count } = await this.prisma.webhookSubscription.updateMany({
|
||||
where: { id, secret: previous },
|
||||
data: { secret: next },
|
||||
});
|
||||
return count === 1;
|
||||
},
|
||||
needsRekey: webhookSecretNeedsRekey,
|
||||
isPlaintext: isPlaintextWebhookSecret,
|
||||
open: openWebhookSecret,
|
||||
seal: sealWebhookSecret,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user