@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.286.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.285.2",
3
+ "version": "0.286.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,7 @@
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",
96
97
  "./delivery": "./src/delivery/index.ts",
97
98
  "./delivery/web": "./src/delivery/web/index.ts",
98
99
  "./channel-in-app": "./src/channel-in-app/index.ts",
@@ -129,12 +130,12 @@
129
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
131
  },
131
132
  "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",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.286.0",
134
+ "@cosmicdrift/kumiko-framework": "0.286.0",
135
+ "@cosmicdrift/kumiko-headless": "0.286.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.286.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.286.0",
138
+ "@cosmicdrift/kumiko-types": "0.286.0",
138
139
  "@mollie/api-client": "^4.5.0",
139
140
  "@node-rs/argon2": "^2.0.2",
140
141
  "@types/mailparser": "^3.4.6",
@@ -163,7 +164,7 @@
163
164
  ],
164
165
  "devDependencies": {
165
166
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.285.2",
167
- "@cosmicdrift/kumiko-locale-es": "0.285.2"
167
+ "@cosmicdrift/kumiko-locale-de": "0.286.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.286.0"
168
169
  }
169
170
  }
@@ -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;
@@ -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
+ });
@@ -1,132 +1,69 @@
1
1
  // Redis-backed pre-activation token store for magic-link signup.
2
2
  //
3
- // Token material: opaque random 256-bit from crypto.randomBytes (see
4
- // signup-request.write.ts → generateToken() from framework/api),
5
- // base64url-encoded to 43 chars. Not designed for human typing — the
6
- // user clicks the mail link, nobody types the token.
3
+ // Subject is the (normalized) email — the user doesn't exist yet, so
4
+ // there's no userId claim for an HMAC-signed token to bind to. The store
5
+ // mechanics (bidirectional token↔subject mapping, sha256-hashed keys,
6
+ // single-use burn) live in shared/single-use-token-store.ts — this file
7
+ // only wires the signup key prefixes onto it and normalizes the email
8
+ // used as the subject id.
7
9
  //
8
- // Unlike reset/verify tokens (HMAC-signed, statelessly verifiable),
9
- // signup tokens need a server-side lookup: the user doesn't exist yet,
10
- // so there's no userId claim for the HMAC to bind to. We map token ↔
11
- // email bidirectionally in Redis and delete the pair on confirm.
12
- // Bidirectional because:
13
- // - by-token: confirm-handler needs token → email
14
- // - by-email: signup-request needs to know whether a token is still
15
- // live for this email, so a resend can invalidate it instead of
16
- // leaving two valid tokens for the same signup around
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-email entry stores the *hash* of the live token, not the token
22
- // itself, so it can only be used to invalidate (delete the matching
23
- // forward entry) — never to recover or resend the original token. A
24
- // resend therefore always mints a fresh token and invalidates the
25
- // previous one, rather than reusing the same link.
26
- //
27
- // No collision with reset/verify tokens: all signup keys carry the
10
+ // No collision with reset/verify/invite tokens: all signup keys carry the
28
11
  // `signup:`-prefix.
29
12
 
30
- import { createHash } from "node:crypto";
31
13
  import type Redis from "ioredis";
14
+ import { createSingleUseTokenStore } from "../shared";
32
15
 
33
- const TOKEN_KEY_PREFIX = "signup:by-token:";
34
- const EMAIL_KEY_PREFIX = "signup:by-email:";
35
- const BURN_KEY_PREFIX = "signup:burn:";
36
-
37
- /** Email-Normalisierung — single source für jede Lookup-Schicht (Store
38
- * intern UND Caller die im Return-Body / Mail-Send eine konsistente
39
- * Form brauchen). Vorher zwei Stellen mit `.toLowerCase()` — eine
40
- * Quelle = kein Drift. */
16
+ /** Email normalization — single source for every lookup layer (used
17
+ * internally by the store AND by callers that need a consistent form
18
+ * in the return body / mail send). Previously two places called
19
+ * `.toLowerCase()` — one source means no drift. */
41
20
  export function normalizeEmail(email: string): string {
42
21
  return email.toLowerCase();
43
22
  }
44
23
 
45
- // Same sha256-hex pattern as hashPatToken (personal-access-tokens/hash.ts)
46
- // and preauthTokenKeyOf (framework/api/auth-routes.ts): the token is
47
- // high-entropy, so a single fast hash is enough — no brute-force surface
48
- // that would justify a slow password-hash.
49
- function hashToken(token: string): string {
50
- return createHash("sha256").update(token).digest("hex");
51
- }
24
+ const store = createSingleUseTokenStore({
25
+ tokenPrefix: "signup:by-token:",
26
+ subjectPrefix: "signup:by-email:",
27
+ burnPrefix: "signup:burn:",
28
+ });
52
29
 
