@cosmicdrift/kumiko-bundled-features 0.204.1 → 0.206.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.206.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.206.0",
130
+ "@cosmicdrift/kumiko-framework": "0.206.0",
131
+ "@cosmicdrift/kumiko-headless": "0.206.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.206.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.206.0",
134
+ "@cosmicdrift/kumiko-types": "0.206.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,
@@ -323,11 +323,11 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
323
323
  });
324
324
  });
325
325
 
326
- describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229)", () => {
326
+ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229, closed by #2155)", () => {
327
327
  // Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
328
- // 20x): the version-conflict race between two step-5 thread-rollup
329
- // appends only fires on genuine transaction-timing interleaving, which
330
- // a single run can't force deterministically.
328
+ // 20x): whether a concurrent thread-rollup commit lands inside the
329
+ // step-5 critical section depends on real transaction-timing
330
+ // interleaving, which a single run can't force deterministically.
331
331
  const ITERATIONS = 20;
332
332
  const CONCURRENCY = 3;
333
333
 
@@ -352,10 +352,10 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
352
352
  ),
353
353
  );
354
354
 
355
- // No writer's transaction fails with a 500 from the thread-rollup
356
- // version conflict each of the CONCURRENCY distinct messages is
357
- // its own aggregate, so step 4 never races; only step 5 (shared
358
- // threadAggId) does, and the bounded retry loop must absorb it.
355
+ // No writer's transaction fails with a 500 each of the CONCURRENCY
356
+ // distinct messages is its own aggregate, so step 4 never races; step
357
+ // 5 (shared threadAggId) is serialized by the pg_advisory_xact_lock,
358
+ // so there's nothing here for the bounded retry loop to absorb.
359
359
  for (const res of responses) {
360
360
  expect(res.status).toBe(200);
361
361
  const body = (await res.json()) as { isSuccess: boolean; data: { duplicate: boolean } };
@@ -366,8 +366,9 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
366
366
  const threadAggId = mailThreadAggregateId(admin.tenantId, `mid:${rootMessageIdHeader}`);
367
367
  const threadRows = await selectMany(db, mailThreadsProjectionTable, { id: threadAggId });
368
368
  expect(threadRows).toHaveLength(1);
369
- // Every message's contribution landed — no drift from a silently
370
- // stale-read-and-succeed append (the bug the retry loop replaces).
369
+ // Every message's contribution landed — the advisory lock closes the
370
+ // TOCTOU gap between countWhere and append that used to let a stale
371
+ // messageCount succeed without ever surfacing as a version conflict.
371
372
  expect(threadRows[0]?.["messageCount"]).toBe(CONCURRENCY);
372
373
  }
373
374
  });
@@ -25,6 +25,7 @@ import {
25
25
  configuredPiiSubjectKms,
26
26
  encryptPiiFieldValues,
27
27
  } from "@cosmicdrift/kumiko-framework/crypto";
28
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
28
29
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
29
30
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
30
31
  import { Temporal } from "temporal-polyfill";
@@ -101,6 +102,10 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
101
102
  }
102
103
 
103
104
  const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
