@cosmicdrift/kumiko-bundled-features 0.285.2 → 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.
Files changed (40) hide show
  1. package/package.json +13 -9
  2. package/src/auth-email-password/changes.json +6 -0
  3. package/src/auth-email-password/invite-token-store.ts +47 -96
  4. package/src/auth-email-password/lockout-store.ts +13 -102
  5. package/src/auth-email-password/signed-token.ts +4 -86
  6. package/src/auth-email-password/signup-token-store.test.ts +29 -0
  7. package/src/auth-email-password/signup-token-store.ts +40 -103
  8. package/src/auth-mfa/changes.json +6 -0
  9. package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
  10. package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
  11. package/src/billing-foundation/billing-info-query.ts +95 -0
  12. package/src/billing-foundation/changes.json +8 -1
  13. package/src/billing-foundation/index.ts +5 -0
  14. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  15. package/src/derivatives-sharp/changes.json +8 -1
  16. package/src/derivatives-sharp/render.ts +172 -5
  17. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  18. package/src/file-derivatives/changes.json +6 -0
  19. package/src/file-derivatives/feature.ts +9 -1
  20. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  21. package/src/shared/changes.json +8 -1
  22. package/src/shared/index.ts +14 -0
  23. package/src/shared/lockout-counter.test.ts +91 -0
  24. package/src/shared/lockout-counter.ts +108 -0
  25. package/src/shared/row-bound-grant.test.ts +294 -0
  26. package/src/shared/row-bound-grant.ts +115 -0
  27. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  28. package/src/shared/signed-token.ts +101 -0
  29. package/src/shared/single-use-token-store.test.ts +75 -0
  30. package/src/shared/single-use-token-store.ts +136 -0
  31. package/src/tenant/seeding.ts +4 -0
  32. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  33. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  34. package/src/user-data-rights/changes.json +12 -0
  35. package/src/user-data-rights/deletion-token.ts +41 -47
  36. package/src/user-data-rights/feature.ts +27 -0
  37. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  38. package/src/user-data-rights/run-export-jobs.ts +69 -1
  39. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  40. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.285.2",
3
+ "version": "0.287.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -93,6 +93,9 @@
93
93
  "./auth-email-password/seeding": "./src/auth-email-password/seeding.ts",
94
94
  "./auth-email-password/testing": "./src/auth-email-password/testing.ts",
95
95
  "./auth-email-password/web": "./src/auth-email-password/web/index.ts",
96
+ "./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
97
+ "./shared/signed-token": "./src/shared/signed-token.ts",
98
+ "./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
96
99
  "./delivery": "./src/delivery/index.ts",
97
100
  "./delivery/web": "./src/delivery/web/index.ts",
98
101
  "./channel-in-app": "./src/channel-in-app/index.ts",
@@ -129,12 +132,12 @@
129
132
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
133
  },
131
134
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.285.2",
133
- "@cosmicdrift/kumiko-framework": "0.285.2",
134
- "@cosmicdrift/kumiko-headless": "0.285.2",
135
- "@cosmicdrift/kumiko-renderer": "0.285.2",
136
- "@cosmicdrift/kumiko-renderer-web": "0.285.2",
137
- "@cosmicdrift/kumiko-types": "0.285.2",
135
+ "@cosmicdrift/kumiko-dispatcher-live": "0.287.0",
136
+ "@cosmicdrift/kumiko-framework": "0.287.0",
137
+ "@cosmicdrift/kumiko-headless": "0.287.0",
138
+ "@cosmicdrift/kumiko-renderer": "0.287.0",
139
+ "@cosmicdrift/kumiko-renderer-web": "0.287.0",
140
+ "@cosmicdrift/kumiko-types": "0.287.0",
138
141
  "@mollie/api-client": "^4.5.0",
139
142
  "@node-rs/argon2": "^2.0.2",
140
143
  "@types/mailparser": "^3.4.6",
@@ -163,7 +166,8 @@
163
166
  ],
