@cosmicdrift/kumiko-bundled-features 0.287.0 → 0.289.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 (32) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
  3. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
  4. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  5. package/src/billing-foundation/changes.json +6 -0
  6. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  7. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  8. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  9. package/src/files-tenant-data/hooks.ts +101 -3
  10. package/src/managed-pages/handlers/branding.query.ts +1 -0
  11. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  12. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  13. package/src/seo/handlers/seo-config.query.ts +1 -0
  14. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  15. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  16. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  17. package/src/tenant-handover/changes.json +8 -0
  18. package/src/tenant-handover/events.ts +26 -0
  19. package/src/tenant-handover/feature.ts +35 -0
  20. package/src/tenant-handover/grant.ts +43 -0
  21. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  22. package/src/tenant-handover/index.ts +9 -0
  23. package/src/tenant-handover/move-entity-graph.ts +228 -0
  24. package/src/tenant-handover/transfer-graph.ts +44 -0
  25. package/src/tenant-lifecycle/stages.ts +2 -0
  26. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  27. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  28. package/src/user-data-rights/changes.json +6 -0
  29. package/src/user-data-rights/deletion-token.ts +9 -11
  30. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  31. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  32. package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
@@ -8,7 +8,20 @@
8
8
  // every fileRef ROW for the tenant via forget() — rebuild-safe, mirrors
9
9
  // document-ingest-foundation's documentExtractTenantDestroyHook. No
10
10
  // isDeleted filter: tenant-destroy purges trashed rows too, a destroyed
11
- // tenant has no "restore from trash" future.
11
+ // tenant has no "restore from trash" future. ALSO sweeps, per row, any
12
+ // storageKey that does NOT sit under one of this tenant's own
13
+ // tenantStoragePrefixes() — the shape a tenant-handover (kumiko-
14
+ // framework#3035) leaves behind: the fileRef row's tenantId moved to
15
+ // this tenant, but the bytes stayed under the SOURCE tenant's prefix
16
+ // (moving them would mean copying bytes, which the handover design
17
+ // explicitly rejects). Must run here, not in the "files" stage below —
18
+ // that stage's own prefix sweep only reaches keys under ITS tenant's
19
+ // prefixes by construction, and by the time it runs this hook has
20
+ // already forgotten (hard-deleted) the rows it would need to read the
21
+ // storageKey off. Deleted by STEM prefix (storageKeyStemPrefix), not the
22
+ // exact key: a derived variant (thumbnail, resized render) has no
23
+ // `file_refs` row of its own, so only the stem reaches original +
24
+ // derivatives together.
12
25
  // - fileRefStorageDestroyHook ("files" stage, EXT_STORAGE_PROVIDER): wipes
13
26
  // every BINARY (original + derivatives + anything else) under the
14
27
  // tenant's storage prefixes. A full prefix sweep per tenantStoragePrefixes()
@@ -16,29 +29,67 @@
16
29
  // first (see tenantExportPrefix), so this must sweep every known prefix,
17
30
  // stays correct even though the "files" stage runs after "app-data"
18
31
  // already forgot the rows, and can never cross into another tenant's keys
19
- // (the provider's own list() prefix already scopes it).
32
+ // (the provider's own list() prefix already scopes it). Still catches a
33
+ // blob with no fileRef row at all (the orphan case the row-driven sweep
34
+ // above cannot see) — REFINED, not replaced, to skip a key a FOREIGN
35
+ // tenant's fileRef row still references (a tenant-handover, kumiko-
36
+ // framework#3035, leaves the row's tenantId moved but the storageKey
37
+ // under THIS tenant's prefix unchanged). By the time this stage runs,
38
+ // the "app-data" stage (stages.ts DESTRUCTION_STAGES: "app-data" before
39
+ // "files") has already hard-deleted every row THIS tenant owned, so any
40
+ // surviving row a listed key still matches is, by construction, a
41
+ // foreign tenant's — no tenant comparison needed, existence is enough.
42
+ // The exception is computed from the SURVIVING row's storageKeyStemPrefix
43
+ // (mirroring the deletion side above), not the listed key's exact match:
44
+ // a derivative has no fileRef row of its own, so excluding only exact
45
+ // matches would still delete it out from under the tenant that claimed
46
+ // its original. Batched: one existence query per chunk of listed keys,
47
+ // not one query per key.
20
48
 