105
+ // pg_advisory_xact_lock namespace (int4) for the step-5 thread-rollup lock
106
+ // — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
107
+ // single-int advisory-lock keys (schema bootstrap, es-ops boot).
108
+ const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
104
109
  export const ingestMessageHandler: WriteHandlerDef = {
105
110
  name: "ingest-message",
106
111
  schema: ingestMessageSchema,
@@ -230,35 +235,23 @@ export const ingestMessageHandler: WriteHandlerDef = {
230
235
  // encrypted once up front (constant across retries, doesn't
231
236
  // depend on the thread's prior state).
232
237
  //
233
- // messageCount deliberately does NOT come from a previously loaded
234
- // threadEvents snapshot (previousCount+1): two concurrent ingests
235
- // on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
236
- // or two different messages of the same thread arriving at once)
237
- // can both read the same stream version before either commits.
238
- // tryAppendEvent's fresh version-read at append time makes the
239
- // loser's append fail with a VersionConflictError ONLY when the
240
- // other TX's commit lands between this reload and the append's own
241
- // version check so we retry: reload the thread stream, recompute
242
- // the live row-count and lastMessageAt, and append again. If the
243
- // other TX instead commits in the gap between the countWhere below
244
- // and the append's version-read, the version check sees the
245
- // already-bumped stream and appends without conflict but with the
246
- // stale row-count this countWhere captured before the concurrent
247
- // insert. That write still lands (messageCount can undercount by
248
- // one round), it is not eliminated by this retry loop; the next
249
- // ingest on the thread re-counts and self-corrects. Unlike step 4,
250
- // a lost version-conflict race here must NOT be treated as a no-op
238
+ // messageCount is a live COUNT, not previousCount+1, so two
239
+ // concurrent ingests on the same thread (Watch-Push vs.
240
+ // Poll-Reconciliation overlap, or two different messages of the
241
+ // same thread arriving at once) converge on the true count.
242
+ // acquireNamespacedAdvisoryLock below (issue #2155) serializes this whole
243
+ // countWhere+append section per threadAggId transaction-scoped,
244
+ // so it survives tryAppendEvent's inner SAVEPOINT and releases at
245
+ // the enclosing TX's commit/rollback. Without it, a concurrent
246
+ // commit landing between countWhere and tryAppendEvent's own fresh
247
+ // version-read makes the append succeed with a stale count instead
248
+ // of a VersionConflictError, so the retry loop below never sees it
249
+ // as a conflict to retry (#1229's original gap). Unlike step 4, a
250
+ // lost version-conflict race here must NOT be treated as a no-op
251
251
  // duplicate — skipping would leave this message's contribution to
252
252
  // messageCount/lastMessageAt out of the thread forever. Exhausting
253
253
  // all attempts throws, rolling back the whole TX (including the
254
- // step-4 message append) for a clean re-ingest on the next poll —
255
- // never return success with a stale snapshot.
256
- //
257
- // A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
258
- // advisory lock on threadAggId) would close the countWhere→append
259
- // gap above and is deliberately not part of this fix — two writers
260
- // (watch + poll) make self-correcting drift an acceptable trade for
261
- // now.
254
+ // step-4 message append) for a clean re-ingest.
262
255
  // ---------------------------------------------------------------
263
256
  const threadPlainPii = { tenantId, subject: payload.subject };
264
257
  const encryptedThreadFields = piiKms
@@ -274,6 +267,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
274
267
  )
275
268
  : threadPlainPii;
276
269
 
270
+ await acquireNamespacedAdvisoryLock(ctx.db.raw, THREAD_ROLLUP_LOCK_NAMESPACE, threadAggId);
271
+
277
272
  let threadAppendOk = false;
278
273
  for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
279
274
  const threadEvents = await ctx.loadAggregate(threadAggId);
@@ -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 } };
@@ -0,0 +1,140 @@
1
+ // fw#2134 — user:create's email-uniqueness check used to be a pure
2
+ // pre-flight fetchOne (TOCTOU: two concurrent creates can both pass the
3
+ // pre-flight and both insert, see handlers/create.write.ts). This proves
4
+ // the DB-level fix directly against the executor (bypassing the handler's
5
+ // pre-flight entirely) — a unique index over email's blind-index column
6
+ // rejects the loser of a genuine race, and the framework's F8 pg-23505
7
+ // mapping turns that into a clean 409 unique_violation, not a 500.
8
+ //
9
+ // createTestDb wires no blind-index key by default (see
10
+ // blind-index.integration.test.ts) — without configureBlindIndexKey the
11
+ // email_bidx column stays NULL for every row, the partial bidx unique
12
+ // index never applies, and a race "passing" here would prove nothing. So
13
+ // this test configures one explicitly and then proves the loser was
14
+ // actually rejected on the bidx constraint (not the plaintext-fallback
15
+ // index) by asserting the constraint name and reading email_bidx back off
16
+ // the surviving row.
17
+
18
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import {
21
+ computeBlindIndex,
22
+ configureBlindIndexKey,
23
+ configurePiiSubjectKms,
24
+ decodeBlindIndexKey,
25
+ InMemoryKmsAdapter,
26
+ } from "@cosmicdrift/kumiko-framework/crypto";
27
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
+ import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
29
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
30
+ import {
31
+ createTestDb,
32
+ type TestDb,
33
+ unsafeCreateEntityTable,
34
+ } from "@cosmicdrift/kumiko-framework/stack";
35
+ import {
36
+ resetBlindIndexKeyForTests,
37
+ resetPiiSubjectKmsForTests,
38
+ } from "@cosmicdrift/kumiko-framework/testing";
39
+ import { userEntity, userTable } from "../schema/user";
40
+
41
+ const TEST_KEY_B64 = Buffer.alloc(32, 9).toString("base64");
42
+ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
43
+
44
+ let testDb: TestDb;
45
+ const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
46
+
47
+ beforeAll(async () => {
48
+ testDb = await createTestDb();
49
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
50
+ await createEventsTable(testDb.db);
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await testDb.cleanup();
55
+ });
56
+
57
+ beforeEach(async () => {
58
+ await asRawClient(testDb.db).unsafe(
59
+ `TRUNCATE kumiko_events, read_users RESTART IDENTITY CASCADE`,
60
+ );
61
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
62
+ configureBlindIndexKey(TEST_KEY_B64);
63
+ });
64
+
65
+ afterEach(() => {
66
+ resetPiiSubjectKmsForTests();
67
+ resetBlindIndexKeyForTests();
68
+ });
69
+
70
+ describe("fw#2134 — email unique constraint on the blind-index column", () => {
71
+ test("two concurrent creates with the same email: exactly one wins, loser fails on the bidx constraint", async () => {
72
+ const email = "race@example.com";
73
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
74
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
75
+
76
+ const [first, second] = await Promise.all([
77
+ executor.create({ email, displayName: "Racer One" }, systemUser, tdb),
78
+ executor.create({ email, displayName: "Racer Two" }, systemUser, tdb),
79
+ ]);
80
+
81
+ const winner = first.isSuccess ? first : second;
82
+ const loser = first.isSuccess ? second : first;
83
+ if (loser.isSuccess || !winner.isSuccess) {
84
+ throw new Error("expected exactly one winner and one loser out of the two racing creates");
85
+ }
86
+
87
+ expect(loser.error.code).toBe("unique_violation");
88
+ expect(loser.error.httpStatus).toBe(409);
89
+ const details = loser.error.details as { constraintName?: string };
90
+ expect(details.constraintName).toBe("read_users_email_unique_bidx");
91
+
92
+ // Proves the race was actually decided by the blind-index constraint,
93
+ // not the plaintext-fallback index: the surviving row's bidx column
94
+ // carries the deterministic HMAC of the email.
95
+ const rows = (await asRawClient(testDb.db).unsafe(
96
+ `SELECT "email_bidx" FROM "read_users" WHERE "id" = $1::uuid`,
97
+ [winner.data.id],
98
+ )) as ReadonlyArray<{ email_bidx: string | null }>;
99
+ expect(rows[0]?.email_bidx).toBe(computeBlindIndex(TEST_KEY, email));
100
+
101
+ // DB-proof: only one row actually survives under that blind index.
102
+ const survivors = (await asRawClient(testDb.db).unsafe(
103
+ `SELECT count(*)::int AS n FROM "read_users" WHERE "email_bidx" = $1`,
104
+ [computeBlindIndex(TEST_KEY, email)],
105
+ )) as ReadonlyArray<{ n: number }>;
106
+ expect(survivors[0]?.n).toBe(1);
107
+ });
108
+
109
+ test("a soft-deleted user still holds its email: constraint agrees with the pre-existing pre-flight behavior", async () => {
110
+ // Soft-delete only flips isDeleted/deletedAt (apply-entity-event.ts) —
111
+ // it never touches email/email_bidx, so the row still occupies the
112
+ // unique slot. This isn't new behavior from fw#2134: the handler's
113
+ // pre-flight fetchOne is a raw, unfiltered query (see create.write.ts)
114
+ // and already saw soft-deleted rows before this fix. This test pins
115
+ // that the new DB constraint doesn't diverge from that — only the
116
+ // GDPR "forgotten" hard-delete (see forget-cleanup) actually frees the
117
+ // email slot.
118
+ const email = "soft-deleted@example.com";
119
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
120
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
121
+
122
+ const created = await executor.create(
123
+ { email, displayName: "Departing User" },
124
+ systemUser,
125
+ tdb,
126
+ );
127
+ if (!created.isSuccess) throw new Error("expected create to succeed");
128
+
129
+ const deleted = await executor.delete({ id: created.data.id }, systemUser, tdb);
130
+ if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
131
+
132
+ const reCreated = await executor.create({ email, displayName: "Squatter" }, systemUser, tdb);
133
+ if (reCreated.isSuccess) {
134
+ throw new Error("expected re-create with the same email to be rejected while soft-deleted");
135
+ }
136
+ expect(reCreated.error.code).toBe("unique_violation");
137
+ const details = reCreated.error.details as { constraintName?: string };
138
+ expect(details.constraintName).toBe("read_users_email_unique_bidx");
139
+ });
140
+ });
@@ -107,6 +107,37 @@ describe("scenario 1: create + me", () => {
107
107
  );
108
108
  expectErrorIncludes(error, UserErrors.emailAlreadyExists);
109
109
  });
