@cosmicdrift/kumiko-bundled-features 0.288.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 (29) 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/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  5. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  6. package/src/files-tenant-data/hooks.ts +101 -3
  7. package/src/managed-pages/handlers/branding.query.ts +1 -0
  8. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  9. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  10. package/src/seo/handlers/seo-config.query.ts +1 -0
  11. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  12. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  13. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  14. package/src/tenant-handover/changes.json +8 -0
  15. package/src/tenant-handover/events.ts +26 -0
  16. package/src/tenant-handover/feature.ts +35 -0
  17. package/src/tenant-handover/grant.ts +43 -0
  18. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  19. package/src/tenant-handover/index.ts +9 -0
  20. package/src/tenant-handover/move-entity-graph.ts +228 -0
  21. package/src/tenant-handover/transfer-graph.ts +44 -0
  22. package/src/tenant-lifecycle/stages.ts +2 -0
  23. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  24. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  25. package/src/user-data-rights/changes.json +6 -0
  26. package/src/user-data-rights/deletion-token.ts +9 -11
  27. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  28. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  29. package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
@@ -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
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ entityTableFromRegistry,
3
+ executeRawQuery,
4
+ extractTableName,
5
+ physicalColumnName,
6
+ } from "@cosmicdrift/kumiko-framework/db";
7
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
10
+ import { z } from "zod";
11
+ import { redeemRowBoundGrant } from "../../shared";
12
+ import { TENANT_HANDOVER_CLAIM_AGGREGATE_TYPE, TENANT_HANDOVER_CLAIMED_EVENT_QN } from "../events";
13
+ import { tenantHandoverPurpose } from "../grant";
14
+ import { moveRootRow, moveTransferGraph } from "../move-entity-graph";
15
+ import { resolveTransferableRoot } from "../transfer-graph";
16
+
17
+ export type ClaimTenantHandoverOptions = {
18
+ readonly grantSecret?: string;
19
+ };
20
+
21
+ // Every rejection before the grant is even peeked is a bare 422 — same
22
+ // no-oracle stance as row-bound-grant.ts. `entityType` itself is not secret
23
+ // (the app's own domain vocabulary), so failing fast on an unknown or
24
+ // undeclared root type is a plain config error, not a leak.
25
+ function notTransferable(entityType: string): ReturnType<typeof writeFailure> {
26
+ return writeFailure(
27
+ new UnprocessableError("entity_not_transferable", {
28
+ i18nKey: "errors.tenantHandover.entityNotTransferable",
29
+ details: { entityType },
30
+ }),
31
+ );
32
+ }
33
+
34
+ // The try-before-signup ownership handover (kumiko-framework#3035): claims an
35
+ // anonymous run into the caller's own tenant, legitimized by a row-bound
36
+ // grant the anonymous flow minted (see ../grant.ts). Idempotent — a second
37
+ // call with the same token fails the same way a forged one would, because
38
+ // the grant's anchor (the run's tenantId) has already moved.
39
+ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOptions = {}) {
40
+ return defineWriteHandler({
41
+ name: "claim",
42
+ schema: z.object({ token: z.string().min(1), entityType: z.string().min(1) }),
43
+ access: { roles: ["Member", "User", "TenantAdmin", "SystemAdmin"] },
44
+ escapeHatch: {
45
+ reason:
46
+ "the ownership change moves rows OUT of a source tenant this caller has no membership " +
47
+ "in — the framework's declared cross-tenant operation for try-before-signup, not a " +
48
+ "per-consumer acknowledgeCrossTenant workaround",
49
+ },
50
+ agent: { expose: false },
51
+ rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
52
+ handler: async (event, ctx) => {
53
+ const rootEntity = resolveTransferableRoot(ctx.registry, event.payload.entityType);
54
+ if (!rootEntity) return notTransferable(event.payload.entityType);
55
+
56
+ const rootTable = entityTableFromRegistry(ctx.registry, event.payload.entityType, rootEntity);
57
+ const rootTableName = extractTableName(rootTable);
58
+ const rootIdCol = physicalColumnName(rootTable, "id");
59
+ const rootTenantCol = physicalColumnName(rootTable, "tenantId");
60
+
61
+ const db = ctx.db.unsafeRaw(
62
+ "tenant-handover claim: cross-tenant read + ownership write, legitimized by the " +
63
+ "row-bound grant redeemed below",
64
+ );
65
+ const destinationTenantId = event.user.tenantId;
66
+
67
+ let sourceTenantId: string | undefined;
68
+ let movedEntities: Readonly<Record<string, number>> = {};
69
+
70
+ const redeemed = await redeemRowBoundGrant({
71
+ token: event.payload.token,
72
+ purpose: tenantHandoverPurpose(event.payload.entityType),
73
+ secret: opts.grantSecret,
74
+ loadAnchor: async (subject) => {
75
+ const rows = await executeRawQuery<{ tenantId: string }>(
76
+ db,
77
+ `SELECT "${rootTenantCol}" AS "tenantId" FROM "${rootTableName}" WHERE "${rootIdCol}" = $1`,
78
+ [subject],
79
+ );
80
+ return rows[0]?.tenantId ?? null;
81
+ },
82
+ commitAnchor: async (subject, expectedAnchor) => {
83
+ const moved = await moveRootRow({
84
+ db,
85
+ tableName: rootTableName,
86
+ idCol: rootIdCol,
87
+ tenantCol: rootTenantCol,
88
+ rowId: subject,
89
+ sourceTenantId: expectedAnchor,
90
+ destinationTenantId,
91
+ });
92
+ if (!moved) return false;
93
+
94
+ sourceTenantId = expectedAnchor;
95
+ movedEntities = await moveTransferGraph({
96
+ db,
97
+ registry: ctx.registry,
98
+ rootEntityName: event.payload.entityType,
99
+ rootRowId: subject,
100
+ sourceTenantId: expectedAnchor,
101
+ destinationTenantId,
102
+ });
103
+
104
+ // Own aggregate, decoupled from the transferred root's stream (see
105
+ // events.ts) — a fresh id needs no predecessor version and can't
106
+ // conflict with a concurrent write on the entity it describes.
107
+ await ctx.unsafeAppendEvent({
108
+ aggregateId: generateId(),
109
+ aggregateType: TENANT_HANDOVER_CLAIM_AGGREGATE_TYPE,
110
+ type: TENANT_HANDOVER_CLAIMED_EVENT_QN,
111
+ payload: {
112
+ entityType: event.payload.entityType,
113
+ rootRowId: subject,
114
+ sourceTenantId: expectedAnchor,
115
+ destinationTenantId,
116
+ movedEntities,
117
+ },
118
+ });
119
+
120
+ return true;
121
+ },
122
+ });
123
+
124
+ // sourceTenantId is only ever undefined here if commitAnchor never ran
125
+ // or returned false before setting it — both already covered by
126
+ // `redeemed.ok`. The explicit check narrows the type without a cast.
127
+ if (!redeemed.ok || sourceTenantId === undefined) {
128
+ return writeFailure(new UnprocessableError("invalid_or_expired_grant"));
129
+ }
130
+
131
+ return {
132
+ isSuccess: true as const,
133
+ data: {
134
+ entityType: event.payload.entityType,
135
+ id: redeemed.subject,
136
+ sourceTenantId,
137
+ destinationTenantId,
138
+ movedEntities,
139
+ },
140
+ };
141
+ },
142
+ });
143
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ createTenantHandoverFeature,
3
+ type TenantHandoverFeatureOptions,
4
+ } from "./feature";
5
+ export { signTenantHandoverGrant, tenantHandoverPurpose } from "./grant";
6
+ export {
7
+ type ClaimTenantHandoverOptions,
8
+ createClaimTenantHandoverHandler,
9
+ } from "./handlers/claim.write";