@cosmicdrift/kumiko-bundled-features 0.288.0 → 0.289.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
  3. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
  4. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  5. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  6. package/src/files-tenant-data/hooks.ts +101 -3
  7. package/src/managed-pages/handlers/branding.query.ts +1 -0
  8. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  9. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  10. package/src/seo/handlers/seo-config.query.ts +1 -0
  11. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  12. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  13. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  14. package/src/tenant-handover/changes.json +8 -0
  15. package/src/tenant-handover/events.ts +26 -0
  16. package/src/tenant-handover/feature.ts +35 -0
  17. package/src/tenant-handover/grant.ts +43 -0
  18. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  19. package/src/tenant-handover/index.ts +9 -0
  20. package/src/tenant-handover/move-entity-graph.ts +228 -0
  21. package/src/tenant-handover/transfer-graph.ts +44 -0
  22. package/src/tenant-lifecycle/stages.ts +2 -0
  23. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  24. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  25. package/src/user-data-rights/changes.json +6 -0
  26. package/src/user-data-rights/deletion-token.ts +9 -11
  27. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  28. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  29. package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
@@ -0,0 +1,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
+ }
@@ -99,6 +99,8 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
99
99
  }),
100
100
  registry: ctx.registry,
101
101
  tenantId: ctx.tenantId,
102
+ fileProviderResolver: ctx.fileProviderResolver,
103
+ log: ctx.log,
102
104
  };
103
105
  await destroy(hookCtx);
104
106
  }
@@ -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: der anonyme Endpoint gibt einen generischen reason zurück und
183
- // leakt NICHT den konkreten User-Status (currentStatus), den ein
184
- // Token-Inhaber sonst proben könnte.
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("cannot_process_deletion");
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", {
@@ -20,6 +20,7 @@ describe("deletion token wire format", () => {
20
20
  token: legacy,
21
21
  secret: SECRET,
22
22
  loadPendingRequestId: async () => REQUEST_ID,
23
+ commitDeletion: async () => true,
23
24
  });
24
25
 
25
26
  expect(result.ok).toBe(true);
@@ -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
  }
@@ -40,17 +40,23 @@ async function readPendingDeletionRequestId(
40
40
  }
41
41
  }
42
42
 
43
- // Anonymer Apex-Flow Schritt 2: Verify-Link-Target. Verifiziert das
44
- // HMAC-Token, extrahiert die userId und startet die Grace-Period über die
45
- // geteilte Logik.
43
+ // Anonymous apex flow step 2: verify-link target. Verifies the HMAC token,
44
+ // extracts the userId, and flips the grace period through the shared logic —
45
+ // in ONE atomic step with the anchor spend (#3024): commitDeletion carries
46
+ // the actual grace-period transition, so a crash after the spend can no
47
+ // longer lose the write step.
46
48
  //
47
- // Replay-Schutz (#354/1): die requestId der Row ist Teil des Verify-Keys. Wir
48
- // lesen sie über die (unverifizierte, nur-Lookup) userId aus dem Token, lehnen
49
- // einen fehlenden Eintrag ab und verifizieren das Token gegen die CURRENT
50
- // requestId. Ein zweites Confirm auf einen noch-pending User trifft zudem
51
- // non-active → cannot_process_deletion. Nach einem cancel-deletion (status →
52
- // Active, pendingDeletionRequestId → null) schlägt ein nachgespieltes Token an
53
- // der genullten/erneuerten requestId fehl — kein re-arm mehr.
49
+ // Replay protection (#354/1): the row's requestId is part of the verify key.
50
+ // We read it via the (unverified, lookup-only) userId from the token, reject
51
+ // a missing entry, and verify the token against the CURRENT requestId. After
52
+ // a cancel-deletion (status → Active, pendingDeletionRequestId → null), a
53
+ // replayed token fails against the nulled/renewed requestId — no re-arm.
54
+ //
55
+ // Concurrency (#3024): two simultaneous confirms of the same token both read
56
+ // the same requestId and both verify the HMAC — commitDeletion spends the
57
+ // anchor AND writes the transition atomically (expect: status===Active &&
58
+ // pendingDeletionRequestId===requestId), so exactly one wins. The loser gets
59
+ // the same generic 422 as an invalid token — no status leak (#354/2).
54
60
  export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