164
167
  "devDependencies": {
165
168
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.285.2",
167
- "@cosmicdrift/kumiko-locale-es": "0.285.2"
169
+ "@cosmicdrift/kumiko-locale-de": "0.287.0",
170
+ "@cosmicdrift/kumiko-locale-es": "0.287.0",
171
+ "jsqr": "^1.4.0"
168
172
  }
169
173
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.286.0",
4
+ "type": "improvement",
5
+ "title": "lockout-store and signup/invite-token-store now delegate to shared/lockout-counter and shared/single-use-token-store",
6
+ "detail": "No behavior change — same exported function/type names and Redis key prefixes as before the extraction."
7
+ },
2
8
  {
3
9
  "version": "0.218.0",
4
10
  "type": "improvement",
@@ -1,127 +1,78 @@
1
1
  // Redis-backed token store for the tenant-invite magic-link flow.
2
2
  //
3
- // Subject is the invitation row ID (DB-row owner: tenant-feature). We
4
- // map token → invitationId in Redis, using the token as an opaque
5
- // random string from generateToken (256-bit base64url, randomBytes).
3
+ // Subject is the invitation row ID (DB-row owner: tenant-feature). The
4
+ // store mechanics (bidirectional token↔subject mapping, sha256-hashed
5
+ // keys, single-use burn) live in shared/single-use-token-store.ts — this
6
+ // file only wires the invite key prefixes onto it.
6
7
  //
7
- // Unlike signup-token-store we don't map bidirectionally for reuse —
8
- // resend-idempotency lives at the invitation-row level (an admin
9
- // inviting the same email twice reuses the existing row and mints a
10
- // fresh token; invite-create looks up the *previous* token's hash via
11
- // a second key to invalidate it before storing the new one).
12
- //
13
- // Bidirectional is still useful for cancel: the admin knows row.id and
14
- // needs the forward key to delete. Hence a second key,
15
- // invite:by-id:<invitationId>, holding the hash of the live token.
16
- // Cancel deletes both.
17
- //
18
- // Every key is derived from sha256(token), never the raw token —
19
- // Redis key names, MONITOR output, replica traffic, and memory/backup
20
- // dumps never carry the bearer secret in the clear (#2174). The
21
- // by-id entry stores the *hash* of the live token, not the token
22
- // itself, so it can only be used to invalidate — never to recover or
23
- // resend the original token. A resend therefore always mints a fresh
24
- // token and invalidates the previous one, rather than reusing the
25
- // same link.
8
+ // Unlike signup-token-store we don't rely on the store's reverse lookup for
9
+ // reuse-detection: resend-idempotency lives at the invitation-row level (an
10
+ // admin inviting the same email twice reuses the existing row and mints a
11
+ // fresh token; invite-create looks up the *previous* token's hash via the
12
+ // by-id entry to invalidate it before storing the new one). The by-id entry
13
+ // is still useful for cancel: the admin knows row.id and needs the forward
14
+ // key to delete it.
26
15
  //
27
16
  // Bug pattern: TTL lives only in Redis. DB-row.expiresAt is UI display
28
17
  // only. On an expired token, invite-accept doesn't find it → invalid-
29
- // invite-token. The DB row stays status="pending" — a cleanup job
30
- // marks it "expired" (separate concern, tracked in U.3-cleanup).
18
+ // invite-token. The DB row stays status="pending" — a cleanup job marks it
19
+ // "expired" (separate concern, tracked in U.3-cleanup).
31
20
  //
32
- // No collision with signup/reset/verify tokens: all invite keys carry
33
- // the `invite:`-prefix.
21
+ // No collision with signup/reset/verify tokens: all invite keys carry the
22
+ // `invite:`-prefix.
34
23
 
35
- import { createHash } from "node:crypto";
36
24
  import type Redis from "ioredis";
25
+ import { createSingleUseTokenStore } from "../shared";
37
26
 
38
- const TOKEN_KEY_PREFIX = "invite:by-token:";
39
- const ID_KEY_PREFIX = "invite:by-id:";
40
- const BURN_KEY_PREFIX = "invite:burn:";
41
-
42
- // Same sha256-hex pattern as hashPatToken (personal-access-tokens/hash.ts)
43
- // and preauthTokenKeyOf (framework/api/auth-routes.ts): the token is
44
- // high-entropy, so a single fast hash is enough — no brute-force surface
45
- // that would justify a slow password-hash.
46
- function hashToken(token: string): string {
47
- return createHash("sha256").update(token).digest("hex");
48
- }
27
+ const store = createSingleUseTokenStore({
28
+ tokenPrefix: "invite:by-token:",
29
+ subjectPrefix: "invite:by-id:",
30
+ burnPrefix: "invite:burn:",
31
+ });
49
32
 
50
- function tokenKey(token: string): string {
51
- return `${TOKEN_KEY_PREFIX}${hashToken(token)}`;
52
- }
53
- // Builds the forward key from an already-hashed value (e.g. read back from
54
- // the by-id entry) — does NOT hash again. Keeping this separate from
55
- // tokenKey() (which hashes a raw token) makes a double-hash mistake visible
56
- // at the call site instead of silently no-op'ing a delete.
57
- function forwardKeyForHash(tokenHash: string): string {
58
- return `${TOKEN_KEY_PREFIX}${tokenHash}`;
59
- }
60
- function idKey(invitationId: string): string {
61
- return `${ID_KEY_PREFIX}${invitationId}`;
62
- }
63
- function burnKey(token: string): string {
64
- return `${BURN_KEY_PREFIX}${hashToken(token)}`;
65
- }
66
-
67
- /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
68
- * Idempotent — re-write derselben Token-Invitation-Kombi ist OK
69
- * (refresh TTL für Resend). The by-id value is the token's hash, not
70
- * the token — see file header. */
33
+ /** Stores the pair bidirectionally and sets TTL on both keys.
34
+ * Idempotent — re-writing the same token-invitation pair is fine
35
+ * (refreshes the TTL for resend). */
71
36
  export async function storeInviteToken(
72
37
  redis: Redis,
73
38
  args: { invitationId: string; token: string; ttlSeconds: number },
74
39
  ): Promise<void> {
75
- await Promise.all([
76
- redis.set(tokenKey(args.token), args.invitationId, "EX", args.ttlSeconds),
77
- redis.set(idKey(args.invitationId), hashToken(args.token), "EX", args.ttlSeconds),
78
- ]);
40
+ return store.store(redis, {
41
+ subjectId: args.invitationId,
42
+ token: args.token,
43
+ ttlSeconds: args.ttlSeconds,
44
+ });
79
45
  }
80
46
 
81
- /** Lookup: invitationId für Token. Null wenn Token nicht (mehr) existiert
82
- * (abgelaufen, schon konsumiert, oder ungültig). */
83
- export async function getInvitationIdForToken(redis: Redis, token: string): Promise<string | null> {
84
- return redis.get(tokenKey(token));
85
- }
47
+ /** Lookup: invitationId for a token. Null if the token no longer exists
48
+ * (expired, already consumed, or invalid). */
49
+ export const getInvitationIdForToken = store.getSubjectForToken;
86
50
 
87
- /** Deletes a still-live invite token for this invitation, if one exists —
88
- * both the forward entry (built from the hash stored in the by-id entry,
89
- * never the raw token) and the by-id entry itself. Returns whether a live
90
- * token existed. Two callers: invite-create on every resend (a fresh
91
- * token + by-id entry follows right after, so this is "at most one live
92
- * token per invitation"), and cancel-invitation (no replacement follows,
93
- * so this is full cleanup). */
51
+ /** Deletes a still-live invite token for this invitation, if one exists.
52
+ * Two callers: invite-create on every resend (a fresh token + by-id entry
53
+ * follows right after, so this is "at most one live token per
54
+ * invitation"), and cancel-invitation (no replacement follows, so this is
55
+ * full cleanup). */
94
56
  export async function invalidateExistingInviteToken(
95
57
  redis: Redis,
96
58
  invitationId: string,
97
59
  ): Promise<boolean> {
98
- const existingHash = await redis.get(idKey(invitationId));
99
- if (existingHash === null) return false;
100
- await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(idKey(invitationId))]);
101
- return true;
60
+ return store.invalidateExistingBySubject(redis, invitationId);
102
61
  }
