@cosmicdrift/kumiko-bundled-features 0.105.1 → 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 (124) 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 +112 -1
  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/web/__tests__/custom-fields-form-section.test.tsx +8 -2
  22. package/src/custom-fields/wire-for-entity.ts +104 -77
  23. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  24. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +76 -1
  25. package/src/data-retention/resolve-for-tenant.ts +16 -4
  26. package/src/data-retention/run-retention-cleanup.ts +72 -10
  27. package/src/delivery/attempt-log.ts +22 -10
  28. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  29. package/src/folders/__tests__/folders.integration.test.ts +81 -0
  30. package/src/folders/feature.ts +4 -2
  31. package/src/folders/handlers/delete-folder.write.ts +40 -0
  32. package/src/folders/web/__tests__/folder-manager.test.tsx +86 -0
  33. package/src/folders/web/folder-manager.tsx +27 -15
  34. package/src/folders/web/i18n.ts +0 -2
  35. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  36. package/src/folders-user-data/hooks.ts +31 -8
  37. package/src/folders-user-data/index.ts +4 -4
  38. package/src/jobs/feature.ts +23 -21
  39. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  40. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  41. package/src/ledger/__tests__/reports.test.ts +20 -0
  42. package/src/ledger/entity.ts +1 -1
  43. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  44. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  45. package/src/ledger/recurring.ts +18 -9
  46. package/src/ledger/schemas.ts +4 -1
  47. package/src/ledger/web/index.ts +3 -2
  48. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  49. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +10 -0
  50. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
  51. package/src/personal-access-tokens/constants.ts +33 -0
  52. package/src/personal-access-tokens/feature.ts +78 -0
  53. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  54. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  55. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  56. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  57. package/src/personal-access-tokens/hash.ts +17 -0
  58. package/src/personal-access-tokens/index.ts +45 -0
  59. package/src/personal-access-tokens/resolver.ts +58 -0
  60. package/src/personal-access-tokens/roles.ts +29 -0
  61. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  62. package/src/personal-access-tokens/scopes.ts +22 -0
  63. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  64. package/src/personal-access-tokens/web/i18n.ts +45 -0
  65. package/src/personal-access-tokens/web/index.ts +7 -0
  66. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  67. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  68. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  69. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  70. package/src/sessions/__tests__/sessions.integration.test.ts +29 -8
  71. package/src/sessions/schema/user-session.ts +8 -2
  72. package/src/sessions/session-callbacks.ts +9 -3
  73. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  74. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  75. package/src/tags/handlers/assign-tag.write.ts +13 -3
  76. package/src/tags/schemas.ts +10 -4
  77. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  78. package/src/template-resolver/testing.ts +4 -3
  79. package/src/tier-engine/feature.ts +22 -10
  80. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  81. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  82. package/src/user/__tests__/user.integration.test.ts +36 -0
  83. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  84. package/src/user/index.ts +4 -0
  85. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  86. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  87. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  89. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  90. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  91. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +44 -2
  92. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  93. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  94. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  95. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +2 -2
  96. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  97. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +69 -6
  98. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  99. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  100. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  101. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  102. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  103. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  104. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  105. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  106. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +42 -0
  107. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  108. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  109. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  110. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  111. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  112. package/src/user-data-rights/run-forget-cleanup.ts +16 -2
  113. package/src/user-data-rights/screens.ts +7 -6
  114. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +40 -5
  115. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  116. package/src/user-data-rights/web/i18n.ts +0 -10
  117. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  118. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  119. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  120. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  121. package/src/user-profile/handlers/change-email.write.ts +11 -8
  122. package/src/user-profile/web/profile-screen.tsx +10 -8
  123. package/src/auth-email-password/stream-tenant.ts +0 -31
  124. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.105.1",
3
+ "version": "0.108.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>",
@@ -77,6 +77,8 @@
77
77
  "./secrets": "./src/secrets/index.ts",
78
78
  "./sessions": "./src/sessions/index.ts",
79
79
  "./sessions/testing": "./src/sessions/testing.ts",