110
+
111
+ // fw#2134 — the pre-flight fetchOne alone can't decide a genuine race
112
+ // (both requests can see "no duplicate" before either commits); this
113
+ // fires two real concurrent HTTP creates and checks the loser still
114
+ // gets a clean 4xx, never an unhandled 500. The DB-constraint-level
115
+ // proof (which layer actually catches the race, and that it's the
116
+ // blind-index column doing it) lives in
117
+ // email-unique-blind-index.integration.test.ts — this test's job is
118
+ // narrower: no request may 500 no matter who wins.
119
+ test("two concurrent creates with the same email: loser gets a clean 4xx, not a 500", async () => {
120
+ const email = "concurrent@example.com";
121
+ const [resA, resB] = await Promise.all([
122
+ stack.http.write(UserHandlers.create, { email, displayName: "Racer A" }, systemAdmin),
123
+ stack.http.write(UserHandlers.create, { email, displayName: "Racer B" }, systemAdmin),
124
+ ]);
125
+
126
+ const bodyA = (await resA.json()) as { isSuccess: boolean };
127
+ const bodyB = (await resB.json()) as { isSuccess: boolean };
128
+ const successes = [bodyA, bodyB].filter((b) => b.isSuccess === true);
129
+ const failures = [
130
+ { status: resA.status, body: bodyA },
131
+ { status: resB.status, body: bodyB },
132
+ ].filter((r) => r.body.isSuccess === false);
133
+
134
+ expect(successes).toHaveLength(1);
135
+ expect(failures).toHaveLength(1);
136
+ const loser = failures[0];
137
+ if (!loser) throw new Error("expected exactly one failing response");
138
+ expect(loser.status).toBeGreaterThanOrEqual(400);
139
+ expect(loser.status).toBeLessThan(500);
140
+ });
110
141
  });