103
62
 
104
- /** Single-Use-Burn. Wenn zwei Tabs gleichzeitig den Accept-Link klicken,
105
- * gewinnt der erste, der zweite kriegt "already-used". TTL = 1h. */
106
- export async function burnInviteToken(
107
- redis: Redis,
108
- token: string,
109
- ): Promise<"burned" | "already-used"> {
110
- const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
111
- return result === "OK" ? "burned" : "already-used";
112
- }
63
+ /** Single-use burn. If two tabs click the accept link at the same time,
64
+ * the first one wins, the second gets "already-used". TTL = 1h. */
65
+ export const burnInviteToken = store.burn;
113
66
 
114
- /** Cleanup nach erfolgreichem Accept ODER Cancel — beide Lookup-Keys
115
- * löschen. Burn-Key bleibt für die restliche Burn-TTL als Replay-Schutz. */
67
+ /** Cleanup after a successful accept OR cancel — deletes both lookup
68
+ * keys. The burn key stays for the remaining burn TTL as replay protection. */
116
69
  export async function deleteInviteToken(
117
70
  redis: Redis,
118
71
  args: { invitationId: string; token: string },
119
72
  ): Promise<void> {
120
- await Promise.all([redis.del(tokenKey(args.token)), redis.del(idKey(args.invitationId))]);
73
+ return store.deleteBoth(redis, { subjectId: args.invitationId, token: args.token });
121
74
  }
