@cosmicdrift/kumiko-bundled-features 0.311.0 → 0.313.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 (77) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +8 -0
  3. package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
  4. package/src/agent-tools/agent-manifest.ts +7 -1
  5. package/src/agent-tools/changes.json +8 -1
  6. package/src/audit/changes.json +6 -0
  7. package/src/audit/feature.ts +1 -2
  8. package/src/audit/i18n.ts +3 -0
  9. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
  10. package/src/auth-email-password/changes.json +6 -0
  11. package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
  12. package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
  13. package/src/auth-email-password/signup-token-store.ts +87 -1
  14. package/src/auth-email-password/web/auth-client.ts +10 -1
  15. package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
  16. package/src/data-retention/changes.json +6 -0
  17. package/src/data-retention/run-retention-cleanup.ts +160 -18
  18. package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
  19. package/src/delivery/address-opt-out.ts +87 -0
  20. package/src/delivery/changes.json +7 -0
  21. package/src/delivery/db/queries/address-opt-outs.ts +23 -0
  22. package/src/delivery/delivery-service.ts +32 -0
  23. package/src/delivery/feature.ts +9 -1
  24. package/src/delivery/index.ts +5 -0
  25. package/src/delivery/tables.ts +37 -0
  26. package/src/delivery/unsubscribe.ts +101 -16
  27. package/src/delivery/upsert-preference.ts +1 -1
  28. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
  29. package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
  30. package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
  31. package/src/document-ingest-foundation/boot-checks.ts +58 -0
  32. package/src/document-ingest-foundation/changes.json +18 -0
  33. package/src/document-ingest-foundation/events.ts +3 -0
  34. package/src/document-ingest-foundation/executor.ts +12 -0
  35. package/src/document-ingest-foundation/feature.ts +125 -72
  36. package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
  37. package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
  38. package/src/document-ingest-foundation/index.ts +17 -0
  39. package/src/document-ingest-foundation/providers.ts +78 -0
  40. package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
  41. package/src/document-ingest-foundation/write-document-extract.ts +48 -0
  42. package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  43. package/src/file-derivatives/changes.json +6 -0
  44. package/src/file-derivatives/feature.ts +5 -0
  45. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  46. package/src/shared/index.ts +10 -1
  47. package/src/shared/run-in-sub-transaction.ts +35 -0
  48. package/src/shared/signup-handover.ts +67 -0
  49. package/src/shared/single-use-token-store.ts +25 -5
  50. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  51. package/src/subscription-stripe/changes.json +9 -1
  52. package/src/subscription-stripe/feature.ts +13 -1
  53. package/src/subscription-stripe/plugin-methods.ts +17 -2
  54. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  55. package/src/tenant/changes.json +6 -0
  56. package/src/tenant/index.ts +1 -0
  57. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  58. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  59. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  60. package/src/tenant/web/member-roles-cell.tsx +11 -2
  61. package/src/tenant/web/member-status-cell.tsx +6 -2
  62. package/src/tenant/web/translate-or-raw.ts +13 -0
  63. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  64. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  65. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  66. package/src/tenant-handover/changes.json +12 -0
  67. package/src/tenant-handover/events.ts +6 -0
  68. package/src/tenant-handover/feature.ts +11 -0
  69. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  70. package/src/tenant-handover/move-entity-graph.ts +30 -1
  71. package/src/tenant-handover/root-anchor.ts +50 -0
  72. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  73. package/src/tenant-handover/transfer-graph.ts +6 -2
  74. package/src/user-data-rights/changes.json +6 -0
  75. package/src/user-data-rights/feature.ts +1 -2
  76. package/src/user-data-rights/i18n.ts +3 -0
  77. package/src/user-data-rights/run-forget-cleanup.ts +1 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.311.0",