49
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
21
50
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
22
51
  import {
23
52
  createSystemUser,
24
53
  type StorageProviderDestroyTenantHook,
54
+ type StorageProviderHookCtx,
25
55
  type TenantDataDestroyHook,
56
+ type TenantDataHookCtx,
26
57
  } from "@cosmicdrift/kumiko-framework/engine";
27
58
  import {
28
59
  assertSafeStorageKey,
29
60
  fileRefEntity,
30
61
  fileRefsTable,
62
+ storageKeyStemPrefix,
31
63
  tenantStoragePrefixes,
32
64
  } from "@cosmicdrift/kumiko-framework/files";
33
65
 
34
66
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
35
67
 
68
+ // Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
69
+ // — bounds each query instead of one `storage_key = ANY(...)` per listed key,
70
+ // or a single unbounded array for a tenant with a huge prefix.
71
+ const SURVIVOR_CHECK_BATCH_SIZE = 500;
72
+
73
+ function chunk<T>(items: readonly T[], size: number): T[][] {
74
+ const chunks: T[][] = [];
75
+ for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
76
+ return chunks;
77
+ }
78
+
79
+ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): boolean {
80
+ return prefixes.some((prefix) => storageKey.startsWith(prefix));
81
+ }
82
+
36
83
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
37
- const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
84
+ const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
38
85
  tenantId: ctx.tenantId,
39
86
  });
87
+ const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
40
88
  const user = createSystemUser(ctx.tenantId);
41
89
  for (const row of rows) {
90
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
91
+ await deleteHandedOverBinary(ctx, row.storageKey);
92
+ }
42
93
  const result = await crud.forget({ id: row.id }, user, ctx.db);
43
94
  // Executor writes return {isSuccess:false} instead of throwing — a
44
95
  // discarded result would report this destroy stage "succeeded" while the
@@ -52,6 +103,32 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
52
103
  }
53
104
  };
54
105
 
106
+ async function deleteHandedOverBinary(ctx: TenantDataHookCtx, storageKey: string): Promise<void> {
107
+ if (!ctx.fileProviderResolver) {
108
+ // skip: same graceful-degradation stance as fileRefStorageDestroyHook —
109
+ // no provider wired means the binary sweep is skipped, not fail-closed.
110
+ ctx.log?.(
111
+ `[files-tenant-data] no fileProviderResolver wired — handed-over binary for ${storageKey} is NOT deleted`,
112
+ );
113
+ // skip: no provider wired, so there is no store to delete from — logged above.
114
+ return;
115
+ }
116
+ let provider: Awaited<ReturnType<NonNullable<TenantDataHookCtx["fileProviderResolver"]>>>;
117
+ try {
118
+ provider = await ctx.fileProviderResolver(ctx.tenantId);
119
+ } catch (err) {
120
+ ctx.log?.(
121
+ `[files-tenant-data] no file provider resolvable for tenant ${ctx.tenantId}: ${err instanceof Error ? err.message : String(err)} — handed-over binary for ${storageKey} NOT deleted`,
122
+ );
123
+ // skip: provider resolution failed, so there is nothing this call can delete — logged above.
124
+ return;
125
+ }
126
+ for (const key of await provider.list(storageKeyStemPrefix(storageKey))) {
127
+ assertSafeStorageKey(key);
128
+ await provider.delete(key);
129
+ }
130
+ }
131
+
55
132
  export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async (
56
133
  tenantId,
57
134
  ctx,
@@ -86,9 +163,30 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
86
163
  // converges rather than double-deleting or erroring on a missing key).
87
164
  for (const prefix of tenantStoragePrefixes(tenantId)) {
88
165
  const keys = await provider.list(prefix);
166
+ const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
89
167
  for (const key of keys) {
168
+ if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix))) continue;
90
169
  assertSafeStorageKey(key);
91
170
  await provider.delete(key);
92
171
  }
93
172
  }
94
173
  };