80
+ "./personal-access-tokens": "./src/personal-access-tokens/index.ts",
81
+ "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
80
82
  "./feature-toggles": "./src/feature-toggles/index.ts",
81
83
  "./text-content": "./src/text-content/index.ts",
82
84
  "./text-content/seeding": "./src/text-content/seeding.ts",
@@ -93,11 +95,11 @@
93
95
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
94
96
  },
95
97
  "dependencies": {
96
- "@cosmicdrift/kumiko-dispatcher-live": "0.105.1",
97
- "@cosmicdrift/kumiko-framework": "0.105.1",
98
- "@cosmicdrift/kumiko-headless": "0.105.1",
99
- "@cosmicdrift/kumiko-renderer": "0.105.1",
100
- "@cosmicdrift/kumiko-renderer-web": "0.105.1",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.108.0",
99
+ "@cosmicdrift/kumiko-framework": "0.108.0",
100
+ "@cosmicdrift/kumiko-headless": "0.108.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.108.0",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.108.0",
101
103
  "@mollie/api-client": "^4.5.0",
102
104
  "@node-rs/argon2": "^2.0.2",
103
105
  "@types/nodemailer": "^8.0.0",
@@ -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, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { asRawClient } 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 { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -12,6 +12,7 @@ import {
12
12
  unsafeCreateEntityTable,
13
13
  unsafePushTables,
14
14
  } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
16
  import { createConfigFeature } from "../../config";
16
17
  import { createConfigResolver } from "../../config/resolver";
17
18
  import { configValuesTable } from "../../config/table";
@@ -119,7 +120,7 @@ async function seedUser(email: string, password: string): Promise<string> {
119
120
  }
120
121
 
121
122
  async function addMembership(userId: string, tenantId: TenantId, roles: string[]): Promise<void> {
122
- await insertOne(stack.db, tenantMembershipsTable, {
123
+ await seedRow(stack.db, tenantMembershipsTable, {
123
124
  userId,
124
125
  tenantId,
125
126
  roles: JSON.stringify(roles),
@@ -1,11 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import {
4
- asRawClient,
5
- insertOne,
6
- selectMany,
7
- updateMany,
8
- } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
10
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
11
6
  import {
@@ -15,6 +10,7 @@ import {
15
10
  unsafeCreateEntityTable,
16
11
  unsafePushTables,
17
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
18
14
  import { Temporal } from "temporal-polyfill";
19
15
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
20
16
  import { createConfigFeature } from "../../config";
@@ -146,7 +142,7 @@ async function seedUser(opts: {
146
142
  // it directly via SQL after create. Row.version is left at 1; no
147
143
  // subsequent event-store writes happen on this row in these tests.
148
144
  if (opts.emailVerified === true) {
149
- await updateMany(stack.db, userTable, { emailVerified: true }, { id: created.id });
145
+ await updateRows(stack.db, userTable, { emailVerified: true }, { id: created.id });
150
146
  }
151
147
  const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
152
148
  await seedTenantMembership(stack.db, {
@@ -252,7 +248,7 @@ describe("POST /auth/verify-email", () => {
252
248
  const firstAttempt = await post("/api/auth/verify-email", { token });
253
249
  expect(firstAttempt.status).toBe(422);
254
250
 
255
- await insertOne(stack.db, userTable, userRow);
251
+ await seedRow(stack.db, userTable, userRow);
256
252
 
257
253
  const secondAttempt = await post("/api/auth/verify-email", { token });
258
254
  expect(secondAttempt.status).toBe(200);
@@ -296,7 +292,7 @@ describe("POST /auth/verify-email", () => {
296
292
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
297
293
 
298
294
  const orphanId = "00000000-0000-4000-8000-0000000000ff";
299
- await insertOne(stack.db, userTable, {
295
+ await seedRow(stack.db, userTable, {
300
296
  ...donorRow,
301
297
  id: orphanId,
302
298
  email: "orphan@example.com",
@@ -28,6 +28,7 @@ import {
28
28
  unsafeCreateEntityTable,
29
29
  unsafePushTables,
30
30
  } from "@cosmicdrift/kumiko-framework/stack";
31
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
31
32
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
32
33
  import { createConfigFeature } from "../../config";
33
34
  import { createConfigResolver } from "../../config/resolver";
@@ -38,7 +39,11 @@ import { createRendererFoundationFeature } from "../../renderer-foundation/featu
38
39
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
39
40
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
40
41
  import { createTenantFeature } from "../../tenant";
41
- import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
42
+ import {
43
+ INVITATION_STATUS,
44
+ tenantInvitationEntity,
45
+ tenantInvitationsTable,
46
+ } from "../../tenant/invitation-table";
42
47
  import { tenantMembershipsTable } from "../../tenant/membership-table";
43
48
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
44
49
  import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
@@ -46,6 +51,7 @@ import { createUserFeature } from "../../user/feature";
46
51
  import { userEntity, userTable } from "../../user/schema/user";
47
52
  import { AuthErrors, AuthHandlers } from "../constants";
48
53
  import { createAuthEmailPasswordFeature } from "../feature";
54
+ import { storeInviteToken } from "../invite-token-store";
49
55
  import { hashPassword } from "../password-hashing";
50
56
  import { seedUser } from "../seeding";
51
57
 
@@ -254,6 +260,12 @@ describe("invite-create", () => {
254
260
  const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
255
261
  expect(rows).toHaveLength(1);
256
262
  expect(rows[0]?.["role"]).toBe("Editor");
263
+
264
+ // inviteEmail() only reads emailTransport.sent.at(-1) — a silent second-
265
+ // dispatch failure would still leave sent.length===1 and .at(-1) pointing
266
+ // at the FIRST mail, making secondToken===firstToken pass for the wrong
267
+ // reason. Assert an actual second dispatch happened.
268
+ expect(emailTransport.sent).toHaveLength(2);
257
269
  });
258
270
  });
259
271
 
@@ -403,6 +415,105 @@ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
403
415
  });
404
416
  });
405
417
 
418
+ describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", () => {
419
+ test("a forbidden role planted directly on the invitation row (bug/migration) is rejected, not silently granted", async () => {
420
+ // Simulates the ONE scenario this depth-layer exists for: a forbidden role
421
+ // reaching the invitation row through some path OTHER than invite-create
422
+ // (which already validates at request time — see "privilege escalation"
423
+ // below). A DB migration or direct write is the only realistic vector.
424
+ const fakeInvitationId = crypto.randomUUID();
425
+ await seedRow(stack.db, tenantInvitationsTable, {
426
+ id: fakeInvitationId,
427
+ tenantId: TENANT_A_ID,
428
+ email: BOB_EMAIL,
429
+ role: "SystemAdmin",
430
+ status: INVITATION_STATUS.pending,
431
+ invitedBy: aliceId,
432
+ expiresAt: "2030-01-01T00:00:00Z",
433
+ });
434
+ const token = crypto.randomUUID();
435
+ await storeInviteToken(stack.redis.redis, {
436
+ invitationId: fakeInvitationId,
437
+ token,
438
+ ttlSeconds: 3600,
439
+ });
440
+
441
+ const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
442
+ expect(res.status).toBe(403);
443
+ const body = (await res.json()) as { error?: { code?: string } };
444
+ expect(body.error?.code).toBe("access_denied");
445
+
446
+ // No membership was granted.
447
+ const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
448
+ expect(memberships).toHaveLength(1); // only the seeded Tenant-B membership
449
+ });
450
+ });
451
+
452
+ describe("invite-accept-with-login/signup-complete defense-in-depth (637/1)", () => {
453
+ // Branch 1 (invite-accept, logged-in) and Branch 3 (invite-signup-complete)
454
+ // both route every new membership through seedTenantMembership, which
455
+ // calls assertAssignableMembershipRoles unconditionally (tenant/seeding.ts)
456
+ // — a forbidden invitation role is rejected outright, 403, before it ever
457
+ // reaches stripForbiddenMembershipRoles. Branch 2 (invite-accept-with-login)
458
+ // is different: it skips seedTenantMembership entirely when the user is
459
+ // ALREADY a member of the invited tenant (idempotent-accept path) — for
460
+ // that one case, stripForbiddenMembershipRoles at the session mint is the
461
+ // ONLY protection. Removing that call would silently mint a
462
+ // SystemAdmin-carrying session with no other test catching it.
463
+ test("invite-accept-with-login: already-member path strips a forbidden invitation role (seedTenantMembership's guard is skipped here)", async () => {
464
+ // Bob already has a (legitimate) membership in TENANT_A_ID — the
465
+ // handler's alreadyMember check short-circuits before seedTenantMembership,
466
+ // so assertAssignableMembershipRoles never runs for this accept.
467
+ await seedTenantMembership(stack.db, {
468
+ userId: bobId,
469
+ tenantId: TENANT_A_ID,
470
+ roles: ["User"],
471
+ });
472
+
473
+ // Create through the real command (valid role → real stream + version),
474
+ // then corrupt the row directly — the migration/DB-surgery scenario this
475
+ // depth-layer guards against, same framing as the Branch-1 test above.
476
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
477
+ await asRawClient(stack.db).unsafe(
478
+ `UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
479
+ [BOB_EMAIL],
480
+ );
481
+
482
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
483
+ token,
484
+ email: BOB_EMAIL,
485
+ password: BOB_PASSWORD,
486
+ });
487
+ expect(res.status).toBe(200);
488
+ const body = (await res.json()) as { user: { roles: string[] } };
489
+ expect(body.user.roles).toEqual([]);
490
+ });
491
+
492
+ test("invite-signup-complete: a brand-new user always hits seedTenantMembership's guard — forbidden role rejected, not silently stripped", async () => {
493
+ // Unlike Branch 2, a brand-new user can never be "already a member" —
494
+ // seedTenantMembership (and its assertAssignableMembershipRoles guard)
495
+ // runs unconditionally. Pins that this branch is NOT exposed to the
496
+ // Branch-2 gap, so a future refactor that adds an alreadyMember-style
497
+ // skip here would be caught immediately.
498
+ const daveEmail = "dave@example.com";
499
+ const token = await inviteEmail(daveEmail, "Admin");
500
+ await asRawClient(stack.db).unsafe(
501
+ `UPDATE "${tenantInvitationsTable.tableName}" SET "role" = 'SystemAdmin' WHERE "email" = $1`,
502
+ [daveEmail],
503
+ );
504
+
505
+ const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
506
+ token,
507
+ password: "dave-new-pw-1234",
508
+ });
509
+ expect(res.status).toBe(403);
510
+
511
+ // No user or membership was created — the whole write rolled back.
512
+ const daveRows = await selectMany(stack.db, userTable, { email: daveEmail });
513
+ expect(daveRows).toHaveLength(0);
514
+ });
515
+ });
516
+
406
517
  describe("Single-Use-Burn (alle Branches)", () => {
407
518
  test("Branch 1: zweiter accept mit gleichem Token → invalid", async () => {
408
519
  const token = await inviteEmail(BOB_EMAIL, "Admin");
@@ -9,7 +9,7 @@
9
9
  // (nur tenant-membership-roles in der Session).
10
10
 
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
- import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import {
15
15
  setupTestStack,
@@ -19,6 +19,7 @@ import {
19
19
  unsafeCreateEntityTable,
20
20
  unsafePushTables,
21
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
22
23
  import { createConfigFeature } from "../../config";
23
24
  import { createConfigResolver } from "../../config/resolver";
24
25
  import { configValuesTable } from "../../config/table";
@@ -102,7 +103,7 @@ async function addMembership(
102
103
  tenantId: TenantId,
103
104
  roles: readonly string[],
104
105
  ): Promise<void> {
105
- await insertOne(stack.db, tenantMembershipsTable, {
106
+ await seedRow(stack.db, tenantMembershipsTable, {
106
107
  userId,
107
108
  tenantId,
108
109
  roles: JSON.stringify(roles),
@@ -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, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
5
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import {
@@ -10,6 +10,7 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
13
14
  import { Temporal } from "temporal-polyfill";
14
15
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
15
16
  import { createConfigFeature } from "../../config";
@@ -302,7 +303,7 @@ describe("POST /auth/reset-password", () => {
302
303
 
303
304
  // Re-insert the captured row verbatim. Same userId, same version, same
304
305
  // token still valid.
305
- await insertOne(stack.db, userTable, userRow);
306
+ await seedRow(stack.db, userTable, userRow);
306
307
 
307
308
  const secondAttempt = await post("/api/auth/reset-password", {
308
309
  token,
@@ -357,7 +358,7 @@ describe("POST /auth/reset-password", () => {
357
358
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
358
359
 
359
360
  const orphanId = "00000000-0000-4000-8000-0000000000ff";
360
- await insertOne(stack.db, userTable, {
361
+ await seedRow(stack.db, userTable, {
361
362
  ...donorRow,
362
363
  id: orphanId,
363
364
  email: "orphan@example.com",
@@ -1,7 +1,11 @@
1
- import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
1
+ import {
2
+ access,
3
+ createSystemUser,
4
+ defineWriteHandler,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import { z } from "zod";
4
- import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
8
+ import { UserHandlers, UserQueries } from "../../user";
5
9
  import { invalidCredentials } from "../errors";
6
10
  import { hashPassword, verifyPassword } from "../password-hashing";
7
11
 
@@ -35,14 +39,10 @@ export const changePasswordWrite = defineWriteHandler({
35
39
 
36
40
  const newHash = await hashPassword(event.payload.newPassword);
37
41
 
38
- // The user aggregate is r.systemScope(): its event stream can live in a
39
- // tenant that isn't the session tenant (a platform operator's stream sits
40
- // in the seed executor's tenant, not their membership). Write against the
41
- // real stream tenant so optimistic locking targets the right stream instead
42
- // of version_conflict-ing against an empty one. Falls back to the session
43
- // tenant when the lookup finds nothing (fresh/unknown stream).
44
- const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
45
- const writer = createSystemUser(streamTenant ?? event.user.tenantId);
42
+ // The user aggregate is systemStream (#497): its event stream lives on
43
+ // SYSTEM_TENANT_ID deterministically. Pre-#497 scattered streams need the
44
+ // one-time backfillUserStreamTenants migration (#762).
45
+ const writer = createSystemUser(SYSTEM_TENANT_ID);
46
46
 
47
47
  // Apply via user feature's update handler — writeAs(system) satisfies
48
48
  // the privileged-only write rule on passwordHash. Pass the current version
@@ -5,9 +5,8 @@
5
5
  // 1. Redis check + burn (single-use enforcement)
6
6
  // 2. Load user + deleted/missing/no-version guard
7
7
  // 3. Optional idempotent short-circuit (verify-email when already done)
8
- // 4. Resolve memberships → tenant-order for stream-matching
9
- // 5. Try each tenant's stream with the handler-specific `changes`
10
- // 6. Release the burn on ANY non-success path so a legit retry isn't
8
+ // 4. Apply the handler-specific `changes` on the user's SYSTEM stream
9
+ // 5. Release the burn on ANY non-success path so a legit retry isn't
11
10
  // locked out by a stale marker
12
11
  //
13
12
  // The top-level `runConfirmTokenFlow` orchestrates and owns the
@@ -15,26 +14,26 @@
15
14
  // burn (success, already-done) flips `committed = true`; everything
16
15
  // else — including future branches a maintainer adds — releases
17
16
  // automatically.
17
+ //
18
+ // The user aggregate is systemStream (#497): its event stream lives on
19
+ // SYSTEM_TENANT_ID deterministically, so the write targets exactly one
20
+ // stream. The former membership-probing (`tryWriteAcrossTenants`) existed
21
+ // for pre-#497 scattered streams; those need the one-time
22
+ // backfillUserStreamTenants migration (#762) — probing them stopped working
23
+ // the moment the executor's stream choke-point landed anyway.
18
24
 
19
25
  import {
20
26
  createSystemUser,
21
27
  type HandlerContext,
22
28
  type SessionUser,
23
29
  SYSTEM_TENANT_ID,
24
- type TenantId,
25
30
  type WriteResult,
26
31
  } from "@cosmicdrift/kumiko-framework/engine";
27
- import {
28
- InternalError,
29
- type WriteFailure,
30
- writeFailure,
31
- } from "@cosmicdrift/kumiko-framework/errors";
32
- import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
32
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
33
33
  import type Redis from "ioredis";
34
- import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
34
+ import { UserHandlers, UserQueries } from "../../user";
35
35
  import type { AuthUserRow } from "../auth-user-row";
36
36
  import { parseAuthUserRow } from "../auth-user-row";
37
- import { orderTenantsByPreference } from "../stream-tenant";
38
37
  import { burnToken, unburnToken } from "../token-burn-store";
39
38
 
40
39
  export type ConfirmTokenFlowSpec<TSuccessData> = {
@@ -46,13 +45,11 @@ export type ConfirmTokenFlowSpec<TSuccessData> = {
46
45
  // is misconfigured, not the caller's fault.
47
46
  readonly redisRequiredMessage: string;
48
47
  // Standard failure returned for every "token can't be consumed" path
49
- // (bad state, missing memberships, every tenant rejected). The route
50
- // layer returns 422 with a uniform code so the caller can't tell which
51
- // branch fired.
48
+ // (bad state, version conflict on the stream). The route layer returns
49
+ // 422 with a uniform code so the caller can't tell which branch fired.
52
50
  readonly invalidToken: () => ReturnType<typeof writeFailure>;
53
- // Handler-specific payload for user:update. Runs once per token the
54
- // result is shared across every tenant-stream attempt. Can be async
55
- // (password-reset hashes here).
51
+ // Handler-specific payload for user:update. Runs once per token. Can be
52
+ // async (password-reset hashes here).
56
53
  readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
57
54
  // Returned verbatim on a successful write.
58
55
  readonly successData: TSuccessData;
@@ -96,20 +93,21 @@ export async function runConfirmTokenFlow<TSuccessData>(
96
93
  return { isSuccess: true, data: spec.alreadyDone.data };
97
94
  }
98
95
 
99
- const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
100
- if (tenantOrder.length === 0) return spec.invalidToken();
101
-
102
96
  const changes = await spec.buildChanges(me);
103
- const writeResult = await tryWriteAcrossTenants(ctx, me, tenantOrder, changes);
104
- if (writeResult.isSuccess) {
97
+ const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
98
+ id: me.id,
99
+ version: me.version,
100
+ changes,
101
+ });
102
+ if (writeRes.isSuccess) {
105
103
  committed = true;
106
104
  return { isSuccess: true, data: spec.successData };
107
105
  }
108
- // `all_conflicts` = every tenant returned version_conflict token-level
109
- // failure. `hard_failure` = a real write error (DB down, access
110
- // denied) that bubbles unchanged.
111
- if (writeResult.reason === "all_conflicts") return spec.invalidToken();
112
- return writeResult.failure;
106
+ // version_conflict = concurrent modification (or an un-migrated pre-#497
107
+ // stream, see header) token-level failure. Anything else (DB down,
108
+ // access denied) bubbles unchanged so ops sees the real failure class.
109
+ if (writeRes.error.code === "version_conflict") return spec.invalidToken();
110
+ return writeRes;
113
111
  } finally {
114
112
  // committed===false covers EVERY failure path — including branches a
115
113
  // future maintainer adds without reading this file. The original
@@ -121,14 +119,13 @@ export async function runConfirmTokenFlow<TSuccessData>(
121
119
  }
122
120
  }
123
121
 
124
- // --- Private helpers ------------------------------------------------------
125
-
126
122
  // Fetches the user row via the privileged findForAuth query and validates
127
- // it's usable for a write: not deleted, has a row.version (the version
128
- // column is a findForAuth contract field absence is a schema bug, but
129
- // we still handle it gracefully rather than throwing past the burn).
130
- // Return type narrows `version` to `number` so the write-callsite doesn't
131
- // need a `?? 0` fallback — the guard lives here, not at every callsite.
123
+ // it's usable for a write: not deleted, has a row.version >= 1 (a row
124
+ // without any event stream e.g. inserted straight into read_users must
125
+ // not be confirmable: the write would otherwise seed a fresh stream with a
126
+ // bare user.updated). Return type narrows `version` to `number` so the
127
+ // write-callsite doesn't need a `?? 0` fallback — the guard lives here,
128
+ // not at every callsite.
132
129
  async function loadValidatedUser(
133
130
  ctx: HandlerContext,
134
131
  systemUser: SessionUser,
@@ -137,74 +134,6 @@ async function loadValidatedUser(
137
134
  const me = parseAuthUserRow(
138
135
  await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
139
136
  );
140
- if (!me || me.isDeleted || me.version === undefined) return null;
137
+ if (!me || me.isDeleted || me.version === undefined || me.version < 1) return null;
141
138
  return { ...me, version: me.version };
142
139
  }
143
-
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.
152
- async function resolveStreamTenants(
153
- ctx: HandlerContext,
154
- systemUser: SessionUser,
155
- me: AuthUserRow,
156
- ): Promise<readonly TenantId[]> {
157
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
158
- userId: me.id,
159
- })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
160
- const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
161
-
162
- // The user aggregate is r.systemScope(): its event stream lives in whichever
163
- // tenant the creating executor used, which need NOT be a membership tenant.
164
- // A platform operator seeded under a fixture/platform tenant is the live case
165
- // — its stream tenant is absent from `ordered`, so a membership-only search
166
- // rejects every write and collapses to invalid_token. Recover the real stream
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.
171
- const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
172
- if (streamTenant && !ordered.includes(streamTenant)) {
173
- return [streamTenant, ...ordered];
174
- }
175
- return ordered;
176
- }
177
-
178
- // Discriminated result for the write-across-tenants loop.
179
- // all_conflicts → every candidate rejected with version_conflict →
180
- // token-level failure; caller returns invalidToken.
181
- // hard_failure → a non-conflict error that should bubble unchanged
182
- // (DB down, access denied, …); caller returns it as-is.
183
- type TenantWriteResult =
184
- | { isSuccess: true }
185
- | { isSuccess: false; reason: "all_conflicts" }
186
- | { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
187
-
188
- // Attempts the update against each candidate stream. memberships-query
189
- // has no deterministic ORDER BY, so the matching stream is discovered by
190
- // attempt: version_conflict → try the next candidate, anything else →
191
- // bubble immediately so ops sees the real failure class.
192
- async function tryWriteAcrossTenants(
193
- ctx: HandlerContext,
194
- me: AuthUserRow & { version: number },
195
- tenantOrder: readonly TenantId[],
196
- changes: Record<string, unknown>,
197
- ): Promise<TenantWriteResult> {
198
- for (const tenantId of tenantOrder) {
199
- const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
200
- id: me.id,
201
- version: me.version,
202
- changes,
203
- });
204
- if (writeRes.isSuccess) return { isSuccess: true };
205
- if (writeRes.error.code !== "version_conflict") {
206
- return { isSuccess: false, reason: "hard_failure", failure: writeRes };
207
- }
208
- }
209
- return { isSuccess: false, reason: "all_conflicts" };
210
- }
@@ -0,0 +1,44 @@
1
+ // InviteAcceptScreen is a public route — anon invite-links are the
2
+ // documented use case (no <SessionProvider> ancestor). Regression 632/1:
3
+ // useSession() throws without a provider, so any consumer mounting this
4
+ // screen on a public route outside SessionAuthGate crashed at runtime.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createStaticLocaleResolver,
9
+ LocaleProvider,
10
+ PrimitivesProvider,
11
+ } from "@cosmicdrift/kumiko-renderer";
12
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
13
+ import { render, screen } from "@testing-library/react";
14
+ import { defaultTranslations } from "../../i18n";
15
+ import { InviteAcceptScreen } from "../invite-accept-screen";
16
+
17
+ const resolver = createStaticLocaleResolver({ locale: "de" });
18
+
19
+ function renderWithoutSessionProvider(token: string) {
20
+ return render(
21
+ <PrimitivesProvider value={defaultPrimitives}>
22
+ <LocaleProvider resolver={resolver} fallbackBundles={[defaultTranslations]}>
23
+ <InviteAcceptScreen token={token} />
24
+ </LocaleProvider>
25
+ </PrimitivesProvider>,
26
+ );
27
+ }
28
+
29
+ describe("InviteAcceptScreen — no <SessionProvider> ancestor (632/1)", () => {
30
+ test("renders the anonymous accept-form instead of throwing", () => {
31
+ renderWithoutSessionProvider("tok-123");
32
+ expect(screen.getByText("Einladung annehmen")).toBeTruthy();
33
+ expect(screen.getByLabelText(/^Passwort/)).toBeTruthy();
34
+ // Anon default mode is "anon-existing" — email field is shown too.
35
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
36
+ });
37
+
38
+ test("missing token still renders (no session access needed for that branch)", () => {
39
+ renderWithoutSessionProvider("");
40
+ expect(
41
+ screen.getByText("Der Einladungs-Link enthält keinen Token oder ist ungültig."),
42
+ ).toBeTruthy();
43
+ });
44
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { SidebarProvider } from "@cosmicdrift/kumiko-renderer-web";
3
- import { screen } from "@testing-library/react";
3
+ import { screen, within } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
  import { UserMenu } from "../user-menu";
6
6
  import { makeSessionApi, renderWithProviders } from "./test-utils";
@@ -31,6 +31,19 @@ describe("UserMenu", () => {
31
31
  // Trim "" → leerer displayName → fallback auf email → erste 2 Chars
32
32
  expect(screen.getByText("BO")).toBeTruthy();
33
33
  });
34
+ test("no displayName → dropdown label shows the email exactly once (not duplicated)", async () => {
35
+ const user = userEvent.setup();
36
+ const session = makeSessionApi({
37
+ user: { id: "u1", email: "bob@example.com", displayName: "", globalRoles: [] },
38
+ });
39
+ renderWithProviders(<UserMenu />, { session });
40
+ await user.click(screen.getByRole("button", { name: /bob@example.com/ }));
41
+ const menu = within(screen.getByRole("menu"));
42
+ // getByText throws if the email appears more than once inside the
43
+ // dropdown panel — displayName falls back to the email, so without the
44
+ // hasName guard the second (redundant) email line would duplicate it.
45
+ expect(menu.getByText("bob@example.com")).toBeTruthy();
46
+ });
34
47
  test("opens dropdown on click and shows logout button", async () => {
35
48
  const user = userEvent.setup();
36
49
  const session = makeSessionApi();
@@ -32,10 +32,9 @@ export function makeAuthGate(
32
32
  return AuthGate;
33
33
  }
34
34
 
35
- /** SessionProvider + AuthGate als ein Gate damit öffentliche Gates davor
36
- * (z.B. /rechner) den Session-Bootstrap nicht mounten. createKumikoApp
37
- * stackt providers außerhalb aller gates; SessionProvider darf deshalb
38
- * kein provider mehr sein. */
35
+ // SessionProvider + AuthGate as one gate, so a public gate in front (e.g.
36
+ // /calculator) doesn't mount the session bootstrap — createKumikoApp stacks
37
+ // providers outside all gates, so SessionProvider can't be a provider anymore.
39
38
  export function makeSessionAuthGate(
40
39
  LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
41
40
  loginProps?: LoginScreenProps,