122
75
 
123
- /** Burn-Release für Failed-Accept-Pfade (DB-Error etc.) damit ein
124
- * legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
125
- export async function unburnInviteToken(redis: Redis, token: string): Promise<void> {
126
- await redis.del(burnKey(token));
127
- }
76
+ /** Burn release for failed-accept paths (DB error etc.) so a legitimate
77
+ * retry isn't blocked by a stale burn marker. */
78
+ export const unburnInviteToken = store.unburn;
@@ -12,112 +12,23 @@
12
12
  // active counter — an attacker could exploit the gap, though the IP-level
13
13
  // rate-limiter (framework rate-limit) is the parallel defense for that
14
14
  // case anyway.
15
+ //
16
+ // The counter mechanics (race-free INCR/NX, TTL rules, monotonic-counter
17
+ // semantics) live in shared/lockout-counter.ts — this file only wires the
18
+ // account-lockout key prefixes onto it. See that file for why a lock
19
+ // re-arms immediately after expiry, and why a successful login (or the
20
+ // account-unlock magic-link flow, #1266, see
21
+ // handlers/confirm-account-unlock.write.ts) is what resets the streak.
15
22
 
16
- import type Redis from "ioredis";
23
+ import { createLockoutCounter, type LockoutCounterState } from "../shared";
17
24
 
18
- export type LockoutState = {
19
- readonly failureCount: number;
20
- // Epoch milliseconds when the account auto-unlocks. null while the
21
- // counter is still below threshold.
22
- readonly lockedUntil: number | null;
23
- };
25
+ export type LockoutState = LockoutCounterState;
24
26
 
