@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.287.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ // HMAC-signed single-purpose tokens for out-of-band auth flows
2
+ // (password-reset, email-verification, future: magic-link).
3
+ //
4
+ // Format: <userId>.<expiresAtMs>.<hmac-base64url>
5
+ //
6
+ // The `purpose` is mixed INTO the HMAC input so a token minted for one
7
+ // purpose (e.g. password-reset) can't be replayed against an endpoint that
8
+ // expects another (e.g. verify-email), even if the caller knows the
9
+ // userId and a valid expiry. Purpose is NOT carried in the token body —
10
+ // verify() takes the purpose as argument and recomputes.
11
+ //
12
+ // Timing-safe comparison on verify so a valid-length forgery can't leak
13
+ // signal through a short-circuit.
14
+
15
+ import { createHmac, timingSafeEqual } from "node:crypto";
16
+ import { Temporal } from "temporal-polyfill";
17
+
18
+ export type VerifyResult =
19
+ | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
20
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
21
+
22
+ function sign(input: string, secret: string): string {
23
+ return createHmac("sha256", secret).update(input).digest("base64url");
24
+ }
25
+
26
+ function payload(purpose: string, userId: string, expiresAtMs: number): string {
27
+ return `${purpose}:${userId}.${expiresAtMs}`;
28
+ }
29
+
30
+ export function signToken(
31
+ userId: string,
32
+ purpose: string,
33
+ ttlMinutes: number,
34
+ secret: string,
35
+ now: Temporal.Instant = Temporal.Now.instant(),
36
+ ): { token: string; expiresAt: Temporal.Instant } {
37
+ const expiresAt = now.add({ minutes: ttlMinutes });
38
+ const expiresAtMs = expiresAt.epochMilliseconds;
39
+ const signature = sign(payload(purpose, userId, expiresAtMs), secret);
40
+ return {
41
+ token: `${userId}.${expiresAtMs}.${signature}`,
42
+ expiresAt,
43
+ };
44
+ }
45
+
46
+ // Reads the subject out of the token body WITHOUT checking the HMAC. Only
47
+ // safe as a lookup key for something that is itself an input to the
48
+ // verification below — the signature stays the gate. Mirrors verifyToken's
49
+ // structural checks, which is why it lives next to it: a token this rejects
50
+ // must never reach a DB lookup.
51
+ export function peekTokenSubject(token: string): string | null {
52
+ const parts = token.split(".");
53
+ if (parts.length !== 3) return null;
54
+ const [subject, expiresAtRaw, sig] = parts;
55
+ if (!subject || !expiresAtRaw || !sig) return null;
56
+ const expiresAtMs = Number(expiresAtRaw);
57
+ if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) return null;
58
+ return subject;
59
+ }
60
+
61
+ export function verifyToken(
62
+ token: string,
63
+ purpose: string,
64
+ secret: string,
65
+ now: Temporal.Instant = Temporal.Now.instant(),
66
+ ): VerifyResult {
67
+ const parts = token.split(".");
68
+ if (parts.length !== 3) return { ok: false, reason: "malformed" };
69
+ const [userId, expiresAtRaw, providedSig] = parts;
70
+ if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
71
+
72
+ const expiresAtMs = Number(expiresAtRaw);
73
+ if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
74
+ return { ok: false, reason: "malformed" };
75
+ }
76
+
77
+ const expected = sign(payload(purpose, userId, expiresAtMs), secret);
78
+ const expectedBuf = Buffer.from(expected, "base64url");
79
+ const providedBuf = Buffer.from(providedSig, "base64url");
80
+ // Length mismatch fails BEFORE timingSafeEqual, which throws on different
81
+ // lengths — but that throw itself leaks via timing. Explicit length check
82
+ // keeps the path uniform.
83
+ if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
84
+ if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
85
+
86
+ if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
87
+ return { ok: false, reason: "expired" };
88
+ }
89
+
90
+ // expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
91
+ // have to re-parse the token themselves.
92
+ return { ok: true, userId, expiresAtMs };
93
+ }
94
+
95
+ // Canonical purposes baked into the framework. Features that introduce new
96
+ // flows extend this set (or just pass an inline string).
97
+ export const TokenPurpose = {
98
+ passwordReset: "reset",
99
+ emailVerification: "verify",
100
+ accountUnlock: "unlock",
101
+ } as const;
@@ -234,6 +234,10 @@ export async function seedTenant(
234
234
  tdb,
235
235
  );
