@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 (115) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
  48. package/src/personal-access-tokens/constants.ts +33 -0
  49. package/src/personal-access-tokens/feature.ts +78 -0
  50. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  51. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  52. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  53. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  54. package/src/personal-access-tokens/hash.ts +17 -0
  55. package/src/personal-access-tokens/index.ts +45 -0
  56. package/src/personal-access-tokens/resolver.ts +58 -0
  57. package/src/personal-access-tokens/roles.ts +29 -0
  58. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  59. package/src/personal-access-tokens/scopes.ts +22 -0
  60. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  61. package/src/personal-access-tokens/web/i18n.ts +45 -0
  62. package/src/personal-access-tokens/web/index.ts +7 -0
  63. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  64. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  65. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  66. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  67. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  68. package/src/sessions/schema/user-session.ts +8 -2
  69. package/src/sessions/session-callbacks.ts +9 -3
  70. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  71. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  72. package/src/tags/handlers/assign-tag.write.ts +13 -3
  73. package/src/tags/schemas.ts +10 -4
  74. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  75. package/src/template-resolver/testing.ts +4 -3
  76. package/src/tier-engine/feature.ts +22 -10
  77. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  78. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  79. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  80. package/src/user/index.ts +4 -0
  81. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  82. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  85. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  86. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  87. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  88. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  89. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  90. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  91. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  92. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  93. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  95. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  96. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  97. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  98. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  99. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  100. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  101. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  102. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  103. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  104. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  105. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  106. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  107. package/src/user-data-rights/web/i18n.ts +0 -10
  108. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  109. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  110. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  111. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  112. package/src/user-profile/handlers/change-email.write.ts +11 -8
  113. package/src/user-profile/web/profile-screen.tsx +10 -8
  114. package/src/auth-email-password/stream-tenant.ts +0 -31
  115. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -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 { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { 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 {
@@ -10,7 +10,12 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
- import { createLateBoundHolder, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
13
+ import {
14
+ createLateBoundHolder,
15
+ deleteRows,
16
+ resetTestTables,
17
+ updateRows,
18
+ } from "@cosmicdrift/kumiko-framework/testing";
14
19
  import { Temporal } from "temporal-polyfill";
15
20
  import { AuthHandlers } from "../../auth-email-password/constants";
16
21
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
@@ -367,7 +372,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
367
372
 
368
373
  // Hard-delete the session row so it's gone from the store (as opposed to
369
374
  // soft-revoked). The JWT stays syntactically valid.
370
- await deleteMany(stack.db, userSessionTable, { id: sid });
375
+ await deleteRows(stack.db, userSessionTable, { id: sid });
371
376
 
372
377
  const res = await h.authedPost("/api/query", token, {
373
378
  type: "user:query:user:me",
@@ -384,7 +389,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
384
389
 
385
390
  // Back-date expiresAt so the row is still present + not revoked, just
386
391
  // past its window. Simulates what a long-lived JWT would hit.
387
- await updateMany(
392
+ await updateRows(
388
393
  stack.db,
389
394
  userSessionTable,
390
395
  { expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") },
@@ -422,7 +427,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
422
427
  // so she gets a fresh JWT with the new role in its claims. This is the
423
428
  // actual production path — roles are tenant-membership data, not JWT
424
429
  // metadata we can fiddle with directly.
425
- await updateMany(
430
+ await updateRows(
426
431
  stack.db,
427
432
  tenantMembershipsTable,
428
433
  { roles: JSON.stringify(["Admin"]) },
@@ -475,7 +480,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
475
480
  test("restricted after login → 401 reason=blocked", async () => {
476
481
  const { userId } = await h.seedUser("restrict@example.com", "pw-long-enough");
477
482
  const { token } = await h.login("restrict@example.com", "pw-long-enough");
478
- await updateMany(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
483
+ await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
479
484
 
480
485
  const res = await h.authedPost("/api/query", token, {
481
486
  type: "user:query:user:me",
@@ -489,7 +494,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
489
494
  test("deleted after login → 401 reason=blocked", async () => {
490
495
  const { userId } = await h.seedUser("gone@example.com", "pw-long-enough");
491
496
  const { token } = await h.login("gone@example.com", "pw-long-enough");
492
- await updateMany(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
497
+ await updateRows(stack.db, userTable, { status: USER_STATUS.Deleted }, { id: userId });
493
498
 
494
499
  const res = await h.authedPost("/api/query", token, {
495
500
  type: "user:query:user:me",
@@ -507,7 +512,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
507
512
  // into a global lockout for every affected session instead of a no-op.
508
513
  const { userId } = await h.seedUser("hardgone@example.com", "pw-long-enough");
509
514
  const { token } = await h.login("hardgone@example.com", "pw-long-enough");
510
- await deleteMany(stack.db, userTable, { id: userId });
515
+ await deleteRows(stack.db, userTable, { id: userId });
511
516
 
512
517
  const res = await h.authedPost("/api/query", token, {
513
518
  type: "user:query:user:me",
@@ -519,7 +524,7 @@ describe("sessions feature — locked accounts blocked on a live session", () =>
519
524
  test("deletionRequested keeps its session live — reversible grace period", async () => {
520
525
  const { userId } = await h.seedUser("leaving@example.com", "pw-long-enough");
521
526
  const { token } = await h.login("leaving@example.com", "pw-long-enough");
522
- await updateMany(
527
+ await updateRows(
523
528
  stack.db,
524
529
  userTable,
525
530
  { status: USER_STATUS.DeletionRequested },
@@ -1,4 +1,4 @@
1
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
1
+ import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
4
  createEntity,
@@ -64,4 +64,10 @@ export const userSessionEntity = createEntity({
64
64
  },
65
65
  });
66
66
 
67
- export const userSessionTable = buildEntityTable("user_session", userSessionEntity);
67
+ // Plain EntityTableMeta, NOT a branded EntityTable: user-session is an
68
+ // unmanaged direct-write store (r.unmanagedTable in feature.ts, no event
69
+ // stream — revocation is a column write, not an aggregate). The feature's
70
+ // handlers write it directly via ctx.db; the meta carries no executor-only
71
+ // brand so those writes stay legal. See feature.ts for the rebuild-exclusion
72
+ // rationale (#494/#498).
73
+ export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity);
@@ -10,14 +10,14 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
10
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
12
12
  import { Temporal } from "temporal-polyfill";
13
- import { USER_STATUS, userTable } from "../user";
13
+ import { USER_STATUS, type UserStatus, userTable } from "../user";
14
14
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
15
15
  import { userSessionTable } from "./schema/user-session";
16
16
 
17
17
  // Locked accounts whose live sessions must be refused. deletionRequested is
18
18
  // intentionally absent — it's a reversible grace period and the user needs
19
19
  // their session to reach cancel-deletion.
20
- const BLOCKED_STATUSES: ReadonlySet<string> = new Set([
20
+ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
21
21
  USER_STATUS.Restricted,
22
22
  USER_STATUS.Deleted,
23
23
  ]);
@@ -104,7 +104,13 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
104
104
  // a stale sid. Fail-OPEN on a lookup miss — this is the second layer,
105
105
  // revocation is primary; never turn a user-row miss into a global
106
106
  // lockout. (+1 PK read on read_users per authenticated request.)
107
- const user = await fetchOne<{ status: string }>(db, userTable, { id: expectedUserId });
107
+ //
108
+ // Fail-open covers a THROW too, not just a null-miss: this read sits on
109
+ // the hot path of every authenticated request, so a DB timeout / lock
110
+ // contention / pool exhaustion here must not turn into a global lockout.
111
+ const user = await fetchOne<{ status: UserStatus }>(db, userTable, {
112
+ id: expectedUserId,
113
+ }).catch(() => null);
108
114
  if (user && BLOCKED_STATUSES.has(user.status)) return "blocked";
109
115
  return "live";
110
116
  },
@@ -44,34 +44,24 @@ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
44
44
 
45
45
  /** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
46
46
  * qualified-name. backing:"secrets" persistiert config-Werte JSON-
47
- * serialisiert — der Stub spiegelt das, damit der Runtime-parseStoredSecret
48
- * denselben Pfad nimmt wie gegen den echten Store. Ignoriert auditCtx. */
49
- function stubSecrets(values: Record<string, string>): SecretsContext {
47
+ * serialisiert — der Stub spiegelt das per Default, damit der Runtime-
48
+ * parseStoredSecret denselben Pfad nimmt wie gegen den echten Store.
49
+ * `raw: true` speichert UN-serialisiert (482/1) um parseStoredSecret's
50
+ * Fehlerpfad zu treffen: ein Credential, das der Store un-JSON-kodiert
51
+ * zurückgibt (Korruption oder außerhalb des backing:"secrets"-Roundtrips
52
+ * geschrieben) muss laut failen, nicht still Müll liefern. Ignoriert
53
+ * auditCtx. */
54
+ function stubSecrets(
55
+ values: Record<string, string>,
56
+ opts?: { readonly raw?: boolean },
57
+ ): SecretsContext {
50
58
  const nameOf = (k: string | { readonly name: string }): string =>
51
59
  typeof k === "string" ? k : k.name;
52
60
  return {
53
61
  get: async (_tenantId, key) => {
54
62
  const value = values[nameOf(key)];
55
- return value === undefined ? undefined : createSecret(JSON.stringify(value));
56
- },
57
- has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
58
- set: async () => undefined,
59
- delete: async () => false,
60
- };
61
- }
62
-
63
- /** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
64
- * parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
65
- * un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
66
- * backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
67
- * still Müll liefern. */
68
- function rawSecretsStub(values: Record<string, string>): SecretsContext {
69
- const nameOf = (k: string | { readonly name: string }): string =>
70
- typeof k === "string" ? k : k.name;
71
- return {
72
- get: async (_tenantId, key) => {
73
- const value = values[nameOf(key)];
74
- return value === undefined ? undefined : createSecret(value); // RAW, not JSON
63
+ if (value === undefined) return undefined;
64
+ return createSecret(opts?.raw ? value : JSON.stringify(value));
75
65
  },
76
66
  has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
77
67
  set: async () => undefined,
@@ -164,7 +154,7 @@ describe("StripeCtxRuntime.clientForCtx", () => {
164
154
  // throw, not silently fall through to undefined/fallback.
165
155
  const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
166
156
  const ctx = stubCtx({
167
- secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
157
+ secrets: stubSecrets({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }, { raw: true }),
168
158
  });
169
159
  await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
170
160
  /Invalid JSON in subscription-stripe credential/,
@@ -306,6 +306,21 @@ describe("tags integration — idempotency", () => {
306
306
  expect(rows).toHaveLength(1);
307
307
  expect(rows[0]?.["tagId"]).toBe(tagId);
308
308
  });
309
+
310
+ test("two concurrent first-time assigns of the same (tag, entity) both succeed — one row", async () => {
311
+ // Both requests read `existing === null` before either write lands, so
312
+ // both fall through to create(); the loser's create() version_conflicts.
313
+ // The handler must converge that into success instead of surfacing a 409
314
+ // for what is, from the caller's perspective, an idempotent operation.
315
+ const tagId = await createTag("racy");
316
+ const [a, b] = await Promise.all([
317
+ assign(tagId, "credit", "credit-race"),
318
+ assign(tagId, "credit", "credit-race"),
319
+ ]);
320
+ expect(a).toBeDefined();
321
+ expect(b).toBeDefined();
322
+ expect(await countAssignments(admin.tenantId)).toBe(1);
323
+ });
309
324
  });
310
325
 
311
326
  describe("tags integration — referential integrity", () => {
@@ -14,8 +14,9 @@ import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
14
14
  // append at version 0 onto the created+deleted stream and version_conflict;
15
15
  // the deterministic id means that stream is permanent.
16
16
  // - never assigned → create() (restore reports not_found).
17
- // A concurrent first-time race still version_conflicts (409); acceptable, since
18
- // assigning is a low-frequency UI action.
17
+ // A concurrent first-time race converges: both callers fall through to
18
+ // create(), the loser's create() version_conflicts, and the handler treats
19
+ // that as success since the winner already wrote the desired end state.
19
20
  //
20
21
  // Referential integrity: there is no FK (event-sourced, no JOIN), so before a
21
22
  // first-time create we verify the tag exists in the catalog — a malformed call
@@ -46,7 +47,7 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
46
47
  const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
47
48
  if (!tag) return writeFailure(new NotFoundError("tag", payload.tagId));
48
49
 
49
- return tagAssignmentExecutor.create(
50
+ const created = await tagAssignmentExecutor.create(
50
51
  {
51
52
  id,
52
53
  tagId: payload.tagId,
@@ -56,6 +57,15 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
56
57
  event.user,
57
58
  ctx.db,
58
59
  );
60
+ if (created.isSuccess) return created;
61
+ // A concurrent first-time assign of the same (tag, entity) races here —
62
+ // both callers' `existing` read above saw null, so both fall through to
63
+ // create(). The loser's create() version_conflicts (409), but the
64
+ // desired end state (the assignment exists) is already true — the
65
+ // winner just wrote it. Converge instead of surfacing a spurious 409
66
+ // for an idempotent operation.
67
+ if (created.error.code !== "version_conflict") return created;
68
+ return { isSuccess: true as const, data: { id } };
59
69
  },
60
70
  };
61
71
  }
@@ -1,8 +1,11 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  export const createTagPayloadSchema = z.object({
4
- name: z.string().min(1).max(64),
5
- color: z.string().max(32).optional(),
4
+ name: z.string().trim().min(1).max(64),
5
+ color: z
6
+ .string()
7
+ .regex(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$/, "color must be a hex code (#rgb or #rrggbb)")
8
+ .optional(),
6
9
  scope: z.string().max(64).optional(),
7
10
  });
8
11
  export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
@@ -16,8 +19,11 @@ export const updateTagPayloadSchema = z
16
19
  .object({
17
20
  id: z.string().min(1),
18
21
  version: z.number().int().nonnegative(),
19
- name: z.string().min(1).max(64).optional(),
20
- color: z.string().max(32).optional(),
22
+ name: z.string().trim().min(1).max(64).optional(),
23
+ color: z
24
+ .string()
25
+ .regex(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))?$/, "color must be a hex code (#rgb or #rrggbb)")
26
+ .optional(),
21
27
  scope: z.string().max(64).optional(),
22
28
  })
23
29
  .refine((p) => p.name !== undefined || p.color !== undefined || p.scope !== undefined, {
@@ -1,11 +1,11 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
3
  import {
5
4
  setupTestStack,
6
5
  type TestStack,
7
6
  unsafeCreateEntityTable,
8
7
  } from "@cosmicdrift/kumiko-framework/stack";
8
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
9
9
  import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResolverApi } from "../api";
10
10
  import {
11
11
  type ContentFormat,
@@ -56,7 +56,7 @@ async function seedTemplate(args: {
56
56
  linkedResources?: Record<string, string>;
57
57
  parentTemplateId?: string;
58
58
  }) {
59
- await insertOne(db, templateResourcesTable, {
59
+ await seedRow(db, templateResourcesTable, {
60
60
  tenantId: args.tenantId,
61
61
  slug: args.slug,
62
62
  kind: args.kind,
@@ -2,8 +2,8 @@
2
2
  // `@cosmicdrift/kumiko-bundled-features/template-resolver/testing`.
3
3
  // No bun:test here — callers register cases with their own test runner.
4
4
 
5
- import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
6
5
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
7
7
  import type { ResolveRequest, TemplateResource } from "./api";
8
8
  import { TemplateNotFoundError } from "./api";
9
9
  import {
@@ -50,7 +50,7 @@ type SeedTemplateArgs = {
50
50
  };
51
51
 
52
52
  async function seedTemplate(db: DbConnection, args: SeedTemplateArgs): Promise<void> {
53
- await insertOne(db, templateResourcesTable, {
53
+ await seedRow(db, templateResourcesTable, {
54
54
  tenantId: args.tenantId,
55
55
  slug: args.slug,
56
56
  kind: args.kind,
@@ -74,10 +74,11 @@ export async function assertConsumerHandlesNotFound(
74
74
  const { tenantId } = opts;
75
75
  let rejected = false;
76
76
  let err: unknown;
77
+ const slug = `conformance-not-found-${crypto.randomUUID()}`;
77
78
  try {
78
79
  await consumer.resolve({
79
80
  tenantId,
80
- slug: "conformance-not-found-slug",
81
+ slug,
81
82
  kind: "mail-html",
82
83
  locale: "de",
83
84
  });
@@ -217,6 +217,7 @@ export function createTierEngineFeature<
217
217
  r.writeHandler(
218
218
  createSetTenantTierWrite({
219
219
  onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
220
+ validTiers: opts.tierMap ? new Set(Object.keys(opts.tierMap)) : undefined,
220
221
  }),
221
222
  );
222
223
  r.queryHandler(getTenantTierQuery);
@@ -452,19 +453,30 @@ export function createTierEngineFeature<
452
453
  // tier-assignment-Row, die der seed-Pfad nicht anlegt) und prüft das
453
454
  // Fenster gegen die aktuelle Zeit. Nur auf dem disabled-Gate-Pfad
454
455
  // konsultiert. inserted_at ist immutable → pro Tenant einmal lesen.
455
- const startedMemo = new Map<TenantId, number>();
456
+ // Promise-memo (not a plain value cache): N concurrent requests for the
457
+ // same fresh tenant must await the SAME in-flight fetchOne, not each
458
+ // fire their own — a plain `Map<TenantId, number>` set AFTER the await
459
+ // lets every concurrent caller miss the cache and issue a redundant
460
+ // read (benign but wasteful on a signup burst).
461
+ const startedMemo = new Map<TenantId, Promise<number | undefined>>();
456
462
  const trialGate: TrialGate = async (tenantId, featureName) => {
457
463
  if (!trialFeatures.has(featureName)) return false;
458
- let startedMs = startedMemo.get(tenantId);
459
- if (startedMs === undefined) {
460
- const row = await fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
464
+ let startedMsPromise = startedMemo.get(tenantId);
465
+ if (startedMsPromise === undefined) {
466
+ startedMsPromise = fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
461
467
  id: tenantId,
462
- });
463
- // Tenant-Row noch nicht projiziert (Replay-Race) keinen Miss
464
- // memoizen, beim nächsten Request neu lesen.
465
- if (row?.insertedAt === undefined) return false;
466
- startedMs = row.insertedAt.epochMilliseconds;
467
- startedMemo.set(tenantId, startedMs);
468
+ }).then((row) => row?.insertedAt?.epochMilliseconds);
469
+ // Evict on rejection too a transient DB error must not poison
470
+ // every future call for this tenant with the same cached rejection.
471
+ startedMsPromise.catch(() => startedMemo.delete(tenantId));
472
+ startedMemo.set(tenantId, startedMsPromise);
473
+ }
474
+ const startedMs = await startedMsPromise;
475
+ // Tenant-Row noch nicht projiziert (Replay-Race) → keinen Miss
476
+ // memoizen, beim nächsten Request neu lesen.
477
+ if (startedMs === undefined) {
478
+ startedMemo.delete(tenantId);
479
+ return false;
468
480
  }
469
481
  return isTrialActive(startedMs, nowMs(), trial.durationHours);
470
482
  };
@@ -44,6 +44,12 @@ export type SetTenantTierOptions = {
44
44
  * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
45
45
  * nicht). Ohne tierMap kein Resolver → no-op. */
46
46
  readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
47
+ /** Tier-Namen aus der tierMap-Closure — ohne sie (storage-only-Mode) bleibt
48
+ * `tier` unvalidiert außer Length/Non-Empty. Mit ihr rejected der Handler
49
+ * einen unbekannten Tier-Namen statt ihn stillschweigend durchzuschreiben
50
+ * (featuresForTier gibt für unbekannte Namen defensiv ein leeres Set
51
+ * zurück — der Tenant verliert sonst lautlos alle Tier-Gates). */
52
+ readonly validTiers?: ReadonlySet<string>;
47
53
  };
48
54
 
49
55
  export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
@@ -51,7 +57,11 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
51
57
  name: "set-tenant-tier",
52
58
  schema: z.object({
53
59
  tenantId: z.string().min(1),
54
- tier: z.string().min(1).max(50),
60
+ tier: z
61
+ .string()
62
+ .min(1)
63
+ .max(50)
64
+ .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
55
65
  }),
56
66
  access: { roles: ["SystemAdmin"] },
57
67
  handler: async (event, ctx) => {
@@ -0,0 +1,186 @@
1
+ // #762 — backfillUserStreamTenants: pre-#497 user streams live on the
2
+ // creating tenant; post-#497 the executor addresses SYSTEM_TENANT_ID, so
3
+ // legacy users version-conflict on every write (password-reset collapses to
4
+ // invalid_token). The backfill retenants + renumbers per aggregate in global
5
+ // id order — including the split-stream case the raw #497-changeset SQL
6
+ // crashes on (legacy tenant v1..n AND post-#497 SYSTEM events for the same
7
+ // aggregate → events_aggregate_version_uq violation).
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
12
+ import {
13
+ createSystemUser,
14
+ SYSTEM_TENANT_ID,
15
+ type TenantId,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { append, createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
+ import {
19
+ createTestDb,
20
+ type TestDb,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { backfillUserStreamTenants } from "../db/queries/stream-tenant-backfill";
24
+ import { userEntity, userTable } from "../schema/user";
25
+
26
+ const T1 = "00000000-0000-4000-8000-000000000011" as TenantId;
27
+ const LEGACY_USER = "aaaaaaaa-0000-4000-8000-000000000001";
28
+ const SPLIT_USER = "aaaaaaaa-0000-4000-8000-000000000002";
29
+ const MODERN_USER = "aaaaaaaa-0000-4000-8000-000000000003";
30
+
31
+ let testDb: TestDb;
32
+
33
+ const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
34
+
35
+ beforeAll(async () => {
36
+ testDb = await createTestDb();
37
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
38
+ await createEventsTable(testDb.db);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await testDb.cleanup();
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await asRawClient(testDb.db).unsafe(
47
+ `TRUNCATE kumiko_events, kumiko_snapshots, kumiko_archived_streams, read_users RESTART IDENTITY CASCADE`,
48
+ );
49
+ });
50
+
51
+ async function appendUserEvent(
52
+ aggregateId: string,
53
+ tenantId: TenantId,
54
+ expectedVersion: number,
55
+ type: string,
56
+ payload: Record<string, unknown>,
57
+ ): Promise<void> {
58
+ await append(testDb.db, {
59
+ aggregateId,
60
+ aggregateType: "user",
61
+ tenantId,
62
+ expectedVersion,
63
+ type,
64
+ payload,
65
+ metadata: { userId: "test-migrator" },
66
+ });
67
+ }
68
+
69
+ async function streamRows(
70
+ aggregateId: string,
71
+ ): Promise<ReadonlyArray<{ tenant_id: string; version: number; type: string }>> {
72
+ return (await asRawClient(testDb.db).unsafe(
73
+ `SELECT "tenant_id", "version", "type" FROM "kumiko_events"
74
+ WHERE "aggregate_id" = $1::uuid ORDER BY "id" ASC`,
75
+ [aggregateId],
76
+ )) as ReadonlyArray<{ tenant_id: string; version: number; type: string }>;
77
+ }
78
+
79
+ function seedUserRow(id: string, tenantId: TenantId, version: number) {
80
+ return asRawClient(testDb.db).unsafe(
81
+ `INSERT INTO "read_users" ("id", "tenant_id", "email", "display_name", "locale", "password_hash", "status", "version")
82
+ VALUES ($1::uuid, $2::uuid, $3, 'Legacy', 'de', 'x', 'active', $4)`,
83
+ [id, tenantId, `${id.slice(0, 8)}@example.com`, version],
84
+ );
85
+ }
86
+
87
+ describe("backfillUserStreamTenants (#762)", () => {
88
+ test("legacy stream: retenanted to SYSTEM, versions contiguous, executor write works again", async () => {
89
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
90
+ await appendUserEvent(LEGACY_USER, T1, 1, "user.updated", { changes: { displayName: "L" } });
91
+ await seedUserRow(LEGACY_USER, T1, 2);
92
+
93
+ const result = await backfillUserStreamTenants(testDb.db);
94
+ expect(result.aggregatesMigrated).toBe(1);
95
+ expect(result.eventsMigrated).toBe(2);
96
+ expect(result.failed).toHaveLength(0);
97
+
98
+ const rows = await streamRows(LEGACY_USER);
99
+ expect(rows.map((r) => r.tenant_id)).toEqual([SYSTEM_TENANT_ID, SYSTEM_TENANT_ID]);
100
+ expect(rows.map((r) => r.version)).toEqual([1, 2]);
101
+
102
+ // The actual #762 symptom: an optimistic-locked executor update with the
103
+ // row version now targets a stream that HAS that version — no conflict.
104
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
105
+ const writeRes = await executor.update(
106
+ { id: LEGACY_USER, version: 2, changes: { displayName: "After" } },
107
+ createSystemUser(SYSTEM_TENANT_ID),
108
+ tdb,
109
+ );
110
+ expect(writeRes.isSuccess).toBe(true);
111
+ });
112
+
113
+ test("split stream (legacy + post-#497 SYSTEM events) merges in global id order", async () => {
114
+ // Legacy: created + updated on T1 (v1, v2). Then a post-#497 lifecycle
115
+ // write appended v1 on SYSTEM — the split the raw changeset-SQL trips on.
116
+ await appendUserEvent(SPLIT_USER, T1, 0, "user.created", { email: "s@example.com" });
117
+ await appendUserEvent(SPLIT_USER, T1, 1, "user.updated", { changes: { a: 1 } });
118
+ await appendUserEvent(SPLIT_USER, SYSTEM_TENANT_ID, 0, "user.updated", {
119
+ changes: { status: "restricted" },
120
+ });
121
+ await seedUserRow(SPLIT_USER, T1, 2);
122
+
123
+ const result = await backfillUserStreamTenants(testDb.db);
124
+ expect(result.aggregatesMigrated).toBe(1);
125
+ expect(result.eventsMigrated).toBe(3);
126
+ expect(result.failed).toHaveLength(0);
127
+
128
+ const rows = await streamRows(SPLIT_USER);
129
+ expect(rows.map((r) => r.tenant_id)).toEqual([
130
+ SYSTEM_TENANT_ID,
131
+ SYSTEM_TENANT_ID,
132
+ SYSTEM_TENANT_ID,
133
+ ]);
134
+ // Renumbered by global event id: created, updated(T1), updated(SYSTEM).
135
+ expect(rows.map((r) => r.version)).toEqual([1, 2, 3]);
136
+ expect(rows[0]?.type).toBe("user.created");
137
+ });
138
+
139
+ test("idempotent: second run finds nothing; modern SYSTEM streams untouched", async () => {
140
+ await appendUserEvent(MODERN_USER, SYSTEM_TENANT_ID, 0, "user.created", {
141
+ email: "m@example.com",
142
+ });
143
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
144
+
145
+ const first = await backfillUserStreamTenants(testDb.db);
146
+ expect(first.aggregatesMigrated).toBe(1);
147
+
148
+ const second = await backfillUserStreamTenants(testDb.db);
149
+ expect(second.aggregatesMigrated).toBe(0);
150
+ expect(second.eventsMigrated).toBe(0);
151
+
152
+ const modern = await streamRows(MODERN_USER);
153
+ expect(modern).toHaveLength(1);
154
+ expect(modern[0]?.version).toBe(1);
155
+ });
156
+
157
+ test("stale snapshots dropped, archived-stream marker moves to SYSTEM", async () => {
158
+ await appendUserEvent(LEGACY_USER, T1, 0, "user.created", { email: "l@example.com" });
159
+ await asRawClient(testDb.db).unsafe(
160
+ `INSERT INTO "kumiko_snapshots" ("aggregate_id", "aggregate_type", "tenant_id", "version", "state")
161
+ VALUES ($1::uuid, 'user', $2::uuid, 1, '{}'::jsonb)`,
162
+ [LEGACY_USER, T1],
163
+ );
164
+ await asRawClient(testDb.db).unsafe(
165
+ `INSERT INTO "kumiko_archived_streams" ("tenant_id", "aggregate_id", "aggregate_type", "archived_by")
166
+ VALUES ($1::uuid, $2::uuid, 'user', 'test')`,
167
+ [T1, LEGACY_USER],
168
+ );
169
+
170
+ const result = await backfillUserStreamTenants(testDb.db);
171
+ expect(result.failed).toHaveLength(0);
172
+
173
+ const snapshots = (await asRawClient(testDb.db).unsafe(
174
+ `SELECT count(*)::int AS n FROM "kumiko_snapshots" WHERE "aggregate_id" = $1::uuid`,
175
+ [LEGACY_USER],
176
+ )) as ReadonlyArray<{ n: number }>;
177
+ expect(snapshots[0]?.n).toBe(0);
178
+
179
+ const archived = (await asRawClient(testDb.db).unsafe(
180
+ `SELECT "tenant_id" FROM "kumiko_archived_streams" WHERE "aggregate_id" = $1::uuid`,
181
+ [LEGACY_USER],
182
+ )) as ReadonlyArray<{ tenant_id: string }>;
183
+ expect(archived).toHaveLength(1);
184
+ expect(archived[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
185
+ });
186
+ });