@cosmicdrift/kumiko-bundled-features 0.280.0 → 0.281.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.280.0",
3
+ "version": "0.281.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.280.0",
133
- "@cosmicdrift/kumiko-framework": "0.280.0",
134
- "@cosmicdrift/kumiko-headless": "0.280.0",
135
- "@cosmicdrift/kumiko-renderer": "0.280.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.280.0",
137
- "@cosmicdrift/kumiko-types": "0.280.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.281.0",
133
+ "@cosmicdrift/kumiko-framework": "0.281.0",
134
+ "@cosmicdrift/kumiko-headless": "0.281.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.281.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.281.0",
137
+ "@cosmicdrift/kumiko-types": "0.281.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.280.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.280.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.281.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.281.0"
168
168
  }
169
169
  }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import {
26
26
  createSystemUser,
27
+ declareEscapeHatch,
27
28
  type HandlerContext,
28
29
  type SessionUser,
29
30
  SYSTEM_TENANT_ID,
@@ -69,6 +70,10 @@ export async function runConfirmTokenFlow<TSuccessData>(
69
70
  expiresAtMs: number,
70
71
  spec: ConfirmTokenFlowSpec<TSuccessData>,
71
72
  ): Promise<WriteResult<TSuccessData>> {
73
+ declareEscapeHatch({
74
+ reason:
75
+ "writes the confirming user's row via UserHandlers.update on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
76
+ });
72
77
  if (!ctx.redis) {
73
78
  return writeFailure(new InternalError({ message: spec.redisRequiredMessage }));
74
79
  }
@@ -131,6 +136,10 @@ async function loadValidatedUser(
131
136
  systemUser: SessionUser,
132
137
  userId: string,
133
138
  ): Promise<(AuthUserRow & { version: number }) | null> {
139
+ declareEscapeHatch({
140
+ reason:
141
+ "reads the confirming user's row via UserQueries.findForAuth on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
142
+ });
134
143
  const me = parseAuthUserRow(
135
144
  await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
136
145
  );
@@ -95,12 +95,9 @@ type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
95
95
 
96
96
  /** Uniform response on any credential miss — burns argon2 cost (#774). */
97
97
  export async function gateResolveAuthUser(
98
- ctx: HandlerContext,
99
- systemUser: SessionUser,
100
- email: string,
98
+ found: AuthUserRow | null,
101
99
  password: string,
102
100
  ): Promise<GateOutcome<AuthenticatableUserRow>> {
103
- const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
104
101
  if (!found?.passwordHash || found.isDeleted) {
105
102
  await verifyDummyPassword(password);
106
103
  return reject(invalidCredentials());
@@ -176,15 +173,9 @@ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefi
176
173
  /** Pick membership (last-active preferred); merge global + tenant roles. */
177
174
  export async function gateResolveMembership(
178
175
  ctx: HandlerContext,
179
- systemUser: SessionUser,
180
176
  found: AuthUserRow,
177
+ memberships: ReadonlyArray<Membership>,
181
178
  ): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
182
- // Still needed for candidate ORDER (preferred tenant first) — the actual
183
- // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
184
- const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
185
- userId: found.id,
186
- })) as Array<Membership>; // @cast-boundary db-runner
187
-
188
179
  if (memberships.length === 0) {
189
180
  return reject(noMembership());
190
181
  }
@@ -296,12 +287,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
296
287
  handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
297
288
  const systemUser = createSystemUser(SYSTEM_USER_ID);
298
289
 
299
- const userGate = await gateResolveAuthUser(
300
- ctx,
301
- systemUser,
302
- event.payload.email,
303
- event.payload.password,
290
+ const foundRow = parseAuthUserRow(
291
+ await ctx.queryAs(systemUser, UserQueries.findForAuth, { email: event.payload.email }),
304
292
  );
293
+ const userGate = await gateResolveAuthUser(foundRow, event.payload.password);
305
294
  if (!userGate.ok) return userGate.result;
306
295
  const found = userGate.value;
307
296
 
@@ -323,7 +312,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
323
312
  const statusGate = gateEnforceAccountStatus(found);
324
313
  if (!statusGate.ok) return statusGate.result;
325
314
 
326
- const membershipGate = await gateResolveMembership(ctx, systemUser, found);
315
+ // Still needed for candidate ORDER (preferred tenant first) — the actual
316
+ // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
317
+ const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
318
+ userId: found.id,
319
+ })) as Array<Membership>; // @cast-boundary db-runner
320
+
321
+ const membershipGate = await gateResolveMembership(ctx, found, memberships);
327
322
  if (!membershipGate.ok) return membershipGate.result;