55
61
  return defineWriteHandler({
56
62
  name: "confirm-deletion-by-token",
@@ -66,16 +72,6 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
66
72
  agent: { expose: false },
67
73
  rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
68
74
  handler: async (event, ctx) => {
69
- // The row's requestId is part of the verify key, so a token from a
70
- // cancelled or superseded cycle fails. Every error path ends in the same
71
- // generic 422.
72
- const verified = await redeemDeletionToken({
73
- token: event.payload.token,
74
- secret: opts.deletionTokenSecret,
75
- loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
76
- });
77
- if (!verified.ok) return writeFailure(invalidToken());
78
-
79
75
  // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
80
76
  // the compliance-profile shape.
81
77
  const profile = (await ctx.queryAs(
@@ -83,26 +79,40 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
83
79
  "compliance-profiles:query:for-tenant",
84
80
  {},
85
81
  )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
86
- const res = await startDeletionGracePeriod(
87
- ctx,
88
- verified.subject,
89
- profile.profile.userRights.gracePeriod,
90
- ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
91
- );
92
- if (!res.ok) {
93
- // Generischer 422 statt res.error: dieser Endpoint ist anonym-öffentlich,
94
- // res.error trägt den konkreten User-Status (currentStatus aus
95
- // user_not_in_active_state) und würde einem Token-Inhaber das Proben des
96
- // Account-Status erlauben (#354/2). Der authentifizierte request-deletion-
97
- // Pfad zeigt dem User legitim seinen eigenen Status.
98
- return writeFailure(new UnprocessableError("cannot_process_deletion"));
99
- }
82
+
83
+ let gracePeriodEndIso: string | undefined;
84
+
85
+ // The row's requestId is part of the verify key, so a token from a
86
+ // cancelled or superseded cycle fails. Every error path ends in the same
87
+ // generic 422 — commitDeletion folding the grace-period write into the
88
+ // anchor-spend means there's no separate res.ok branch left to leak a
89
+ // concrete status through.
90
+ const verified = await redeemDeletionToken({
91
+ token: event.payload.token,
92
+ secret: opts.deletionTokenSecret,
93
+ loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
94
+ commitDeletion: async (userId, requestId) => {
95
+ const res = await startDeletionGracePeriod(
96
+ ctx,
97
+ userId,
98
+ profile.profile.userRights.gracePeriod,
99
+ ctx.db.unsafeRaw(
100
+ "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream",
101
+ ),
102
+ { pendingDeletionRequestId: requestId },
103
+ );
104
+ if (!res.ok) return false;
105
+ gracePeriodEndIso = res.gracePeriodEnd.toString();
106
+ return true;
107
+ },
108
+ });
109
+ if (!verified.ok || gracePeriodEndIso === undefined) return writeFailure(invalidToken());
100
110
 
101
111
  return {
102
112
  isSuccess: true as const,
103
113
  data: {
104
114
  status: USER_STATUS.DeletionRequested,
105
- gracePeriodEnd: res.gracePeriodEnd.toString(),
115
+ gracePeriodEnd: gracePeriodEndIso,
106
116
  },
107
117
  };
108
118
  },
@@ -25,6 +25,25 @@ export type StartGracePeriodResult =
25
25
  //
26
26
  // The user row is tenant-agnostic (account-wide deletion), so it is read via
27
27
  // ctx.db.global(userTable); only the grace period duration is tenant-configured.
28
+ // That read only supplies email/locale for the caller's notification — it is
29
+ // NOT the transition's guard. The guard is `expect: { status: Active }` on
30
+ // the lifecycle write itself (#3024): the executor re-checks it against a
31
+ // fresh row right before writing, so a concurrent caller that already moved
32
+ // the user off Active is rejected even though this read saw Active.
33
+ //
34
+ // `additionalExpect` lets a caller fold its own precondition into the SAME
35
+ // atomic write — the confirm-by-token path uses it to spend a row-bound
36
+ // grant's anchor (pendingDeletionRequestId) in the same statement that flips
37
+ // status, per shared/row-bound-grant.ts's "write in the same statement that
38
+ // spends the anchor" contract. The write also always clears
39
+ // pendingDeletionRequestId: a request-by-email token is meant for one
40
+ // confirm only, and leaving the id in place would let it re-arm later if
41
+ // something ever moves the user back to Active without going through
42
+ // cancel-deletion's explicit null (restrict/lift-restriction can't today,
43
+ // since status is a single field and Restricted/DeletionRequested are
44
+ // mutually exclusive — but nulling it here doesn't depend on that staying
45
+ // true). No-op for the authenticated request-deletion path, which never set
46
+ // a pendingDeletionRequestId to begin with.
28
47
  //
29
48
  // `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
30
49
  // require an escalation (reading the tenant compliance profile, appending to
@@ -35,6 +54,7 @@ export async function startDeletionGracePeriod(
35
54
  userId: string,
36
55
  gracePeriod: DurationSpec,
37
56
  lifecycleRunner: DbRunner,
57
+ additionalExpect?: Readonly<Record<string, string | number | boolean | null>>,
38
58
  ): Promise<StartGracePeriodResult> {
39
59
  const userRow = await ctx.db
40
60
  .global(userTable)
@@ -47,7 +67,17 @@ export async function startDeletionGracePeriod(
47
67
  }),
48
68
  };
49
69
  }
50
- if (userRow["status"] !== USER_STATUS.Active) {
70
+
71
+ const T = getTemporal();
72
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
73
+
74
+ const { applied } = await updateUserLifecycle(
75
+ lifecycleRunner,
76
+ userId,
77
+ { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null },
78
+ { expect: { status: USER_STATUS.Active, ...additionalExpect } },
79
+ );
80
+ if (!applied) {
51
81
  return {
52
82
  ok: false,
53
83
  error: new UnprocessableError("user_not_in_active_state", {
@@ -56,14 +86,6 @@ export async function startDeletionGracePeriod(
56
86
  };
57
87
  }
58
88
 
59
- const T = getTemporal();
60
- const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
61
-
62
- await updateUserLifecycle(lifecycleRunner, userId, {
63
- status: USER_STATUS.DeletionRequested,
64
- gracePeriodEnd,
65
- });
66
-
67
89
  return {
68
90
  ok: true,
69
91
  gracePeriodEnd,
@@ -21,30 +21,68 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
21
21
  // kann abweichen).
22
22
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
23
 
24
+ export type UpdateUserLifecycleOptions = {
25
+ // Declarative "genau einmal"-precondition (#3024): the update only applies
26
+ // if the row still holds these field values at write time. Forwarded
27
+ // as-is to the executor's `expect:` — see event-store-executor-write.ts
28
+ // for the atomicity story. Omitted: behaves exactly as before (unconditional
29
+ // overwrite, skipOptimisticLock). Scalars only — see the executor's
30
+ // `expect:` doc for why (`!==` comparison, no object/Date/Instant values).
31
+ readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
32
+ };
33
+
24
34
  // `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
25
35
  // (forget-cleanup sub-tx) — keeps the event append atomic with the write.
36
+ //
37
+ // Returns `{ applied: false }` instead of throwing only when `options.expect`
38
+ // was set AND the executor rejected the write because the precondition no
39
+ // longer held (precondition_failed) or a concurrent writer won the race on
40
+ // the same stream version (version_conflict) — both mean "someone else's
41
+ // transition already landed", the expected outcome for a caller that opted
42
+ // into `expect`. Any other failure, or a failure with no `expect` set (every
43
+ // pre-#3024 caller), still throws — those callers never checked a return
44
+ // value, so silently dropping their write would be silent data loss.
26
45
  export async function updateUserLifecycle(
27
46
  conn: DbRunner,
28
47
  userId: string,
29
48
  changes: Record<string, unknown>,
30
- ): Promise<void> {
49
+ options?: UpdateUserLifecycleOptions,
50
+ ): Promise<{ readonly applied: boolean }> {
31
51
  // user ist systemStream (#497): der Executor-Choke-Point addressiert den
32
52
  // Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
33
53
  // waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
34
54
  // deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
55
+ // skipOptimisticLock stays true (kept correct, not just carried over): it
56
+ // was set from this function's very first version (#494) because every
57
+ // caller here is a server-side business transition (restrict/lift-
58
+ // restriction/grace-period/cancel/forget), never a client edit-conflict UI
59
+ // round-trip — none of them read-then-hold a row version to pass as
60
+ // `payload.version`, so the executor's version gate would reject every
61
+ // call outright without it (`update()` requires `payload.version` unless
62
+ // skipOptimisticLock is set). #3024 replaces the safety that decision gave
63
+ // up with the purpose-built `expect:` precondition above, checked fresh at
64
+ // write time — the mechanism now genuinely matches the caller's shape
65
+ // (a business-field guard) instead of a repurposed version check.
35
66
  const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
36
67
  const result = await userExecutor.update(
37
68
  { id: userId, changes },
38
69
  createSystemUser(SYSTEM_TENANT_ID),
39
70
  tenantDb,
40
- { skipOptimisticLock: true },
71
+ { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) },
41
72
  );
42
73
 
43
- if (!result.isSuccess) {
44
- throw new InternalError({
45
- message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
46
- });
74
+ if (result.isSuccess) return { applied: true };
75
+
76
+ if (
77
+ options?.expect &&
78
+ (result.error.code === "precondition_failed" || result.error.code === "version_conflict")
79
+ ) {
80
+ return { applied: false };
47
81
  }
82
+
83
+ throw new InternalError({
84
+ message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
85
+ });
48
86
  }
49
87
 
50
88
  // #494 Bestandsdaten-Reconcile: Rows, deren Lifecycle-State der alte