@cosmicdrift/kumiko-bundled-features 0.322.0 → 0.324.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.
@@ -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,
@@ -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
+ });