174
+
175
+ // Any listed key that is itself a surviving row's exact storageKey means a
176
+ // foreign tenant still owns it (see header comment) — its stem prefix then
177
+ // also protects any derivative of that same original sitting in the same
178
+ // listed batch, none of which have their own fileRef row.
179
+ async function survivorStemPrefixes(
180
+ db: StorageProviderHookCtx["db"],
181
+ keys: readonly string[],
182
+ ): Promise<readonly string[]> {
183
+ const stemPrefixes: string[] = [];
184
+ for (const batch of chunk(keys, SURVIVOR_CHECK_BATCH_SIZE)) {
185
+ if (batch.length === 0) continue;
186
+ const survivors = await selectMany<{ storageKey: string }>(db, fileRefsTable, {
187
+ storageKey: batch,
188
+ });
189
+ for (const survivor of survivors) stemPrefixes.push(storageKeyStemPrefix(survivor.storageKey));
190
+ }
191
+ return stemPrefixes;
192
+ }
@@ -19,6 +19,7 @@ export function createBrandingQuery(opts: { readonly allowCustomCss: boolean })
19
19
  name: "branding",
20
20
  schema: z.object({}),
21
21
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
22
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
22
23
  description:
23
24
  "Reads the tenant's public branding values for the rendered page shell, adding the raw custom CSS only when the app opted in and the per-tenant CSS toggle is on; anonymous callers may read it because it dresses public pages.",
