@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.288.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.
Files changed (28) hide show
  1. package/package.json +12 -9
  2. package/src/auth-email-password/signed-token.ts +4 -86
  3. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  4. package/src/billing-foundation/changes.json +6 -0
  5. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  6. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  7. package/src/derivatives-sharp/changes.json +8 -1
  8. package/src/derivatives-sharp/render.ts +172 -5
  9. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  10. package/src/file-derivatives/changes.json +6 -0
  11. package/src/file-derivatives/feature.ts +9 -1
  12. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  13. package/src/shared/changes.json +8 -1
  14. package/src/shared/index.ts +12 -0
  15. package/src/shared/row-bound-grant.test.ts +294 -0
  16. package/src/shared/row-bound-grant.ts +115 -0
  17. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  18. package/src/shared/signed-token.ts +101 -0
  19. package/src/tenant/seeding.ts +4 -0
  20. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  21. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  22. package/src/user-data-rights/changes.json +12 -0
  23. package/src/user-data-rights/deletion-token.ts +41 -47
  24. package/src/user-data-rights/feature.ts +27 -0
  25. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  26. package/src/user-data-rights/run-export-jobs.ts +69 -1
  27. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  28. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
@@ -0,0 +1,294 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Temporal } from "temporal-polyfill";
3
+ import { redeemRowBoundGrant, signRowBoundGrant } from "./row-bound-grant";
4
+
5
+ const SECRET = "test-secret-value";
6
+ const PURPOSE = "waitlist-enrich";
7
+ const SUBJECT = "row-42";
8
+ const NOW = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
9
+
10
+ function grantFor(anchor: string, ttlMinutes = 30): string {
11
+ return signRowBoundGrant({
12
+ subject: SUBJECT,
13
+ purpose: PURPOSE,
14
+ anchor,
15
+ ttlMinutes,
16
+ secret: SECRET,
17
+ now: NOW,
18
+ }).token;
19
+ }
20
+
21
+ function anchorIs(anchor: string | null): (subject: string) => Promise<string | null> {
22
+ return async () => anchor;
23
+ }
24
+
25
+ const SKIP_COMMIT = { unsafeSkip: { reason: "test covers verification only" } } as const;
26
+
27
+ // Stands in for a conditional UPDATE ... WHERE anchor = expected: the first
28
+ // caller to spend the live anchor wins, everyone after it gets false.
29
+ function spendableAnchor(initial: string) {
30
+ let current: string | null = initial;
31
+ return {
32
+ load: async () => current,
33
+ commit: async (_subject: string, expected: string) => {
34
+ if (current !== expected) return false;
35
+ current = null;
36
+ return true;
37
+ },
38
+ };
39
+ }
40
+
41
+ describe("redeemRowBoundGrant", () => {
42
+ test("accepts a grant whose row still carries the anchor it was minted for", async () => {
43
+ const result = await redeemRowBoundGrant({
44
+ token: grantFor("pending"),
45
+ purpose: PURPOSE,
46
+ secret: SECRET,
47
+ loadAnchor: anchorIs("pending"),
48
+ commitAnchor: SKIP_COMMIT,
49
+ now: NOW,
50
+ });
51
+
52
+ expect(result.ok).toBe(true);
53
+ expect(result.ok && result.subject).toBe(SUBJECT);
54
+ });
55
+
56
+ test("passes the token's subject to loadAnchor so the row is looked up by it", async () => {
57
+ const seen: string[] = [];
58
+
59
+ await redeemRowBoundGrant({
60
+ token: grantFor("pending"),
61
+ purpose: PURPOSE,
62
+ secret: SECRET,
63
+ loadAnchor: async (subject) => {
64
+ seen.push(subject);
65
+ return "pending";
66
+ },
67
+ commitAnchor: SKIP_COMMIT,
68
+ now: NOW,
69
+ });
70
+
71
+ expect(seen).toEqual([SUBJECT]);
72
+ });
73
+
74
+ test("rejects a replayed grant once the row moved on to another anchor", async () => {
75
+ const token = grantFor("pending");
76
+
77
+ const first = await redeemRowBoundGrant({
78
+ token,
79
+ purpose: PURPOSE,
80
+ secret: SECRET,
81
+ loadAnchor: anchorIs("pending"),
82
+ commitAnchor: SKIP_COMMIT,
83
+ now: NOW,
84
+ });
85
+ const replayed = await redeemRowBoundGrant({
86
+ token,
87
+ purpose: PURPOSE,
88
+ secret: SECRET,
89
+ loadAnchor: anchorIs("enriched"),
90
+ commitAnchor: SKIP_COMMIT,
91
+ now: NOW,
92
+ });
93
+
94
+ expect(first.ok).toBe(true);
95
+ expect(replayed.ok).toBe(false);
96
+ });
97
+
98
+ test("rejects when the row has no anchor at all", async () => {
99
+ const result = await redeemRowBoundGrant({
100
+ token: grantFor("pending"),
101
+ purpose: PURPOSE,
102
+ secret: SECRET,
103
+ loadAnchor: anchorIs(null),
104
+ commitAnchor: SKIP_COMMIT,
105
+ now: NOW,
106
+ });
107
+
108
+ expect(result.ok).toBe(false);
109
+ });
110
+
111
+ test("rejects instead of surfacing a throwing row lookup", async () => {
112
+ const result = await redeemRowBoundGrant({
113
+ token: grantFor("pending"),
114
+ purpose: PURPOSE,
115
+ secret: SECRET,
116
+ loadAnchor: async () => {
117
+ throw new Error("invalid input syntax for type uuid");
118
+ },
119
+ commitAnchor: SKIP_COMMIT,
120
+ now: NOW,
121
+ });
122
+
123
+ expect(result.ok).toBe(false);
124
+ });
125
+
126
+ test("rejects an expired grant", async () => {
127
+ const result = await redeemRowBoundGrant({
128
+ token: grantFor("pending", 30),
129
+ purpose: PURPOSE,
130
+ secret: SECRET,
131
+ loadAnchor: anchorIs("pending"),
132
+ commitAnchor: SKIP_COMMIT,
133
+ now: NOW.add({ minutes: 31 }),
134
+ });
135
+
136
+ expect(result.ok).toBe(false);
137
+ });
138
+
139
+ test("rejects a grant redeemed against a different purpose", async () => {
140
+ const result = await redeemRowBoundGrant({
141
+ token: grantFor("pending"),
142
+ purpose: "waitlist-delete",
143
+ secret: SECRET,
144
+ loadAnchor: anchorIs("pending"),
145
+ commitAnchor: SKIP_COMMIT,
146
+ now: NOW,
147
+ });
148
+
149
+ expect(result.ok).toBe(false);
150
+ });
151
+
152
+ test("rejects a tampered signature", async () => {
153
+ const [subject, expiresAt] = grantFor("pending").split(".");
154
+
155
+ const result = await redeemRowBoundGrant({
156
+ token: `${subject}.${expiresAt}.YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY`,
157
+ purpose: PURPOSE,
158
+ secret: SECRET,
159
+ loadAnchor: anchorIs("pending"),
160
+ commitAnchor: SKIP_COMMIT,
161
+ now: NOW,
162
+ });
163
+
164
+ expect(result.ok).toBe(false);
165
+ });
166
+
167
+ test("rejects a grant minted with a different secret", async () => {
168
+ const foreign = signRowBoundGrant({
169
+ subject: SUBJECT,
170
+ purpose: PURPOSE,
171
+ anchor: "pending",
172
+ ttlMinutes: 30,
173
+ secret: "someone-elses-secret",
174
+ now: NOW,
175
+ }).token;
176
+
177
+ const result = await redeemRowBoundGrant({
178
+ token: foreign,
179
+ purpose: PURPOSE,
180
+ secret: SECRET,
181
+ loadAnchor: anchorIs("pending"),
182
+ commitAnchor: SKIP_COMMIT,
183
+ now: NOW,
184
+ });
185
+
186
+ expect(result.ok).toBe(false);
187
+ });
188
+
189
+ test("rejects without touching the row when no secret is configured", async () => {
190
+ let looked = false;
191
+
192
+ const result = await redeemRowBoundGrant({
193
+ token: grantFor("pending"),
194
+ purpose: PURPOSE,
195
+ secret: undefined,
196
+ loadAnchor: async () => {
197
+ looked = true;
198
+ return "pending";
199
+ },
200
+ commitAnchor: SKIP_COMMIT,
201
+ now: NOW,
202
+ });
203
+
204
+ expect(result.ok).toBe(false);
205
+ expect(looked).toBe(false);
206
+ });
207
+
208
+ test("rejects a malformed token without touching the row", async () => {
209
+ let looked = false;
210
+
211
+ const result = await redeemRowBoundGrant({
212
+ token: "not-a-token",
213
+ purpose: PURPOSE,
214
+ secret: SECRET,
215
+ loadAnchor: async () => {
216
+ looked = true;
217
+ return "pending";
218
+ },
219
+ commitAnchor: SKIP_COMMIT,
220
+ now: NOW,
221
+ });
222
+
223
+ expect(result.ok).toBe(false);
224
+ expect(looked).toBe(false);
225
+ });
226
+
227
+ test("only one of two simultaneous redemptions of the same grant wins", async () => {
228
+ const row = spendableAnchor("pending");
229
+ const token = grantFor("pending");
230
+ const redeem = () =>
231
+ redeemRowBoundGrant({
232
+ token,
233
+ purpose: PURPOSE,
234
+ secret: SECRET,
235
+ loadAnchor: row.load,
236
+ commitAnchor: row.commit,
237
+ now: NOW,
238
+ });
239
+
240
+ const [first, second] = await Promise.all([redeem(), redeem()]);
241
+
242
+ expect([first.ok, second.ok].filter(Boolean)).toHaveLength(1);
243
+ });
244
+
245
+ test("does not spend the anchor for a token that fails verification", async () => {
246
+ const row = spendableAnchor("pending");
247
+
248
+ const rejected = await redeemRowBoundGrant({
249
+ token: grantFor("some-other-anchor"),
250
+ purpose: PURPOSE,
251
+ secret: SECRET,
252
+ loadAnchor: row.load,
253
+ commitAnchor: row.commit,
254
+ now: NOW,
255
+ });
256
+ const legitimate = await redeemRowBoundGrant({
257
+ token: grantFor("pending"),
258
+ purpose: PURPOSE,
259
+ secret: SECRET,
260
+ loadAnchor: row.load,
261
+ commitAnchor: row.commit,
262
+ now: NOW,
263
+ });
264
+
265
+ expect(rejected.ok).toBe(false);
266
+ expect(legitimate.ok).toBe(true);
267
+ });
268
+
269
+ test("refuses an unsafeSkip without a reason", async () => {
270
+ const redeem = redeemRowBoundGrant({
271
+ token: grantFor("pending"),
272
+ purpose: PURPOSE,
273
+ secret: SECRET,
274
+ loadAnchor: anchorIs("pending"),
275
+ commitAnchor: { unsafeSkip: { reason: " " } },
276
+ now: NOW,
277
+ });
278
+
279
+ expect(redeem).rejects.toThrow(/non-empty reason/);
280
+ });
281
+
282
+ test("rejects when the anchor can no longer be spent", async () => {
283
+ const result = await redeemRowBoundGrant({
284
+ token: grantFor("pending"),
285
+ purpose: PURPOSE,
286
+ secret: SECRET,
287
+ loadAnchor: anchorIs("pending"),
288
+ commitAnchor: async () => false,
289
+ now: NOW,
290
+ });
291
+
292
+ expect(result.ok).toBe(false);
293
+ });
294
+ });
@@ -0,0 +1,115 @@
1
+ // A short-lived grant that lets a caller without a session perform exactly
2
+ // one named operation on exactly one row — the shape behind
3
+ // user-data-rights' confirm-deletion-by-token and any "you just created this
4
+ // row, now enrich it" flow.
5
+ //
6
+ // The row's own anchor (a value that moves on when the row is consumed: a
7
+ // request id, a status, a version) is mixed INTO the HMAC purpose rather
8
+ // than carried in the token. Verification recomputes it from the row's
9
+ // CURRENT anchor, so a replayed token dies the moment the row moves on —
10
+ // single-use semantics without a burn key or Redis. Minting and redeeming
11
+ // share one purpose-building function so the two can't drift apart; that
12
+ // coupling is the whole point of this module, since an unanchored purpose
13
+ // silently degrades to a bearer token valid for the full TTL.
14
+ //
15
+ // Anchoring alone closes the replay window only AFTER the row has moved on.
16
+ // Two requests redeeming the same grant simultaneously both read the live
17
+ // anchor and both verify, so spending it is a separate, mandatory step:
18
+ // `commitAnchor` must move the row on atomically (a conditional update on
19
+ // the anchor) and report whether this caller was the one who did. Skipping
20
+ // it is possible but has to be declared with a reason, the way the framework
21
+ // handles escapeHatch — an undeclared skip is how a single-use grant quietly
22
+ // becomes a bearer token for the length of its TTL. Put the actual write into
23
+ // the same statement that spends the anchor: a write issued after `ok: true`
24
+ // can lose its work to a crash while the grant is already burned.
25
+ //
26
+ // The subject is NOT secret: signToken puts it in the token body in the
27
+ // clear. A grant on a row therefore exposes that row's id to whoever holds
28
+ // the link. Where the id itself must stay hidden, use an opaque handle
29
+ // (single-use-token-store) instead.
30
+
31
+ import type { Temporal } from "temporal-polyfill";
32
+ import { peekTokenSubject, signToken, verifyToken } from "./signed-token";
33
+
34
+ export type RowBoundGrantResult =
35
+ | { readonly ok: true; readonly subject: string; readonly expiresAtMs: number }
36
+ | { readonly ok: false };
37
+
38
+ // Atomically spends the anchor: must return true only for the single caller
39
+ // that moved the row on from `expectedAnchor`, false for everyone else. A
40
+ // plain "read, check, then write" is not enough — two requests redeeming the
41
+ // same grant at once both read the live anchor and both pass.
42
+ export type AnchorCommit =
43
+ | ((subject: string, expectedAnchor: string) => Promise<boolean>)
44
+ | { readonly unsafeSkip: { readonly reason: string } };
45
+
46
+ const FAILED: RowBoundGrantResult = { ok: false };
47
+
48
+ function anchoredPurpose(purpose: string, anchor: string): string {
49
+ return `${purpose}:${anchor}`;
50
+ }
51
+
52
+ export function signRowBoundGrant(args: {
53
+ readonly subject: string;
54
+ readonly purpose: string;
55
+ readonly anchor: string;
56
+ readonly ttlMinutes: number;
57
+ readonly secret: string;
58
+ readonly now?: Temporal.Instant;
59
+ }): { readonly token: string; readonly expiresAt: Temporal.Instant } {
60
+ return signToken(
61
+ args.subject,
62
+ anchoredPurpose(args.purpose, args.anchor),
63
+ args.ttlMinutes,
64
+ args.secret,
65
+ args.now,
66
+ );
67
+ }
68
+
69
+ // Every rejection returns the same bare `{ ok: false }` — no reason, by
70
+ // design. A caller that could tell "bad signature" from "no such row" would
71
+ // hand an attacker a row-existence oracle on an endpoint that is open to
72
+ // anonymous callers.
73
+ export async function redeemRowBoundGrant(args: {
74
+ readonly token: string;
75
+ readonly purpose: string;
76
+ readonly secret: string | undefined;
77
+ readonly loadAnchor: (subject: string) => Promise<string | null>;
78
+ readonly commitAnchor: AnchorCommit;
79
+ readonly now?: Temporal.Instant;
80
+ }): Promise<RowBoundGrantResult> {
81
+ if (!args.secret) return FAILED;
82
+
83
+ const subject = peekTokenSubject(args.token);
84
+ if (!subject) return FAILED;
85
+
86
+ // The subject is unverified attacker input at this point, so a lookup that
87
+ // throws on it (e.g. a non-uuid value against a uuid column) must not
88
+ // surface as a 500.
89
+ let anchor: string | null;
90
+ try {
91
+ anchor = await args.loadAnchor(subject);
92
+ } catch {
93
+ return FAILED;
94
+ }
95
+ if (!anchor) return FAILED;
96
+
97
+ const verified = verifyToken(
98
+ args.token,
99
+ anchoredPurpose(args.purpose, anchor),
100
+ args.secret,
101
+ args.now,
102
+ );
103
+ if (!verified.ok) return FAILED;
104
+
105
+ // Spending happens after verification, never before: a caller that burned
106
+ // the anchor on an unverified token could invalidate any row it can name.
107
+ if (typeof args.commitAnchor === "function") {
108
+ const spent = await args.commitAnchor(subject, anchor);
109
+ if (!spent) return FAILED;
110
+ } else if (!args.commitAnchor.unsafeSkip.reason.trim()) {
111
+ throw new Error("row-bound grant: unsafeSkip needs a non-empty reason");
112
+ }
113
+
114
+ return { ok: true, subject, expiresAtMs: verified.expiresAtMs };
115
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Temporal } from "temporal-polyfill";
3
- import { signToken, TokenPurpose, verifyToken } from "../signed-token";
3
+ import { signToken, TokenPurpose, verifyToken } from "./signed-token";
4
4
 
5
5
  const SECRET = "test-hmac-secret-32-bytes-minimum!!";
6
6
  const USER_ID = "11111111-1111-4111-8111-111111111111";
@@ -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();