@cosmicdrift/kumiko-bundled-features 0.288.0 → 0.290.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 +10 -9
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +125 -5
- package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -0
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +2 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +8 -1
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
- package/src/files-tenant-data/hooks.ts +101 -3
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -0
- package/src/managed-pages/handlers/branding.query.ts +1 -0
- package/src/managed-pages/handlers/by-slug.query.ts +1 -0
- package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
- package/src/seo/handlers/seo-config.query.ts +1 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +3 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +4 -0
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
- package/src/tenant-handover/changes.json +8 -0
- package/src/tenant-handover/events.ts +26 -0
- package/src/tenant-handover/feature.ts +35 -0
- package/src/tenant-handover/grant.ts +43 -0
- package/src/tenant-handover/handlers/claim.write.ts +143 -0
- package/src/tenant-handover/index.ts +9 -0
- package/src/tenant-handover/move-entity-graph.ts +228 -0
- package/src/tenant-handover/transfer-graph.ts +44 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +108 -0
- package/src/tenant-lifecycle/stages.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/deletion-token.ts +9 -11
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
- package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
- package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
|
@@ -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";
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The actual ownership change (kumiko-framework#3035): raw SQL against the
|
|
2
|
+
// event store + read-model tables, because moving a row across the tenant
|
|
3
|
+
// boundary is exactly the kind of write the framework's entity write map
|
|
4
|
+
// cannot express (it is single-tenant-scoped by design — that boundary is
|
|
5
|
+
// the whole point of `assertTenantMatch`). This is the framework's OWN
|
|
6
|
+
// declared operation, not a consumer's `acknowledgeCrossTenant` escape.
|
|
7
|
+
//
|
|
8
|
+
// Every statement here runs inside the calling write handler's own
|
|
9
|
+
// transaction (HandlerContext.db is transaction-scoped for the handler's
|
|
10
|
+
// full duration — confirmed by dispatch-batch.ts's runBatch: a write handler
|
|
11
|
+
// returning writeFailure throws BatchRollback inside the wrapping
|
|
12
|
+
// `transaction()` call, rolling back everything the handler already did).
|
|
13
|
+
// `moveRootRow` — called as row-bound-grant's `commitAnchor` — is therefore
|
|
14
|
+
// the ONLY statement that must itself combine the anchor-spend with the
|
|
15
|
+
// first write (#3023's hard rule: a write issued after `ok: true` can lose
|
|
16
|
+
// its work to a crash while the grant is already burned). Everything after
|
|
17
|
+
// it rides the same transaction: if a later step throws (an undeclared
|
|
18
|
+
// child entity, kumiko-framework#3035's named-error requirement), the WHOLE
|
|
19
|
+
// transaction — root row included — rolls back, so a caller can safely
|
|
20
|
+
// retry the same token after fixing the app's `transferable` declarations.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type DbRunner,
|
|
24
|
+
entityTableFromRegistry,
|
|
25
|
+
executeRawQuery,
|
|
26
|
+
extractTableName,
|
|
27
|
+
physicalColumnName,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
29
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
31
|
+
import { resolveChildCandidates } from "./transfer-graph";
|
|
32
|
+
|
|
33
|
+
const FILE_REFS_TABLE = "file_refs";
|
|
34
|
+
const EVENTS_TABLE = "kumiko_events";
|
|
35
|
+
const SNAPSHOTS_TABLE = "kumiko_snapshots";
|
|
36
|
+
|
|
37
|
+
// The commitAnchor statement itself: spends the anchor (WHERE tenant_id =
|
|
38
|
+
// expected) AND performs the ownership write (SET tenant_id = destination)
|
|
39
|
+
// in one UPDATE — see file header. Returns whether THIS caller won.
|
|
40
|
+
export async function moveRootRow(args: {
|
|
41
|
+
readonly db: DbRunner;
|
|
42
|
+
readonly tableName: string;
|
|
43
|
+
readonly idCol: string;
|
|
44
|
+
readonly tenantCol: string;
|
|
45
|
+
readonly rowId: string;
|
|
46
|
+
readonly sourceTenantId: string;
|
|
47
|
+
readonly destinationTenantId: string;
|
|
48
|
+
}): Promise<boolean> {
|
|
49
|
+
const rows = await executeRawQuery<{ id: string }>(
|
|
50
|
+
args.db,
|
|
51
|
+
`UPDATE "${args.tableName}" SET "${args.tenantCol}" = $2 ` +
|
|
52
|
+
`WHERE "${args.idCol}" = $1 AND "${args.tenantCol}" = $3 ` +
|
|
53
|
+
`RETURNING "${args.idCol}" AS id`,
|
|
54
|
+
[args.rowId, args.destinationTenantId, args.sourceTenantId],
|
|
55
|
+
);
|
|
56
|
+
return rows.length === 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function moveEventHistory(args: {
|
|
60
|
+
readonly db: DbRunner;
|
|
61
|
+
readonly aggregateType: string;
|
|
62
|
+
readonly aggregateIds: readonly string[];
|
|
63
|
+
readonly sourceTenantId: string;
|
|
64
|
+
readonly destinationTenantId: string;
|
|
65
|
+
}): Promise<void> {
|
|
66
|
+
// skip: an empty id list has nothing to move — both callers already
|
|
67
|
+
// filter to a non-empty list before calling, this only guards a future
|
|
68
|
+
// caller that forgets to.
|
|
69
|
+
if (args.aggregateIds.length === 0) return;
|
|
70
|
+
await executeRawQuery(
|
|
71
|
+
args.db,
|
|
72
|
+
`UPDATE ${EVENTS_TABLE} SET tenant_id = $1 ` +
|
|
73
|
+
`WHERE tenant_id = $2 AND aggregate_type = $3 AND aggregate_id = ANY($4)`,
|
|
74
|
+
[args.destinationTenantId, args.sourceTenantId, args.aggregateType, args.aggregateIds],
|
|
75
|
+
);
|
|
76
|
+
// Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
|
|
77
|
+
// dropped rather than rewritten, forcing a full replay from the just-moved
|
|
78
|
+
// events on next read instead of carrying the snapshot's own generation
|
|
79
|
+
// bookkeeping across the tenant boundary.
|
|
80
|
+
await executeRawQuery(
|
|
81
|
+
args.db,
|
|
82
|
+
`DELETE FROM ${SNAPSHOTS_TABLE} WHERE tenant_id = $1 AND aggregate_id = ANY($2)`,
|
|
83
|
+
[args.sourceTenantId, args.aggregateIds],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function moveFileRefs(args: {
|
|
88
|
+
readonly db: DbRunner;
|
|
89
|
+
readonly entityType: string;
|
|
90
|
+
readonly entityIds: readonly string[];
|
|
91
|
+
readonly sourceTenantId: string;
|
|
92
|
+
readonly destinationTenantId: string;
|
|
93
|
+
}): Promise<number> {
|
|
94
|
+
if (args.entityIds.length === 0) return 0;
|
|
95
|
+
const rows = await executeRawQuery<{ id: string }>(
|
|
96
|
+
args.db,
|
|
97
|
+
`UPDATE ${FILE_REFS_TABLE} SET tenant_id = $1 ` +
|
|
98
|
+
`WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`,
|
|
99
|
+
[args.destinationTenantId, args.sourceTenantId, args.entityType, args.entityIds],
|
|
100
|
+
);
|
|
101
|
+
return rows.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `idCol` is a `parentRef.entityIdField` — by convention a text column (see
|
|
105
|
+
// tags' tag-assignment entity: "Host entity ids are uuid/text; 128 covers
|
|
106
|
+
// uuid plus non-uuid text keys"), never enforced as a type. A future
|
|
107
|
+
// uuid-typed entityIdField would need the same CASE-guarded comparison
|
|
108
|
+
// parent-ref-clause.ts uses for reads, to avoid a 22P02 on a non-uuid value
|
|
109
|
+
// instead of silently matching zero rows.
|
|
110
|
+
async function moveChildRows(args: {
|
|
111
|
+
readonly db: DbRunner;
|
|
112
|
+
readonly tableName: string;
|
|
113
|
+
readonly typeCol: string;
|
|
114
|
+
readonly idCol: string;
|
|
115
|
+
readonly tenantCol: string;
|
|
116
|
+
readonly pkCol: string;
|
|
117
|
+
readonly rootEntityType: string;
|
|
118
|
+
readonly rootRowId: string;
|
|
119
|
+
readonly sourceTenantId: string;
|
|
120
|
+
readonly destinationTenantId: string;
|
|
121
|
+
}): Promise<readonly string[]> {
|
|
122
|
+
const rows = await executeRawQuery<{ id: string }>(
|
|
123
|
+
args.db,
|
|
124
|
+
`UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
|
|
125
|
+
`WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = $3 AND "${args.tenantCol}" = $4 ` +
|
|
126
|
+
`RETURNING "${args.pkCol}" AS id`,
|
|
127
|
+
[args.destinationTenantId, args.rootEntityType, args.rootRowId, args.sourceTenantId],
|
|
128
|
+
);
|
|
129
|
+
return rows.map((row) => row.id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Everything the root ownership write does NOT cover: the root's own event
|
|
133
|
+
// history + attached files, plus every parentRef-linked child's rows, event
|
|
134
|
+
// history, and attached files. Returns a count per moved entity name (the
|
|
135
|
+
// write handler turns this into the audit entry) — `fileRef` is a single
|
|
136
|
+
// pooled count across root + every child, since it is not itself part of the
|
|
137
|
+
// declared transfer graph (see files-tenant-data's own handover coverage for
|
|
138
|
+
// why file BYTES never move, only the fileRef row's ownership).
|
|
139
|
+
export async function moveTransferGraph(args: {
|
|
140
|
+
readonly db: DbRunner;
|
|
141
|
+
readonly registry: Registry;
|
|
142
|
+
readonly rootEntityName: string;
|
|
143
|
+
readonly rootRowId: string;
|
|
144
|
+
readonly sourceTenantId: string;
|
|
145
|
+
readonly destinationTenantId: string;
|
|
146
|
+
}): Promise<Readonly<Record<string, number>>> {
|
|
147
|
+
const { db, registry, rootEntityName, rootRowId, sourceTenantId, destinationTenantId } = args;
|
|
148
|
+
const movedCounts: Record<string, number> = { [rootEntityName]: 1 };
|
|
149
|
+
const trackFileMove = (count: number) => {
|
|
150
|
+
// skip: nothing moved (this entity had no attached files) — the audit
|
|
151
|
+
// payload should list `fileRef` only when a file actually moved, not a
|
|
152
|
+
// stray zero entry for every root/child that happens to have none.
|
|
153
|
+
if (count === 0) return;
|
|
154
|
+
movedCounts["fileRef"] = (movedCounts["fileRef"] ?? 0) + count;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await moveEventHistory({
|
|
158
|
+
db,
|
|
159
|
+
aggregateType: rootEntityName,
|
|
160
|
+
aggregateIds: [rootRowId],
|
|
161
|
+
sourceTenantId,
|
|
162
|
+
destinationTenantId,
|
|
163
|
+
});
|
|
164
|
+
trackFileMove(
|
|
165
|
+
await moveFileRefs({
|
|
166
|
+
db,
|
|
167
|
+
entityType: rootEntityName,
|
|
168
|
+
entityIds: [rootRowId],
|
|
169
|
+
sourceTenantId,
|
|
170
|
+
destinationTenantId,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
for (const candidate of resolveChildCandidates(registry, rootEntityName)) {
|
|
175
|
+
const parentRef = candidate.entity.parentRef;
|
|
176
|
+
if (!parentRef) continue; // resolveChildCandidates already filtered on this — narrows for TS
|
|
177
|
+
const table = entityTableFromRegistry(registry, candidate.entityName, candidate.entity);
|
|
178
|
+
const tableName = extractTableName(table);
|
|
179
|
+
const typeCol = physicalColumnName(table, parentRef.entityTypeField);
|
|
180
|
+
const idCol = physicalColumnName(table, parentRef.entityIdField);
|
|
181
|
+
const tenantCol = physicalColumnName(table, "tenantId");
|
|
182
|
+
const pkCol = physicalColumnName(table, "id");
|
|
183
|
+
|
|
184
|
+
const childIds = await moveChildRows({
|
|
185
|
+
db,
|
|
186
|
+
tableName,
|
|
187
|
+
typeCol,
|
|
188
|
+
idCol,
|
|
189
|
+
tenantCol,
|
|
190
|
+
pkCol,
|
|
191
|
+
rootEntityType: rootEntityName,
|
|
192
|
+
rootRowId,
|
|
193
|
+
sourceTenantId,
|
|
194
|
+
destinationTenantId,
|
|
195
|
+
});
|
|
196
|
+
if (childIds.length === 0) continue;
|
|
197
|
+
|
|
198
|
+
// Moved first, validated second — safe only because everything here
|
|
199
|
+
// shares the root UPDATE's transaction (see file header): throwing now
|
|
200
|
+
// rolls this move back together with the root's, not just this one.
|
|
201
|
+
if (candidate.entity.transferable !== true) {
|
|
202
|
+
throw new UnprocessableError("entity_not_transferable", {
|
|
203
|
+
i18nKey: "errors.tenantHandover.entityNotTransferable",
|
|
204
|
+
details: { entityName: candidate.entityName },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
movedCounts[candidate.entityName] = childIds.length;
|
|
209
|
+
await moveEventHistory({
|
|
210
|
+
db,
|
|
211
|
+
aggregateType: candidate.entityName,
|
|
212
|
+
aggregateIds: childIds,
|
|
213
|
+
sourceTenantId,
|
|
214
|
+
destinationTenantId,
|
|
215
|
+
});
|
|
216
|
+
trackFileMove(
|
|
217
|
+
await moveFileRefs({
|
|
218
|
+
db,
|
|
219
|
+
entityType: candidate.entityName,
|
|
220
|
+
entityIds: childIds,
|
|
221
|
+
sourceTenantId,
|
|
222
|
+
destinationTenantId,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return movedCounts;
|
|
228
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Resolves the declared transfer graph for a root entity type: the root
|
|
2
|
+
// itself, plus every registered entity whose `parentRef` can name it as a
|
|
3
|
+
// host (kumiko-framework#3035, framework CLAUDE.md premise 4 — the graph
|
|
4
|
+
// depth is read off the existing parentRef declaration, never a hand-
|
|
5
|
+
// maintained per-app list). parentRef is one level deep by construction (the
|
|
6
|
+
// boot validator at engine/boot-validator/parent-ref.ts rejects a host that
|
|
7
|
+
// itself declares a parentRef), so this resolves in one registry pass.
|
|
8
|
+
|
|
9
|
+
import type { EntityDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
|
|
11
|
+
export type TransferChildCandidate = {
|
|
12
|
+
readonly entityName: string;
|
|
13
|
+
readonly entity: EntityDefinition;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function resolveTransferableRoot(
|
|
17
|
+
registry: Registry,
|
|
18
|
+
entityType: string,
|
|
19
|
+
): EntityDefinition | undefined {
|
|
20
|
+
const entity = registry.getAllEntities().get(entityType);
|
|
21
|
+
return entity?.transferable === true ? entity : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Broad on purpose: a candidate's `parentRef.allowedTypes` is a declaration
|
|
25
|
+
// of which host TYPES it accepts, not proof any row of it currently points
|
|
26
|
+
// at THIS root instance. The caller queries actual rows per candidate and
|
|
27
|
+
// only enforces `transferable` on candidates that turn out to have matching
|
|
28
|
+
// rows — narrowing here to "has rows" would need the very query the caller
|
|
29
|
+
// is about to run anyway, and would make an unrelated candidate with
|
|
30
|
+
// `allowedTypes: undefined` (any host) silently invisible instead of
|
|
31
|
+
// query-checked.
|
|
32
|
+
export function resolveChildCandidates(
|
|
33
|
+
registry: Registry,
|
|
34
|
+
rootEntityType: string,
|
|
35
|
+
): readonly TransferChildCandidate[] {
|
|
36
|
+
const candidates: TransferChildCandidate[] = [];
|
|
37
|
+
for (const [entityName, entity] of registry.getAllEntities()) {
|
|
38
|
+
const parentRef = entity.parentRef;
|
|
39
|
+
if (!parentRef) continue;
|
|
40
|
+
if (parentRef.allowedTypes && !parentRef.allowedTypes.includes(rootEntityType)) continue;
|
|
41
|
+
candidates.push({ entityName, entity });
|
|
42
|
+
}
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
@@ -469,3 +469,111 @@ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's
|
|
|
469
469
|
expect(eventsB.some((e) => e.type.endsWith("tenant-destruction-stage-succeeded"))).toBe(true);
|
|
470
470
|
});
|
|
471
471
|
});
|
|
472
|
+
|
|
473
|
+
// No authConfig here on purpose: buildServer derives the 410 gate from the
|
|
474
|
+
// mounted tenantLifecycleStatus provider. The stacks above keep the explicit
|
|
475
|
+
// auth.resolveTenantLifecycleStatus override covered.
|
|
476
|
+
describe("tenant-lifecycle :: 410 gate derived from the mounted provider", () => {
|
|
477
|
+
let derivedStack: TestStack;
|
|
478
|
+
|
|
479
|
+
beforeAll(async () => {
|
|
480
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
481
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
482
|
+
derivedStack = await setupTestStack({
|
|
483
|
+
features: [
|
|
484
|
+
createConfigFeature(),
|
|
485
|
+
createUserFeature(),
|
|
486
|
+
createTenantFeature(),
|
|
487
|
+
createComplianceProfilesFeature(),
|
|
488
|
+
authFoundationFeature,
|
|
489
|
+
createSessionsFeature(),
|
|
490
|
+
createTenantLifecycleFeature(),
|
|
491
|
+
],
|
|
492
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
493
|
+
});
|
|
494
|
+
await unsafeCreateEntityTable(derivedStack.db, tenantEntity);
|
|
495
|
+
await unsafeCreateEntityTable(derivedStack.db, userSessionEntity);
|
|
496
|
+
await unsafeCreateEntityTable(derivedStack.db, tenantComplianceProfileEntity);
|
|
497
|
+
await unsafeCreateEntityTable(derivedStack.db, tenantMembershipEntity);
|
|
498
|
+
await createEventsTable(derivedStack.db);
|
|
499
|
+
await unsafePushTables(derivedStack.db, { configValuesTable });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
afterAll(async () => {
|
|
503
|
+
await derivedStack.cleanup();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
beforeEach(async () => {
|
|
507
|
+
derivedStack.events.reset();
|
|
508
|
+
resetTenantLifecycleGateCacheForTests();
|
|
509
|
+
await resetTestTables(derivedStack.db, [
|
|
510
|
+
tenantTable,
|
|
511
|
+
tenantComplianceProfileTable,
|
|
512
|
+
userSessionTable,
|
|
513
|
+
tenantMembershipsTable,
|
|
514
|
+
eventsTable,
|
|
515
|
+
]);
|
|
516
|
+
await derivedStack.http.writeOk(
|
|
517
|
+
TenantHandlers.create,
|
|
518
|
+
{ id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
|
|
519
|
+
TestUsers.systemAdmin,
|
|
520
|
+
);
|
|
521
|
+
await derivedStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
async function rejectionCode(res: Response): Promise<string | undefined> {
|
|
525
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
526
|
+
return body.error?.code;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
test("active tenant passes the gate", async () => {
|
|
530
|
+
const res = await derivedStack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
531
|
+
expect(res.status).toBe(200);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("destroyRequested tenant gets 410 tenant_unavailable without any hand-wiring", async () => {
|
|
535
|
+
await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
536
|
+
resetTenantLifecycleGateCacheForTests();
|
|
537
|
+
|
|
538
|
+
const res = await derivedStack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
539
|
+
expect(res.status).toBe(410);
|
|
540
|
+
expect(await rejectionCode(res)).toBe("tenant_unavailable");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("cancel-destruction stays exempt while destroyRequested", async () => {
|
|
544
|
+
await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
545
|
+
resetTenantLifecycleGateCacheForTests();
|
|
546
|
+
|
|
547
|
+
const cancelled = await derivedStack.http.writeOk<{ status: string }>(CANCEL, {}, tenantAdmin);
|
|
548
|
+
expect(cancelled.status).toBe("active");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("batch mixing cancel-destruction with another write gets 410", async () => {
|
|
552
|
+
await derivedStack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
553
|
+
resetTenantLifecycleGateCacheForTests();
|
|
554
|
+
|
|
555
|
+
const res = await derivedStack.http.batch(
|
|
556
|
+
[
|
|
557
|
+
{ type: CANCEL, payload: {} },
|
|
558
|
+
{ type: SET_PROFILE, payload: { profileKey: "eu-dsgvo" } },
|
|
559
|
+
],
|
|
560
|
+
tenantAdmin,
|
|
561
|
+
);
|
|
562
|
+
expect(res.status).toBe(410);
|
|
563
|
+
expect(await rejectionCode(res)).toBe("tenant_unavailable");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("destroying tenant has no cancel exemption", async () => {
|
|
567
|
+
await updateRows(
|
|
568
|
+
derivedStack.db,
|
|
569
|
+
tenantTable,
|
|
570
|
+
{ status: "destroying" },
|
|
571
|
+
{ id: tenantAdmin.tenantId },
|
|
572
|
+
);
|
|
573
|
+
resetTenantLifecycleGateCacheForTests();
|
|
574
|
+
|
|
575
|
+
const res = await derivedStack.http.write(CANCEL, {}, tenantAdmin);
|
|
576
|
+
expect(res.status).toBe(410);
|
|
577
|
+
expect(await rejectionCode(res)).toBe("tenant_unavailable");
|
|
578
|
+
});
|
|
579
|
+
});
|
|
@@ -179,18 +179,53 @@ describe("anonymous deletion flow", () => {
|
|
|
179
179
|
expect(second.status).toBe(422);
|
|
180
180
|
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
181
181
|
|
|
182
|
-
// #354/2:
|
|
183
|
-
//
|
|
184
|
-
//
|
|
182
|
+
// #354/2 + #3024: the anonymous endpoint returns the same generic reason
|
|
183
|
+
// for EVERY error path as an invalid token — since the grace-period
|
|
184
|
+
// transition now lives inside commitDeletion (the anchor spend), there is
|
|
185
|
+
// no separate res.ok branch left that could leak the concrete user status
|
|
186
|
+
// (currentStatus).
|
|
185
187
|
const body = (await second.json()) as {
|
|
186
188
|
error: { details?: { reason?: string } };
|
|
187
189
|
};
|
|
188
|
-
expect(body.error.details?.reason).toBe("
|
|
190
|
+
expect(body.error.details?.reason).toBe("invalid_or_expired_token");
|
|
189
191
|
const serialized = JSON.stringify(body.error);
|
|
190
192
|
expect(serialized).not.toContain("currentStatus");
|
|
191
193
|
expect(serialized).not.toContain(USER_STATUS.DeletionRequested);
|
|
192
194
|
});
|
|
193
195
|
|
|
196
|
+
test("concurrent confirm-by-token (#3024): two simultaneous redemptions of the same token leave exactly one winner", async () => {
|
|
197
|
+
// Real concurrency case, real HTTP calls via setupTestStack, no sleep —
|
|
198
|
+
// the interleaving width varies between runs, hence 20 repetitions
|
|
199
|
+
// instead of a single run (probabilistic test).
|
|
200
|
+
for (let i = 0; i < 20; i++) {
|
|
201
|
+
await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
|
|
202
|
+
await seedAlice();
|
|
203
|
+
verifyCalls.length = 0;
|
|
204
|
+
await stack.http.raw("POST", "/api/write", {
|
|
205
|
+
type: REQUEST_BY_EMAIL,
|
|
206
|
+
payload: { email: ALICE_EMAIL },
|
|
207
|
+
});
|
|
208
|
+
const token = tokenFromLastVerifyCall();
|
|
209
|
+
|
|
210
|
+
const [first, second] = await Promise.all([
|
|
211
|
+
stack.http.raw("POST", "/api/write", { type: CONFIRM_BY_TOKEN, payload: { token } }),
|
|
212
|
+
stack.http.raw("POST", "/api/write", { type: CONFIRM_BY_TOKEN, payload: { token } }),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
expect([first.status, second.status].sort()).toEqual([200, 422]);
|
|
216
|
+
// Exactly ONE lifecycle transition: the row lands at DeletionRequested,
|
|
217
|
+
// not in a last-write-wins in-between state from two applied writes.
|
|
218
|
+
expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
|
|
219
|
+
|
|
220
|
+
const loser = first.status === 422 ? first : second;
|
|
221
|
+
const body = (await loser.json()) as { error: { details?: { reason?: string } } };
|
|
222
|
+
expect(body.error.details?.reason).toBe("invalid_or_expired_token");
|
|
223
|
+
const serialized = JSON.stringify(body.error);
|
|
224
|
+
expect(serialized).not.toContain("currentStatus");
|
|
225
|
+
expect(serialized).not.toContain(USER_STATUS.DeletionRequested);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
194
229
|
test("replay-after-cancel (#354/1): Token nach cancel-deletion re-armt NICHT → 422, bleibt Active", async () => {
|
|
195
230
|
await seedAlice();
|
|
196
231
|
await stack.http.raw("POST", "/api/write", {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.289.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Deletion tokens are genuinely single-use, and a losing redeem no longer leaks status",
|
|
6
|
+
"detail": "`redeemDeletionToken` previously carried an `unsafeSkip` and a separate status check before the lifecycle write, so two concurrent redemptions of the same token could both get through. The lifecycle transition now rides the executor's `expect:` precondition inside the anchor spend, so exactly one redemption wins.\nExternally visible: the loser of a concurrent redeem now receives the same generic `invalid_or_expired_token` reason on the anonymous path as an unknown token, instead of `cannot_process_deletion`. That was the point — the old reason distinguished \"known token, wrong state\" from \"unknown token\" to an unauthenticated caller. Integrators matching on the old string on that endpoint should expect the generic one.\n`pendingDeletionRequestId` is now cleared on confirm as well, closing a pre-existing gap. `skipOptimisticLock: true` stays in the lifecycle path and now carries the reason it is correct there: callers structurally never hold a row version to pass through."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.287.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -33,6 +33,14 @@ export function redeemDeletionToken(args: {
|
|
|
33
33
|
readonly token: string;
|
|
34
34
|
readonly secret: string | undefined;
|
|
35
35
|
readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
|
|
36
|
+
// Spends the anchor AND performs the actual lifecycle transition in one
|
|
37
|
+
// atomic step (#3024) — the caller (confirm-deletion-by-token) folds the
|
|
38
|
+
// Active→DeletionRequested write itself in here via `updateUserLifecycle`'s
|
|
39
|
+
// `expect: { status: Active, pendingDeletionRequestId }`, so a write issued
|
|
40
|
+
// after `ok: true` can't lose its work to a crash while the grant is
|
|
41
|
+
// already burned (see shared/row-bound-grant.ts). Returns whether this
|
|
42
|
+
// caller was the one who moved the row on.
|
|
43
|
+
readonly commitDeletion: (userId: string, requestId: string) => Promise<boolean>;
|
|
36
44
|
readonly now?: Temporal.Instant;
|
|
37
45
|
}): Promise<RowBoundGrantResult> {
|
|
38
46
|
return redeemRowBoundGrant({
|
|
@@ -40,17 +48,7 @@ export function redeemDeletionToken(args: {
|
|
|
40
48
|
purpose: DELETION_REQUEST_PURPOSE,
|
|
41
49
|
secret: args.secret,
|
|
42
50
|
loadAnchor: args.loadPendingRequestId,
|
|
43
|
-
commitAnchor:
|
|
44
|
-
unsafeSkip: {
|
|
45
|
-
reason:
|
|
46
|
-
"pendingDeletionRequestId may only be changed through updateUserLifecycle — a " +
|
|
47
|
-
"conditional UPDATE would bypass the user.updated event and lose the field on a " +
|
|
48
|
-
"projection rebuild (see update-user-lifecycle.ts). The concurrent-redeem window " +
|
|
49
|
-
"this leaves open predates row-bound grants: startDeletionGracePeriod already " +
|
|
50
|
-
"read-then-writes the Active check. Closing it needs an atomic lifecycle " +
|
|
51
|
-
"transition, which is its own change.",
|
|
52
|
-
},
|
|
53
|
-
},
|
|
51
|
+
commitAnchor: args.commitDeletion,
|
|
54
52
|
now: args.now,
|
|
55
53
|
});
|
|
56
54
|
}
|