@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.205.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.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",
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): 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);
@@ -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);