@cosmicdrift/kumiko-bundled-features 0.312.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 (63) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +4 -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/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
  29. package/src/file-derivatives/changes.json +6 -0
  30. package/src/file-derivatives/feature.ts +5 -0
  31. package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
  32. package/src/shared/index.ts +10 -1
  33. package/src/shared/run-in-sub-transaction.ts +35 -0
  34. package/src/shared/signup-handover.ts +67 -0
  35. package/src/shared/single-use-token-store.ts +25 -5
  36. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
  37. package/src/subscription-stripe/changes.json +9 -1
  38. package/src/subscription-stripe/feature.ts +13 -1
  39. package/src/subscription-stripe/plugin-methods.ts +17 -2
  40. package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
  41. package/src/tenant/changes.json +6 -0
  42. package/src/tenant/index.ts +1 -0
  43. package/src/tenant/is-tenant-serving-public-content.ts +13 -0
  44. package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
  45. package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
  46. package/src/tenant/web/member-roles-cell.tsx +11 -2
  47. package/src/tenant/web/member-status-cell.tsx +6 -2
  48. package/src/tenant/web/translate-or-raw.ts +13 -0
  49. package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
  50. package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
  51. package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
  52. package/src/tenant-handover/changes.json +12 -0
  53. package/src/tenant-handover/events.ts +6 -0
  54. package/src/tenant-handover/feature.ts +11 -0
  55. package/src/tenant-handover/handlers/claim.write.ts +8 -25
  56. package/src/tenant-handover/move-entity-graph.ts +30 -1
  57. package/src/tenant-handover/root-anchor.ts +50 -0
  58. package/src/tenant-handover/signup-handover-provider.ts +62 -0
  59. package/src/tenant-handover/transfer-graph.ts +6 -2
  60. package/src/user-data-rights/changes.json +6 -0
  61. package/src/user-data-rights/feature.ts +1 -2
  62. package/src/user-data-rights/i18n.ts +3 -0
  63. package/src/user-data-rights/run-forget-cleanup.ts +1 -38
@@ -143,6 +143,48 @@ const handoverFixturesFeature = defineFeature("handover-fixtures", (r) => {
143
143
  r.entity("linkB", linkBEntity);
144
144
  });
145
145
 
146
+ // kumiko-framework#3088 fix: a `reference` field's `entity` may carry a
147
+ // feature prefix ("<feature>:<entity>", engine/parse-ref-target.ts) for
148
+ // cross-feature refs — transfer-graph resolution must strip it, or an
149
+ // entity linked this way is never reachable from the root and the claim
150
+ // silently moves only the rows it happened to name directly. Root (run,
151
+ // "handover-fixtures") -> child (own feature) -> grandchild (a THIRD
152
+ // feature), both edges declared with a feature-prefixed target.
153
+ const prefixedChildEntity: EntityDefinition = createEntity({
154
+ table: "handover_prefixed_child",
155
+ idType: "uuid",
156
+ transferable: true,
157
+ fields: {
158
+ runId: { type: "reference", entity: "handover-fixtures:run", required: true },
159
+ label: createTextField({ personal: false, reason: "technical_reference" }),
160
+ },
161
+ });
162
+
163
+ const handoverPrefixedChildFeature = defineFeature("handover-fixtures-prefixed-child", (r) => {
164
+ r.entity("prefixedChild", prefixedChildEntity);
165
+ });
166
+
167
+ const prefixedGrandchildEntity: EntityDefinition = createEntity({
168
+ table: "handover_prefixed_grandchild",
169
+ idType: "uuid",
170
+ transferable: true,
171
+ fields: {
172
+ parentId: {
173
+ type: "reference",
174
+ entity: "handover-fixtures-prefixed-child:prefixedChild",
175
+ required: true,
176
+ },
177
+ label: createTextField({ personal: false, reason: "technical_reference" }),
178
+ },
179
+ });
180
+
181
+ const handoverPrefixedGrandchildFeature = defineFeature(
182
+ "handover-fixtures-prefixed-grandchild",
183
+ (r) => {
184
+ r.entity("prefixedGrandchild", prefixedGrandchildEntity);
185
+ },
186
+ );
187
+
146
188
  const runTable = buildEntityTable("run", runEntity);