236
236
  if (!result.isSuccess) {
237
+ // Same idempotency case as the stream-version check above, only detected
238
+ // one instruction later: a dispatcher cycle appended to the stream between
239
+ // that read and this create. Both paths return without firing postSave.
240
+ if (result.error.code === "version_conflict") return { id: options.id };
237
241
  throw new Error(
238
242
  `seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
239
243
  );
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Temporal } from "temporal-polyfill";
3
+ import { signToken } from "../../shared";
4
+ import { redeemDeletionToken, signDeletionToken } from "../deletion-token";
5
+
6
+ const SECRET = "deletion-token-compat-secret";
7
+ const USER_ID = "user-7";
8
+ const REQUEST_ID = "req-99";
9
+ const NOW = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
10
+
11
+ // Deletion tokens sit in mails that are already out. Moving the minting onto
12
+ // shared/row-bound-grant must not change a single byte of them, so this pins
13
+ // the wire format against the formula the hand-rolled version used:
14
+ // signToken(userId, `deletion-request:${requestId}`, ...).
15
+ describe("deletion token wire format", () => {
16
+ test("a token minted with the pre-refactor formula still redeems", async () => {
17
+ const legacy = signToken(USER_ID, `deletion-request:${REQUEST_ID}`, 30, SECRET).token;
18
+
19
+ const result = await redeemDeletionToken({
20
+ token: legacy,
21
+ secret: SECRET,
22
+ loadPendingRequestId: async () => REQUEST_ID,
23
+ });
24
+
25
+ expect(result.ok).toBe(true);
26
+ expect(result.ok && result.subject).toBe(USER_ID);
27
+ });
28
+
29
+ test("minting produces exactly the pre-refactor bytes", () => {
30
+ const minted = signDeletionToken(USER_ID, REQUEST_ID, 30, SECRET, NOW);
31
+ const legacy = signToken(USER_ID, `deletion-request:${REQUEST_ID}`, 30, SECRET, NOW);
32
+
33
+ expect(minted.token).toBe(legacy.token);
34
+ });
35
+ });
@@ -24,6 +24,7 @@ import {
24
24
  createInMemoryFileProvider,
25
25
  type FileStorageProvider,
26
26
  } from "@cosmicdrift/kumiko-framework/files";
27
+ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
27
28
  import {
28
29
  createTestUser,
29
30
  setupTestStack,
@@ -43,7 +44,7 @@ import { createSessionsFeature, userSessionEntity } from "../../sessions";
43
44
  import { tenantMembershipsTable } from "../../tenant";
44
45
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
45
46
  import { createUserDataRightsFeature } from "../feature";
46
- import { runExportJobs } from "../run-export-jobs";
47
+ import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, runExportJobs } from "../run-export-jobs";
47
48
  import { exportDownloadTokenEntity, exportDownloadTokensTable } from "../schema/download-token";
48
49
  import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
49
50
  import { hashDownloadToken } from "../token-helpers";
@@ -133,6 +134,20 @@ function buildProvider(tenantId: string): Promise<FileStorageProvider> {
133
134
  return Promise.resolve(p);
134
135
  }
135
136
 
137
+ // Typed fake standing in for the ctx.meter-backed MetricsHandle feature.ts
138
+ // builds for the real cron — records `set()` calls without a Meter/registry.
139
+ function createRecordingMetricsHandle(): MetricsHandle & { readonly values: Map<string, number> } {
140
+ const values = new Map<string, number>();
141
+ return {
142
+ values,
143
+ inc: () => {},
144
+ observe: () => {},
145
+ set: (name, value) => {
146
+ values.set(name, value);
147
+ },
148
+ };
149
+ }
150
+
136
151
  // Seedet einen pending Job via realen request-export-Handler — echter
137
152
  // ES-Pfad (crud.create emittiert event "export-job.created"), nicht
138
153
  // direct-INSERT. Direct-INSERT wuerde stream-version=0/row-version=1
@@ -444,6 +459,80 @@ describe("runExportJobs :: storage-cleanup", () => {
444
459
  });
445
460
  });
446
461
 
462
+ describe("runExportJobs :: export-cleanup backlog metric", () => {
463
+ test("done-Job mit abgelaufener TTL + weiterhin gesetztem downloadStorageKey → Metrik > 0", async () => {
464
+ const jobId = await seedPendingJob();
465
+ const T = getTemporal();
466
+ const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
467
+ const storageKey = `exports/${tenantA}/${jobId}.zip`;
468
+ const provider = await buildProvider(tenantA);
469
+ await provider.write(storageKey, new Uint8Array([7, 8, 9]));
470
+
471
+ await updateRows(
472
+ stack.db,
473
+ exportJobsTable,
474
+ {
475
+ status: EXPORT_JOB_STATUS.Done,
476
+ startedAt: longAgo,
477
+ completedAt: longAgo,
478
+ downloadStorageKey: storageKey,
479
+ expiresAt: longAgo,
480
+ },
481
+ { id: jobId },
482
+ );
483
+
484
+ const metrics = createRecordingMetricsHandle();
485
+ await runExportJobs({
486
+ db: stack.db,
487
+ registry: stack.registry,
488
+ buildStorageProvider: async () => ({
489
+ ...provider,
490
+ delete: async () => {
491
+ throw new Error("synthetic storage delete failure");
492
+ },
493
+ }),
494
+ now: NOW(),
495
+ metrics,
496
+ });
497
+
498
+ expect(metrics.values.get(EXPORT_CLEANUP_BACKLOG_AGE_METRIC)).toBeGreaterThan(0);
499
+ });
500
+
501
+ test("done-Job wird erfolgreich cleaned → Metrik ist 0", async () => {
502
+ const jobId = await seedPendingJob();
503
+ const T = getTemporal();
504
+ const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
505
+ const storageKey = `exports/${tenantA}/${jobId}.zip`;
506
+ const provider = await buildProvider(tenantA);
507
+ await provider.write(storageKey, new Uint8Array([1, 2, 3]));
508
+
509
+ await updateRows(
510
+ stack.db,
511
+ exportJobsTable,
512
+ {
513
+ status: EXPORT_JOB_STATUS.Done,
514
+ startedAt: longAgo,
515
+ completedAt: longAgo,
516
+ downloadStorageKey: storageKey,
517
+ expiresAt: longAgo,
518
+ },
519
+ { id: jobId },
520
+ );
521
+
522
+ const metrics = createRecordingMetricsHandle();
523
+ const result = await runExportJobs({
524
+ db: stack.db,
525
+ registry: stack.registry,
526
+ buildStorageProvider: buildProvider,
527
+ now: NOW(),
528
+ metrics,
529
+ });
530
+
531
+ expect(result.cleanedJobIds).toContain(jobId);
532
+ expect(metrics.values.get(EXPORT_CLEANUP_BACKLOG_AGE_METRIC)).toBe(0);
533
+ });
534
+ });
535
+
447
536
  describe("runExportJobs :: idempotency", () => {
448
537
  test("zweiter Run nach done ist no-op fuer den selben Job", async () => {
449
538
  const jobId = await seedPendingJob();
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.287.0",
4
+ "type": "improvement",
5
+ "title": "export-cleanup cron now emits an export_cleanup_backlog_age gauge to surface stalled runs",
6
+ "detail": "storageCleanupPass deletes expired export bundles via provider.delete, but the storage provider only exposes list() (no timestamps), so a silent failure left unencrypted PII bundles sitting in the bucket with no signal. runExportJobs now accepts an optional MetricsHandle and, after the cleanup loop, reports the age in seconds of the oldest done-status job whose expiresAt+grace has passed while downloadStorageKey is still set (0 if none) via the new EXPORT_CLEANUP_BACKLOG_AGE_METRIC (\"export_cleanup_backlog_age\", a gauge — no _total/_seconds suffix per validateMetricName, unit: \"seconds\" carried in the r.metric() definition instead). feature.ts's job handler builds the handle itself with createMetricsHandle(ctx.meter, \"user-data-rights\") since JobContext has no bound ctx.metrics (that only exists on HandlerContext, built per write/query dispatch) — falls back to createNoopMetricsHandle() when ctx.meter is unset. Metric emission is wrapped in try/catch so an observability hiccup never fails an otherwise-successful cleanup pass. Scope is deliberately done-status only; failed-status cleanup candidates have no expiresAt/TTL."
7
+ },
8
+ {
9
+ "version": "0.287.0",
10
+ "type": "fix",
11
+ "title": "GDPR forget only hard-deletes files that are PII of the forgotten person",
12
+ "detail": "fileRefDeleteHook picked rows via insertedById (who uploaded the file) and treated every matched row the same on strategy=\"delete\", so forgetting an uploader hard-deleted third-party business files they had merely uploaded (e.g. a dealer's vehicle photos uploaded by an employee). FileFieldDef/ImageFieldDef/FilesFieldDef/ImagesFieldDef now support \"& ResolvedPiiFlags\" like every other field type (createFileField/createImageField/createFilesField/createImagesField thread personal/reason through expandPersonalAnnotations), and fileRefDeleteHook's strategy=\"delete\" branch splits rows per-row via the new isPersonalFileRow/isPersonalPiiField predicates: pii/userOwned/recordOwned fields take the existing hard-delete path (storageProvider.delete() + derivatives + row), every other row (explicit personal:false, tenantOwned, subjectRef, no annotation, or an unresolvable entity/field) goes through severPersonLink instead, same as the existing strategy=\"anonymize\" path. Unattached uploads (no entityType/fieldName) still hard-delete. Derivatives have no own file_refs row, so they keep following their original's decision (the #2461 \"GDPR derivatives survive forget\" test stays green). POST /files now resolves entityType/fieldName against the registry and rejects the upload with 400 when they're set but don't resolve to a real field, closing the gap that let a client attach a file to a field the forget-hook could never look up; both unset (unattached upload) still passes. No new column or migration — the decision uses the entityType/fieldName file_refs already stores."
13
+ },
2
14
  {
3
15
  "version": "0.285.2",
4
16
  "type": "fix",
@@ -1,62 +1,56 @@
1
- // Thin wrapper around the shared HMAC-signed-token primitive, pinning the
2
- // purpose to "deletion-request". Mirrors auth-email-password/reset-token.ts —
3
- // email-verified account deletion is an auth-adjacent proof-of-email-ownership
4
- // flow, so it reuses the same self-contained token mechanism (no DB row, no
5
- // Redis: the userId + expiry are baked into the signed token).
1
+ // Email-verified account deletion, expressed as a row-bound grant: the
2
+ // subject is the user, the anchor is the pending request id stored on the
3
+ // user row. Minting and verification both run through
4
+ // shared/row-bound-grant, so a token from a cancelled cycle (id nulled) or a
5
+ // superseded one (row holds a newer id) fails verification — see #354/1.
6
6
  //
7
- // Replay-after-cancel (#354/1): the per-request `requestId` is folded INTO the
8
- // HMAC purpose (`deletion-request:<requestId>`), not carried in the token body.
9
- // The same id is stored on the user row when the request is minted and nulled
10
- // on cancel. confirm recomputes the HMAC with the row's CURRENT id, so a token
11
- // from a cancelled cycle (row id nulled) or a superseded one (row holds a newer
12
- // id) fails verification — the bounded-TTL replay window is closed without
13
- // touching the shared signToken/verifyToken primitive.
7
+ // The purpose string and the anchoring are unchanged from the hand-rolled
8
+ // version this replaced, so tokens stay byte-compatible.
14
9
 
15
10
  import type { Temporal } from "temporal-polyfill";
16
- import { signToken, verifyToken } from "../auth-email-password";
11
+ import { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant } from "../shared";
17
12
 
18
13
  const DELETION_REQUEST_PURPOSE = "deletion-request";
19
14
 
20
- export type VerifyResult =
21
- | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
22
- | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
23
-
24
- function deletionPurpose(requestId: string): string {
25
- return `${DELETION_REQUEST_PURPOSE}:${requestId}`;
26
- }
27
-
28
15
  export function signDeletionToken(
29
16
  userId: string,
30
17
  requestId: string,
31
18
  ttlMinutes: number,
32
19
  secret: string,
33
20
  now?: Temporal.Instant,
34
- ): { token: string; expiresAt: Temporal.Instant } {
35
- return signToken(userId, deletionPurpose(requestId), ttlMinutes, secret, now);
21
+ ): { readonly token: string; readonly expiresAt: Temporal.Instant } {
22
+ return signRowBoundGrant({
23
+ subject: userId,
24
+ purpose: DELETION_REQUEST_PURPOSE,
25
+ anchor: requestId,
26
+ ttlMinutes,
27
+ secret,
28
+ now,
29
+ });
36
30
  }
37
31
 
38
- export function verifyDeletionToken(
39
- token: string,
40
- requestId: string,
41
- secret: string,
42
- now?: Temporal.Instant,
43
- ): VerifyResult {
44
- return verifyToken(token, deletionPurpose(requestId), secret, now);
45
- }
46
-
47
- // Reads the userId from the token body WITHOUT verifying the HMAC — used only
48
- // to look up the row's current requestId, which is itself an input to the
49
- // verification below. The signature is still the gate; this peek never grants
50
- // trust. Token format is `<userId>.<expiresAtMs>.<sig>`.
51
- //
52
- // Mirrors verifyToken's structural malformed-checks so an obviously-bogus token
53
- // returns null here (→ generic reject) instead of reaching the DB lookup.
54
- export function peekDeletionTokenUserId(token: string): string | null {
55
- const parts = token.split(".");
56
- if (parts.length !== 3) return null;
57
- const [userId, expiresAtRaw, sig] = parts;
58
- if (!userId || !expiresAtRaw || !sig) return null;
59
- const expiresAtMs = Number(expiresAtRaw);
60
- if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) return null;
61
- return userId;
32
+ export function redeemDeletionToken(args: {
33
+ readonly token: string;
34
+ readonly secret: string | undefined;
35
+ readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
36
+ readonly now?: Temporal.Instant;
37
+ }): Promise<RowBoundGrantResult> {
38
+ return redeemRowBoundGrant({
39
+ token: args.token,
40
+ purpose: DELETION_REQUEST_PURPOSE,
41
+ secret: args.secret,
42
+ loadAnchor: args.loadPendingRequestId,
43
+ commitAnchor: {
44
+ unsafeSkip: {
45
+ reason:
46
+ "pendingDeletionRequestId may only be changed through updateUserLifecycle — a " +
47
+ "conditional UPDATE would bypass the user.updated event and lose the field on a " +
48
+ "projection rebuild (see update-user-lifecycle.ts). The concurrent-redeem window " +
49
+ "this leaves open predates row-bound grants: startDeletionGracePeriod already " +
50
+ "read-then-writes the Active check. Closing it needs an atomic lifecycle " +
51
+ "transition, which is its own change.",
52
+ },
53
+ },
54
+ now: args.now,
55
+ });
62
56
  }
@@ -7,6 +7,10 @@ import {
7
7
  i18nKey,
8
8
  SYSTEM_USER_ID,
9
9
  } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ createMetricsHandle,
12
+ createNoopMetricsHandle,
13
+ } from "@cosmicdrift/kumiko-framework/observability";
10
14
  import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
11
15
  import {
12
16
  EXPORT_SECTION_EXTENSION_NAME,
@@ -49,6 +53,7 @@ import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver";
49
53
  import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
50
54
  import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
51
55
  import {
56
+ EXPORT_CLEANUP_BACKLOG_AGE_METRIC,
52
57
  runExportJobs,
53
58
  type SendExportFailedEmailFn,
54
59
  type SendExportReadyEmailFn,
@@ -528,6 +533,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
528
533
  },
529
534
  });
530
535
 
536
+ // Surfaces a stalled export-cleanup cron: if the daily
537
+ // pass stops running, done-status export bundles keep sitting in
538
+ // storage past their TTL+grace with downloadStorageKey still set
539
+ // (unencrypted PII at rest). Emitted from the same job that cleans up
540
+ // (see handler below) — a dead cron makes the metric go stale too,
541
+ // which an `absent()` alert catches without a second liveness signal.
542
+ // Scope: Done-status bundles only — Failed-status cleanup candidates
543
+ // have no expiresAt/TTL (immediate cleanup, see storageCleanupPass).
544
+ r.metric(EXPORT_CLEANUP_BACKLOG_AGE_METRIC, {
545
+ type: "gauge",
546
+ unit: "seconds",
547
+ description:
548
+ "Age in seconds of the oldest done-status export bundle whose expiresAt+grace has passed but downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
549
+ });
550
+
531
551
  // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
532
552
  const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
533
553
  r.job({
@@ -548,6 +568,12 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
548
568
  RUN_EXPORT_JOBS_REASON,
549
569
  ) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
550
570
  const exportRegistry = ctx.registry;
571
+ // JobContext carries ctx.meter (raw), not ctx.metrics — that bound
572
+ // handle only exists on HandlerContext (built per write/query call
573
+ // in dispatch-shared.ts). Build the same handle explicitly here.
574
+ const exportMetrics = ctx.meter
575
+ ? createMetricsHandle(ctx.meter, "user-data-rights")
576
+ : createNoopMetricsHandle();
551
577
 
552
578
  // C6 — ohne eigene send*Email-Opts aber mit gemountetem mail-transport
553
579
  // versendet der Cron die Export-Notifications selbst (Default-Templates).
@@ -612,6 +638,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
612
638
  }),
613
639
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
614
640
  actor: ctx.systemUser.id,
641
+ metrics: exportMetrics,
615
642
  });
616
643
  },
617
644
  });
@@ -7,7 +7,7 @@ import {
7
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { z } from "zod";
9
9
  import { USER_STATUS, userTable } from "../../user";
10
- import { peekDeletionTokenUserId, verifyDeletionToken } from "../deletion-token";
10
+ import { redeemDeletionToken } from "../deletion-token";
11
11
  import { startDeletionGracePeriod } from "./deletion-grace-period";
12
12
 
13
13
  export type ConfirmDeletionByTokenOptions = {
@@ -66,24 +66,14 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
66
66
  agent: { expose: false },
67
67
  rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
68
68
  handler: async (event, ctx) => {
69
- if (!opts.deletionTokenSecret) return writeFailure(invalidToken());
70
-
71
- const peekedUserId = peekDeletionTokenUserId(event.payload.token);
72
- if (!peekedUserId) return writeFailure(invalidToken());
73
-
74
- // Die requestId der Row ist Teil des Verify-Keys (HMAC-Purpose). Kein
75
- // offener Antrag (null) → das Token gehört zu einem abgebrochenen Zyklus
76
- // → Reject ohne weitere Signal-Preisgabe (gleicher generischer 422). Der
77
- // peekedUserId ist unverifizierte Angreifer-Eingabe — ein Lookup-Fehler
78
- // (z.B. typfremde id) wird zu demselben generischen 422, nie zu einem 500.
79
- const requestId = await readPendingDeletionRequestId(ctx, peekedUserId);
80
- if (!requestId) return writeFailure(invalidToken());
81
-
82
- const verified = verifyDeletionToken(
83
- event.payload.token,
84
- requestId,
85
- opts.deletionTokenSecret,
86
- );
69
+ // The row's requestId is part of the verify key, so a token from a
70
+ // cancelled or superseded cycle fails. Every error path ends in the same
71
+ // generic 422.
72
+ const verified = await redeemDeletionToken({
73
+ token: event.payload.token,
74
+ secret: opts.deletionTokenSecret,
75
+ loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
76
+ });
87
77
  if (!verified.ok) return writeFailure(invalidToken());
88
78
 
89
79
  // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
@@ -95,7 +85,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
95
85
  )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
96
86
  const res = await startDeletionGracePeriod(
97
87
  ctx,
98
- verified.userId,
88
+ verified.subject,
99
89
  profile.profile.userRights.gracePeriod,
100
90
  ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
101
91
  );
@@ -61,6 +61,7 @@ import {
61
61
  tenantExportPrefix,
62
62
  type ZipEntry,
63
63
  } from "@cosmicdrift/kumiko-framework/files";
64
+ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
64
65
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
65
66
  import { resolveProfileForTenant } from "../compliance-profiles";
66
67
  import { decryptStoredPii } from "../shared";
@@ -73,6 +74,12 @@ import { generateDownloadToken } from "./token-helpers";
73
74
 
74
75
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
75
76
 
77
+ // Gauge short name (feature.ts registers it via r.metric + resolves ctx.meter
78
+ // into a MetricsHandle — jobs get no bound ctx.metrics, see feature.ts).
79
+ // No "_seconds"/"_total" suffix: validateMetricName rejects both on a gauge.
80
+ // Unit lives in the r.metric() `unit: "seconds"` declaration instead.
81
+ export const EXPORT_CLEANUP_BACKLOG_AGE_METRIC = "export_cleanup_backlog_age";
82
+
76
83
  const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
77
84
  entityName: "export-job",
78
85
  });
@@ -165,6 +172,12 @@ export interface RunExportJobsArgs {
165
172
  // systemUser.id); threaded into runUserExport's per-hook TenantDb.
166
173
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
167
174
  readonly actor?: string;
175
+
176
+ /**
177
+ * Reports EXPORT_CLEANUP_BACKLOG_AGE_METRIC after the storage-cleanup
178
+ * pass. Optional so callers without an observability provider still work.
179
+ */
180
+ readonly metrics?: MetricsHandle;
168
181
  }
169
182
 
170
183
  export interface ExportJobError {
@@ -302,6 +315,7 @@ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJ
302
315
  db,
303
316
  buildStorageProvider,
304
317
  now,
318
+ metrics: args.metrics,
305
319
  });
306
320
 
307
321
  return {
@@ -621,8 +635,9 @@ async function storageCleanupPass(args: {
621
635
  db: DbConnection;
622
636
  buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
623
637
  now: Instant;
638
+ metrics?: MetricsHandle;
624
639
  }): Promise<readonly string[]> {
625
- const { db, buildStorageProvider, now } = args;
640
+ const { db, buildStorageProvider, now, metrics } = args;
626
641
 
627
642
  // Zwei Cleanup-Pfade:
628
643
  //
@@ -659,6 +674,13 @@ async function storageCleanupPass(args: {
659
674
  );
660
675
 
661
676
  const cleaned: string[] = [];
677
+ // Done-status candidates past expiresAt+grace, keyed by that due-instant —
678
+ // used below to report how far cleanup has fallen behind if a candidate
679
+ // is still standing (delete threw, or the version-checked null-out lost
680
+ // a race) once the pass is done. Failed-status jobs have no expiresAt
681
+ // (immediate cleanup, no TTL) so they're out of scope for this signal —
682
+ // see EXPORT_CLEANUP_BACKLOG_AGE_METRIC's r.metric() description.
683
+ const overdueDoneCleanupAfterMs = new Map<string, number>();
662
684
  for (const c of candidates) {
663
685
  if (!c.downloadStorageKey) continue;
664
686
 
@@ -674,6 +696,7 @@ async function storageCleanupPass(args: {
674
696
  c.expiresAt.epochMilliseconds +
675
697
  profile.profile.userRights.exportStorageCleanupGraceHours * 60 * 60 * 1000;
676
698
  if (now.epochMilliseconds < cleanupAfter) continue;
699
+ overdueDoneCleanupAfterMs.set(c.id, cleanupAfter);
677
700
  }
678
701
  // Failed-Job-Branch: kein TTL-Check, sofort cleanup.
679
702
 
@@ -696,9 +719,54 @@ async function storageCleanupPass(args: {
696
719
  );
697
720
  if (result.isSuccess) cleaned.push(c.id);
698
721
  }
722
+
723
+ reportOldestOverdueAge(metrics, overdueDoneCleanupAfterMs, cleaned, now);
724
+
699
725
  return cleaned;
700
726
  }
701
727
 
728
+ // Seconds since the longest-overdue Done-status bundle should have been
729
+ // deleted (expiresAt+grace passed) but still isn't — 0 when the pass
730
+ // cleaned everything that was due, or nothing was due.
731
+ function oldestOverdueAgeSeconds(
732
+ overdueDoneCleanupAfterMs: ReadonlyMap<string, number>,
733
+ cleanedIds: readonly string[],
734
+ now: Instant,
735
+ ): number {
736
+ const cleaned = new Set(cleanedIds);
737
+ let oldestAgeSeconds = 0;
738
+ for (const [id, cleanupAfterMs] of overdueDoneCleanupAfterMs) {
739
+ if (cleaned.has(id)) continue;
740
+ const ageSeconds = (now.epochMilliseconds - cleanupAfterMs) / 1000;
741
+ if (ageSeconds > oldestAgeSeconds) oldestAgeSeconds = ageSeconds;
742
+ }
743
+ return oldestAgeSeconds;
744
+ }
745
+
746
+ // Best-effort like the email callbacks above: an observability hiccup
747
+ // (metric not yet registered on this meter, provider misconfig) must not
748
+ // fail a pass that otherwise cleaned up fine.
749
+ function reportOldestOverdueAge(
750
+ metrics: MetricsHandle | undefined,
751
+ overdueDoneCleanupAfterMs: ReadonlyMap<string, number>,
752
+ cleanedIds: readonly string[],
753
+ now: Instant,
754
+ ): void {
755
+ // skip: no meter configured means there is nothing to report — the cleanup pass itself already ran to completion.
756
+ if (!metrics) return;
757
+ try {
758
+ metrics.set(
759
+ EXPORT_CLEANUP_BACKLOG_AGE_METRIC,
760
+ oldestOverdueAgeSeconds(overdueDoneCleanupAfterMs, cleanedIds, now),
761
+ );
762
+ } catch (err) {
763
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for observability-emit-failure
764
+ console.warn(
765
+ `[user-data-rights:run-export-jobs] metrics.set(${EXPORT_CLEANUP_BACKLOG_AGE_METRIC}) failed err=${err instanceof Error ? err.message : String(err)}`,
766
+ );
767
+ }
768
+ }
769
+
702
770
  // Wrapper: crud.update braucht TenantDb. ExportJob ist tenant-agnostisch
703
771
  // (1 Job pro userId), aber der event-store-Stream-Lookup erwartet einen
704
772
  // Tenant-Context. system-mode TenantDb bypassed den auto-tenant-filter