@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.
- package/package.json +9 -9
- package/src/__tests__/extension-points-typed.test.ts +4 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- 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: [
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|