@cosmicdrift/kumiko-bundled-features 0.205.0 → 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 +7 -7
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +11 -10
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +21 -26
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +140 -0
- package/src/user/__tests__/user.integration.test.ts +31 -0
- package/src/user/handlers/create.write.ts +54 -8
- package/src/user/schema/user.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
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",
|
|
@@ -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):
|
|
329
|
-
//
|
|
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
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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 —
|
|
370
|
-
//
|
|
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
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
|
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);
|
|
@@ -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 {
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/src/user/schema/user.ts
CHANGED
|
@@ -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);
|