3
+ "version": "0.313.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>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.311.0",
137
- "@cosmicdrift/kumiko-framework": "0.311.0",
138
- "@cosmicdrift/kumiko-headless": "0.311.0",
139
- "@cosmicdrift/kumiko-renderer": "0.311.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.311.0",
141
- "@cosmicdrift/kumiko-types": "0.311.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.313.0",
137
+ "@cosmicdrift/kumiko-framework": "0.313.0",
138
+ "@cosmicdrift/kumiko-headless": "0.313.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.313.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.313.0",
141
+ "@cosmicdrift/kumiko-types": "0.313.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.311.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.311.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.313.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.313.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -11,11 +11,13 @@ import { authFoundationFeature } from "../auth-foundation/feature";
11
11
  import { billingFoundationFeature } from "../billing-foundation/feature";
12
12
  import { createCustomFieldsFeature } from "../custom-fields/feature";
13
13
  import { createDeliveryFeature } from "../delivery/feature";
14
+ import { documentIngestFoundationFeature } from "../document-ingest-foundation/feature";
14
15
  import { createFileDerivativesFeature } from "../file-derivatives/feature";
15
16
  import { fileFoundationFeature } from "../file-foundation/feature";
16
17
  import { inboundMailFoundationFeature } from "../inbound-mail-foundation/feature";
17
18
  import { mailFoundationFeature } from "../mail-foundation/feature";
18
19
  import { createRendererFoundationFeature } from "../renderer-foundation/feature";
20
+ import { createTenantHandoverFeature } from "../tenant-handover/feature";
19
21
  import { createTenantLifecycleFeature } from "../tenant-lifecycle/feature";
20
22
  import { createTierEngineFeature } from "../tier-engine/feature";
21
23
  import { createUserFeature } from "../user/feature";
@@ -44,6 +46,8 @@ const TYPED_EXTENSION_POINTS = [
44
46
  "deliveryChannel",
45
47
  "subscriptionProvider",
46
48
  "mailTransport",
49
+ "documentIngestProvider",
50
+ "signupHandover",
47
51
  ] as const satisfies readonly (keyof KumikoExtensionOptionsMap)[];
48
52
 
