@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.
- package/package.json +12 -9
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/subscription-tier-sync.ts +9 -4
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +12 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- 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 "
|
|
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;
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -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();
|