@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 (116) 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 +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. 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.2",
3
+ "version": "0.109.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.2",
97
- "@cosmicdrift/kumiko-framework": "0.105.2",
98
- "@cosmicdrift/kumiko-headless": "0.105.2",
99
- "@cosmicdrift/kumiko-renderer": "0.105.2",
100
- "@cosmicdrift/kumiko-renderer-web": "0.105.2",
98
+ "@cosmicdrift/kumiko-dispatcher-live": "0.109.0",
99
+ "@cosmicdrift/kumiko-framework": "0.109.0",
100
+ "@cosmicdrift/kumiko-headless": "0.109.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.109.0",
102
+ "@cosmicdrift/kumiko-renderer-web": "0.109.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",
@@ -15,7 +15,7 @@
15
15
  // 5. DB-State + Membership + Cookies/JWT verifizieren
16
16
 
17
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
- import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
18
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
19
  import {
20
20
  createSystemUser,
21
21
  type SessionUser,
@@ -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";
@@ -421,7 +422,7 @@ describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", ()
421
422
  // (which already validates at request time — see "privilege escalation"
422
423
  // below). A DB migration or direct write is the only realistic vector.
423
424
  const fakeInvitationId = crypto.randomUUID();
424
- await insertOne(stack.db, tenantInvitationsTable, {
425
+ await seedRow(stack.db, tenantInvitationsTable, {
425
426
  id: fakeInvitationId,
426
427
  tenantId: TENANT_A_ID,
427
428
  email: BOB_EMAIL,
@@ -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,
@@ -17,10 +17,10 @@
17
17
  // auch zu loggedInHref damit der invitee in seinem neuen Tenant landet.
18
18
 
19
19
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
20
- import { type FormEvent, type ReactNode, useState } from "react";
20
+ import { type FormEvent, type ReactNode, useContext, useState } from "react";
21
21
  import { csrfHeader } from "./auth-client";
22
22
  import { AuthCard, authMutedLinkClass, parseUrlToken } from "./auth-form-primitives";
23
- import { useSession } from "./session";
23
+ import { SessionContext, UNAUTHENTICATED } from "./session";
24
24
 
25
25
  export type InviteAcceptScreenProps = {
26
26
  readonly title?: string;
@@ -42,7 +42,11 @@ export function InviteAcceptScreen({
42
42
  }: InviteAcceptScreenProps): ReactNode {
43
43
  const t = useTranslation();
44
44
  const { Form, Field, Input, Button, Banner } = usePrimitives();
45
- const session = useSession();
45
+ // NOT useSession(): InviteAcceptScreen is a public route (anon invite-links
46
+ // are the documented use case) — useSession() throws without a
47
+ // <SessionProvider> ancestor. Read the context directly and fall back to
48
+ // the anonymous state when no provider wraps this screen (632/1).
49
+ const session = useContext(SessionContext) ?? UNAUTHENTICATED;
46
50
  const [token] = useState(() => tokenProp ?? parseUrlToken());
47
51
  const [mode, setMode] = useState<Mode>(() =>
48
52
  session.status === "authenticated" ? "loggedin" : "anon-existing",
@@ -44,7 +44,11 @@ export type SessionApi = SessionState & {
44
44
  readonly switchTenant: (tenantId: string) => Promise<void>;
45
45
  };
46
46
 
47
- const UNAUTHENTICATED: SessionState = {
47
+ // Exported so screens that render on BOTH authenticated and anonymous public
48
+ // routes (e.g. InviteAcceptScreen) can read `.status` without requiring a
49
+ // <SessionProvider> ancestor — see useContext(SessionContext) ?? UNAUTHENTICATED
50
+ // in invite-accept-screen.tsx (632/1).
51
+ export const UNAUTHENTICATED: SessionState = {
48
52
  status: "unauthenticated",
49
53
  user: null,
50
54
  activeTenantId: null,
@@ -64,7 +64,7 @@ export function UserMenu({ children, variant = "pill" }: UserMenuProps): ReactNo
64
64
  <DropdownMenuContent align="end" aria-label={t("auth.user.menu.label")}>
65
65
  <DropdownMenuLabel className="text-xs">
66
66
  <div className="font-medium text-foreground truncate">{displayName}</div>
67
- <div className="truncate">{user.email}</div>
67
+ {hasName && <div className="truncate">{user.email}</div>}
68
68
  </DropdownMenuLabel>
69
69
  <DropdownMenuSeparator />
70
70
  {children}
@@ -1,12 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { deserializeValue } from "../resolver";
3
3
 
4
- // deserializeValue is the read boundary for every config value: the DB stores
5
- // the JSON-encoded raw string, this turns it back into a typed primitive per the
6
- // key's declared `type`. The coercion has non-obvious paths worth pinning so a
7
- // refactor can't quietly change them: a stored value whose JSON type disagrees
8
- // with the declared type is NOT rejected — it is coerced (number via Number(),
9
- // boolean only via literal true / the string "true", text via String()).
4
+ // Type mismatches are coerced, not rejected (number via Number(), boolean
5
+ // only via literal true/"true", text via String()) pinned so a refactor
6
+ // can't quietly change that.
10
7
 
11
8
  describe("deserializeValue", () => {
12
9
  test("null raw short-circuits to undefined before any parse", () => {