53
- function tokenKey(token: string): string {
54
- return `${TOKEN_KEY_PREFIX}${hashToken(token)}`;
55
- }
56
- // Builds the forward key from an already-hashed value (e.g. read back from
57
- // the by-email entry) — does NOT hash again. Keeping this separate from
58
- // tokenKey() (which hashes a raw token) makes a double-hash mistake visible
59
- // at the call site instead of silently no-op'ing a delete.
60
- function forwardKeyForHash(tokenHash: string): string {
61
- return `${TOKEN_KEY_PREFIX}${tokenHash}`;
62
- }
63
- // @wrapper-known semantic-alias
64
- function emailKey(email: string): string {
65
- return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
66
- }
67
- function burnKey(token: string): string {
68
- return `${BURN_KEY_PREFIX}${hashToken(token)}`;
69
- }
70
-
71
- /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
72
- * Idempotent — re-write derselben Token-Email-Kombi ist OK. The
73
- * by-email value is the token's hash, not the token — see file header. */
30
+ /** Stores the pair bidirectionally and sets TTL on both keys.
31
+ * Idempotent — re-writing the same token-email pair is fine. */
74
32
  export async function storeSignupToken(
75
33
  redis: Redis,
76
34
  args: { email: string; token: string; ttlSeconds: number },
77
35
  ): Promise<void> {
78
- await Promise.all([
79
- redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
80
- redis.set(emailKey(args.email), hashToken(args.token), "EX", args.ttlSeconds),
81
- ]);
36
+ return store.store(redis, {
37
+ subjectId: normalizeEmail(args.email),
38
+ token: args.token,
39
+ ttlSeconds: args.ttlSeconds,
40
+ });
82
41
  }
83
42
 
84
- /** Lookup: Email für einen Token. Null wenn Token nicht (mehr) existiert
85
- * (abgelaufen, schon konsumiert, oder ungültig). */
86
- export async function getEmailForSignupToken(redis: Redis, token: string): Promise<string | null> {
87
- return redis.get(tokenKey(token));
88
- }
43
+ /** Lookup: email for a token. Null if the token no longer exists
44
+ * (expired, already consumed, or invalid). */
45
+ export const getEmailForSignupToken = store.getSubjectForToken;
89
46
 
90
- /** Deletes a still-live signup token for this email, if one exists — both
91
- * the forward entry (built from the hash already stored in the by-email
92
- * entry, never recovers the raw token) and the by-email entry itself.
93
- * Returns whether a live token existed. Used by signup-request on every
94
- * request; a fresh token + by-email entry follows right after, so this
95
- * is "at most one live token per email." Deleting the by-email entry
96
- * here too (not just the forward key) avoids leaving a dangling hash
97
- * pointing at nothing if the request crashes before storeSignupToken. */
47
+ /** Deletes a still-live signup token for this email, if one exists. Used
48
+ * by signup-request on every request; a fresh token + by-email entry
49
+ * follows right after, so this is "at most one live token per email." */
98
50
  export async function invalidateExistingSignupToken(redis: Redis, email: string): Promise<boolean> {
99
- const existingHash = await redis.get(emailKey(email));
100
- if (existingHash === null) return false;
101
- await Promise.all([redis.del(forwardKeyForHash(existingHash)), redis.del(emailKey(email))]);
102
- return true;
51
+ return store.invalidateExistingBySubject(redis, normalizeEmail(email));
103
52
  }
104
53
 
105
- /** Single-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
106
- * gewinnt der erste, der zweite kriegt "already-used". TTL = 1 Stunde
107
- * (kurz genug damit der Burn-Key Redis nicht dauerhaft belastet, lang
108
- * genug damit Replays in normalen Race-Windows abgefangen werden). */
109
- export async function burnSignupToken(
110
- redis: Redis,
111
- token: string,
112
- ): Promise<"burned" | "already-used"> {
113
- // SET NX EX — atomic check-and-set. Returnt "OK" wenn Key neu, null
114
- // wenn schon da.
115
- const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
116
- return result === "OK" ? "burned" : "already-used";
117
- }
54
+ /** Single-use burn: if two tabs click the confirm link at the same time,
55
+ * the first one wins, the second gets "already-used". */
56
+ export const burnSignupToken = store.burn;
118
57
 
119
- /** Cleanup nach erfolgreichem Confirm — beide Lookup-Keys löschen.
120
- * Burn-Key bleibt (verhindert Replay innerhalb der Burn-TTL). */
58
+ /** Cleanup after a successful confirm — deletes both lookup keys.
59
+ * The burn key stays (prevents replay within the burn TTL). */
121
60
  export async function deleteSignupToken(
122
61
  redis: Redis,
123
62
  args: { email: string; token: string },
124
63
  ): Promise<void> {
125
- await Promise.all([redis.del(tokenKey(args.token)), redis.del(emailKey(args.email))]);
64
+ return store.deleteBoth(redis, { subjectId: normalizeEmail(args.email), token: args.token });
126
65
  }
127
66
 
128
- /** Burn-Release für Failed-Confirm-Pfade (DB-Error etc.) damit ein
129
- * legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
130
- export async function unburnSignupToken(redis: Redis, token: string): Promise<void> {
131
- await redis.del(burnKey(token));
132
- }
67
+ /** Burn release for failed-confirm paths (DB error etc.) so a legitimate
68
+ * retry isn't blocked by a stale burn marker. */
69
+ export const unburnSignupToken = store.unburn;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.286.0",
4
+ "type": "improvement",
5
+ "title": "mfa-verify-attempts now delegates to shared/lockout-counter",
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.259.0",
4
10
  "type": "improvement",