@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
  4. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
  5. package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
  6. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  8. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  9. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  10. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  11. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
  12. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  13. package/src/custom-fields/constants.ts +8 -7
  14. package/src/custom-fields/db/queries/field-access.ts +1 -1
  15. package/src/custom-fields/db/queries/projection.ts +13 -5
  16. package/src/custom-fields/db/queries/quota.ts +1 -1
  17. package/src/custom-fields/db/queries/retention.ts +20 -6
  18. package/src/custom-fields/executor.ts +10 -0
  19. package/src/custom-fields/feature.ts +32 -39
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  21. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  22. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  23. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  24. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  25. package/src/custom-fields/lib/field-access.ts +4 -0
  26. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  27. package/src/custom-fields/run-retention.ts +6 -5
  28. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  29. package/src/custom-fields/web/client-plugin.tsx +2 -0
  30. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  31. package/src/custom-fields/web/i18n.ts +30 -0
  32. package/src/custom-fields/wire-for-entity.ts +1 -1
  33. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  34. package/src/feature-toggles/handlers/set.write.ts +13 -8
  35. package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
  36. package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
  37. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
  38. package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
  39. package/src/foundation-shared/config-helpers.ts +7 -3
  40. package/src/secrets/feature.ts +4 -11
  41. package/src/subscription-stripe/feature.ts +2 -2
  42. package/src/template-resolver/handlers/list.query.ts +12 -10
  43. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  44. package/src/tenant/seeding.ts +3 -3
  45. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
  46. package/src/tier-engine/feature.ts +8 -2
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.24.1",
3
+ "version": "0.26.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>",
@@ -1,10 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import {
4
- composeEnvSchema,
5
- type KumikoBootError,
6
- parseEnv,
7
- } from "@cosmicdrift/kumiko-framework/env";
3
+ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko-framework/env";
8
4
  import { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "../auth-email-password";
9
5
  import { createSecretsFeature, secretsEnvSchema } from "../secrets";
10
6
  import {
@@ -18,6 +14,13 @@ import {
18
14
 
19
15
  const validKek = randomBytes(32).toString("base64");
20
16
 
17
+ function asBootError(err: unknown): KumikoBootError {
18
+ if (!(err instanceof KumikoBootError)) {
19
+ throw err instanceof Error ? err : new Error(String(err));
20
+ }
21
+ return err;
22
+ }
23
+
21
24
  describe("secretsEnvSchema", () => {
22
25
  it("accepts a base64-32 KEK and defaults CURRENT_VERSION to '1'", () => {
23
26
  const env = parseEnv(secretsEnvSchema, {
@@ -32,7 +35,7 @@ describe("secretsEnvSchema", () => {
32
35
  parseEnv(secretsEnvSchema, { KUMIKO_SECRETS_MASTER_KEY_V1: "dGVzdA==" });
33
36
  throw new Error("should have thrown");
34
37
  } catch (err) {
35
- const boot = err as KumikoBootError;
38
+ const boot = asBootError(err);
36
39
  const v1 = boot.errors.find((e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_V1");
37
40
  expect(v1?.kind).toBe("invalid");
38
41
  expect(v1?.message).toContain("32 bytes");
@@ -47,13 +50,36 @@ describe("secretsEnvSchema", () => {
47
50
  });
48
51
  throw new Error("should have thrown");
49
52
  } catch (err) {
50
- const cur = (err as KumikoBootError).errors.find(
53
+ const cur = asBootError(err).errors.find(
54
+ (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
55
+ );
56
+ expect(cur?.kind).toBe("invalid");
57
+ }
58
+ });
59
+
60
+ it("rejects CURRENT_VERSION '0' (V0 never exists, selector starts at V1)", () => {
61
+ try {
62
+ parseEnv(secretsEnvSchema, {
63
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
64
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "0",
65
+ });
66
+ throw new Error("should have thrown");
67
+ } catch (err) {
68
+ const cur = asBootError(err).errors.find(
51
69
  (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
52
70
  );
53
71
  expect(cur?.kind).toBe("invalid");
54
72
  }
55
73
  });
56
74
 
75
+ it("accepts CURRENT_VERSION '2' (positive version selector)", () => {
76
+ const env = parseEnv(secretsEnvSchema, {
77
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
78
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
79
+ });
80
+ expect(env.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("2");
81
+ });
82
+
57
83
  it("attaches the schema via r.envSchema() on createSecretsFeature()", () => {
58
84
  const f = createSecretsFeature();
59
85
  expect(f.envSchema).toBe(secretsEnvSchema);
@@ -74,7 +100,7 @@ describe("authEmailPasswordEnvSchema", () => {
74
100
  parseEnv(authEmailPasswordEnvSchema, { JWT_SECRET: "short" });
75
101
  throw new Error("should have thrown");
76
102
  } catch (err) {
77
- const jwt = (err as KumikoBootError).errors.find((e) => e.name === "JWT_SECRET");
103
+ const jwt = asBootError(err).errors.find((e) => e.name === "JWT_SECRET");
78
104
  expect(jwt?.kind).toBe("invalid");
79
105
  }
80
106
  });
@@ -103,11 +129,27 @@ describe("subscriptionStripeEnvSchema", () => {
103
129
  });
104
130
  throw new Error("should have thrown");
105
131
  } catch (err) {
106
- const boot = err as KumikoBootError;
132
+ const boot = asBootError(err);
107
133
  expect(boot.errors.length).toBe(2);
108
134
  }
109
135
  });
110
136
 
137
+ it("rejects a publishable key and a non-whsec webhook secret", () => {
138
+ try {
139
+ parseEnv(subscriptionStripeEnvSchema, {
140
+ STRIPE_WEBHOOK_SECRET: "wrong_abc",
141
+ STRIPE_API_KEY: "pk_live_xyz",
142
+ });
143
+ throw new Error("should have thrown");
144
+ } catch (err) {
145
+ const boot = asBootError(err);
146
+ const api = boot.errors.find((e) => e.name === "STRIPE_API_KEY");
147
+ const hook = boot.errors.find((e) => e.name === "STRIPE_WEBHOOK_SECRET");
148
+ expect(api?.kind).toBe("invalid");
149
+ expect(hook?.kind).toBe("invalid");
150
+ }
151
+ });
152
+
111
153
  it("attaches the schema via r.envSchema() on the factory", () => {
112
154
  const f = createSubscriptionStripeFeature({
113
155
  webhookSecret: "whsec_x",
@@ -129,7 +171,7 @@ describe("subscriptionMollieEnvSchema", () => {
129
171
  parseEnv(subscriptionMollieEnvSchema, { MOLLIE_API_KEY: "no-prefix" });
130
172
  throw new Error("should have thrown");
131
173
  } catch (err) {
132
- const k = (err as KumikoBootError).errors.find((e) => e.name === "MOLLIE_API_KEY");
174
+ const k = asBootError(err).errors.find((e) => e.name === "MOLLIE_API_KEY");
133
175
  expect(k?.kind).toBe("invalid");
134
176
  }
135
177
  });
@@ -200,7 +242,7 @@ describe("compose across all Phase-2 features", () => {
200
242
  parseEnv(composed.schema, {}, { sources: composed.sources });
201
243
  throw new Error("should have thrown");
202
244
  } catch (err) {
203
- const out = (err as KumikoBootError).format();
245
+ const out = asBootError(err).format();
204
246
  expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
205
247
  expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
206
248
  expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import { asRawClient, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import {
4
+ asRawClient,
5
+ insertOne,
6
+ selectMany,
7
+ updateMany,
8
+ } from "@cosmicdrift/kumiko-framework/bun-db";
4
9
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
5
10
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import {
@@ -204,27 +209,86 @@ describe("POST /auth/verify-email", () => {
204
209
  });
205
210
 
206
211
  test("verify that fails before the write is retryable (burn released on failure)", async () => {
207
- // Symmetric to the reset-password retry test: if the confirm-flow
208
- // fails AFTER burning (here: no memberships empty tenantOrder),
209
- // the finally-block in runConfirmTokenFlow releases the burn so
210
- // the user can click the same link again once ops restores state.
212
+ // Symmetric to the reset-password retry test: if the confirm-flow fails
213
+ // AFTER burning, the finally-block in runConfirmTokenFlow releases the
214
+ // burn so the user can click the same link again once ops restores state.
215
+ //
216
+ // Trigger: delete the user READ-MODEL row (kumiko_events untouched) →
217
+ // loadValidatedUser returns null after the burn → invalidToken + unburn.
218
+ // Re-insert the same row verbatim → retry with the SAME token succeeds.
219
+ //
220
+ // (Membership-deletion no longer fails: the stream lives in
221
+ // systemAdmin.tenantId and is recovered with zero memberships — see the
222
+ // zero-membership-sysadmin test below.)
211
223
  const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
212
224
  const { token } = signVerificationToken(seed.id, 60, verifySecret);
213
225
 
214
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
226
+ const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
227
+ if (!userRow) throw new Error("seeded user row missing");
228
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
229
+ seed.id,
230
+ ]);
231
+
215
232
  const firstAttempt = await post("/api/auth/verify-email", { token });
216
233
  expect(firstAttempt.status).toBe(422);
217
234
 
218
- await seedTenantMembership(stack.db, {
219
- userId: seed.id,
220
- tenantId: seed.tenantId,
221
- roles: ["User"],
222
- });
235
+ await insertOne(stack.db, userTable, userRow);
223
236
 
224
237
  const secondAttempt = await post("/api/auth/verify-email", { token });
225
238
  expect(secondAttempt.status).toBe(200);
226
239
  });
227
240
 
241
+ test("zero-membership sysadmin can still verify (stream recovered without any membership)", async () => {
242
+ // 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
243
+ // (…0001) but who holds NO membership must still resolve. The stream-
244
+ // tenant recovery runs BEFORE the empty-membership check, so verify
245
+ // targets …0001 and lands instead of collapsing to invalid_token.
246
+ const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-lonely-1234" });
247
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
248
+
249
+ const streamRows = (await asRawClient(stack.db).unsafe(
250
+ `SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
251
+ [seed.id],
252
+ )) as ReadonlyArray<{ tenant_id: string }>;
253
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
254
+
255
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
256
+ const res = await post("/api/auth/verify-email", { token });
257
+ expect(res.status).toBe(200);
258
+
259
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
260
+ expect(row?.["emailVerified"]).toBe(true);
261
+ });
262
+
263
+ test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
264
+ // 205#1 "strikt sicher" boundary: a user inserted straight into the read
265
+ // model (no create-event → no event stream) with no membership has
266
+ // nothing to recover → invalidToken. Proves the fix only gains "empty
267
+ // memberships + recoverable stream", never blanket-opens zero-membership.
268
+ // Build a fully-populated user row with NO event stream: seed a normal
269
+ // user (gets all NOT-NULL columns), capture its row, then re-key it to a
270
+ // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
271
+ // (the stream lives under the original id), and no membership is seeded
272
+ // → tenantOrder is empty.
273
+ const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
274
+ const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
275
+ if (!donorRow) throw new Error("donor row missing");
276
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
277
+
278
+ const orphanId = "00000000-0000-4000-8000-0000000000ff";
279
+ await insertOne(stack.db, userTable, {
280
+ ...donorRow,
281
+ id: orphanId,
282
+ email: "orphan@example.com",
283
+ });
284
+
285
+ const { token } = signVerificationToken(orphanId, 60, verifySecret);
286
+ const res = await post("/api/auth/verify-email", { token });
287
+ expect(res.status).toBe(422);
288
+ const body = await res.json();
289
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
290
+ });
291
+
228
292
  test("cross-purpose burn isolation: consuming a reset-token doesn't block a verify-token for the same user+expiry", async () => {
229
293
  // The burn-store key includes purpose ("reset" vs "verify"). Tokens
230
294
  // signed with the SAME userId + expiresAtMs but different purpose
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
5
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import {
@@ -251,31 +251,39 @@ describe("POST /auth/reset-password", () => {
251
251
 
252
252
  test("reset that fails before the write is retryable (burn is released on failure)", async () => {
253
253
  // The burn marker goes down BEFORE the state change so a racing replay
254
- // can't slip through. But if the state change itself fails — e.g. no
255
- // memberships in the row, every tenant stream rejected, DB error
256
- // the token was never actually consumed. The handler releases the
257
- // burn in those branches so the user can click the link again
258
- // without hitting a stuck "already-used".
254
+ // can't slip through. But if the state change itself fails — DB error,
255
+ // user-row vanished, every tenant stream rejected the token was never
256
+ // actually consumed. The handler releases the burn in those branches so
257
+ // the user can click the link again once ops restores state.
259
258
  //
260
- // Repro: drop the user's membership tenantOrder is empty
261
- // invalidToken + unburn. Re-insert membershipsecond attempt
262
- // with the same token succeeds (proves the burn was released).
259
+ // Repro: delete the user READ-MODEL row (kumiko_events untouched)
260
+ // loadValidatedUser returns null AFTER the burn invalidToken + unburn.
261
+ // Re-insert the same row verbatim (restores version optimistic write
262
+ // still matches the untouched event stream) → second attempt with the
263
+ // SAME token succeeds, proving the burn was released.
264
+ //
265
+ // (Deleting the membership no longer works as a failure trigger: the
266
+ // user aggregate stream lives in systemAdmin.tenantId and is recovered
267
+ // by resolveStreamTenants even with zero memberships — see the
268
+ // zero-membership-sysadmin test below.)
263
269
  const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
264
270
  const { token } = signResetToken(seed.id, 15, resetSecret);
265
271
 
266
- await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
272
+ const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
273
+ if (!userRow) throw new Error("seeded user row missing");
274
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
275
+ seed.id,
276
+ ]);
277
+
267
278
  const firstAttempt = await post("/api/auth/reset-password", {
268
279
  token,
269
280
  newPassword: "never-lands-1234",
270
281
  });
271
282
  expect(firstAttempt.status).toBe(422);
272
283
 
273
- // Re-insert the membership. Same userId, same token still valid.
274
- await seedTenantMembership(stack.db, {
275
- userId: seed.id,
276
- tenantId: seed.tenantId,
277
- roles: ["User"],
278
- });
284
+ // Re-insert the captured row verbatim. Same userId, same version, same
285
+ // token still valid.
286
+ await insertOne(stack.db, userTable, userRow);
279
287
 
280
288
  const secondAttempt = await post("/api/auth/reset-password", {
281
289
  token,
@@ -284,6 +292,68 @@ describe("POST /auth/reset-password", () => {
284
292
  expect(secondAttempt.status).toBe(200);
285
293
  });
286
294
 
295
+ test("zero-membership sysadmin can still reset (stream recovered without any membership)", async () => {
296
+ // 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
297
+ // (…0001) but who holds NO membership must still resolve. The stream-
298
+ // tenant recovery in resolveStreamTenants runs BEFORE the empty-
299
+ // membership check, so the reset targets …0001 and lands — instead of
300
+ // collapsing to invalid_token. Mirrors change-password's unconditional
301
+ // recovery.
302
+ const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-old-lonely!" });
303
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
304
+
305
+ // Confirm the stream tenant the recovery must find: the user aggregate
306
+ // was created via systemAdmin, so its stream lives in …0001.
307
+ const streamRows = (await asRawClient(stack.db).unsafe(
308
+ `SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
309
+ [seed.id],
310
+ )) as ReadonlyArray<{ tenant_id: string }>;
311
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
312
+
313
+ const { token } = signResetToken(seed.id, 15, resetSecret);
314
+ const res = await post("/api/auth/reset-password", {
315
+ token,
316
+ newPassword: "pw-new-lonely-1234",
317
+ });
318
+ expect(res.status).toBe(200);
319
+
320
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
321
+ expect(await verifyPassword(row?.["passwordHash"] as string, "pw-new-lonely-1234")).toBe(true);
322
+ });
323
+
324
+ test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
325
+ // 205#1 "strikt sicher" boundary: a user inserted straight into the read
326
+ // model (no create-event → no event stream) with no membership has
327
+ // nothing to recover. resolveStreamTenants returns [] → invalidToken.
328
+ // Proves the fix only gains "empty memberships + recoverable stream",
329
+ // never blanket-opens zero-membership.
330
+ // Build a fully-populated user row with NO event stream: seed a normal
331
+ // user (gets all NOT-NULL columns), capture its row, then re-key it to a
332
+ // fresh id + email. getAggregateStreamTenant(orphanId) finds no events
333
+ // (the stream lives under the original id), and no membership is seeded
334
+ // → tenantOrder is empty.
335
+ const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
336
+ const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
337
+ if (!donorRow) throw new Error("donor row missing");
338
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
339
+
340
+ const orphanId = "00000000-0000-4000-8000-0000000000ff";
341
+ await insertOne(stack.db, userTable, {
342
+ ...donorRow,
343
+ id: orphanId,
344
+ email: "orphan@example.com",
345
+ });
346
+
347
+ const { token } = signResetToken(orphanId, 15, resetSecret);
348
+ const res = await post("/api/auth/reset-password", {
349
+ token,
350
+ newPassword: "should-not-land-1234",
351
+ });
352
+ expect(res.status).toBe(422);
353
+ const body = await res.json();
354
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
355
+ });
356
+
287
357
  test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
288
358
  // Reset tokens are single-use: the handler burns them in Redis via
289
359
  // SETNX before the state change. First click wins; replay within TTL
@@ -141,11 +141,14 @@ async function loadValidatedUser(
141
141
  return { ...me, version: me.version };
142
142
  }
143
143
 
144
- // Loads the user's memberships and returns a prioritised tenant list.
145
- // Empty when the user has no memberships at all — the caller treats that
146
- // as invalid_token (a user without memberships can't own a usable auth
147
- // flow anyway, and a deterministic early-return is cleaner than
148
- // discovering it at write time).
144
+ // Loads the user's memberships and returns a prioritised tenant list, with the
145
+ // aggregate's real stream tenant recovered from the event log prepended.
146
+ //
147
+ // Empty only when the user has NO memberships AND no recoverable stream tenant
148
+ // the caller treats that as invalid_token. A zero-membership systemScope user
149
+ // whose stream lives outside any membership (a platform operator seeded under a
150
+ // fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
151
+ // before the empty check rather than being short-circuited by it.
149
152
  async function resolveStreamTenants(
150
153
  ctx: HandlerContext,
151
154
  systemUser: SessionUser,
@@ -155,15 +158,16 @@ async function resolveStreamTenants(
155
158
  userId: me.id,
156
159
  })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
157
160
  const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
158
- if (ordered.length === 0) return [];
159
161
 
160
162
  // The user aggregate is r.systemScope(): its event stream lives in whichever
161
163
  // tenant the creating executor used, which need NOT be a membership tenant.
162
164
  // A platform operator seeded under a fixture/platform tenant is the live case
163
165
  // — its stream tenant is absent from `ordered`, so a membership-only search
164
166
  // rejects every write and collapses to invalid_token. Recover the real stream
165
- // tenant from the event log and try it first; memberships stay as fallback,
166
- // and an empty/unknown lookup degrades to the prior membership-only behaviour.
167
+ // tenant from the event log and try it first; memberships stay as fallback.
168
+ // Pulled BEFORE the empty-membership check so a zero-membership operator whose
169
+ // stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
170
+ // invalid_token — mirrors change-password.write.ts's unconditional recovery.
167
171
  const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
168
172
  if (streamTenant && !ordered.includes(streamTenant)) {
169
173
  return [streamTenant, ...ordered];
@@ -265,13 +265,40 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
265
265
  adminWithAudit,
266
266
  );
267
267
 
268
+ // Tenant-2 defines its OWN field. Without this, an audit query that
269
+ // returned zero rows for ANY reason (e.g. a broken filter) would still
270
+ // pass the "doesn't see leakyField" assertion — a false-positive that
271
+ // reads "isolated" but actually means "blind". Asserting tenant-2 sees its
272
+ // own event proves the query genuinely returns tenant-2's data.
273
+ const otherTenantDefiner = createTestUser({
274
+ id: 11,
275
+ roles: ["TenantAdmin"],
276
+ tenantId: otherTenantAdmin.tenantId,
277
+ });
278
+ await stack.http.writeOk(
279
+ "custom-fields:write:define-tenant-field",
280
+ {
281
+ entityName: "property",
282
+ fieldKey: "ownField",
283
+ serializedField: { type: "text" },
284
+ required: false,
285
+ searchable: false,
286
+ displayOrder: 0,
287
+ },
288
+ otherTenantDefiner,
289
+ );
290
+
268
291
  const res = await stack.http.queryOk<AuditResponse>(
269
292
  AuditQueries.list,
270
293
  { aggregateType: "field-definition" },
271
294
  otherTenantAdmin,
272
295
  );
273
296
 
274
- const leak = res.rows.find((r) => (r.payload["fieldKey"] as string) === "leakyField");
297
+ // Tenant-2 sees its own event ...
298
+ const own = res.rows.find((r) => r.payload["fieldKey"] === "ownField");
299
+ expect(own).toBeDefined();
300
+ // ... but never tenant-1's.
301
+ const leak = res.rows.find((r) => r.payload["fieldKey"] === "leakyField");
275
302
  expect(leak).toBeUndefined();
276
303
  });
277
304
  });
@@ -19,6 +19,7 @@ import {
19
19
  createTextField,
20
20
  defineEntityListHandler,
21
21
  defineFeature,
22
+ SYSTEM_TENANT_ID,
22
23
  } from "@cosmicdrift/kumiko-framework/engine";
23
24
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
24
25
  import {
@@ -102,6 +103,15 @@ beforeEach(async () => {
102
103
  // (Memory: feedback_role_naming_drift — bundled-features-Convention vs.
103
104
  // platform-Convention). Wir bauen einen tenant-admin für die Tests.
104
105
  const admin = createTestUser({ roles: ["TenantAdmin"] });
106
+ const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
107
+
108
+ async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
109
+ const rows = await asRawClient(stack.db).unsafe(
110
+ "SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
111
+ [tenantId, fieldKey],
112
+ );
113
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
114
+ }
105
115
 
106
116
  async function defineField(entityName: string, fieldKey: string, type = "text") {
107
117
  return stack.http.writeOk(
@@ -260,6 +270,84 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
260
270
  });
261
271
  });
262
272
 
273
+ describe("custom-fields integration — define/delete handler coverage (B1)", () => {
274
+ // feature.test.ts only covers schema/aggregate-id/registration shape. These
275
+ // drive the handler bodies through the real dispatcher: the deterministic
276
+ // aggregate-id → version_conflict on a duplicate define, the system-tenant
277
+ // guard on define-tenant-field, and the system-scope define→delete roundtrip.
278
+
279
+ test("re-defining the same tenant-field → 409 (deterministic aggregate-id conflict)", async () => {
280
+ await defineField("property", "color", "text");
281
+ const err = await stack.http.writeErr(
282
+ "custom-fields:write:define-tenant-field",
283
+ {
284
+ entityName: "property",
285
+ fieldKey: "color",
286
+ serializedField: { type: "text" },
287
+ required: false,
288
+ searchable: false,
289
+ displayOrder: 0,
290
+ },
291
+ admin,
292
+ );
293
+ expect(err.httpStatus).toBe(409);
294
+ // Only the first define produced a row.
295
+ expect(await countDefinitions(admin.tenantId, "color")).toBe(1);
296
+ });
297
+
298
+ test("define-tenant-field rejects a caller whose tenant IS the system tenant", async () => {
299
+ // The strict guard (isSystemTenant) blocks system-scope writes through the
300
+ // tenant handler — system definitions must go via define-system-field.
301
+ const systemScopedAdmin = createTestUser({
302
+ roles: ["TenantAdmin"],
303
+ tenantId: SYSTEM_TENANT_ID,
304
+ });
305
+ const err = await stack.http.writeErr(
306
+ "custom-fields:write:define-tenant-field",
307
+ {
308
+ entityName: "property",
309
+ fieldKey: "leaky",
310
+ serializedField: { type: "text" },
311
+ required: false,
312
+ searchable: false,
313
+ displayOrder: 0,
314
+ },
315
+ systemScopedAdmin,
316
+ );
317
+ // The guard throws a plain Error → 500 internal_error. Pin the guard's own
318
+ // message (surfaced as the InternalError cause in test/dev) so this can't
319
+ // be satisfied by some unrelated 5xx that also happens to write no row.
320
+ expect(err.httpStatus).toBe(500);
321
+ expect(err.code).toBe("internal_error");
322
+ const causeMessage = (err.details as { causeMessage?: string } | undefined)?.causeMessage ?? "";
323
+ expect(causeMessage).toContain("define-system-field");
324
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "leaky")).toBe(0);
325
+ });
326
+
327
+ test("define-system-field → delete-system-field roundtrip (SystemAdmin, system scope)", async () => {
328
+ const defineRes = await stack.http.writeOk(
329
+ "custom-fields:write:define-system-field",
330
+ {
331
+ entityName: "property",
332
+ fieldKey: "vendorTag",
333
+ serializedField: { type: "text" },
334
+ required: false,
335
+ searchable: false,
336
+ displayOrder: 0,
337
+ },
338
+ systemAdmin,
339
+ );
340
+ expect(defineRes).toBeDefined();
341
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(1);
342
+ await stack.http.writeOk(
343
+ "custom-fields:write:delete-system-field",
344
+ { entityName: "property", fieldKey: "vendorTag" },
345
+ systemAdmin,
346
+ );
347
+ expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(0);
348
+ });
349
+ });
350
+
263
351
  describe("custom-fields integration — value validation (Builder-Reuse)", () => {
264
352
  async function setErr(entityId: string, fieldKey: string, value: unknown) {
265
353
  return stack.http.writeErr(
@@ -393,6 +481,57 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
393
481
  expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
394
482
  });
395
483
 
484
+ async function setMissingValueErr(entityId: string, fieldKey: string) {
485
+ // value omitted entirely — JSON drops undefined, so the payload arrives
486
+ // without `value` and the schema-level refine rejects it.
487
+ return stack.http.writeErr(
488
+ "custom-fields:write:set-custom-field",
489
+ { entityName: "property", entityId, fieldKey },
490
+ admin,
491
+ );
492
+ }
493
+
494
+ test("missing value → 400 validation_error, no event (set requires a value)", async () => {
495
+ // The payload refine (set-custom-field.write.ts) rejects a missing value
496
+ // before the handler runs — otherwise `undefined` would bind as a jsonb
497
+ // NULL against the NOT-NULL custom_fields column. clear-custom-field is the
498
+ // documented way to remove a value.
499
+ const id = "11111111-2222-4000-8000-00000000000e";
500
+ await defineField("property", "label", "text");
501
+ await createProperty(id, "MissingValue");
502
+
503
+ const err = await setMissingValueErr(id, "label");
504
+ expect(err.httpStatus).toBe(400);
505
+ expect(err.code).toBe("validation_error");
506
+ expect(err.details).toMatchObject({ fields: [{ path: "value" }] });
507
+ expect(await countSetEvents(id)).toBe(0);
508
+ });
509
+
510
+ test("default-having field: a missing value is still rejected (default not silently applied)", async () => {
511
+ // Pre-fix bug: `z.number().default(0).safeParse(undefined)` succeeded with
512
+ // data=0, and the handler emitted `payload.value` (= undefined). The refine
513
+ // now rejects the missing value outright — no event, no defaulted-undefined.
514
+ const id = "22222222-3333-4000-8000-00000000000f";
515
+ await stack.http.writeOk(
516
+ "custom-fields:write:define-tenant-field",
517
+ {
518
+ entityName: "property",
519
+ fieldKey: "rank",
520
+ serializedField: { type: "number", default: 0 },
521
+ required: false,
522
+ searchable: false,
523
+ displayOrder: 0,
524
+ },
525
+ admin,
526
+ );
527
+ await createProperty(id, "DefaultMissing");
528
+
529
+ const err = await setMissingValueErr(id, "rank");
530
+ expect(err.httpStatus).toBe(400);
531
+ expect(err.code).toBe("validation_error");
532
+ expect(await countSetEvents(id)).toBe(0);
533
+ });
534
+
396
535
  test("embedded field rejects a non-object value → 422, no event", async () => {
397
536
  const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
398
537
  // embedded carries a sub-field schema in serializedField — exercises