24
25
  handler: async (_query, ctx) => {
@@ -16,6 +16,7 @@ export const bySlugQuery = defineQueryHandler({
16
16
  lang: z.string().min(2).max(8),
17
17
  }),
18
18
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
19
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
19
20
  description:
20
21
  "Reads the body and metadata of one published managed page of the caller's tenant by slug and language, returning null for drafts and bodyless pages; use it to render a public page, not to load one for editing.",
21
22
  handler: async (query, ctx) => {
@@ -28,6 +28,7 @@ export const byTenantPublishedQuery = defineQueryHandler({
28
28
  tenantIdOverride: z.string().min(1).optional(),
29
29
  }),
30
30
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
31
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
31
32
  description:
32
33
  "Lists every published page of a tenant with slug, language, title and last-change time but no body; use it to enumerate the public pages for sitemap.xml or llms.txt, rather than by-slug which fetches one page's content.",
33
34
  handler: async (query, ctx) => {
@@ -18,6 +18,7 @@ export const seoConfigQuery = defineQueryHandler({
18
18
  name: "config",
19
19
  schema: z.object({}),
20
20
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
21
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
21
22
  description:
22
23
  "Reads the tenant's SEO metadata settings — organization name and logo URL, Twitter site handle, LLMs summary and default OG image — as plain strings; use it to inspect what the sitemap, llms.txt and page meta tags will publish.",
23
24
  handler: async (_query, ctx) => {
@@ -28,6 +28,7 @@ export const bySlugQuery = defineQueryHandler({
28
28
  tenantIdOverride: z.string().min(1).optional(),
29
29
  }),
30
30
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
31
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
31
32
  description:
32
33
  "Reads one text-block of a tenant by slug and locale together with its body and format; it is pinned to the text-block kind so mail templates and AI prompts in the same table stay unreachable through this anonymous-capable path.",
33
34
  handler: async (query, ctx) => {
@@ -33,6 +33,7 @@ export const byTenantQuery = defineQueryHandler({
33
33
  tenantIdOverride: z.string().min(1).optional(),
34
34
  }),
35
35
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
36
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
36
37
  description:
37
38
  "Lists every text-block of a tenant with slug, locale, title and body so a public content tree can be rendered in one call; use it for the whole sidebar, and by-slug when only one block is needed.",
38
39
  handler: async (query, ctx) => {
@@ -0,0 +1,337 @@
1
+ // tenant-handover's `claim` write end-to-end over real /api/write calls
2
+ // (kumiko-framework#3035): an anonymous run (root entity + a parentRef-linked
3
+ // child) created under a source tenant, claimed into a destination tenant's
4
+ // account via a row-bound grant. Fixture entities stand in for offlot-app's
5
+ // vehicle/photo pair — see ../grant.ts and ../move-entity-graph.ts headers
6
+ // for the identity + same-transaction design this exercises.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import {
10
+ buildEntityTable,
11
+ createEventStoreExecutor,
12
+ createTenantDb,
13
+ executeRawQuery,
14
+ } from "@cosmicdrift/kumiko-framework/db";
15
+ import {
16
+ createEntity,
17
+ createSystemUser,
18
+ createTextField,
19
+ defineFeature,
20
+ type EntityDefinition,
21
+ type TenantId,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
24
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
25
+ import {
26
+ createTestUser,
27
+ setupTestStack,
28
+ type TestStack,
29
+ testTenantId,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
33
+ import { signTenantHandoverGrant } from "../grant";
34
+ import { createTenantHandoverFeature } from "../index";
35
+
36
+ const CLAIM = "tenant-handover:write:claim";
37
+ const SECRET = "tenant-handover-integration-secret";
38
+
39
+ const runEntity: EntityDefinition = createEntity({
40
+ table: "handover_run",
41
+ idType: "uuid",
42
+ transferable: true,
43
+ fields: {
44
+ name: createTextField({ required: true, personal: false, reason: "technical_reference" }),
45
+ },
46
+ });
47
+
48
+ const photoEntity: EntityDefinition = createEntity({
49
+ table: "handover_photo",
50
+ idType: "uuid",
51
+ transferable: true,
52
+ parentRef: { entityTypeField: "hostType", entityIdField: "hostId", allowedTypes: ["run"] },
53
+ fields: {
54
+ hostType: createTextField({ required: true, personal: false, reason: "technical_reference" }),
55
+ hostId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
56
+ caption: createTextField({ personal: false, reason: "technical_reference" }),
57
+ },
58
+ });
59
+
60
+ // Deliberately declares NO `transferable` — proves the named-error rejection.
61
+ const noteEntity: EntityDefinition = createEntity({
62
+ table: "handover_note",
63
+ idType: "uuid",
64
+ parentRef: { entityTypeField: "hostType", entityIdField: "hostId", allowedTypes: ["run"] },
65
+ fields: {
66
+ hostType: createTextField({ required: true, personal: false, reason: "technical_reference" }),
67
+ hostId: createTextField({ required: true, personal: false, reason: "technical_reference" }),
68
+ body: createTextField({ personal: false, reason: "technical_reference" }),
69
+ },
70
+ });
71
+
72
+ const handoverFixturesFeature = defineFeature("handover-fixtures", (r) => {
73
+ r.entity("run", runEntity);
74
+ r.entity("photo", photoEntity);
75
+ r.entity("note", noteEntity);
76
+ });
77
+
78
+ const runTable = buildEntityTable("run", runEntity);
79
+ const photoTable = buildEntityTable("photo", photoEntity);
80
+ const noteTable = buildEntityTable("note", noteEntity);
81
+
82
+ const runCrud = createEventStoreExecutor(runTable, runEntity, { entityName: "run" });
83
+ const photoCrud = createEventStoreExecutor(photoTable, photoEntity, { entityName: "photo" });
84
+ const noteCrud = createEventStoreExecutor(noteTable, noteEntity, { entityName: "note" });
85
+ const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
86
+ entityName: "fileRef",
87
+ });
88
+
89
+ let stack: TestStack;
90
+
91
+ const SOURCE_TENANT = testTenantId(3);
92
+
93
+ // The claim handler rate-limits `per: "user"` — a fresh, distinct user id per
94
+ // call keeps every test (and every concurrency-loop iteration) in its own
95
+ // bucket instead of tripping the limiter across unrelated calls.
96
+ let nextUserId = 1;
97
+
98
+ function destinationUser(tenantN: number) {
99
+ return createTestUser({ id: nextUserId++, tenantId: testTenantId(tenantN), roles: ["User"] });
100
+ }
101
+
102
+ beforeAll(async () => {
103
+ stack = await setupTestStack({
104
+ features: [createTenantHandoverFeature({ grantSecret: SECRET }), handoverFixturesFeature],
105
+ });
106
+ await unsafeCreateEntityTable(stack.db, runEntity, "run");
107
+ await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
108
+ await unsafeCreateEntityTable(stack.db, noteEntity, "note");
109
+ await unsafeCreateEntityTable(stack.db, fileRefEntity);
110
+ await createEventsTable(stack.db);
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await stack.cleanup();
115
+ });
116
+
117
+ beforeEach(async () => {
118
+ stack.events.reset();
119
+ await stack.db.unsafe?.(
120
+ `TRUNCATE kumiko_events, kumiko_snapshots, handover_run, handover_photo, handover_note, file_refs RESTART IDENTITY CASCADE`,
121
+ );
122
+ });
123
+
124
+ async function seedRun(tenantId: TenantId, name: string): Promise<string> {
125
+ const user = createSystemUser(tenantId);
126
+ const db = createTenantDb(stack.db, tenantId, "system");
127
+ const result = await runCrud.create({ name }, user, db);
128
+ if (!result.isSuccess) throw new Error(`seedRun failed: ${result.error.message}`);
129
+ return String(result.data.id);
130
+ }
131
+
132
+ async function seedPhoto(tenantId: TenantId, hostId: string, caption: string): Promise<string> {
133
+ const user = createSystemUser(tenantId);
134
+ const db = createTenantDb(stack.db, tenantId, "system");
135
+ const result = await photoCrud.create({ hostType: "run", hostId, caption }, user, db);
136
+ if (!result.isSuccess) throw new Error(`seedPhoto failed: ${result.error.message}`);
137
+ return String(result.data.id);
138
+ }
139
+
140
+ async function seedNote(tenantId: TenantId, hostId: string, body: string): Promise<string> {
141
+ const user = createSystemUser(tenantId);
142
+ const db = createTenantDb(stack.db, tenantId, "system");
143
+ const result = await noteCrud.create({ hostType: "run", hostId, body }, user, db);
144
+ if (!result.isSuccess) throw new Error(`seedNote failed: ${result.error.message}`);
145
+ return String(result.data.id);
146
+ }
147
+
148
+ async function seedFileRef(
149
+ tenantId: TenantId,
150
+ entityType: string,
151
+ entityId: string,
152
+ ): Promise<void> {
153
+ const user = createSystemUser(tenantId);
154
+ const db = createTenantDb(stack.db, tenantId, "system");
155
+ const result = await fileRefCrud.create(
156
+ {
157
+ storageKey: `${tenantId}/${entityType}/${entityId}/photo/x.jpg`,
158
+ fileName: "photo.jpg",
159
+ mimeType: "image/jpeg",
160
+ size: 10,
161
+ entityType,
162
+ entityId,
163
+ fieldName: "photo",
164
+ },
165
+ user,
166
+ db,
167
+ );
168
+ if (!result.isSuccess) throw new Error(`seedFileRef failed: ${result.error.message}`);
169
+ }
170
+
171
+ function grantFor(rowId: string): string {
172
+ return signTenantHandoverGrant({
173
+ entityType: "run",
174
+ rowId,
175
+ sourceTenantId: SOURCE_TENANT,
176
+ ttlMinutes: 30,
177
+ secret: SECRET,
178
+ }).token;
179
+ }
180
+
181
+ async function readTenantId(table: string, id: string): Promise<string | undefined> {
182
+ const rows = await executeRawQuery<{ tenantId: string }>(
183
+ stack.db,
184
+ `SELECT tenant_id AS "tenantId" FROM ${table} WHERE id = $1`,
185
+ [id],
186
+ );
187
+ return rows[0]?.tenantId;
188
+ }
189
+
190
+ describe("tenant-handover :: claim", () => {
191
+ test("claims the root, its parentRef-linked child, and both attached files into the caller's tenant, leaving an unrelated run of the same type untouched", async () => {
192
+ const runId = await seedRun(SOURCE_TENANT, "my run");
193
+ const photoId = await seedPhoto(SOURCE_TENANT, runId, "front");
194
+ await seedFileRef(SOURCE_TENANT, "run", runId);
195
+ await seedFileRef(SOURCE_TENANT, "photo", photoId);
196
+
197
+ // A second, unrelated run of the SAME entity type, still in the source
198
+ // tenant — proves the claim moves only the identified run's rows, not
199
+ // every "run" row that happens to share the type.
200
+ const otherRunId = await seedRun(SOURCE_TENANT, "someone else's run");
201
+ const otherPhotoId = await seedPhoto(SOURCE_TENANT, otherRunId, "side");
202
+
203
+ const dest = destinationUser(1);
204
+ const data = await stack.http.writeOk<{
205
+ id: string;
206
+ sourceTenantId: string;
207
+ destinationTenantId: string;
208
+ movedEntities: Record<string, number>;
209
+ }>(CLAIM, { token: grantFor(runId), entityType: "run" }, dest);
210
+
211
+ expect(data.id).toBe(runId);
212
+ expect(data.sourceTenantId).toBe(SOURCE_TENANT);
213
+ expect(data.destinationTenantId).toBe(dest.tenantId);
214
+ expect(data.movedEntities).toEqual({ run: 1, photo: 1, fileRef: 2 });
215
+
216
+ // Only the identified run's rows moved — the projection tables themselves
217
+ // now carry the destination tenant.
218
+ expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
219
+ expect(await readTenantId("handover_photo", photoId)).toBe(dest.tenantId);
220
+ const fileRows = await executeRawQuery<{ tenantId: string; entityId: string }>(
221
+ stack.db,
222
+ `SELECT tenant_id AS "tenantId", entity_id AS "entityId" FROM file_refs WHERE entity_id = ANY($1)`,
223
+ [[runId, photoId]],
224
+ );
225
+ expect(fileRows).toHaveLength(2);
226
+ for (const row of fileRows) expect(row.tenantId).toBe(dest.tenantId);
227
+
228
+ // The event history moved too, not just the projection row: loading the
229
+ // aggregate under the NEW tenant still sees its full history.
230
+ const events = await loadAggregate(stack.db, runId, dest.tenantId);
231
+ expect(events.some((e) => e.type === "run.created")).toBe(true);
232
+
233
+ // The audit entry: its own aggregate (events.ts), naming source tenant,
234
+ // destination tenant, and a count per moved entity.
235
+ const auditEvents = await executeRawQuery<{ payload: Record<string, unknown> }>(
236
+ stack.db,
237
+ `SELECT payload FROM kumiko_events WHERE type = 'tenant-handover:event:claimed' AND tenant_id = $1`,
238
+ [dest.tenantId],
239
+ );
240
+ expect(auditEvents).toHaveLength(1);
241
+ expect(auditEvents[0]?.payload).toMatchObject({
242
+ entityType: "run",
243
+ rootRowId: runId,
244
+ sourceTenantId: SOURCE_TENANT,
245
+ destinationTenantId: dest.tenantId,
246
+ movedEntities: { run: 1, photo: 1, fileRef: 2 },
247
+ });
248
+
249
+ // Nothing is left behind under the source tenant.
250
+ const eventsUnderSource = await loadAggregate(stack.db, runId, SOURCE_TENANT);
251
+ expect(eventsUnderSource).toHaveLength(0);
252
+
253
+ // The unrelated run (same entity type, different row) never moved.
254
+ expect(await readTenantId("handover_run", otherRunId)).toBe(SOURCE_TENANT);
255
+ expect(await readTenantId("handover_photo", otherPhotoId)).toBe(SOURCE_TENANT);
256
+ });
257
+
258
+ test("replaying the same grant fails the same way an invalid one would, and changes nothing", async () => {
259
+ const runId = await seedRun(SOURCE_TENANT, "my run");
260
+ const dest = destinationUser(1);
261
+ const token = grantFor(runId);
262
+
263
+ await stack.http.writeOk(CLAIM, { token, entityType: "run" }, dest);
264
+ const replay = await stack.http.writeErr(CLAIM, { token, entityType: "run" }, dest);
265
+
266
+ expect(replay.httpStatus).toBe(422);
267
+ expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
268
+ });
269
+
270
+ test("two simultaneous claims of one grant into different tenants leave exactly one winner", async () => {
271
+ for (let i = 0; i < 20; i++) {
272
+ const runId = await seedRun(SOURCE_TENANT, `run-${i}`);
273
+ const token = grantFor(runId);
274
+ const destA = destinationUser(1);
275
+ const destB = destinationUser(2);
276
+
277
+ const [resA, resB] = await Promise.all([
278
+ stack.http.write(CLAIM, { token, entityType: "run" }, destA),
279
+ stack.http.write(CLAIM, { token, entityType: "run" }, destB),
280
+ ]);
281
+
282
+ expect([resA.status, resB.status].sort()).toEqual([200, 422]);
283
+
284
+ const finalTenant = await readTenantId("handover_run", runId);
285
+ if (finalTenant === undefined) throw new Error("run row disappeared after the race");
286
+ expect([destA.tenantId, destB.tenantId]).toContain(finalTenant);
287
+
288
+ await stack.db.unsafe?.(
289
+ `TRUNCATE kumiko_events, kumiko_snapshots, handover_run RESTART IDENTITY CASCADE`,
290
+ );
291
+ }
292
+ });
293
+
294
+ test("a caller without an allowed role is rejected before the handler body runs", async () => {
295
+ // "Driver" is a real role in this framework's test fixtures but is not
296
+ // in claim's access.roles list (Member/User/TenantAdmin/SystemAdmin) —
297
+ // role-gating rejects it at dispatch, before the handler ever reads
298
+ // event.payload, so a garbage token/entityType still proves the point.
299
+ const outsider = createTestUser({
300
+ id: nextUserId++,
301
+ tenantId: testTenantId(1),
302
+ roles: ["Driver"],
303
+ });
304
+ const err = await stack.http.writeErr(
305
+ CLAIM,
306
+ { token: "not.a.token", entityType: "run" },
307
+ outsider,
308
+ );
309
+ expectErrorIncludes(err, "access_denied");
310
+ });
311
+
312
+ test("an unregistered or not-declared-transferable entityType is rejected before the grant is even touched", async () => {
313
+ const dest = destinationUser(1);
314
+ const err = await stack.http.writeErr(
315
+ CLAIM,
316
+ { token: "not.a.token", entityType: "unknown-entity" },
317
+ dest,
318
+ );
319
+ expect(err.httpStatus).toBe(422);
320
+ });
321
+
322
+ test("a parentRef-linked child that is not declared transferable blocks the whole claim, root included", async () => {
323
+ const runId = await seedRun(SOURCE_TENANT, "my run");
324
+ await seedNote(SOURCE_TENANT, runId, "undeclared child");
325
+ const dest = destinationUser(1);
326
+
327
+ const err = await stack.http.writeErr(
328
+ CLAIM,
329
+ { token: grantFor(runId), entityType: "run" },
330
+ dest,
331
+ );
332
+ expect(err.httpStatus).toBe(422);
333
+
334
+ // The whole transaction rolled back — the root never moved either.
335
+ expect(await readTenantId("handover_run", runId)).toBe(SOURCE_TENANT);
336
+ });
337
+ });
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.289.0",
4
+ "type": "improvement",
5
+ "title": "tenant-handover: try-before-signup ownership handover",
6
+ "detail": "New bundled feature: claims an anonymous run's declared-transferable entity graph (root plus parentRef-linked children) into a freshly signed-up account's tenant, legitimized by a row-bound grant. EntityDefinition gains an optional transferable flag; files-tenant-data's tenant-destroy hooks learn to handle handed-over fileRef rows (their bytes stay under the source tenant's storage prefix by design) on both sides."
7
+ }
8
+ ]
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+
3
+ // Audit trail for a completed claim (kumiko-framework#3035's audit-entry
4
+ // requirement: names the source tenant, destination tenant, entities and
5
+ // their counts). Its own aggregate, decoupled from the transferred root's
6
+ // stream — same reasoning as
7
+ // sessions' SESSION_REVOKED_EVENT: a fresh aggregate id per occurrence needs
8
+ // no predecessor version and can never conflict with a concurrent write on
9
+ // the entity it describes.
10
+ //
11
+ // _SHORT — r.defineEvent(short, schema) in feature.ts, framework prefixes it
12
+ // to the QN below.
13
+ // _QN — ctx.unsafeAppendEvent({ type })'s `type` field.
14
+ export const TENANT_HANDOVER_CLAIMED_EVENT_SHORT = "claimed" as const;
15
+ export const TENANT_HANDOVER_CLAIMED_EVENT_QN = "tenant-handover:event:claimed" as const;
16
+ export const TENANT_HANDOVER_CLAIM_AGGREGATE_TYPE = "tenant-handover-claim" as const;
17
+
18
+ export const tenantHandoverClaimedSchema = z.object({
19
+ entityType: z.string().min(1),
20
+ rootRowId: z.string().min(1),
21
+ sourceTenantId: z.string().min(1),
22
+ destinationTenantId: z.string().min(1),
23
+ movedEntities: z.record(z.string(), z.number().int().nonnegative()),
24
+ });
25
+
26
+ export type TenantHandoverClaimedPayload = z.infer<typeof tenantHandoverClaimedSchema>;
@@ -0,0 +1,35 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { TENANT_HANDOVER_CLAIMED_EVENT_SHORT, tenantHandoverClaimedSchema } from "./events";
3
+ import {
4
+ type ClaimTenantHandoverOptions,
5
+ createClaimTenantHandoverHandler,
6
+ } from "./handlers/claim.write";
7
+
8
+ export type TenantHandoverFeatureOptions = ClaimTenantHandoverOptions;
9
+
10
+ // tenant-handover — Option A from kumiko-framework#3035: the ONE write a
11
+ // try-before-signup (or guest-checkout, demo-to-account) flow needs once an
12
+ // anonymous run is done and the visitor has a fresh tenant. No general
13
+ // tenant-merge tool, no copy — this is an ownership change on rows a
14
+ // declared-transferable entity graph already points at.
15
+ export function createTenantHandoverFeature(
16
+ opts: TenantHandoverFeatureOptions = {},
17
+ ): FeatureDefinition {
18
+ return defineFeature("tenant-handover", (r) => {
19
+ r.describe(
20
+ "Try-before-signup ownership handover: claims the rows of a declared-transferable " +
21
+ "entity graph (root plus its parentRef-linked children) from an anonymous/public " +
22
+ "tenant into the caller's own tenant, legitimized by a row-bound grant the anonymous " +
23
+ "flow minted. Idempotent, audited via a `<entityType>.tenantHandover` domain event.",
24
+ );
25
+ r.uiHints({
26
+ displayLabel: "Tenant Handover",
27
+ category: "identity",
28
+ recommended: false,
29
+ });
30
+ r.defineEvent(TENANT_HANDOVER_CLAIMED_EVENT_SHORT, tenantHandoverClaimedSchema, {
31
+ piiFields: "none",
32
+ });
33
+ r.writeHandler(createClaimTenantHandoverHandler(opts));
34
+ });
35
+ }
@@ -0,0 +1,43 @@
1
+ // Try-before-signup ownership handover (kumiko-framework#3035), expressed as a
2
+ // row-bound grant: the subject is the transferred run's own row id, the
3
+ // anchor is the SOURCE tenant id it currently lives under. Redeeming the
4
+ // grant is the ownership change itself — the claim handler's commitAnchor
5
+ // moves the row's tenantId from the anchor to the caller's own tenant in the
6
+ // same conditional UPDATE that spends the anchor, so a replayed or raced
7
+ // token can never re-target a row that already moved.
8
+ //
9
+ // The subject is NOT secret (row-bound-grant.ts) — a grant on a run exposes
10
+ // its row id to whoever holds the token. That is only safe because this
11
+ // token never leaves the anonymous visitor's own browser: it must not be
12
+ // put in a URL, logged, or emailed. The run's own photos may show plates —
13
+ // the grant must stay exactly where the anonymous flow minted it.
14
+
15
+ import type { Temporal } from "temporal-polyfill";
16
+ import { signRowBoundGrant } from "../shared";
17
+
18
+ const HANDOVER_PURPOSE_PREFIX = "tenant-handover";
19
+
20
+ // Namespaced by entityType so a grant minted for one transferable entity can
21
+ // never be redeemed against another — the claim handler verifies against the
22
+ // SAME purpose it builds from the caller-supplied entityType.
23
+ export function tenantHandoverPurpose(entityType: string): string {
24
+ return `${HANDOVER_PURPOSE_PREFIX}:${entityType}`;
25
+ }
26
+
27
+ export function signTenantHandoverGrant(args: {
28
+ readonly entityType: string;
29
+ readonly rowId: string;
30
+ readonly sourceTenantId: string;
31
+ readonly ttlMinutes: number;
32
+ readonly secret: string;
33
+ readonly now?: Temporal.Instant;
34
+ }): { readonly token: string; readonly expiresAt: Temporal.Instant } {
35
+ return signRowBoundGrant({
36
+ subject: args.rowId,
37
+ purpose: tenantHandoverPurpose(args.entityType),
38
+ anchor: args.sourceTenantId,
39
+ ttlMinutes: args.ttlMinutes,
40
+ secret: args.secret,
41
+ now: args.now,
42
+ });
43
+ }