147
189
  const photoTable = buildEntityTable("photo", photoEntity);
148
190
  const noteTable = buildEntityTable("note", noteEntity);
@@ -151,6 +193,8 @@ const campaignTable = buildEntityTable("campaign", campaignEntity);
151
193
  const channelTextTable = buildEntityTable("channelText", channelTextEntity);
152
194
  const linkATable = buildEntityTable("linkA", linkAEntity);
153
195
  const linkBTable = buildEntityTable("linkB", linkBEntity);
196
+ const prefixedChildTable = buildEntityTable("prefixedChild", prefixedChildEntity);
197
+ const prefixedGrandchildTable = buildEntityTable("prefixedGrandchild", prefixedGrandchildEntity);
154
198
 
155
199
  const runCrud = createEventStoreExecutor(runTable, runEntity, { entityName: "run" });
156
200
  const photoCrud = createEventStoreExecutor(photoTable, photoEntity, { entityName: "photo" });
@@ -164,6 +208,14 @@ const channelTextCrud = createEventStoreExecutor(channelTextTable, channelTextEn
164
208
  });
165
209
  const linkACrud = createEventStoreExecutor(linkATable, linkAEntity, { entityName: "linkA" });
166
210
  const linkBCrud = createEventStoreExecutor(linkBTable, linkBEntity, { entityName: "linkB" });
211
+ const prefixedChildCrud = createEventStoreExecutor(prefixedChildTable, prefixedChildEntity, {
212
+ entityName: "prefixedChild",
213
+ });
214
+ const prefixedGrandchildCrud = createEventStoreExecutor(
215
+ prefixedGrandchildTable,
216
+ prefixedGrandchildEntity,
217
+ { entityName: "prefixedGrandchild" },
218
+ );
167
219
  const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
168
220
  entityName: "fileRef",
169
221
  });