111
142
 
112
143
  // --- Scenario 2: field-level read access hides passwordHash ---
@@ -1,7 +1,12 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
- import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import {
5
+ ConflictError,
6
+ InternalError,
7
+ type WriteErrorInfo,
8
+ writeFailure,
9
+ } from "@cosmicdrift/kumiko-framework/errors";
5
10
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
11
  import { z } from "zod";
7
12
  import { UserErrors } from "../constants";
@@ -11,12 +16,16 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
11
16
 
12
17
  // Only the Auth features (running as SYSTEM) or a SystemAdmin may create users.
13
18
  //
14
- // Email uniqueness is checked via a pre-flight query — the framework has no
15
- // `unique:` field flag yet. This check is race-prone: two concurrent requests
16
- // can both see "no duplicate" and both insert. Acceptable MVP behavior since
17
- // user creation is low-frequency and gated by privileged roles; the DB will
18
- // still surface a pg unique violation once we add the constraint.
19
- // TODO: replace with a real `unique:` field flag + DB constraint.
19
+ // Email uniqueness has two layers (fw#2134):
20
+ // 1. A pre-flight fetchOne fast-fails with a friendly error on the
21
+ // common case, but is race-prone on its own: two concurrent requests
22
+ // can both see "no duplicate" and both proceed to crud.create.
23
+ // 2. The real guard is the unique index over email's blind-index column
24
+ // (schema/user.ts). A losing concurrent create hits that constraint
25
+ // and crud.create returns a UniqueViolationError (framework's F8
26
+ // pg-23505 mapping) instead of throwing — remapped below to the same
27
+ // emailAlreadyExists shape the pre-flight path returns, so callers see
28
+ // one consistent error regardless of which layer caught the race.
20
29
  export const createWrite = defineWriteHandler({
21
30
  name: "user:create",
22
31
  schema: z.object({
@@ -56,6 +65,43 @@ export const createWrite = defineWriteHandler({
56
65
  );
57
66
  }
58
67
 
59
- return crud.create(event.payload, event.user, db);
68
+ const result = await crud.create(event.payload, event.user, db);
69
+ if (!result.isSuccess && isEmailUniqueViolation(result.error)) {
70
+ return writeFailure(
71
+ new ConflictError({
72
+ message: "email already exists",
73
+ i18nKey: "user.errors.emailAlreadyExists",
74
+ details: {
75
+ reason: UserErrors.emailAlreadyExists,
76
+ field: "email",
77
+ constraintName: constraintNameOf(result.error),
78
+ },
79
+ }),
80
+ );
81
+ }
82
+ return result;
60
83
  },
61
84
  });
85
+
86
+ // schema/user.ts's `read_users_email_unique` (+ its generated `_bidx`
87
+ // pendant) is the actual race-safety net behind the pre-flight check
88
+ // above — a losing concurrent create surfaces here as a generic
89
+ // UniqueViolationError (framework F8 pg-23505 mapping). Only the email
90
+ // constraint gets remapped; any other unique_violation on this entity
91
+ // passes through unchanged.
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null;
94
+ }
95
+
96
+ function constraintNameOf(error: WriteErrorInfo): string | undefined {
97
+ if (!isRecord(error.details)) return undefined;
98
+ const constraintName = error.details["constraintName"];
99
+ return typeof constraintName === "string" ? constraintName : undefined;
100
+ }
101
+
102
+ function isEmailUniqueViolation(error: WriteErrorInfo): boolean {
103
+ return (
104
+ error.code === "unique_violation" &&
105
+ (constraintNameOf(error)?.startsWith("read_users_email_unique") ?? false)
106
+ );
107
+ }
@@ -169,6 +169,17 @@ export const userEntity = createEntity({
169
169
  access: { write: access.privileged },
170
170
  }),
171
171
  },
172
+ // fw#2134 — email is `pii: true, lookupable: true`, so the column holds
173
+ // per-row ciphertext: a plain unique index on it can't catch duplicates.
174
+ // `unique: true` over a lookupable column makes buildEntityTable /
175
+ // deriveEntityTableMeta emit a second, partial unique index over the
176
+ // deterministic `emailBidx` companion column (WHERE email_bidx IS NOT
177
+ // NULL — erased/key-less rows stay excluded), which is what actually
178
+ // closes the TOCTOU race between two concurrent user:create calls.
179
+ // Global (not tenant-scoped): user is a tenant-agnostic identity
180
+ // aggregate (systemStream: true) — a composite (tenantId, email) would
181
+ // leave the same email resolvable twice.
182
+ indexes: [{ columns: ["email"], unique: true, name: "read_users_email_unique" }],
172
183
  });
173
184
 
174
185
  export const userTable = buildEntityTable("user", userEntity);