@cosmicdrift/kumiko-bundled-features 0.204.1 → 0.205.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.204.1",
3
+ "version": "0.205.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.204.1",
130
- "@cosmicdrift/kumiko-framework": "0.204.1",
131
- "@cosmicdrift/kumiko-headless": "0.204.1",
132
- "@cosmicdrift/kumiko-renderer": "0.204.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.204.1",
134
- "@cosmicdrift/kumiko-types": "0.204.1",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.205.0",
130
+ "@cosmicdrift/kumiko-framework": "0.205.0",
131
+ "@cosmicdrift/kumiko-headless": "0.205.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.205.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.205.0",
134
+ "@cosmicdrift/kumiko-types": "0.205.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -250,22 +250,42 @@ describe("invite-create", () => {
250
250
  expect(rows[0]?.["tenantId"]).toBe(TENANT_A_ID);
251
251
  });
252
252
 
253
- test("Resend: zweiter invite für selbe email existing row updated, gleicher token", async () => {
253
+ test("Resend: second invite for the same email reuses the row but mints a new token and invalidates the previous one (#2174)", async () => {
254
254
  const firstToken = await inviteEmail(BOB_EMAIL, "Admin");
255
255
  const secondToken = await inviteEmail(BOB_EMAIL, "Editor");
256
256
 
257
- expect(secondToken).toBe(firstToken);
257
+ expect(secondToken).not.toBe(firstToken);
258
258
 
259
- // Eine Row, role updated
259
+ // Same row, role updated
260
260
  const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
261
261
  expect(rows).toHaveLength(1);
262
262
  expect(rows[0]?.["role"]).toBe("Editor");
263
263
 
264
264
  // inviteEmail() only reads emailTransport.sent.at(-1) — a silent second-
265
- // dispatch failure would still leave sent.length===1 and .at(-1) pointing
266
- // at the FIRST mail, making secondToken===firstToken pass for the wrong
267
- // reason. Assert an actual second dispatch happened.
265
+ // dispatch failure would still leave sent.length===1, making the
266
+ // not-equal check above pass for the wrong reason. Assert an actual
267
+ // second dispatch happened.
268
268
  expect(emailTransport.sent).toHaveLength(2);
269
+
270
+ // The first mail's link must actually stop working (not just look
271
+ // different) — this is what catches a double-hash bug in invalidation.
272
+ const firstRes = await authedRaw(
273
+ "POST",
274
+ "/api/auth/invite-accept",
275
+ { token: firstToken },
276
+ bobSession(),
277
+ );
278
+ expect(firstRes.status).toBe(422);
279
+ const firstBody = (await firstRes.json()) as { error?: { details?: { reason?: string } } };
280
+ expect(firstBody.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
281
+
282
+ const secondRes = await authedRaw(
283
+ "POST",
284
+ "/api/auth/invite-accept",
285
+ { token: secondToken },
286
+ bobSession(),
287
+ );
288
+ expect(secondRes.status).toBe(200);
269
289
  });
270
290
  });
271
291
 
@@ -6,8 +6,9 @@
6
6
  // Pinst:
7
7
  // 1. POST signup-request mit valid email → 200, Activation-Mail via
8
8
  // delivery (channel-email in-memory transport) mit Token-URL.
9
- // 2. Resend-Idempotenz: zweiter Request für selbe email → gleicher
10
- // Token in Mail (existing token in Redis wird re-genutzt).
9
+ // 2. Resend: second request for the same email → a NEW token, and
10
+ // the previous token is invalidated (#2174 Redis never stores
11
+ // the raw token as a value, so it can't be recovered/reused).
11
12
  // 3. POST signup-confirm mit captured Token + Password → 200, Cookies
12
13
  // gesetzt (kumiko_auth + kumiko_csrf), Body mit user + tenantKey,
13
14
  // DB hat user (emailVerified=true) + tenant + Admin-membership.
@@ -206,14 +207,26 @@ describe("POST /api/auth/signup-request", () => {
206
207
  expect(sent.html).toContain(`${APP_ACTIVATION_URL}?token=`);
207
208
  });
208
209
 
209
- test("Resend: zweiter Request für selbe email gleicher token in Mail", async () => {
210
+ test("Resend: second request for the same email mints a new token and invalidates the previous one", async () => {
210
211
  await postSignupRequest("resend@example.com");
211
212
  await postSignupRequest("resend@example.com");
212
213
 
213
214
  expect(emailTransport.sent).toHaveLength(2);
214
215
  const [first, second] = emailTransport.sent;
215
216
  if (!first || !second) throw new Error("missing mails");
216
- expect(extractTokenFromMail(second.html)).toBe(extractTokenFromMail(first.html));
217
+ const firstToken = extractTokenFromMail(first.html);
218
+ const secondToken = extractTokenFromMail(second.html);
219
+ expect(secondToken).not.toBe(firstToken);
220
+
221
+ // The first mail's link must actually stop working (not just look
222
+ // different) — this is what catches a double-hash bug in invalidation.
223
+ const firstConfirm = await postSignupConfirm(firstToken, "irrelevant-pw-1234");
224
+ expect(firstConfirm.status).toBe(422);
225
+ const firstBody = (await firstConfirm.json()) as { error?: { details?: { reason?: string } } };
226
+ expect(firstBody.error?.details?.reason).toBe(AuthErrors.invalidSignupToken);
227
+
228
+ const secondConfirm = await postSignupConfirm(secondToken, "fresh-secure-pw-1234");
229
+ expect(secondConfirm.status).toBe(200);
217
230
  });
218
231
 
219
232
  test("malformed body → 200 (silent success, anti-enumeration)", async () => {
@@ -6,10 +6,11 @@
6
6
  // wie reset/verify/signup. Der Token geht NICHT an den Admin zurück (er soll
7
7
  // die Annahme nicht impersonieren können).
8
8
  //
9
- // Resend-Idempotenz: Re-Invite für gleiche (tenantId, email) während
10
- // pending existing row + token re-genutzt + TTL refresh + zweite Mail
11
- // mit GLEICHEM Link. Bei status="cancelled" oder "accepted": existing
12
- // row updated zurück auf status=pending + neuer token.
9
+ // Re-invite for the same (tenantId, email): the existing row is reused
10
+ // regardless of its prior status (pending/cancelled/accepted), reset to
11
+ // status=pending, and a fresh token is minted every time — any token
12
+ // still live for that invitation is invalidated first, so the previous
13
+ // mail's link stops working (see invite-token-store.ts).
13
14
  //
14
15
  // Always-200 für unbekannten User: bei invitee-Email die nicht in users
15
16
  // existiert wird trotzdem ein Invite erstellt — Branch-3-Accept-Flow
@@ -38,7 +39,7 @@ import {
38
39
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
39
40
  import type { AuthMailLocale } from "../email-templates";
40
41
  import { renderInviteEmail } from "../email-templates";
41
- import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
42
+ import { invalidateExistingInviteToken, storeInviteToken } from "../invite-token-store";
42
43
  import { dispatchMagicLinkMail } from "../magic-link-mail";
43
44
 
44
45
  const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
@@ -114,10 +115,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
114
115
  if (existing) {
115
116
  invitationId = existing["id"] as string; // @cast-boundary db-row
116
117
  const existingVersion = existing["version"] as number; // @cast-boundary db-row
117
- // Resend-Idempotenz: Token aus Redis re-use wenn noch lebend.
118
- // Sonst neuen mintinen (alter ist abgelaufen).
119
- const existingToken = await getTokenForInvitation(ctx.redis, invitationId);
120
- token = existingToken ?? generateToken();
118
+ // At most one live invite token per invitation: invalidate
119
+ // whatever's there before minting the new one.
120
+ await invalidateExistingInviteToken(ctx.redis, invitationId);
121
+ token = generateToken();
121
122
 
122
123
  const updateResult = await executor.update(
123
124
  {
@@ -7,11 +7,10 @@
7
7
  // existiert der User noch nicht). Ob die Email bereits ein Konto hat,
8
8
  // entscheidet bewusst der Confirm-Schritt, nicht dieser.
9
9
  //
10
- // Resend-Idempotenz: wenn für die Email bereits ein lebender Token in
11
- // Redis liegt, geben wir denselben Token zurück (und refreshen TTL auf
12
- // beiden Keys). Der User bekommt dann eine zweite Mail mit dem GLEICHEN
13
- // Activation-Link. Erste Mail bleibt gültig kein "old link broken"-
14
- // annoyance.
10
+ // Resend: if a token is still live for this email, we invalidate it and
11
+ // mint a fresh one the user gets a second mail with a NEW activation
12
+ // link, and the first link stops working. Deliberate: at most one live
13
+ // signup token per email at any time (see signup-token-store.ts).
15
14
  //
16
15
  // Always-200 (enumeration-safe): das Response sieht für jede Email gleich
17
16
  // aus, egal ob sie schon registriert ist oder nicht. Eine Email KANN bereits
@@ -32,7 +31,11 @@ import type { AuthMailLocale } from "../email-templates";
32
31
  import { renderActivationEmail } from "../email-templates";
33
32
  import { dispatchMagicLinkMail } from "../magic-link-mail";
34
33
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
35
- import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
34
+ import {
35
+ invalidateExistingSignupToken,
36
+ normalizeEmail,
37
+ storeSignupToken,
38
+ } from "../signup-token-store";
36
39
 
37
40
  const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
38
41
 
@@ -104,10 +107,9 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
104
107
  // lowercased haben.
105
108
  const email = event.payload.email;
106
109
 
107
- // Resend-Idempotenz: wenn ein Token für diese Email noch lebt,
108
- // re-use ihn und refreshe beide Keys. Der User kriegt eine zweite
109
- // Mail mit dem GLEICHEN Link.
110
- const existingToken = await getTokenForSignupEmail(ctx.redis, email);
110
+ // At most one live signup token per email: invalidate whatever's
111
+ // there before minting the new one (see signup-token-store.ts).
112
+ await invalidateExistingSignupToken(ctx.redis, email);
111
113
  // 32 random bytes = 256 bits unguessable randomness, base64url
112
114
  // encoded zu 43 chars. Math.random war früher ein Bug:
113
115
  // xorshift128+ hat ~128 Bit State der nach ~5 beobachteten
@@ -115,7 +117,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
115
117
  // signup-requests triggern und die Tokens fremder User
116
118
  // vorhersagen. generateToken nutzt randomBytes aus node:crypto,
117
119
  // dieselbe Quelle wie CSRF/Session-Tokens.
118
- const token = existingToken ?? generateToken();
120
+ const token = generateToken();
119
121
 
120
122
  const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
121
123
  const expiresAtIso = expiresAt.toString();
@@ -1,53 +1,80 @@
1
- // Redis-backed Token-Store für Tenant-Invite-Magic-Link-Flow.
1
+ // Redis-backed token store for the tenant-invite magic-link flow.
2
2
  //
3
- // Subject ist die Invitation-Row-ID (DB-row owner: tenant-feature). Wir
4
- // mappen Token → invitationId in Redis und nutzen den Token als opaque
5
- // random string aus generateToken (256 bit base64url, randomBytes).
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).
6
6
  //
7
- // Anders als signup-token-store mappen wir hier NICHT bidirektional
8
- // — Resend-Idempotenz lebt auf der Invitation-Row-Ebene (Admin invitet
9
- // dieselbe email zweimal existing row + token wird re-genutzt; das
10
- // invite-create-handler holt den existing token aus Redis via
11
- // invitationId-Lookup auf einem zweiten Key).
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
12
  //
13
- // Bidirektional ist trotzdem nützlich für Cancel: Admin cancelt row.id
14
- // bekannt, ich brauche den token um Redis-Key zu löschen. Daher: zweiter
15
- // Key invite:by-id:<invitationId> token. Cancel löscht beide.
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.
16
17
  //
17
- // Bug-Pattern: TTL liegt nur in Redis. DB-row.expiresAt ist UI-Anzeige.
18
- // Bei expired-token: invite-accept findet den Token nicht invalid-
19
- // invite-token. DB-row bleibt mit status="pending" Cleanup-Job
20
- // markiert sie zu "expired" (separater Concern, kommt im U.3-Cleanup).
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.
21
26
  //
22
- // Keine Kollision mit signup/reset/verify-Tokens: alle Invite-Keys haben
23
- // `invite:`-Prefix.
27
+ // Bug pattern: TTL lives only in Redis. DB-row.expiresAt is UI display
28
+ // 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).
31
+ //
32
+ // No collision with signup/reset/verify tokens: all invite keys carry
33
+ // the `invite:`-prefix.
24
34
 
35
+ import { createHash } from "node:crypto";
25
36
  import type Redis from "ioredis";
26
37
 
27
38
  const TOKEN_KEY_PREFIX = "invite:by-token:";
28
39
  const ID_KEY_PREFIX = "invite:by-id:";
29
40
  const BURN_KEY_PREFIX = "invite:burn:";
30
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
+ }
49
+
31
50
  function tokenKey(token: string): string {
32
- return `${TOKEN_KEY_PREFIX}${token}`;
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}`;
33
59
  }
34
60
  function idKey(invitationId: string): string {
35
61
  return `${ID_KEY_PREFIX}${invitationId}`;
36
62
  }
37
63
  function burnKey(token: string): string {
38
- return `${BURN_KEY_PREFIX}${token}`;
64
+ return `${BURN_KEY_PREFIX}${hashToken(token)}`;
39
65
  }
40
66
 
41
67
  /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
42
68
  * Idempotent — re-write derselben Token-Invitation-Kombi ist OK
43
- * (refresh TTL für Resend). */
69
+ * (refresh TTL für Resend). The by-id value is the token's hash, not
70
+ * the token — see file header. */
44
71
  export async function storeInviteToken(
45
72
  redis: Redis,
46
73
  args: { invitationId: string; token: string; ttlSeconds: number },
47
74
  ): Promise<void> {
48
75
  await Promise.all([
49
76
  redis.set(tokenKey(args.token), args.invitationId, "EX", args.ttlSeconds),
50
- redis.set(idKey(args.invitationId), args.token, "EX", args.ttlSeconds),
77
+ redis.set(idKey(args.invitationId), hashToken(args.token), "EX", args.ttlSeconds),
51
78
  ]);
52
79
  }
53
80
 
@@ -57,13 +84,21 @@ export async function getInvitationIdForToken(redis: Redis, token: string): Prom
57
84
  return redis.get(tokenKey(token));
58
85
  }
59
86
 
60
- /** Lookup: Existierender Token für eine invitationId für Resend-
61
- * Idempotenz (Admin invitet dieselbe email zweimal re-use token). */
62
- export async function getTokenForInvitation(
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). */
94
+ export async function invalidateExistingInviteToken(
63
95
  redis: Redis,
64
96
  invitationId: string,
65
- ): Promise<string | null> {
66
- return redis.get(idKey(invitationId));
97
+ ): 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;
67
102
  }
68
103
 
69
104
  /** Single-Use-Burn. Wenn zwei Tabs gleichzeitig den Accept-Link klicken,
@@ -1,31 +1,33 @@
1
- // Redis-backed Pre-Activation-Token-Store für Magic-Link-Signup.
1
+ // Redis-backed pre-activation token store for magic-link signup.
2
2
  //
3
- // Token-Material: opaque random 256-bit aus crypto.randomBytes
4
- // (siehe signup-request.write.ts → generateToken() aus framework/api).
5
- // Base64url-codiert zu 43 chars. NICHT no-confusable und NICHT für
6
- // menschliches Tippen der User klickt den Mail-Link, niemand tippt
7
- // den Token ab.
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.
8
7
  //
9
- // Anders als reset/verify-Tokens (HMAC-signed, stateless verifizierbar)
10
- // brauchen Signup-Tokens einen serverside Lookup: der User existiert
11
- // noch nicht, also gibt's keinen userId-claim den der HMAC binden
12
- // könnte. Wir mappen daher Token Email bidirektional in Redis und
13
- // löschen das Pair beim Confirm. Bidirektional weil:
14
- // - by-token: confirm-handler braucht TokenEmail
15
- // - by-email: signup-request muss bei Resend einen existierenden
16
- // Token wiederverwenden statt einen zweiten parallel laufen zu
17
- // lassen (sonst hätte der User zwei Mails mit zwei verschiedenen
18
- // Tokens, beide gültig, beide könnten zu zwei separaten Tenants
19
- // führen wenn er beide klickt — unnötiges Risiko)
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 tokenemail
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
20
17
  //
21
- // TTL-Refresh bei Resend: wenn der Token noch lebt, refreshen wir
22
- // einfach beide Keys auf die volle TTL der User bekommt eine neue
23
- // Mail mit dem GLEICHEN Token, alte Mail bleibt gültig (idempotent
24
- // für den User).
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.
25
26
  //
26
- // Keine Kollision mit reset/verify-Tokens: alle Signup-Keys haben
27
- // `signup:`-Prefix.
27
+ // No collision with reset/verify tokens: all signup keys carry the
28
+ // `signup:`-prefix.
28
29
 
30
+ import { createHash } from "node:crypto";
29
31
  import type Redis from "ioredis";
30
32
 
31
33
  const TOKEN_KEY_PREFIX = "signup:by-token:";
@@ -40,26 +42,42 @@ export function normalizeEmail(email: string): string {
40
42
  return email.toLowerCase();
41
43
  }
42
44
 
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
+ }
52
+
43
53
  function tokenKey(token: string): string {
44
- return `${TOKEN_KEY_PREFIX}${token}`;
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}`;
45
62
  }
46
63
  // @wrapper-known semantic-alias
47
64
  function emailKey(email: string): string {
48
65
  return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
49
66
  }
50
67
  function burnKey(token: string): string {
51
- return `${BURN_KEY_PREFIX}${token}`;
68
+ return `${BURN_KEY_PREFIX}${hashToken(token)}`;
52
69
  }
53
70
 
54
71
  /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
55
- * Idempotent — re-write derselben Token-Email-Kombi ist OK. */
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. */
56
74
  export async function storeSignupToken(
57
75
  redis: Redis,
58
76
  args: { email: string; token: string; ttlSeconds: number },
59
77
  ): Promise<void> {
60
78
  await Promise.all([
61
79
  redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
62
- redis.set(emailKey(args.email), args.token, "EX", args.ttlSeconds),
80
+ redis.set(emailKey(args.email), hashToken(args.token), "EX", args.ttlSeconds),
63
81
  ]);
64
82
  }
65
83
 
@@ -69,10 +87,19 @@ export async function getEmailForSignupToken(redis: Redis, token: string): Promi
69
87
  return redis.get(tokenKey(token));
70
88
  }
71
89
 
72
- /** Lookup: Existierenden Token für eine Email falls noch valid und
73
- * noch nicht konsumiert. Für Resend-Idempotenz im signup-request-Handler. */
74
- export async function getTokenForSignupEmail(redis: Redis, email: string): Promise<string | null> {
75
- return redis.get(emailKey(email));
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. */
98
+ 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;
76
103
  }
77
104
 
78
105
  /** Single-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
@@ -16,10 +16,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
16
16
  import { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
17
17
  import { z } from "zod";
18
18
  // kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
19
- import {
20
- deleteInviteToken,
21
- getTokenForInvitation,
22
- } from "../../auth-email-password/invite-token-store";
19
+ import { invalidateExistingInviteToken } from "../../auth-email-password/invite-token-store";
23
20
  import {
24
21
  INVITATION_STATUS,
25
22
  tenantInvitationEntity,
@@ -82,13 +79,7 @@ export const cancelInvitationWrite = defineWriteHandler({
82
79
  // unavailable or the token already expired: not a problem, the DB
83
80
  // row is the single source of truth for the UI.
84
81
  if (ctx.redis) {
85
- const token = await getTokenForInvitation(ctx.redis, event.payload.invitationId);
86
- if (token) {
87
- await deleteInviteToken(ctx.redis, {
88
- invitationId: event.payload.invitationId,
89
- token,
90
- });
91
- }
82
+ await invalidateExistingInviteToken(ctx.redis, event.payload.invitationId);
92
83
  }
93
84
 
94
85
  return { isSuccess: true, data: { id: event.payload.invitationId, alreadyDone: false } };