25
- // Two keys per user so each can carry its own TTL:
26
- // - count-key: 24h, carries the streak. Monotonic — once threshold is
27
- // crossed it STAYS crossed until a successful login clears it.
28
- // - until-key: exactly the lockout duration, auto-expires when the lock
29
- // ends (Redis TTL replaces a "timer" that would otherwise need a job).
30
- //
31
- // Consequence of the monotonic counter: once a user has been locked, the
32
- // NEXT wrong password after the lock expires re-locks immediately — the
33
- // INCR still returns a value ≥ threshold, so the SET NX re-arms the lock.
34
- // A successful login is one way to reset the streak; the other is the
35
- // account-unlock magic-link flow (#1266, see
36
- // handlers/confirm-account-unlock.write.ts), a deliberate escape hatch for
37
- // a legitimate user who can't currently produce the right password (e.g.
38
- // they forgot it too) but can prove mailbox ownership. Intentional:
39
- // brute-force resistance favours strictness over UX for anonymous login
40
- // attempts, while the unlock flow keeps the DoS from being permanent.
41
27
  const COUNT_KEY_PREFIX = "kumiko:auth:lockout:count:";
42
28
  const UNTIL_KEY_PREFIX = "kumiko:auth:lockout:until:";
43
29
 
44
- function countKey(userId: string): string {
45
- return `${COUNT_KEY_PREFIX}${userId}`;
46
- }
47
- function untilKey(userId: string): string {
48
- return `${UNTIL_KEY_PREFIX}${userId}`;
49
- }
50
-
51
- export async function getLockoutState(redis: Redis, userId: string): Promise<LockoutState | null> {
52
- const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
53
- if (countRaw === null) return null;
54
- const failureCount = Number(countRaw);
55
- if (!Number.isFinite(failureCount)) return null;
56
- const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
57
- return {
58
- failureCount,
59
- lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
60
- };
61
- }
62
-
63
- // Race-free: INCR is atomic at the Redis level, so N concurrent wrong-
64
- // password attempts produce exactly N increments — no GET-SET window to
65
- // lose an increment through. The NX on the until-key likewise guarantees
66
- // only one attempt out of a concurrent batch sets the lock timestamp;
67
- // subsequent concurrent attempts find the key already set and leave it
68
- // alone, so the lock window stays anchored to the first-to-cross, not
69
- // the last.
70
- export async function recordFailedAttempt(
71
- redis: Redis,
72
- userId: string,
73
- maxFailedAttempts: number,
74
- lockoutDurationMinutes: number,
75
- ): Promise<LockoutState> {
76
- const lockDurationMs = lockoutDurationMinutes * 60 * 1000;
77
- // TTL on the count-key: 24h covers "I fat-fingered yesterday". The
78
- // lockout duration is on the until-key; the count-key outlives it so an
79
- // expired lock leaves a counter ≥ threshold — that's what makes the next
80
- // miss immediately re-lock (strict-semantic; see the type-comment above).
81
- const ttlSec = Math.max(lockoutDurationMinutes * 60, 24 * 3600);
82
-
83
- const count = await redis.incr(countKey(userId));
84
- if (count === 1) {
85
- // First failure → set the TTL. INCR doesn't set one; a counter without
86
- // TTL would leak forever for users that never return.
87
- await redis.expire(countKey(userId), ttlSec);
88
- }
89
-
90
- let lockedUntil: number | null = null;
91
- if (count >= maxFailedAttempts) {
92
- const computedUntil = Date.now() + lockDurationMs;
93
- // NX: only set if no lock is currently armed. A second concurrent attempt
94
- // arriving after the first crossed the threshold must NOT reset the
95
- // timer — the lock window should align with the attempt that crossed,
96
- // not the one that happened a millisecond later.
97
- const setOk = await redis.set(
98
- untilKey(userId),
99
- String(computedUntil),
100
- "PX",
101
- lockDurationMs,
102
- "NX",
103
- );
104
- if (setOk === "OK") {
105
- lockedUntil = computedUntil;
106
- } else {
107
- // Another concurrent attempt already locked — read the authoritative
108
- // timestamp so the returned state matches what a follow-up
109
- // getLockoutState would see.
110
- const existing = await redis.get(untilKey(userId));
111
- lockedUntil = existing !== null ? Number(existing) : null;
112
- }
113
- }
114
-
115
- return { failureCount: count, lockedUntil };
116
- }
30
+ const counter = createLockoutCounter(COUNT_KEY_PREFIX, UNTIL_KEY_PREFIX);
117
31
 
