@cosmicdrift/kumiko-bundled-features 0.321.0 → 0.323.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 (28) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
  3. package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
  4. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
  5. package/src/agent-tools/agent-manifest.ts +13 -3
  6. package/src/agent-tools/tool-dispatch.ts +17 -12
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +124 -5
  8. package/src/auth-email-password/changes.json +7 -0
  9. package/src/auth-email-password/constants.ts +3 -0
  10. package/src/auth-email-password/feature.ts +4 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -6
  12. package/src/auth-email-password/handlers/invite-info.query.ts +89 -0
  13. package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
  14. package/src/delivery/__tests__/delivery.integration.test.ts +232 -0
  15. package/src/delivery/address-opt-out.ts +113 -24
  16. package/src/delivery/changes.json +7 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +5 -1
  19. package/src/delivery/handlers/resubscribe-address.write.ts +42 -0
  20. package/src/delivery/handlers/resubscribe-user.write.ts +41 -0
  21. package/src/delivery/public-names.ts +13 -0
  22. package/src/delivery/tables.ts +5 -2
  23. package/src/delivery/unsubscribe.ts +104 -7
  24. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +72 -0
  25. package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
  26. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
  27. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
  28. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
@@ -0,0 +1,42 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { removeAddressOptOut } from "../address-opt-out";
5
+
6
+ export const resubscribeAddressWrite = defineWriteHandler({
7
+ name: "resubscribeAddress",
8
+ schema: z.object({
9
+ addressHash: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoutes' POST /resubscribe
15
+ // dispatchSystemWrite, once verify() has already proven the address-token
16
+ // signature — not a surface for the agent tool-picker to offer directly.
17
+ // risk: "high" — hard-deletes the opt-out row (see removeAddressOptOut,
18
+ // the entity has no softDelete).
19
+ agent: { expose: false, risk: "high" },
20
+ description:
21
+ "Removes a no-account address opt-out for one notificationType/channel combination; dispatched by the signed resubscribe route, not meant for UI callers.",
22
+ handler: async (event, ctx) => {
23
+ const { addressHash, notificationType, channel } = event.payload;
24
+ const { tenantId } = event.user;
25
+
26
+ if (!ctx.systemDb) {
27
+ throw new InternalError({
28
+ message: "resubscribeAddress: ctx.systemDb missing on a system-scoped handler",
29
+ });
30
+ }
31
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
32
+
33
+ const result = await removeAddressOptOut(db, event.user, {
34
+ tenantId,
35
+ addressHash,
36
+ notificationType,
37
+ channel,
38
+ });
39
+ if (!result.isSuccess) return result;
40
+ return { isSuccess: true, data: { notificationType, channel } };
41
+ },
42
+ });
@@ -0,0 +1,41 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import { upsertPreference } from "../upsert-preference";
5
+
6
+ export const resubscribeUserWrite = defineWriteHandler({
7
+ name: "resubscribeUser",
8
+ schema: z.object({
9
+ userId: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ }),
13
+ access: { roles: access.systemAdmin },
14
+ // Only reachable via createUnsubscribeRoutes' POST /resubscribe
15
+ // dispatchSystemWrite, once verify() has already proven the user-token
16
+ // signature — not a surface for the agent tool-picker to offer directly.
17
+ agent: { expose: false },
18
+ description:
19
+ "Re-enables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed resubscribe route, not meant for UI callers.",
20
+ handler: async (event, ctx) => {
21
+ const { userId, notificationType, channel } = event.payload;
22
+ const { tenantId } = event.user;
23
+
24
+ if (!ctx.systemDb) {
25
+ throw new InternalError({
26
+ message: "resubscribeUser: ctx.systemDb missing on a system-scoped handler",
27
+ });
28
+ }
29
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
30
+
31
+ const result = await upsertPreference(db, event.user, {
32
+ tenantId,
33
+ userId,
34
+ notificationType,
35
+ channel,
36
+ enabled: true,
37
+ });
38
+ if (!result.isSuccess) return result;
39
+ return { isSuccess: true, data: { notificationType, channel } };
40
+ },
41
+ });
@@ -7,12 +7,21 @@ export const DeliveryHandlers = {
7
7
  setPreference: "delivery:write:set-preference",
8
8
  unsubscribeAddress: "delivery:write:unsubscribe-address",
9
9
  unsubscribeUser: "delivery:write:unsubscribe-user",
10
+ resubscribeAddress: "delivery:write:resubscribe-address",
11
+ resubscribeUser: "delivery:write:resubscribe-user",
10
12
  } as const;
11
13
 
12
14
  // Fixed so links mailed out today keep working — the unsubscribe route is
13
15
  // mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
14
16
  export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
15
17
 
18
+ // Mounted alongside the unsubscribe route by the same
19
+ // `createUnsubscribeRoutes(...)` call — same token, same verify() path, the
20
+ // undo direction. Token is taken from the JSON/form body only, never the
21
+ // query, so a mail-client link prefetcher can't accidentally resubscribe
22
+ // someone.
23
+ export const DELIVERY_RESUBSCRIBE_PATH = "/api/delivery/resubscribe" as const;
24
+
16
25
  export const DeliveryQueries = {
17
26
  log: "delivery:query:log",
18
27
  preferences: "delivery:query:preferences",
@@ -28,6 +37,10 @@ export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
28
37
  export const DeliveryErrors = {
29
38
  noRecipient: "delivery_no_recipient",
30
39
  channelFailed: "delivery_channel_failed",
40
+ // removeAddressOptOut refuses to delete the opt-out row at the last
41
+ // available id generation, so an address can always still unsubscribe
42
+ // (see MAX_ADDRESS_OPT_OUT_GENERATIONS in address-opt-out.ts).
43
+ resubscribeLimitReached: "resubscribe_limit_reached",
31
44
  } as const;
32
45
 
33
46
  export const DeliveryStatus = {
@@ -110,8 +110,11 @@ export const notificationPreferencesTable = pgTable(
110
110
  // `ctx.notify(type, { route: { email } })`). addressHash is a keyed HMAC
111
111
  // (computeBlindIndex over the normalized address) — the plaintext address
112
112
  // never reaches this entity or the token that unsubscribes it.
113
- // A row's mere existence is the opt-out; there is no `enabled` toggle to flip
114
- // back (re-subscribing a no-account address has no signed-in flow to do it from).
113
+ // A row's mere existence is the opt-out; there is no `enabled` toggle to flip.
114
+ // Resubscribing (removeAddressOptOut) hard-deletes the row instead — the
115
+ // entity has no softDelete, and a later re-opt-out for the same address
116
+ // picks the next id generation (see addressOptOutAggregateId) since the
117
+ // deleted stream can't be revived.
115
118
  export const notificationAddressOptOutEntity = createEntity({
116
119
  table: "read_notification_address_opt_outs",
117
120
  fields: {
@@ -10,7 +10,12 @@ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
10
10
  import * as jose from "jose";
11
11
  import * as z from "zod";
12
12
  import { hashUnsubscribeAddress } from "./address-opt-out";
13
- import { DELIVERY_UNSUBSCRIBE_PATH, DeliveryHandlers } from "./constants";
13
+ import {
14
+ DELIVERY_RESUBSCRIBE_PATH,
15
+ DELIVERY_UNSUBSCRIBE_PATH,
16
+ DeliveryErrors,
17
+ DeliveryHandlers,
18
+ } from "./constants";
14
19
 
15
20
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
16
21
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
@@ -130,11 +135,10 @@ export async function signAddressUnsubscribeToken(
130
135
  .setSubject(addressHash)
131
136
  .setIssuer("kumiko:unsubscribe")
132
137
  .setIssuedAt()
133
- // No expiry, unlike the user token above: a leaked address token can
134
- // only opt that one address out of one notificationType/channel, and
135
- // an unsubscribe link mailed out today must keep working indefinitely
136
- // — there is no signed-in flow for a no-account address to re-subscribe
137
- // or request a fresh link from.
138
+ // No expiry, unlike the user token above: this same token also signs
139
+ // the resubscribe (undo) direction, and an unsubscribe link mailed out
140
+ // today must keep working indefinitely — there's no signed-in flow for
141
+ // a no-account address to request a fresh link from.
138
142
  .sign(encodedSecret)
139
143
  );
140
144
  }
@@ -262,6 +266,50 @@ async function dispatchUnsubscribeWrite(
262
266
  return dispatched.isSuccess;
263
267
  }
264
268
 
269
+ // "limit_reached" is a distinct outcome, not a generic failure: it's the
270
+ // address-side generation cap (removeAddressOptOut) refusing to delete the
271
+ // opt-out row so unsubscribe always still works — the route answers 409,
272
+ // not the 500 a real write failure gets.
273
+ type ResubscribeOutcome = "success" | "limit_reached" | "failed";
274
+
275
+ function isResubscribeLimitReached(error: { readonly details?: unknown }): boolean {
276
+ const details = error.details;
277
+ return (
278
+ typeof details === "object" &&
279
+ details !== null &&
280
+ (details as { reason?: unknown }).reason === DeliveryErrors.resubscribeLimitReached
281
+ );
282
+ }
283
+
284
+ async function dispatchResubscribeWrite(
285
+ verified: VerifiedUnsubscribe,
286
+ deps: SignatureExtraRouteDeps,
287
+ ): Promise<ResubscribeOutcome> {
288
+ const payload =
289
+ verified.kind === "address"
290
+ ? {
291
+ addressHash: verified.addressHash,
292
+ notificationType: verified.notificationType,
293
+ channel: verified.channel,
294
+ }
295
+ : {
296
+ userId: verified.userId,
297
+ notificationType: verified.notificationType,
298
+ channel: verified.channel,
299
+ };
300
+
301
+ const dispatched = await deps.dispatchSystemWrite({
302
+ handlerQn:
303
+ verified.kind === "address"
304
+ ? DeliveryHandlers.resubscribeAddress
305
+ : DeliveryHandlers.resubscribeUser,
306
+ tenantId: verified.tenantId,
307
+ payload,
308
+ });
309
+ if (dispatched.isSuccess) return "success";
310
+ return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
311
+ }
312
+
265
313
  function confirmationPage(token: string): string {
266
314
  return `<!doctype html>
267
315
  <html>
@@ -291,6 +339,30 @@ function tokenFromPostRequest(request: SignatureExtraRouteVerifyRequest): string
291
339
  return request.query["token"];
292
340
  }
293
341
 
342
+ // Resubscribe is deliberately NOT reachable via the query string — a mail
343
+ // client's link prefetcher (or a scanner) following an unsubscribe link
344
+ // eagerly is the exact threat model unsubscribe's one-click POST body
345
+ // convention is built to avoid; resubscribe carries the same risk in
346
+ // reverse (a prefetch that undoes a real opt-out) and gets no query-param
347
+ // fallback at all.
348
+ function tokenFromResubscribeRequest(
349
+ request: SignatureExtraRouteVerifyRequest,
350
+ ): string | undefined {
351
+ const contentType = request.headers["content-type"];
352
+ if (contentType?.includes("application/x-www-form-urlencoded")) {
353
+ return new URLSearchParams(request.rawBody).get("token") ?? undefined;
354
+ }
355
+ if (contentType?.includes("application/json")) {
356
+ try {
357
+ const parsed = JSON.parse(request.rawBody) as { token?: unknown };
358
+ return typeof parsed.token === "string" ? parsed.token : undefined;
359
+ } catch {
360
+ return undefined;
361
+ }
362
+ }
363
+ return undefined;
364
+ }
365
+
294
366
  // GET renders a confirmation page without writing; POST performs the opt-out.
295
367
  export function createUnsubscribeRoutes(
296
368
  options: UnsubscribeRouteOptions,
@@ -335,5 +407,30 @@ export function createUnsubscribeRoutes(
335
407
  },
336
408
  });
337
409
 
338
- return [confirmRoute, writeRoute];
410
+ // Undo direction: token only from the JSON/form body, never the query
411
+ // (see tokenFromResubscribeRequest) — no GET confirmation-page variant,
412
+ // this is a one-shot POST from the unsubscribe-page's "Undo" button.
413
+ const resubscribeRoute = signatureRoute<VerifiedUnsubscribe>({
414
+ method: "POST",
415
+ path: DELIVERY_RESUBSCRIBE_PATH,
416
+ entry: "signature",
417
+ verify: async (request) =>
418
+ verifyUnsubscribeToken(tokenFromResubscribeRequest(request), encodedSecret),
419
+ handler: async (c, verified, deps) => {
420
+ const outcome = await dispatchResubscribeWrite(verified, deps);
421
+ if (outcome === "limit_reached") {
422
+ return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
423
+ "Cache-Control": "no-store",
424
+ });
425
+ }
426
+ if (outcome === "failed") {
427
+ return c.json({ error: { code: "resubscribe_failed" } }, 500, {
428
+ "Cache-Control": "no-store",
429
+ });
430
+ }
431
+ return c.json({ isSuccess: true }, 200, { "Cache-Control": "no-store" });
432
+ },
433
+ });
434
+
435
+ return [confirmRoute, writeRoute, resubscribeRoute];
339
436
  }
@@ -6,6 +6,12 @@
6
6
  // hardcoded allowlist/cap — there is none anymore.
7
7
 
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import {
10
+ buildAgentManifest,
11
+ buildToolCatalog,
12
+ dispatchToolCall,
13
+ toolNameForQn,
14
+ } from "@cosmicdrift/kumiko-bundled-features/agent-tools";
9
15
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
16
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
11
17
  import {
@@ -27,6 +33,7 @@ import {
27
33
  } from "@cosmicdrift/kumiko-framework/stack";
28
34
  import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
29
35
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
36
+ import * as z from "zod";
30
37
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
31
38
  import { createConfigFeature } from "../../config";
32
39
  import { createTenantFeature } from "../../tenant/feature";
@@ -136,6 +143,22 @@ const documentExtractExecutorForSeed = createEventStoreExecutor(
136
143
  { entityName: "document-extract" },
137
144
  );
138
145
 
146
+ // A mid-risk agent tool (not the raw HTTP DELETE route) appends fileRef.deleted under a real
147
+ // entryHandler, proving the forget-extract-with-file-ref consumer stays ungated regardless.
148
+ const AGENT_DELETE_FILEREF_QN = "agent-tools-test-delete-fileref:write:delete-fileref-mid";
149
+
150
+ const agentDeleteFileRefFeature = defineFeature("agent-tools-test-delete-fileref", (r) => {
151
+ r.writeHandler(
152
+ "delete-fileref-mid",
153
+ z.object({ id: z.uuid() }),
154
+ async (event, ctx) => fileRefExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
155
+ {
156
+ access: { roles: ["Admin"] },
157
+ description: "Soft-delete a fileRef (mid risk, default agent exposure).",
158
+ },
159
+ );
160
+ });
161
+
139
162
  beforeAll(async () => {
140
163
  provider = createInMemoryFileProvider();
141
164
  stack = await setupTestStack({
@@ -147,6 +170,7 @@ beforeAll(async () => {
147
170
  documentIngestFoundationFeature,
148
171
  testProviderAFeature,
149
172
  testProviderBFeature,
173
+ agentDeleteFileRefFeature,
150
174
  ],
151
175
  files: { storageProvider: provider },
152
176
  // Distinct prefix: avoids sharing a BullMQ queue namespace with any other
@@ -579,6 +603,54 @@ describe("fileRef.restored → re-ingest", () => {
579
603
  });
580
604
  });
581
605
 
606
+ // Regression proof for the deferred-context exception on assertIrreversibleOperationAllowed:
607
+ // the consumer (separate dispatcher pass, no entryHandler) still forgets the extract cleanly.
608
+ describe("fileRef soft-delete via a mid-risk agent tool", () => {
609
+ test("dispatchToolCall delete succeeds, the consumer forgets the extract without a gate error, restore re-ingests", async () => {
610
+ const { id: fileRefId } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
611
+
612
+ await waitFor(async () => {
613
+ await stack.eventDispatcher?.runOnce();
614
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
615
+ });
616
+
617
+ const manifest = buildAgentManifest(stack.registry, { locale: "en", roles: admin.roles });
618
+ const catalog = buildToolCatalog(stack.registry, manifest, { mode: "edit" });
619
+ const deleteResult = await dispatchToolCall({
620
+ dispatcher: stack.dispatcher,
621
+ user: admin,
622
+ toolName: toolNameForQn(AGENT_DELETE_FILEREF_QN),
623
+ input: { id: fileRefId },
624
+ dispatchTable: catalog.dispatchTable,
625
+ runId: "run-delete-fileref",
626
+ toolCallId: `call-delete-fileref-${fileRefId}`,
627
+ });
628
+ expect(deleteResult.ok).toBe(true);
629
+
630
+ await stack.eventDispatcher?.runOnce();
631
+
632
+ const consumerRows = await asRawClient(stack.db).unsafe(
633
+ `SELECT status, last_error FROM kumiko_event_consumers WHERE name = $1`,
634
+ ["document-ingest-foundation:projection:forget-extract-with-file-ref"],
635
+ );
636
+ const consumer = (consumerRows as { status: string; last_error: string | null }[])[0];
637
+ expect(consumer?.last_error).toBeNull();
638
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(0);
639
+
640
+ const user = createSystemUser(admin.tenantId);
641
+ const tdb = createTenantDb(stack.db, admin.tenantId);
642
+ const restoreResult = await fileRefExecutor.restore({ id: fileRefId }, user, tdb);
643
+ if (!restoreResult.isSuccess) {
644
+ throw new Error(`restore failed: ${restoreResult.error.message}`);
645
+ }
646
+
647
+ await waitFor(async () => {
648
+ await stack.eventDispatcher?.runOnce();
649
+ expect(await selectMany(stack.db, documentExtractsTable, { fileRefId })).toHaveLength(1);
650
+ });
651
+ });
652
+ });
653
+
582
654
  describe("orphan extract guard", () => {
583
655
  const testMeta = {
584
656
  provider: PROVIDER_A_NAME,
@@ -6,6 +6,7 @@
6
6
  // so adding a facet option without translating it fails here rather than in
7
7
  // production.
8
8
  import { describe, expect, test } from "bun:test";
9
+ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
9
10
  import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
10
11
  import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
11
12
  import { MEMBERS_SCREEN_ID } from "../constants";
@@ -67,7 +68,7 @@ describe("members status filter labels are localized in every shipped locale (fw
67
68
  const label = translations[key]?.["en"];
68
69
  expect(label).toBeString();
69
70
  expect(label).not.toBe(key);
70
- expect((label ?? "").trim()).not.toBe("");
71
+ expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
71
72
  }
72
73
  });
73
74
 
@@ -77,7 +78,7 @@ describe("members status filter labels are localized in every shipped locale (fw
77
78
  const label = translations[key]?.["en"];
78
79
  expect(label).toBeString();
79
80
  expect(label).not.toBe(key);
80
- expect((label ?? "").trim()).not.toBe("");
81
+ expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
81
82
  }
82
83
  });
83
84
 
@@ -14,20 +14,16 @@
14
14
  import { describe, expect, test } from "bun:test";
15
15
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
16
16
  import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
17
18
  import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
18
19
  import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
19
- import { translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
20
+ import { type TranslationsByKey, translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
20
21
  import { createTenantSettingsFeature } from "../feature";
21
22
 
22
- function translate(
23
- translations: Readonly<Record<string, Readonly<Record<string, string>> | undefined>>,
24
- key: string,
25
- locale = "en",
26
- ): string {
27
- const byLocale = translationsByLocaleFromKeys(
28
- translations as Record<string, Readonly<Record<string, string>>>,
29
- );
30
- return byLocale[locale]?.[key] ?? key;
23
+ function translate(translations: TranslationsByKey, key: string, locale = "en"): string {
24
+ const byLocale = translationsByLocaleFromKeys(translations);
25
+ const value = byLocale[locale]?.[key];
26
+ return value === undefined ? key : translationValueOtherText(value);
31
27
  }
32
28
 
33
29
  function settingsHubLabelKeys(): readonly string[] {
@@ -15,10 +15,10 @@
15
15
  // Unit-Test pinned (policy-to-strategy.test.ts), nicht hier. Hier nur
16
16
  // der end-to-end-Default-Pfad (delete).
17
17
 
18
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
20
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
- import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
21
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
22
22
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
23
23
  import {
24
24
  createSystemUser,
@@ -29,10 +29,15 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
29
29
  import {
30
30
  setupTestStack,
31
31
  type TestStack,
32
+ TestUsers,
32
33
  unsafeCreateEntityTable,
33
34
  unsafePushTables,
34
35
  } from "@cosmicdrift/kumiko-framework/stack";
35
- import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
36
+ import {
37
+ resetPiiSubjectKmsForTests,
38
+ resetTestTables,
39
+ seedRow,
40
+ } from "@cosmicdrift/kumiko-framework/testing";
36
41
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
37
42
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
38
43
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
@@ -1092,3 +1097,35 @@ describe("runForgetCleanup :: crypto-shredding (subject key erase)", () => {
1092
1097
  expect(alice?.email).toBe("alice.erase-fail@example.com");
1093
1098
  });
1094
1099
  });
1100
+
1101
+ // The manual operator trigger (agent.risk "high") must erase a real subject key
1102
+ // through configuredPiiSubjectKms(), not a directly-injected args.kms that bypasses the wrapper.
1103
+ describe("run-forget-cleanup write handler (manual, risk high) via configuredPiiSubjectKms", () => {
1104
+ afterEach(() => {
1105
+ resetPiiSubjectKmsForTests();
1106
+ });
1107
+
1108
+ test("erases the subject key through the gated KMS wrapper", async () => {
1109
+ await seedUser(ALICE_ID, {
1110
+ status: USER_STATUS.DeletionRequested,
1111
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
1112
+ });
1113
+ await seedMembership(ALICE_ID, TENANT_A);
1114
+
1115
+ const kms = new InMemoryKmsAdapter();
1116
+ await kms.createKey({ kind: "user", userId: ALICE_ID });
1117
+ configurePiiSubjectKms(kms);
1118
+
1119
+ await stack.http.writeOk(
1120
+ "user-data-rights:write:run-forget-cleanup",
1121
+ {},
1122
+ TestUsers.systemAdmin,
1123
+ );
1124
+
1125
+ const aliceRow = await fetchUser(ALICE_ID);
1126
+ expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
1127
+ await expect(kms.getKey({ kind: "user", userId: ALICE_ID })).rejects.toThrow(
1128
+ "Subject key erased",
1129
+ );
1130
+ });
1131
+ });
@@ -31,7 +31,9 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
31
31
  name: "run-forget-cleanup",
32
32
  schema: z.object({}),
33
33
  access: { roles: access.privileged },
34
- agent: { expose: false },
34
+ // Cascades into per-user forget hooks that hard-purge (executor.forget)
35
+ // records without softDelete — an agent must never be able to trigger it.
36
+ agent: { expose: false, risk: "high" },
35
37
  escapeHatch: {
36
38
  reason: RUN_FORGET_CLEANUP_REASON,
37
39
  },