49
53
  const UNTYPED_BY_DESIGN: Record<string, string> = {
@@ -59,11 +63,13 @@ const DECLARING_FEATURE_DIRS = [
59
63
  "billing-foundation",
60
64
  "custom-fields",
61
65
  "delivery",
66
+ "document-ingest-foundation",
62
67
  "file-derivatives",
63
68
  "file-foundation",
64
69
  "inbound-mail-foundation",
65
70
  "mail-foundation",
66
71
  "renderer-foundation",
72
+ "tenant-handover",
67
73
  "tenant-lifecycle",
68
74
  "tier-engine",
69
75
  "user",
@@ -97,11 +103,13 @@ function allRegistrarExtensionNames(): readonly string[] {
97
103
  billingFoundationFeature,
98
104
  createCustomFieldsFeature(),
99
105
  createDeliveryFeature(),
106
+ documentIngestFoundationFeature,
100
107
  createFileDerivativesFeature(),
101
108
  fileFoundationFeature,
102
109
  inboundMailFoundationFeature,
103
110
  mailFoundationFeature,
104
111
  createRendererFoundationFeature(),
112
+ createTenantHandoverFeature({ grantSecret: "test-secret-min-32-chars-long-enough" }),
105
113
  createTenantLifecycleFeature(),
106
114
  createTierEngineFeature(),
107
115
  createUserFeature(),
@@ -219,4 +219,34 @@ describe("buildAgentManifest", () => {
219
219
  expect(manifest.navs.map((n) => n.screen)).toEqual(["nav-leak-test:screen:public-board"]);
220
220
  expect(manifest.screens.map((s) => s.id)).toEqual(["nav-leak-test:screen:public-board"]);
221
221
  });
222
+
223
+ test("a screen description that is an i18n key resolves to its English prose, not the raw key", () => {
224
+ const feature = defineFeature("i18n-description-test", (r) => {
225
+ r.translations({
226
+ keys: { "i18n-description-test.subtitle": { en: "Prose the agent should see." } },
227
+ });
228
+ r.screen({
229
+ id: "with-i18n-description",
230
+ type: "custom",
231
+ renderer: { react: "stub" },
232
+ description: "i18n-description-test.subtitle",
233
+ });
234
+ r.screen({
235
+ id: "with-literal-description",
236
+ type: "custom",
237
+ renderer: { react: "stub" },
238
+ description: "Literal, never registered as a key.",
239
+ });
240
+ });
241
+
242
+ const manifest = buildAgentManifest(createRegistry([feature]), {
243
+ locale: "en",
244
+ roles: ["admin"],
245
+ });
246
+
247
+ const withKey = manifest.screens.find((s) => s.id.endsWith("with-i18n-description"));
248
+ const withLiteral = manifest.screens.find((s) => s.id.endsWith("with-literal-description"));
249
+ expect(withKey?.description).toBe("Prose the agent should see.");
250
+ expect(withLiteral?.description).toBe("Literal, never registered as a key.");
251
+ });
222
252
  });
@@ -276,7 +276,13 @@ function buildScreens(
276
276
  id: screen.id,
277
277
  type: screen.type,
278
278
  titles: labelsForSuffix(translations, `screen:${shortId}.title`),
279
- ...(screen.description !== undefined && { description: screen.description }),
279
+ // `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
280
+ // double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
281
+ // `labelsForSuffix` does for entity/field labels, else fall back to the
282
+ // literal string as authored.
283
+ ...(screen.description !== undefined && {
284
+ description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
285
+ }),
280
286
  ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
281
287
  // A detail screen takes the row id; everything else is parameterless.
282
288
  params: screen.detailFor !== undefined ? ["id"] : [],
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "Agent manifest now resolves a screen's i18n-key description to English prose",
6
+ "detail": "buildAgentManifest passed screen.description straight through even when\na feature registered it as an i18n key (e.g. \"audit.log.detail.subtitle\")\nrather than literal text, so agents saw the raw key instead of prose.\nIt now resolves through the registry's translations the same way\nlabelsForSuffix already does for entity/field labels, falling back to\nthe literal string when the description isn't a registered key."
7
+ }
8
+ ]
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "Audit-log-detail screen subtitle is translatable; aggregate columns get de/es copy",
6
+ "detail": "The audit-log-detail screen's description was hardcoded English prose;\nit's now the audit.log.detail.subtitle key with de/es translations.\nSeparately, audit.log.col.aggregateType/aggregateId (the actual column\nkeys the audit-log-detail screen renders) had no de/es copy at all —\nonly the dead, unused audit.log.col.aggregate key did. Added\naggregateType/aggregateId to de/es, removed the dead aggregate key, and\nfixed the German filter label typo \"Aggregate-Typ\" -> \"Aggregattyp\"."
7
+ },
2
8
  {
3
9
  "version": "0.292.0",
4
10
  "type": "improvement",
@@ -126,8 +126,7 @@ export function createAuditFeature(): FeatureDefinition {
126
126
  },
127
127
  ],
128
128
  },
129
- description:
130
- "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
129
+ description: "audit.log.detail.subtitle",
131
130
  listScreenId: AUDIT_LOG_SCREEN_ID,
132
131
  access: { roles: access.admin },
133
132
  });
package/src/audit/i18n.ts CHANGED
@@ -6,6 +6,9 @@ type LocalizedString = { readonly en: string };
6
6
  export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
7
7
  "screen:audit-log.title": { en: "Audit log" },
8
8
  "screen:audit-log-detail.title": { en: "Event" },
9
+ "audit.log.detail.subtitle": {
10
+ en: "Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
11
+ },
9
12
  "audit:nav.auditLog": { en: "Audit" },
10
13
  "audit.log.col.when": { en: "When" },
11
14
  "audit.log.col.type": { en: "Event" },