118
- // Called on successful login, and on a confirmed account-unlock token
119
- // (#1266). Idempotent — deleting missing keys is a no-op, so a replayed
120
- // unlock link just re-clears harmlessly.
121
- export async function clearLockoutState(redis: Redis, userId: string): Promise<void> {
122
- await redis.del(countKey(userId), untilKey(userId));
123
- }
32
+ export const getLockoutState = counter.getState;
33
+ export const recordFailedAttempt = counter.recordFailedAttempt;
34
+ export const clearLockoutState = counter.clearState;
@@ -1,86 +1,4 @@
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
- export function verifyToken(
47
- token: string,
48
- purpose: string,
49
- secret: string,
50
- now: Temporal.Instant = Temporal.Now.instant(),
51
- ): VerifyResult {
52
- const parts = token.split(".");
53
- if (parts.length !== 3) return { ok: false, reason: "malformed" };
54
- const [userId, expiresAtRaw, providedSig] = parts;
55
- if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
56
-
57
- const expiresAtMs = Number(expiresAtRaw);
58
- if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
59
- return { ok: false, reason: "malformed" };
60
- }
61
-
62
- const expected = sign(payload(purpose, userId, expiresAtMs), secret);
63
- const expectedBuf = Buffer.from(expected, "base64url");
64
- const providedBuf = Buffer.from(providedSig, "base64url");
65
- // Length mismatch fails BEFORE timingSafeEqual, which throws on different
66
- // lengths — but that throw itself leaks via timing. Explicit length check
67
- // keeps the path uniform.
68
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
69
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
70
-
71
- if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
72
- return { ok: false, reason: "expired" };
73
- }
74
-
75
- // expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
76
- // have to re-parse the token themselves.
77
- return { ok: true, userId, expiresAtMs };
78
- }
79
-
80
- // Canonical purposes baked into the framework. Features that introduce new
81
- // flows extend this set (or just pass an inline string).
82
- export const TokenPurpose = {
83
- passwordReset: "reset",
84
- emailVerification: "verify",
85
- accountUnlock: "unlock",
86
- } as const;
1
+ // Moved to shared/signed-token.ts — the mechanism was never specific to
2
+ // email/password auth (user-data-rights and any row-bound grant use it too).
3
+ // Re-exported so this feature's barrel and external importers keep working.
4
+ export * from "../shared/signed-token";
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeEmail, storeSignupToken } from "./signup-token-store";
3
+
4
+ // Only integration tests (signup-flow.integration.test.ts) exercised this
5
+ // module before — none assert on the raw Redis key, so a case-sensitivity
6
+ // regression in the by-email key wouldn't be caught: two signups from
7
+ // "User@Example.com" and "user@example.com" would silently get separate
8
+ // live-token entries instead of the second invalidating the first.
9
+ function fakeRedis() {
10
+ const calls: { method: string; args: unknown[] }[] = [];
11
+ const redis = {
12
+ set: async (...args: unknown[]) => {
13
+ calls.push({ method: "set", args });
14
+ return "OK";
15
+ },
16
+ // biome-ignore lint/suspicious/noExplicitAny: minimal ioredis stand-in for key-string assertions
17
+ } as any;
18
+ return { redis, calls };
19
+ }
20
+
21
+ describe("storeSignupToken", () => {
22
+ test("builds the by-email key from the normalized (lowercased) email", async () => {
23
+ const { redis, calls } = fakeRedis();
24
+ await storeSignupToken(redis, { email: "User@Example.com", token: "tok-1", ttlSeconds: 60 });
25
+ const subjectKey = calls[1]?.args[0];
26
+ expect(subjectKey).toBe(`signup:by-email:${normalizeEmail("User@Example.com")}`);
27
+ expect(subjectKey).toBe("signup:by-email:user@example.com");
28
+ });
29
+ });