328
323
  const { chosen, mergedRoles } = membershipGate.value;
329
324
 
@@ -1,5 +1,9 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ declareEscapeHatch,
4
+ type HandlerContext,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import { findUserMfaRow } from "./db/queries";
4
8
  import { verifyMfaFactor } from "./verify-factor";
5
9
 
@@ -21,6 +25,10 @@ export type MfaCodeVerifier = (
21
25
  // codes exist for.
22
26
  export function createMfaCodeVerifier(): MfaCodeVerifier {
23
27
  return async (ctx, userId, tenantId, code) => {
28
+ declareEscapeHatch({
29
+ reason:
30
+ "reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
31
+ });
24
32
  const scopedDb = createTenantDb(
25
33
  ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
26
34
  tenantId,
@@ -1,6 +1,7 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
+ declareEscapeHatch,
4
5
  type HandlerContext,
5
6
  type SessionUser,
6
7
  type TenantId,
@@ -38,6 +39,10 @@ export function createMfaStatusChecker(opts: {
38
39
  readonly challengeTokenSecret: string;
39
40
  }): MfaStatusChecker {
40
41
  return async (ctx, userId, tenantId, roles) => {
42
+ declareEscapeHatch({
43
+ reason:
44
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
45
+ });
41
46
  const scopedDb = createTenantDb(
42
47
  ctx.db.unsafeRaw(
43
48
  "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
@@ -192,9 +192,9 @@ async function appendDenialAuditEvent(
192
192
  subjectKey: string,
193
193
  subjectKind: SubjectIdInput["kind"],
194
194
  denialCode: string,
195
+ denialAuditRunner: DbRunner | undefined,
195
196
  ): Promise<WriteFailure | null> {
196
- const outsideTx = ctx.dbOutsideTransaction;
197
- if (!outsideTx) {
197
+ if (!denialAuditRunner) {
198
198
  return writeFailure(
199
199
  new InternalError({
200
200
  message:
@@ -230,28 +230,23 @@ async function appendDenialAuditEvent(
230
230
  // This event is the only proof the denial happened. Skipping
231
231
  // runProjectionsForEvent here is fine — nothing projects
232
232
  // crypto-shredding:event:forget-denied.
233
- await append(
234
- outsideTx.unsafeRaw(
235
- "denial audit append: names the prober's own tenant stream on the outside-transaction db",
236
- ),
237
- {
238
- aggregateId: generateId(),
239
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
240
- // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
241
- // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
242
- tenantId: event.user.tenantId,
243
- expectedVersion: 0,
244
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
245
- eventVersion: eventDef.version,
246
- payload,
247
- metadata: {
248
- userId: event.user.id,
249
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
250
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
251
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
252
- },
233
+ await append(denialAuditRunner, {
234
+ aggregateId: generateId(),
235
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
236
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
237
+ // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
238
+ tenantId: event.user.tenantId,
239
+ expectedVersion: 0,
240
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
241
+ eventVersion: eventDef.version,
242
+ payload,
243
+ metadata: {
244
+ userId: event.user.id,
245
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
246
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
247
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
253
248
  },
254
- );
249
+ });
255
250
  return null;
256
251
  }
257
252
 
@@ -316,6 +311,9 @@ export const forgetSubjectWrite = defineWriteHandler({
316
311
  subjectKey,
317
312
  raw.kind,
318
313
  tenantScopeDenial.error.code,
314
+ ctx.dbOutsideTransaction?.unsafeRaw(
315
+ "denial audit append: names the prober's own tenant stream on the outside-transaction db",
316
+ ),
319
317
  );
320
318
  return auditFailure ?? tenantScopeDenial;
321
319
  }
@@ -335,6 +333,9 @@ export const forgetSubjectWrite = defineWriteHandler({
335
333
  subjectKey,
336
334
  raw.kind,
337
335
  TARGET_RECORD_RETENTION_BLOCK_DELETE,
336
+ ctx.dbOutsideTransaction?.unsafeRaw(
337
+ "denial audit append: names the prober's own tenant stream on the outside-transaction db",
338
+ ),
338
339
  );
339
340
  return auditFailure ?? retentionDenial;
340
341
  }
@@ -43,19 +43,14 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
43
43
  describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
44
44
  test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
45
45
  const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
46
- const result = await selectSerializedFieldDefinition(
47
- { unsafeRaw: () => raw } as never,
48
- "t1",
49
- "entity",
50
- "field",
51
- );
46
+ const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
52
47
  expect(result).toBe("sf1");
53
48
  expect(raw.calls).toBe(2);
54
49
  });
55
50
 
56
51
  test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
57
52
  const raw = fakeClient([closedConnectionError()], { n: 3 });
58
- const result = await countTenantFieldDefinitions({ unsafeRaw: () => raw } as never, "t1");
53
+ const result = await countTenantFieldDefinitions(raw, "t1");
59
54
  expect(result).toBe(3);
60
55
  expect(raw.calls).toBe(2);
61
56
  });
@@ -90,9 +85,9 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
90
85
  const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
91
86
  serialized_field: "sf1",
92
87
  });
93
- await expect(
94
- selectSerializedFieldDefinition({ unsafeRaw: () => raw } as never, "t1", "entity", "field"),
95
- ).rejects.toThrow("connection was closed");
88
+ await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toThrow(
89
+ "connection was closed",
90
+ );
96
91
  expect(raw.calls).toBe(2);
97
92
  });
98
93
  });
@@ -1,16 +1,14 @@
1
1
  import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function selectSerializedFieldDefinition(
5
- db: TenantDb,
5
+ runner: DbRunner,
6
6
  tenantId: string,
7
7
  entityName: string,
8
8
  fieldKey: string,
9
9
  ): Promise<unknown | null> {
10
10
  const rows = await unsafeReadRetrying(
11
- db.unsafeRaw(
12
- "reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
13
- ),
11
+ runner,
14
12
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
15
13
  [entityName, fieldKey, tenantId],
16
14
  );
@@ -1,13 +1,14 @@
1
1
  import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
- export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
4
+ export async function countTenantFieldDefinitions(
5
+ runner: DbRunner,
6
+ tenantId: string,
7
+ ): Promise<number> {
5
8
  // Active definitions only — delete soft-deletes (the deterministic stream is
6
9
  // kept so a re-define can restore it), so isDeleted rows must not consume quota.
7
10
  const rowsResult = await unsafeReadRetrying(
8
- db.unsafeRaw(
9
- "counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
10
- ),
11
+ runner,
11
12
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
12
13
  [tenantId],
13
14
  );
@@ -39,7 +39,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
39
39
  const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
40
40
 
41
41
  const accessCheck = await checkFieldAccessForWrite(
42
- ctx.db,
42
+ ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
43
43
  event.user.tenantId,
44
44
  payload.entityName,
45
45
  payload.fieldKey,
@@ -73,7 +73,10 @@ export function createDefineTenantFieldHandler(
73
73
  }
74
74
 
75
75
  if (limit !== undefined) {
76
- const current = await countTenantFieldDefinitions(ctx.db, tenantId);
76
+ const current = await countTenantFieldDefinitions(
77
+ ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
78
+ tenantId,
79
+ );
77
80
  if (current >= limit) {
78
81
  return failUnprocessable("cap_exceeded", {
79
82
  capName: "customFields.fieldDefinition.count",
@@ -62,7 +62,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
62
62
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
63
63
 
64
64
  const loaded = await loadFieldDefinition(
65
- ctx.db,
65
+ ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
66
66
  event.user.tenantId,
67
67
  payload.entityName,
68
68
  payload.fieldKey,
@@ -2,7 +2,7 @@
2
2
  // Plus single-fetch loader so set-custom-field can run access-check AND
3
3
  // value-validation off one DB read (no double fetch).
4
4
 
5
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
5
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
6
6
  import { selectSerializedFieldDefinition } from "../db/queries/field-access";
7
7
  import { parseSerializedField, type SerializedFieldShape } from "./parse-serialized-field";
8
8
 
@@ -22,12 +22,12 @@ export type LoadedFieldDefinition =
22
22
  | { found: true; field: SerializedFieldShape | null };
23
23
 
24
24
  export async function loadFieldDefinition(
25
- db: TenantDb,
25
+ runner: DbRunner,
26
26
  tenantId: string,
27
27
  entityName: string,
28
28
  fieldKey: string,
29
29
  ): Promise<LoadedFieldDefinition> {
30
- const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
30
+ const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
31
31
  if (serialized === null) return { found: false };
32
32
  // parseSerializedField throws on a #972 legacy `sensitive` definition —
33
33
  // that's a corrupt/unprocessable row from this write-gate's perspective,
@@ -57,13 +57,13 @@ export function fieldWriteAccessDeniedRoles(
57
57
  // Convenience wrapper retained for clear-custom-field (no value-validation
58
58
  // needed there) — does the load + access-check in one call.
59
59
  export async function checkFieldAccessForWrite(
60
- db: TenantDb,
60
+ runner: DbRunner,
61
61
  tenantId: string,
62
62
  entityName: string,
63
63
  fieldKey: string,
64
64
  userRoles: ReadonlyArray<string>,
65
65
  ): Promise<FieldAccessCheckResult> {
66
- const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
66
+ const loaded = await loadFieldDefinition(runner, tenantId, entityName, fieldKey);
67
67
  if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
68
68
  if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
69
69
 
@@ -1,4 +1,9 @@
1
- import { defineWriteHandler, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
2
+ import {
3
+ createSystemUser,
4
+ defineWriteHandler,
5
+ type HandlerContext,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
2
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
8
  import { z } from "zod";
4
9
  import { USER_STATUS, userTable } from "../../user";
@@ -81,7 +86,19 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
81
86
  );
82
87
  if (!verified.ok) return writeFailure(invalidToken());
83
88
 
84
- const res = await startDeletionGracePeriod(ctx, verified.userId, event.user.tenantId);
89
+ // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
90
+ // the compliance-profile shape.
91
+ const profile = (await ctx.queryAs(
92
+ createSystemUser(event.user.tenantId),
93
+ "compliance-profiles:query:for-tenant",
94
+ {},
95
+ )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
96
+ const res = await startDeletionGracePeriod(
97
+ ctx,
98
+ verified.userId,
99
+ profile.profile.userRights.gracePeriod,
100
+ ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
101
+ );
85
102
  if (!res.ok) {
86
103
  // Generischer 422 statt res.error: dieser Endpoint ist anonym-öffentlich,
87
104
  // res.error trägt den konkreten User-Status (currentStatus aus
@@ -1,5 +1,6 @@
1
1
  import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
2
- import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
5
6
  import { decryptStoredPii } from "../../shared";
@@ -17,19 +18,23 @@ export type StartGracePeriodResult =
17
18
  }
18
19
  | { readonly ok: false; readonly error: UnprocessableError };
19
20
 
20
- // Flippt einen aktiven User auf DeletionRequested + setzt gracePeriodEnd aus
21
- // dem Compliance-Profile. Geteilt zwischen dem authentifizierten
22
- // request-deletion-Pfad (event.user) und dem anonymen confirm-by-token-Pfad
23
- // (userId aus verifiziertem Token) — eine Quelle für die Grace-Period-Logik.
21
+ // Flips an active user to DeletionRequested and sets gracePeriodEnd from the
22
+ // caller-supplied grace period. Shared between the authenticated
23
+ // request-deletion path (event.user) and the anonymous confirm-by-token path
24
+ // (userId from a verified token) — one source for the grace-period logic.
24
25
  //
25
- // `complianceTenantId`: Tenant dessen Compliance-Profile die Grace-Dauer
26
- // liefert. Authenticated = aktiver Tenant des Users; anonym = Dispatch-Tenant
27
- // der Apex-Surface. Die User-Row ist tenant-agnostisch (Account-weite
28
- // Löschung), nur die Grace-Dauer ist tenant-konfiguriert.
26
+ // The user row is tenant-agnostic (account-wide deletion), so it is read via
27
+ // ctx.db.global(userTable); only the grace period duration is tenant-configured.
28
+ //
29
+ // `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
30
+ // require an escalation (reading the tenant compliance profile, appending to
31
+ // the SYSTEM_TENANT_ID user stream) that only the declaring handler's
32
+ // escapeHatch covers.
29
33
  export async function startDeletionGracePeriod(
30
34
  ctx: HandlerContext,
31
35
  userId: string,
32
- complianceTenantId: string,
36
+ gracePeriod: DurationSpec,
37
+ lifecycleRunner: DbRunner,
33
38
  ): Promise<StartGracePeriodResult> {
34
39
  const userRow = await ctx.db
35
40
  .global(userTable)
@@ -51,26 +56,13 @@ export async function startDeletionGracePeriod(
51
56
  };
52
57
  }
53
58
 
54
- // @cast-boundary engine-payload — queryAs liefert unknown, narrow auf den
55
- // effektiven Profile-Shape (siehe request-deletion-Original).
56
- const profile = (await ctx.queryAs(
57
- createSystemUser(complianceTenantId),
58
- "compliance-profiles:query:for-tenant",
59
- {},
60
- )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
61
-
62
- const gracePeriod = profile.profile.userRights.gracePeriod;
63
59
  const T = getTemporal();
64
60
  const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
65
61
 
66
- await updateUserLifecycle(
67
- ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
68
- userId,
69
- {
70
- status: USER_STATUS.DeletionRequested,
71
- gracePeriodEnd,
72
- },
73
- );
62
+ await updateUserLifecycle(lifecycleRunner, userId, {
63
+ status: USER_STATUS.DeletionRequested,
64
+ gracePeriodEnd,
65
+ });
74
66
 
75
67
  return {
76
68
  ok: true,
@@ -1,5 +1,6 @@
1
1
  import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
2
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
+ import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { z } from "zod";
5
6
  import { USER_STATUS } from "../../user";
@@ -61,7 +62,19 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
61
62
  "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
62
63
  agent: { risk: "high" },
63
64
  handler: async (event, ctx) => {
64
- const res = await startDeletionGracePeriod(ctx, event.user.id, event.user.tenantId);
65
+ // @cast-boundary engine-payload — queryAs returns unknown, narrowed to
66
+ // the compliance-profile shape.
67
+ const profile = (await ctx.queryAs(
68
+ createSystemUser(event.user.tenantId),
69
+ "compliance-profiles:query:for-tenant",
70
+ {},
71
+ )) as { profile: { userRights: { gracePeriod: DurationSpec } } };
72
+ const res = await startDeletionGracePeriod(
73
+ ctx,
74
+ event.user.id,
75
+ profile.profile.userRights.gracePeriod,
76
+ ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
77
+ );
65
78
  if (!res.ok) return writeFailure(res.error);
66
79
  const { gracePeriodEnd, userEmail, userLocale } = res;
67
80