@@ -0,0 +1,424 @@
1
+ // Cross-device try-first handover, end-to-end (kumiko-framework#3035
2
+ // follow-up, offlot-app#454): an anonymous visitor holds a tenant-handover
3
+ // grant in one browser, but the magic-link activation may be opened in a
4
+ // DIFFERENT browser that never had it. signup-request verifies the grant and
5
+ // binds it to the signup token server-side; signup-confirm redeems that
6
+ // binding into the freshly provisioned tenant. Spec is this test itself.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ buildEntityTable,
12
+ createEventStoreExecutor,
13
+ createTenantDb,
14
+ executeRawQuery,
15
+ } from "@cosmicdrift/kumiko-framework/db";
16
+ import {
17
+ createEntity,
18
+ createSystemUser,
19
+ createTextField,
20
+ defineFeature,
21
+ type EntityDefinition,
22
+ type TenantId,
23
+ } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
25
+ import {
26
+ createTestUser,
27
+ setupTestStack,
28
+ type TestStack,
29
+ testTenantId,
30
+ unsafeCreateEntityTable,
31
+ unsafePushTables,
32
+ } from "@cosmicdrift/kumiko-framework/stack";
33
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
34
+ import { createConfigFeature } from "../../config";
35
+ import { createConfigResolver } from "../../config/resolver";
36
+ import { configValuesTable } from "../../config/table";
37
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
38
+ import { notificationPreferencesTable } from "../../delivery/tables";
39
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
40
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
41
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
42
+ import { createTenantFeature } from "../../tenant";
43
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
44
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
45
+ import { signTenantHandoverGrant } from "../../tenant-handover/grant";
46
+ import { createTenantHandoverFeature } from "../../tenant-handover/index";
47
+ import { createUserFeature } from "../../user/feature";
48
+ import { userEntity, userTable } from "../../user/schema/user";
49
+ import { AuthHandlers } from "../constants";
50
+ import { createAuthEmailPasswordFeature } from "../feature";
51
+
52
+ const GRANT_SECRET = "signup-handover-test-secret-min-32-chars-long-enough";
53
+ const CLAIM_QN = "tenant-handover:write:claim";
54
+ const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
55
+
56
+ const runEntity: EntityDefinition = createEntity({
57
+ table: "signup_handover_run",
58
+ idType: "uuid",
59
+ transferable: true,
60
+ fields: {
61
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
62
+ },
63
+ });
64
+
65
+ const photoEntity: EntityDefinition = createEntity({
66
+ table: "signup_handover_photo",
67
+ idType: "uuid",
68
+ transferable: true,
69
+ parentRef: { entityTypeField: "hostType", entityIdField: "hostId", allowedTypes: ["run"] },
70
+ fields: {
71
+ hostType: createTextField({ required: true, personal: false, reason: "technical_reference" }),
72
+ hostId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
73
+ caption: createTextField({ personal: false, reason: "technical_reference" }),
74
+ },
75
+ });
76
+
77
+ // Deliberately declares NO `transferable` — models an entity type the claim
78
+ // mover must refuse mid-graph (see claim.integration.test.ts's own noteEntity).
79
+ const noteEntity: EntityDefinition = createEntity({
80
+ table: "signup_handover_note",
81
+ idType: "uuid",
82
+ parentRef: { entityTypeField: "hostType", entityIdField: "hostId", allowedTypes: ["run"] },
83
+ fields: {
84
+ hostType: createTextField({ required: true, personal: false, reason: "technical_reference" }),
85
+ hostId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
86
+ body: createTextField({ personal: false, reason: "technical_reference" }),
87
+ },
88
+ });
89
+
90
+ const handoverFixturesFeature = defineFeature("signup-handover-fixtures", (r) => {
91
+ r.entity("run", runEntity);
92
+ r.entity("photo", photoEntity);
93
+ r.entity("note", noteEntity);
94
+ });
95
+
96
+ const runTable = buildEntityTable("run", runEntity);
97
+ const photoTable = buildEntityTable("photo", photoEntity);
98
+ const noteTable = buildEntityTable("note", noteEntity);
99
+ const runCrud = createEventStoreExecutor(runTable, runEntity, { entityName: "run" });
100
+ const photoCrud = createEventStoreExecutor(photoTable, photoEntity, { entityName: "photo" });
101
+ const noteCrud = createEventStoreExecutor(noteTable, noteEntity, { entityName: "note" });
102
+
103
+ let stack: TestStack;
104
+
105
+ const SOURCE_TENANT = testTenantId(3);
106
+ let nextUserId = 1;
107
+
108
+ function firstDeviceUser(tenantN: number) {
109
+ return createTestUser({ id: nextUserId++, tenantId: testTenantId(tenantN), roles: ["User"] });
110
+ }
111
+
112
+ const emailTransport = createInMemoryTransport();
113
+
114
+ beforeAll(async () => {
115
+ stack = await setupTestStack({
116
+ features: [
117
+ createConfigFeature(),
118
+ createUserFeature(),
119
+ createTenantFeature(),
120
+ createTemplateResolverFeature(),
121
+ createRendererFoundationFeature(),
122
+ createDeliveryFeature(),
123
+ createRendererSimpleFeature(),
124
+ createChannelEmailFeature({
125
+ transport: emailTransport,
126
+ renderer: simpleRenderer,
127
+ resolveEmail: async () => "unused@test.local",
128
+ }),
129
+ createAuthEmailPasswordFeature({
130
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
131
+ }),
132
+ createTenantHandoverFeature({ grantSecret: GRANT_SECRET }),
133
+ handoverFixturesFeature,
134
+ ],
135
+ extraContext: (deps) => ({
136
+ ...createDeliveryTestContext(deps),
137
+ configResolver: createConfigResolver(),
138
+ }),
139
+ authConfig: {
140
+ membershipQuery: "tenant:query:memberships",
141
+ loginHandler: AuthHandlers.login,
142
+ signup: {
143
+ requestHandler: AuthHandlers.signupRequest,
144
+ confirmHandler: AuthHandlers.signupConfirm,
145
+ },
146
+ },
147
+ });
148
+
149
+ await unsafeCreateEntityTable(stack.db, userEntity);
150
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
151
+ await unsafeCreateEntityTable(stack.db, runEntity, "run");
152
+ await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
153
+ await unsafeCreateEntityTable(stack.db, noteEntity, "note");
154
+ await unsafeCreateEntityTable(stack.db, fileRefEntity);
155
+ await unsafePushTables(stack.db, {
156
+ configValuesTable,
157
+ tenantMembershipsTable,
158
+ notificationPreferencesTable,
159
+ });
160
+ });
161
+
162
+ afterAll(async () => {
163
+ await stack.cleanup();
164
+ });
165
+
166
+ beforeEach(async () => {
167
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
168
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
169
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
170
+ await asRawClient(stack.db).unsafe(
171
+ `TRUNCATE kumiko_events, kumiko_snapshots, signup_handover_run, signup_handover_photo, signup_handover_note, file_refs RESTART IDENTITY CASCADE`,
172
+ );
173
+ emailTransport.sent.length = 0;
174
+ const allKeys = await stack.redis.redis.keys("signup:*");
175
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
176
+ });
177
+
178
+ async function seedRun(tenantId: TenantId, name: string): Promise<string> {
179
+ const user = createSystemUser(tenantId);
180
+ const db = createTenantDb(stack.db, tenantId, "system");
181
+ const result = await runCrud.create({ name }, user, db);
182
+ if (!result.isSuccess) throw new Error(`seedRun failed: ${result.error.message}`);
183
+ return String(result.data.id);
184
+ }
185
+
186
+ async function seedPhoto(tenantId: TenantId, hostId: string, caption: string): Promise<string> {
187
+ const user = createSystemUser(tenantId);
188
+ const db = createTenantDb(stack.db, tenantId, "system");
189
+ const result = await photoCrud.create({ hostType: "run", hostId, caption }, user, db);
190
+ if (!result.isSuccess) throw new Error(`seedPhoto failed: ${result.error.message}`);
191
+ return String(result.data.id);
192
+ }
193
+
194
+ async function seedNote(tenantId: TenantId, hostId: string, body: string): Promise<string> {
195
+ const user = createSystemUser(tenantId);
196
+ const db = createTenantDb(stack.db, tenantId, "system");
197
+ const result = await noteCrud.create({ hostType: "run", hostId, body }, user, db);
198
+ if (!result.isSuccess) throw new Error(`seedNote failed: ${result.error.message}`);
199
+ return String(result.data.id);
200
+ }
201
+
202
+ // beforeEach empties read_users, so a plain count is enough to prove nothing
203
+ // was provisioned — email is encrypted at rest (personal: "self"), so a raw
204
+ // WHERE email = $1 wouldn't match the ciphertext anyway.
205
+ async function countAllUsers(): Promise<number> {
206
+ const rows = await executeRawQuery<{ count: string }>(
207
+ stack.db,
208
+ `SELECT COUNT(*) AS count FROM "${userTable.tableName}"`,
209
+ );
210
+ return Number(rows[0]?.count ?? "0");
211
+ }
212
+
213
+ function grantFor(runId: string, secret: string = GRANT_SECRET): string {
214
+ return signTenantHandoverGrant({
215
+ entityType: "run",
216
+ rowId: runId,
217
+ sourceTenantId: SOURCE_TENANT,
218
+ ttlMinutes: 30,
219
+ secret,
220
+ }).token;
221
+ }
222
+
223
+ async function readTenantId(table: string, id: string): Promise<string | undefined> {
224
+ const rows = await executeRawQuery<{ tenantId: string }>(
225
+ stack.db,
226
+ `SELECT tenant_id AS "tenantId" FROM ${table} WHERE id = $1`,
227
+ [id],
228
+ );
229
+ return rows[0]?.tenantId;
230
+ }
231
+
232
+ async function postSignupRequest(
233
+ body: unknown,
234
+ headers?: Record<string, string>,
235
+ ): Promise<Response> {
236
+ return stack.http.raw("POST", "/api/auth/signup-request", body, headers);
237
+ }
238
+
239
+ async function postSignupConfirm(token: string, password: string): Promise<Response> {
240
+ return stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
241
+ }
242
+
243
+ function extractTokenFromMail(html: string): string {
244
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
245
+ if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
246
+ return decodeURIComponent(match[1]);
247
+ }
248
+
249
+ async function requestSignupAndCaptureToken(body: unknown): Promise<string> {
250
+ emailTransport.sent.length = 0;
251
+ const res = await postSignupRequest(body);
252
+ expect(res.status).toBe(200);
253
+ const sent = emailTransport.sent[0];
254
+ if (!sent) throw new Error("signup-request fixture didn't send mail");
255
+ return extractTokenFromMail(sent.html);
256
+ }
257
+
258
+ describe("cross-device try-first handover :: signup-request + signup-confirm", () => {
259
+ test("(a) grant bound at request time is claimed on confirm even without the grant or the anonymous cookie present", async () => {
260
+ const runId = await seedRun(SOURCE_TENANT, "my run");
261
+ const photoId = await seedPhoto(SOURCE_TENANT, runId, "front");
262
+
263
+ const email = "handover-a@example.com";
264
+ const signupToken = await requestSignupAndCaptureToken({
265
+ email,
266
+ handover: { entityType: "run", token: grantFor(runId) },
267
+ });
268
+
269
+ // Confirm carries NO handover/grant of its own — this is the
270
+ // different-browser leg of the flow.
271
+ const confirmRes = await postSignupConfirm(signupToken, "fresh-secure-pw-1234");
272
+ expect(confirmRes.status).toBe(200);
273
+ const body = (await confirmRes.json()) as {
274
+ user?: { id: string; tenantId: string };
275
+ handover?: { entityType: string; id: string };
276
+ };
277
+ const destinationTenantId = body.user?.tenantId;
278
+ if (!destinationTenantId) throw new Error("confirm did not return a tenantId");
279
+
280
+ expect(body.handover).toEqual({ entityType: "run", id: runId });
281
+ expect(await readTenantId("signup_handover_run", runId)).toBe(destinationTenantId);
282
+ expect(await readTenantId("signup_handover_photo", photoId)).toBe(destinationTenantId);
283
+
284
+ const auditEvents = await executeRawQuery<{ payload: Record<string, unknown> }>(
285
+ stack.db,
286
+ `SELECT payload FROM kumiko_events WHERE type = 'tenant-handover:event:claimed' AND tenant_id = $1`,
287
+ [destinationTenantId],
288
+ );
289
+ expect(auditEvents).toHaveLength(1);
290
+ expect(auditEvents[0]?.payload).toMatchObject({
291
+ entityType: "run",
292
+ rootRowId: runId,
293
+ sourceTenantId: SOURCE_TENANT,
294
+ destinationTenantId,
295
+ });
296
+ });
297
+
298
+ test("(b) a grant already claimed by another device before confirm does not fail the signup and leaves the data with the first claimer", async () => {
299
+ const runId = await seedRun(SOURCE_TENANT, "someone else's run");
300
+ const email = "handover-b@example.com";
301
+ const signupToken = await requestSignupAndCaptureToken({
302
+ email,
303
+ handover: { entityType: "run", token: grantFor(runId) },
304
+ });
305
+
306
+ // First device: a different, already-logged-in user claims the SAME
307
+ // grant via the regular tenant-handover write before confirm runs.
308
+ const firstClaimer = firstDeviceUser(9);
309
+ const firstClaim = await stack.http.writeOk<{ destinationTenantId: string }>(
310
+ CLAIM_QN,
311
+ { token: grantFor(runId), entityType: "run" },
312
+ firstClaimer,
313
+ );
314
+ expect(firstClaim.destinationTenantId).toBe(firstClaimer.tenantId);
315
+
316
+ const confirmRes = await postSignupConfirm(signupToken, "fresh-secure-pw-1234");
317
+ expect(confirmRes.status).toBe(200);
318
+ const body = (await confirmRes.json()) as {
319
+ isSuccess: boolean;
320
+ token?: string;
321
+ user?: { id: string; tenantId: string };
322
+ handover?: { entityType: string; id: string };
323
+ };
324
+ expect(body.isSuccess).toBe(true);
325
+ expect(body.token).toBeTruthy();
326
+ expect(body.handover).toBeUndefined();
327
+
328
+ // Account/login for the signup still works — a failed claim redeem
329
+ // must never fail the signup itself.
330
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
331
+ email,
332
+ password: "fresh-secure-pw-1234",
333
+ });
334
+ expect(loginRes.status).toBe(200);
335
+
336
+ // Data stayed with the first claimer, not the new signup tenant.
337
+ expect(await readTenantId("signup_handover_run", runId)).toBe(firstClaimer.tenantId);
338
+ if (body.user?.tenantId) {
339
+ expect(await readTenantId("signup_handover_run", runId)).not.toBe(body.user.tenantId);
340
+ }
341
+ });
342
+
343
+ test("(c) a forged grant (wrong secret) verifies to nothing — request and confirm both stay 200, nothing moves", async () => {
344
+ const runId = await seedRun(SOURCE_TENANT, "forged-target run");
345
+ const email = "handover-c@example.com";
346
+ const forgedToken = grantFor(runId, "wrong-secret-min-32-chars-long-enough!!");
347
+
348
+ const signupToken = await requestSignupAndCaptureToken({
349
+ email,
350
+ handover: { entityType: "run", token: forgedToken },
351
+ });
352
+
353
+ const confirmRes = await postSignupConfirm(signupToken, "fresh-secure-pw-1234");
354
+ expect(confirmRes.status).toBe(200);
355
+ const body = (await confirmRes.json()) as { handover?: unknown };
356
+ expect(body.handover).toBeUndefined();
357
+ expect(await readTenantId("signup_handover_run", runId)).toBe(SOURCE_TENANT);
358
+ });
359
+
360
+ test("(d) resend without a fresh grant carries the earlier verified binding to the NEW token; the old token stops working", async () => {
361
+ const runId = await seedRun(SOURCE_TENANT, "resend-target run");
362
+ const email = "handover-d@example.com";
363
+
364
+ const firstSignupToken = await requestSignupAndCaptureToken({
365
+ email,
366
+ handover: { entityType: "run", token: grantFor(runId) },
367
+ });
368
+ // Resend WITHOUT a handover in the payload — the visitor just retyped
369
+ // their email, the earlier verified grant must carry over.
370
+ const secondSignupToken = await requestSignupAndCaptureToken({ email });
371
+ expect(secondSignupToken).not.toBe(firstSignupToken);
372
+
373
+ const oldConfirm = await postSignupConfirm(firstSignupToken, "irrelevant-pw-1234");
374
+ expect(oldConfirm.status).toBe(422);
375
+
376
+ const newConfirm = await postSignupConfirm(secondSignupToken, "fresh-secure-pw-1234");
377
+ expect(newConfirm.status).toBe(200);
378
+ const body = (await newConfirm.json()) as {
379
+ user?: { tenantId: string };
380
+ handover?: { entityType: string; id: string };
381
+ };
382
+ expect(body.handover).toEqual({ entityType: "run", id: runId });
383
+ expect(await readTenantId("signup_handover_run", runId)).toBe(body.user?.tenantId);
384
+ });
385
+
386
+ test("(e) the signup-request response body never contains the grant token", async () => {
387
+ const runId = await seedRun(SOURCE_TENANT, "leak-check run");
388
+ const grantToken = grantFor(runId);
389
+ const res = await postSignupRequest({
390
+ email: "handover-e@example.com",
391
+ handover: { entityType: "run", token: grantToken },
392
+ });
393
+ expect(res.status).toBe(200);
394
+ const rawBody = await res.text();
395
+ expect(rawBody).not.toContain(grantToken);
396
+ });
397
+
398
+ test("(f) a claim that would leave a non-transferable child behind fails the signup instead of committing a partial move", async () => {
399
+ const runId = await seedRun(SOURCE_TENANT, "graph-guard run");
400
+ await seedNote(SOURCE_TENANT, runId, "undeclared child");
401
+ const email = "handover-f@example.com";
402
+ const signupToken = await requestSignupAndCaptureToken({
403
+ email,
404
+ handover: { entityType: "run", token: grantFor(runId) },
405
+ });
406
+
407
+ const confirmRes = await postSignupConfirm(signupToken, "fresh-secure-pw-1234");
408
+ expect(confirmRes.status).not.toBe(200);
409
+ expect(confirmRes.status).toBe(422);
410
+ const body = (await confirmRes.json()) as { error?: { details?: { reason?: string } } };
411
+ expect(body.error?.details?.reason).toBe("entity_not_transferable");
412
+
413
+ expect(await countAllUsers()).toBe(0);
414
+ expect(await readTenantId("signup_handover_run", runId)).toBe(SOURCE_TENANT);
415
+
416
+ // The activation link stays retryable — same error again, not a dead
417
+ // invalid_signup_token, because the failure aborted before the token
418
+ // was burned/deleted.
419
+ const retryRes = await postSignupConfirm(signupToken, "fresh-secure-pw-1234");
420
+ expect(retryRes.status).toBe(422);
421
+ const retryBody = (await retryRes.json()) as { error?: { details?: { reason?: string } } };
422
+ expect(retryBody.error?.details?.reason).toBe("entity_not_transferable");
423
+ });
424
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "improvement",
5
+ "title": "magic-link self-signup can now claim a try-first tenant-handover grant across devices",
6
+ "detail": "An anonymous visitor's tenant-handover grant (packages/bundled-features/src/\ntenant-handover) lives only in the browser that minted it, but the signup\nactivation link is often opened somewhere else (a different device, or the\nmail app's in-app browser) — the grant never reaches signup-confirm there.\nrequestSignup(email, handover?) can now pass { entityType, token } alongside\nthe email; signup-request verifies the grant against its anchor row\n(read-only, tenant-handover's own redeemRowBoundGrant with commitAnchor\nskipped) and binds only { entityType, rowId, sourceTenantId } to the signup\ntoken in Redis — never the grant token or the email. A resend without a\nfresh grant carries the previous verified binding to the new token instead\nof dropping it. signup-confirm reads the binding after provisioning the new\ntenant, mints a fresh short-lived (5 min) grant server-side, and redeems it\nvia the same claim write tenant-handover already exposes — riding the\nconfirm handler's own transaction. A benign rejection (already claimed\nelsewhere, forged grant, row gone) never fails the signup; any other claim\nfailure (e.g. a non-transferable child row) rolls the whole signup back and\nleaves the activation link retryable, instead of committing a partial move\nnext to a new account. On success\nthe signup-confirm response gains an optional `handover: { entityType, id }`\nfield; auth-client's confirmSignup result type reflects it. New extension\npoint `signupHandover` (packages/bundled-features/src/shared/signup-handover.ts)\nlets tenant-handover provide this without auth-email-password importing it\ndirectly; tenant-handover self-registers as its own provider."
7
+ },
2
8
  {
3
9
  "version": "0.309.0",
4
10
  "type": "fix",