@@ -183,7 +235,12 @@ function destinationUser(tenantN: number) {
183
235
 
184
236
  beforeAll(async () => {
185
237
  stack = await setupTestStack({
186
- features: [createTenantHandoverFeature({ grantSecret: SECRET }), handoverFixturesFeature],
238
+ features: [
239
+ createTenantHandoverFeature({ grantSecret: SECRET }),
240
+ handoverFixturesFeature,
241
+ handoverPrefixedChildFeature,
242
+ handoverPrefixedGrandchildFeature,
243
+ ],
187
244
  });
188
245
  await unsafeCreateEntityTable(stack.db, runEntity, "run");
189
246
  await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
@@ -193,6 +250,8 @@ beforeAll(async () => {
193
250
  await unsafeCreateEntityTable(stack.db, channelTextEntity, "channelText");
194
251
  await unsafeCreateEntityTable(stack.db, linkAEntity, "linkA");
195
252
  await unsafeCreateEntityTable(stack.db, linkBEntity, "linkB");
253
+ await unsafeCreateEntityTable(stack.db, prefixedChildEntity, "prefixedChild");
254
+ await unsafeCreateEntityTable(stack.db, prefixedGrandchildEntity, "prefixedGrandchild");
196
255
  await unsafeCreateEntityTable(stack.db, fileRefEntity);
197
256
  });
198
257
 
@@ -203,7 +262,7 @@ afterAll(async () => {
203
262
  beforeEach(async () => {
204
263
  stack.events.reset();
205
264
  await stack.db.unsafe?.(
206
- `TRUNCATE kumiko_events, kumiko_snapshots, handover_run, handover_photo, handover_note, handover_bundle, handover_campaign, handover_channel_text, handover_link_a, handover_link_b, file_refs RESTART IDENTITY CASCADE`,
265
+ `TRUNCATE kumiko_events, kumiko_snapshots, handover_run, handover_photo, handover_note, handover_bundle, handover_campaign, handover_channel_text, handover_link_a, handover_link_b, handover_prefixed_child, handover_prefixed_grandchild, file_refs RESTART IDENTITY CASCADE`,
207
266
  );
208
267
  });
209
268
 
@@ -300,6 +359,30 @@ async function pointLinkABack(tenantId: TenantId, linkAId: string, viaB: string)
300
359
  if (!result.isSuccess) throw new Error(`pointLinkABack failed: ${result.error.message}`);
301
360
  }
302
361
 
362
+ async function seedPrefixedChild(
363
+ tenantId: TenantId,
364
+ runId: string,
365
+ label: string,
366
+ ): Promise<string> {
367
+ const user = createSystemUser(tenantId);
368
+ const db = createTenantDb(stack.db, tenantId, "system");
369
+ const result = await prefixedChildCrud.create({ runId, label }, user, db);
370
+ if (!result.isSuccess) throw new Error(`seedPrefixedChild failed: ${result.error.message}`);
371
+ return String(result.data.id);
372
+ }
373
+
374
+ async function seedPrefixedGrandchild(
375
+ tenantId: TenantId,
376
+ parentId: string,
377
+ label: string,
378
+ ): Promise<string> {
379
+ const user = createSystemUser(tenantId);
380
+ const db = createTenantDb(stack.db, tenantId, "system");
381
+ const result = await prefixedGrandchildCrud.create({ parentId, label }, user, db);
382
+ if (!result.isSuccess) throw new Error(`seedPrefixedGrandchild failed: ${result.error.message}`);
383
+ return String(result.data.id);
384
+ }
385
+
303
386
  async function seedFileRef(
304
387
  tenantId: TenantId,
305
388
  entityType: string,
@@ -605,6 +688,36 @@ describe("tenant-handover :: claim", () => {
605
688
  expect(err.httpStatus).toBe(422);
606
689
  });
607
690
 
691
+ // kumiko-framework#3088 fix: transfer-graph resolution previously took a
692
+ // reference field's `entity` raw, so "<feature>:<entity>" targets never
693
+ // formed an edge and only the root ever moved. Root and child live in
694
+ // different features (both prefixed edges), grandchild in a third.
695
+ test("claims a chain of feature-prefixed reference edges whole: root -> child -> grandchild", async () => {
696
+ const runId = await seedRun(SOURCE_TENANT, "my run");
697
+ const childId = await seedPrefixedChild(SOURCE_TENANT, runId, "child");
698
+ const grandchildId = await seedPrefixedGrandchild(SOURCE_TENANT, childId, "grandchild");
699
+
700
+ const dest = destinationUser(1);
701
+ const data = await stack.http.writeOk<{ movedEntities: Record<string, number> }>(
702
+ CLAIM,
703
+ { token: grantFor(runId), entityType: "run" },
704
+ dest,
705
+ );
706
+
707
+ expect(data.movedEntities).toEqual({ run: 1, prefixedChild: 1, prefixedGrandchild: 1 });
708
+
709
+ expect(await readTenantId("handover_run", runId)).toBe(dest.tenantId);
710
+ expect(await readTenantId("handover_prefixed_child", childId)).toBe(dest.tenantId);
711
+ expect(await readTenantId("handover_prefixed_grandchild", grandchildId)).toBe(dest.tenantId);
712
+
713
+ expect(
714
+ (await loadAggregate(stack.db, grandchildId, dest.tenantId)).some(
715
+ (e) => e.type === "prefixedGrandchild.created",
716
+ ),
717
+ ).toBe(true);
718
+ expect(await loadAggregate(stack.db, grandchildId, SOURCE_TENANT)).toHaveLength(0);
719
+ });
720
+
608
721
  test("a parentRef-linked child that is not declared transferable blocks the whole claim, root included", async () => {
609
722
  const runId = await seedRun(SOURCE_TENANT, "my run");
610
723
  await seedNote(SOURCE_TENANT, runId, "undeclared child");
@@ -161,6 +161,21 @@ describe("resolveTransferAdjacency", () => {
161
161
  expect(childrenOf(adjacency, "run")).toEqual(["note"]);
162
162
  });
163
163
 
164
+ // "<feature>:<entity>" is a legal ReferenceFieldDef.entity form for
165
+ // cross-feature refs (engine/parse-ref-target.ts) — an edge must resolve
166
+ // by entity name regardless of which feature's prefix the declaration used.
167
+ test("resolves a feature-prefixed reference target to the same edge", () => {
168
+ const adjacency = resolveTransferAdjacency(
169
+ registryOf({
170
+ run: entity({}),
171
+ campaign: entity({ references: { runId: "vehicles:run" } }),
172
+ }),
173
+ "run",
174
+ );
175
+
176
+ expect(childrenOf(adjacency, "run")).toEqual(["campaign"]);
177
+ });
178
+
164
179
  test("ignores a multiple reference, which the boot validator rejects up front", () => {
165
180
  const bulk = createEntity({
166
181
  table: "t",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "tenant-handover claim now moves a fileRef's own event history and storage-usage counters",
6
+ "detail": "moveFileRefs only flipped the file_refs read-model row's tenant_id: the\nfileRef aggregate's own events in kumiko_events stayed under the source\ntenant, so a later write against the moved fileRef couldn't load its\nstream, and a projection rebuild put the row back into the source\ntenant. It also never touched the fileRef's share of the\ntenant-storage-usage MSP counters, leaving stale bytes/fileCount behind\nunder the source tenant and none under the destination. moveFileRefs now\nalso calls moveEventHistory for the fileRef aggregate and a new\ntransferTenantStorageUsage (framework, exported via the files barrel),\nwhich locks the MSP's consumer cursor row, sums the already-applied\nfileRef deltas via the newly extracted fileRefStorageDelta, and moves\nthem from source to destination before the tenant_id rewrite runs (the\n\"already applied\" lookup keys off the pre-rewrite tenant_id). No\nmigration needed."
7
+ },
8
+ {
9
+ "version": "0.313.0",
10
+ "type": "fix",
11
+ "title": "tenant-handover transfer graph now resolves feature-prefixed reference targets",
12
+ "detail": "ReferenceFieldDef.entity may carry a feature prefix\n(\"<feature>:<entity>\") for cross-feature refs. The tenant-handover\ntransfer graph and the framework boot validator's depth check both took\nthat value raw, so a prefixed reference never formed an edge: the claim\nhandler silently moved only the root row, leaving every entity reachable\nsolely through a prefixed reference behind in the source tenant. Both\nnow resolve the target through the shared parseRefTargetEntityName.\nConsumer-visible: an entity reachable only via a prefixed reference that\nhas rows and is not declared `transferable: true` now fails the claim\nwith `entity_not_transferable` instead of being left behind, and the\nboot-time MAX_TRANSFER_DEPTH check now counts prefixed chains too. No\nmigration needed; declare `transferable: true` on a newly caught entity\nor flatten a newly caught over-deep chain."
13
+ },
2
14
  {
3
15
  "version": "0.296.0",
4
16
  "type": "improvement",
@@ -15,6 +15,12 @@ export const TENANT_HANDOVER_CLAIMED_EVENT_SHORT = "claimed" as const;
15
15
  export const TENANT_HANDOVER_CLAIMED_EVENT_QN = "tenant-handover:event:claimed" as const;
16
16
  export const TENANT_HANDOVER_CLAIM_AGGREGATE_TYPE = "tenant-handover-claim" as const;
17
17
 
18
+ // The claim write handler's own qualified name — `<feature>:write:<handler>`,
19
+ // same convention as auth-email-password/constants.ts's AuthHandlers. Named
20
+ // here so a signup-handover provider (see ../shared/signup-handover.ts) can
21
+ // mint a claim payload for it without hand-duplicating the literal.
22
+ export const TENANT_HANDOVER_CLAIM_WRITE_QN = "tenant-handover:write:claim" as const;
23
+
18
24
  export const tenantHandoverClaimedSchema = z.object({
19
25
  entityType: z.string().min(1),
20
26
  rootRowId: z.string().min(1),
@@ -1,9 +1,11 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { EXT_SIGNUP_HANDOVER } from "../shared";
2
3
  import { TENANT_HANDOVER_CLAIMED_EVENT_SHORT, tenantHandoverClaimedSchema } from "./events";
3
4
  import {
4
5
  type ClaimTenantHandoverOptions,
5
6
  createClaimTenantHandoverHandler,
6
7
  } from "./handlers/claim.write";
8
+ import { createSignupHandoverProvider } from "./signup-handover-provider";
7
9
 
8
10
  export type TenantHandoverFeatureOptions = ClaimTenantHandoverOptions;
9
11
 
@@ -31,5 +33,14 @@ export function createTenantHandoverFeature(
31
33
  piiFields: "none",
32
34
  });
33
35
  r.writeHandler(createClaimTenantHandoverHandler(opts));
36
+
37
+ // Self-extension: this feature both defines the signupHandover contract
38
+ // and is its only implementation (self-extension is legitimate — an app
39
+ // without auth-email-password's signup flow, or without this feature at
40
+ // all, still boots; requires(self) would be circular). See
41
+ // shared/signup-handover.ts for why this indirection exists instead of
42
+ // auth-email-password importing tenant-handover directly.
43
+ r.extendsRegistrar(EXT_SIGNUP_HANDOVER, {});
44
+ r.useExtension(EXT_SIGNUP_HANDOVER, "tenant-handover", createSignupHandoverProvider(opts));
34
45
  });
35
46
  }
@@ -1,18 +1,12 @@
1
- import {
2
- entityTableFromRegistry,
3
- executeRawQuery,
4
- extractTableName,
5
- physicalColumnName,
6
- } from "@cosmicdrift/kumiko-framework/db";
7
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
8
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
3
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
10
4
  import * as z from "zod";
11
- import { redeemRowBoundGrant } from "../../shared";
5
+ import { redeemRowBoundGrant, SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE } from "../../shared";
12
6
  import { TENANT_HANDOVER_CLAIM_AGGREGATE_TYPE, TENANT_HANDOVER_CLAIMED_EVENT_QN } from "../events";
13
7
  import { tenantHandoverPurpose } from "../grant";
14
8
  import { moveRootRow, moveTransferGraph } from "../move-entity-graph";
15
- import { resolveTransferableRoot } from "../transfer-graph";
9
+ import { resolveRootAnchorLocation } from "../root-anchor";
16
10
 
17
11
  export type ClaimTenantHandoverOptions = {
18
12
  readonly grantSecret?: string;
@@ -50,18 +44,14 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
50
44
  agent: { expose: false },
51
45
  rateLimit: { per: "user", limit: 10, windowSeconds: 60 },
52
46
  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
47
  const db = ctx.db.unsafeRaw(
62
48
  "tenant-handover claim: cross-tenant read + ownership write, legitimized by the " +
63
49
  "row-bound grant redeemed below",
64
50
  );
51
+ const rootLocation = resolveRootAnchorLocation(ctx.registry, db, event.payload.entityType);
52
+ if (!rootLocation) return notTransferable(event.payload.entityType);
53
+ const { rootTableName, rootIdCol, rootTenantCol, loadAnchor } = rootLocation;
54
+
65
55
  const destinationTenantId = event.user.tenantId;
66
56
 
67
57
  let sourceTenantId: string | undefined;
@@ -71,14 +61,7 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
71
61
  token: event.payload.token,
72
62
  purpose: tenantHandoverPurpose(event.payload.entityType),
73
63
  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
- },
64
+ loadAnchor,
82
65
  commitAnchor: async (subject, expectedAnchor) => {
83
66
  const moved = await moveRootRow({
84
67
  db,
@@ -125,7 +108,7 @@ export function createClaimTenantHandoverHandler(opts: ClaimTenantHandoverOption
125
108
  // or returned false before setting it — both already covered by
126
109
  // `redeemed.ok`. The explicit check narrows the type without a cast.
127
110
  if (!redeemed.ok || sourceTenantId === undefined) {
128
- return writeFailure(new UnprocessableError("invalid_or_expired_grant"));
111
+ return writeFailure(new UnprocessableError(SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE));
129
112
  }
130
113
 
131
114
  return {
@@ -28,6 +28,7 @@ import {
28
28
  } from "@cosmicdrift/kumiko-framework/db";
29
29
  import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
30
30
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
31
+ import { transferTenantStorageUsage } from "@cosmicdrift/kumiko-framework/files";
31
32
  import { resolveTransferAdjacency, type TransferEdge } from "./transfer-graph";
32
33
 
33
34
  const FILE_REFS_TABLE = "file_refs";
@@ -84,8 +85,15 @@ async function moveEventHistory(args: {
84
85
  );
85
86
  }
86
87
 
88
+ // Moves the file_refs row itself PLUS the fileRef aggregate's own event
89
+ // history + storage-usage counters — fileRef is a standard ES entity (see
90
+ // files-storage-tracking.md), so without those two follow-ups a later write
91
+ // against a moved fileRef can't load its stream, and a projection rebuild
92
+ // would put the row back into the source tenant (kumiko-framework#3131-adjacent
93
+ // gap, item 4).
87
94
  async function moveFileRefs(args: {
88
95
  readonly db: DbRunner;
96
+ readonly registry: Registry;
89
97
  readonly entityType: string;
90
98
  readonly entityIds: readonly string[];
91
99
  readonly sourceTenantId: string;
@@ -98,7 +106,26 @@ async function moveFileRefs(args: {
98
106
  `WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`,
99
107
  [args.destinationTenantId, args.sourceTenantId, args.entityType, args.entityIds],
100
108
  );
101
- return rows.length;
109
+ const movedIds = rows.map((row) => row.id);
110
+ if (movedIds.length === 0) return 0;
111
+
112
+ // Must run before moveEventHistory rewrites these events' tenant_id — see
113
+ // transferTenantStorageUsage's own comment for why.
114
+ await transferTenantStorageUsage({
115
+ db: args.db,
116
+ registry: args.registry,
117
+ fileRefIds: movedIds,
118
+ sourceTenantId: args.sourceTenantId,
119
+ destinationTenantId: args.destinationTenantId,
120
+ });
121
+ await moveEventHistory({
122
+ db: args.db,
123
+ aggregateType: "fileRef",
124
+ aggregateIds: movedIds,
125
+ sourceTenantId: args.sourceTenantId,
126
+ destinationTenantId: args.destinationTenantId,
127
+ });
128
+ return movedIds.length;
102
129
  }
103
130
 
104
131
  // `idCol` is a `parentRef.entityIdField` — by convention a text column (see
@@ -231,6 +258,7 @@ export async function moveTransferGraph(args: {
231
258
  trackFileMove(
232
259
  await moveFileRefs({
233
260
  db,
261
+ registry,
234
262
  entityType: rootEntityName,
235
263
  entityIds: [rootRowId],
236
264
  sourceTenantId,
@@ -299,6 +327,7 @@ export async function moveTransferGraph(args: {
299
327
  trackFileMove(
300
328
  await moveFileRefs({
301
329
  db,
330
+ registry,
302
331
  entityType: edge.entityName,
303
332
  entityIds: childIds,
304
333
  sourceTenantId,
@@ -0,0 +1,50 @@
1
+ // The row-bound grant's anchor for a transferable root (see grant.ts): the
2
+ // root row's CURRENT tenantId, read via the same SELECT the claim handler
3
+ // (which spends the anchor) and the signup-handover verify-only path
4
+ // (which never does, see shared/signup-handover.ts) both need — extracted
5
+ // so the two callers can't drift on table/column resolution.
6
+
7
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
8
+ import {
9
+ entityTableFromRegistry,
10
+ executeRawQuery,
11
+ extractTableName,
12
+ physicalColumnName,
13
+ } from "@cosmicdrift/kumiko-framework/db";
14
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { resolveTransferableRoot } from "./transfer-graph";
16
+
17
+ export type RootAnchorLocation = {
18
+ readonly rootTableName: string;
19
+ readonly rootIdCol: string;
20
+ readonly rootTenantCol: string;
21
+ readonly loadAnchor: (rowId: string) => Promise<string | null>;
22
+ };
23
+
24
+ export function resolveRootAnchorLocation(
25
+ registry: Registry,
26
+ db: DbRunner,
27
+ entityType: string,
28
+ ): RootAnchorLocation | undefined {
29
+ const rootEntity = resolveTransferableRoot(registry, entityType);
30
+ if (!rootEntity) return undefined;
31
+
32
+ const rootTable = entityTableFromRegistry(registry, entityType, rootEntity);
33
+ const rootTableName = extractTableName(rootTable);
34
+ const rootIdCol = physicalColumnName(rootTable, "id");
35
+ const rootTenantCol = physicalColumnName(rootTable, "tenantId");
36
+
37
+ return {
38
+ rootTableName,
39
+ rootIdCol,
40
+ rootTenantCol,
41
+ loadAnchor: async (rowId) => {
42
+ const rows = await executeRawQuery<{ tenantId: string }>(
43
+ db,
44
+ `SELECT "${rootTenantCol}" AS "tenantId" FROM "${rootTableName}" WHERE "${rootIdCol}" = $1`,
45
+ [rowId],
46
+ );
47
+ return rows[0]?.tenantId ?? null;
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,62 @@
1
+ // tenant-handover's implementation of the shared signup-handover extension
2
+ // point (kumiko-framework#3035 follow-up, offlot-app#454): lets
3
+ // auth-email-password's signup-request verify a grant against its root row
4
+ // WITHOUT spending the anchor (the anchor is spent once, by the claim
5
+ // signup-confirm runs afterwards, via a fresh short-lived grant this module
6
+ // mints) — see shared/signup-handover.ts for the extension contract and
7
+ // grant.ts for the row-bound-grant mechanics being verified.
8
+
9
+ import type { SignupHandoverBinding, SignupHandoverProvider } from "../shared";
10
+ import { redeemRowBoundGrant } from "../shared";
11
+ import { TENANT_HANDOVER_CLAIM_WRITE_QN } from "./events";
12
+ import { signTenantHandoverGrant, tenantHandoverPurpose } from "./grant";
13
+ import { resolveRootAnchorLocation } from "./root-anchor";
14
+
15
+ const MINTED_CLAIM_GRANT_TTL_MINUTES = 5;
16
+
17
+ export function createSignupHandoverProvider(opts: {
18
+ readonly grantSecret?: string;
19
+ }): SignupHandoverProvider {
20
+ return {
21
+ verifyGrant: async ({ db, registry, entityType, token }) => {
22
+ const rootLocation = resolveRootAnchorLocation(registry, db, entityType);
23
+ if (!rootLocation) return null;
24
+
25
+ let sourceTenantId: string | undefined;
26
+ const redeemed = await redeemRowBoundGrant({
27
+ token,
28
+ purpose: tenantHandoverPurpose(entityType),
29
+ secret: opts.grantSecret,
30
+ loadAnchor: async (rowId) => {
31
+ const anchor = await rootLocation.loadAnchor(rowId);
32
+ sourceTenantId = anchor ?? undefined;
33
+ return anchor;
34
+ },
35
+ // Verify-only at signup-request; the anchor is spent by the claim
36
+ // that signup-confirm runs.
37
+ commitAnchor: { unsafeSkip: { reason: "signup_handover_verify_only" } },
38
+ });
39
+ if (!redeemed.ok || sourceTenantId === undefined) return null;
40
+
41
+ return { entityType, rowId: redeemed.subject, sourceTenantId };
42
+ },
43
+ mintClaim: (binding: SignupHandoverBinding) => {
44
+ // verifyGrant is fail-closed without a secret (redeemRowBoundGrant),
45
+ // so a binding only ever exists here when opts.grantSecret was set.
46
+ if (!opts.grantSecret) {
47
+ throw new Error("tenant-handover signup-handover: mintClaim called without grantSecret");
48
+ }
49
+ const { token } = signTenantHandoverGrant({
50
+ entityType: binding.entityType,
51
+ rowId: binding.rowId,
52
+ sourceTenantId: binding.sourceTenantId,
53
+ ttlMinutes: MINTED_CLAIM_GRANT_TTL_MINUTES,
54
+ secret: opts.grantSecret,
55
+ });
56
+ return {
57
+ qualifiedName: TENANT_HANDOVER_CLAIM_WRITE_QN,
58
+ payload: { token, entityType: binding.entityType },
59
+ };
60
+ },
61
+ };
62
+ }
@@ -23,7 +23,11 @@
23
23
  // row currently travels it. Narrowing here would need the very query the caller
24
24
  // is about to run anyway.
25
25
 
26
- import type { EntityDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
26
+ import {
27
+ type EntityDefinition,
28
+ parseRefTargetEntityName,
29
+ type Registry,
30
+ } from "@cosmicdrift/kumiko-framework/engine";
27
31
 
28
32
  export type TransferEdgeLink =
29
33
  | { readonly kind: "parentRef"; readonly typeField: string; readonly idField: string }
@@ -59,7 +63,7 @@ function referenceEdgesFrom(entityName: string, entity: EntityDefinition): reado
59
63
  edges.push({
60
64
  entityName,
61
65
  entity,
62
- parentEntityName: field.entity,
66
+ parentEntityName: parseRefTargetEntityName(field.entity),
63
67
  link: { kind: "reference", field: fieldName },
64
68
  });
65
69
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.313.0",
4
+ "type": "fix",
5
+ "title": "Privacy-center screen subtitle is now translatable",
6
+ "detail": "The Privacy screen's description was hardcoded English prose baked\ninto feature.ts instead of an i18n key, so it never localized. It's\nnow userDataRights.privacyCenter.subtitle, registered in i18n.ts with\nde/es translations."
7
+ },
2
8
  {
3
9
  "version": "0.308.0",
4
10
  "type": "fix",
@@ -355,8 +355,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
355
355
  },
356
356
  },
357
357
  dormant: true,
358
- description:
359
- "Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
358
+ description: "userDataRights.privacyCenter.subtitle",
360
359
  fieldLabels: {
361
360
  status: i18nKey("userDataRights.privacyCenter.field.status"),
362
361
  gracePeriodEnd: i18nKey("userDataRights.privacyCenter.field.gracePeriodEnd"),
@@ -9,6 +9,9 @@ export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> =
9
9
  "screen:export-job-detail.title": { en: "Export job" },
10
10
  "screen:download-attempt-list.title": { en: "Download attempts" },
11
11
  "screen:privacy-center.title": { en: "Privacy" },
12
+ "userDataRights.privacyCenter.subtitle": {
13
+ en: "Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
14
+ },
12
15
  "user-data-rights:entity:export-job:field:userId": { en: "User" },
13
16
  "user-data-rights:entity:export-job:field:status": { en: "Status" },
14
17
  "user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
@@ -64,7 +64,7 @@ import {
64
64
  } from "@cosmicdrift/kumiko-framework/search";
65
65
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
66
66
  import { resolveRetentionPolicyForTenant } from "../data-retention";
67
- import { decryptStoredPii } from "../shared";
67
+ import { decryptStoredPii, runInSubTransaction } from "../shared";
68
68
  import { tenantMembershipsTable } from "../tenant";
69
69
  import { USER_STATUS, userTable } from "../user";
70
70
  import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup";
@@ -539,43 +539,6 @@ async function processUser(args: {
539
539
  };
540
540
  }
541
541
 
542
- // Per-user sub-transaction, nesting-aware across both db shapes:
543
- // - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
544
- // - TransactionSql (inside the dispatcher's outer tx, where every
545
- // writeHandler already runs) → `.savepoint` (SAVEPOINT)
546
- // A TransactionSql has no `.begin`, so the previous unconditional `.begin`
547
- // threw "is not a function" on every user when invoked through the dispatcher
548
- // (the cron path) → zero deletions in production, while direct-connection tests
549
- // stayed green. Selecting the available method makes the sub-tx work in both
550
- // contexts; on throw the savepoint rolls back just this user (others survive).
551
- async function runInSubTransaction(
552
- db: DbRunner,
553
- fn: (tx: DbRunner) => Promise<void>,
554
- ): Promise<void> {
555
- // `db` is already the raw runner (the handler passes ctx.db.unsafeRaw(...),
556
- // the tests a top-level connection) — cast to read the transaction surface directly,
557
- // without asRawClient (a test-only escape hatch). A top-level connection
558
- // exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
559
- // exclusive, so prefer whichever is present.
560
- const runner = db as {
561
- begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
562
- savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
563
- };
564
- // savepoint-FIRST — empirisch (Bun 1.3.14, unter 1.4.0 nicht neu geprüft)
565
- // sind die Flächen NICHT mutually exclusive: eine TransactionSql exposed
566
- // begin UND savepoint, nur die Top-Level-Connection hat ausschließlich
567
- // begin. begin-first wählte im Tx-Fall das nested BEGIN (Prod-Incident-
568
- // Klasse, s. Header); savepoint-first trifft im Tx-Fall den Savepoint
569
- // und fällt top-level sauber auf begin zurück.
570
- const open = runner.savepoint ?? runner.begin;
571
- if (!open) {
572
- throw new Error(
573
- "runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
574
- );
575
- }
576
- await open.call(runner, fn);
577
- }
578
-
579
542
  // Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
580
543
  // Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
581
544
  // tenant-agnostische Hooks (z.B. user) operieren auf der globalen