@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.
- package/package.json +9 -9
- package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +66 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +124 -5
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/constants.ts +3 -0
- package/src/auth-email-password/feature.ts +4 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -6
- package/src/auth-email-password/handlers/invite-info.query.ts +89 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +232 -0
- package/src/delivery/address-opt-out.ts +113 -24
- package/src/delivery/changes.json +7 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/feature.ts +5 -1
- package/src/delivery/handlers/resubscribe-address.write.ts +42 -0
- package/src/delivery/handlers/resubscribe-user.write.ts +41 -0
- package/src/delivery/public-names.ts +13 -0
- package/src/delivery/tables.ts +5 -2
- package/src/delivery/unsubscribe.ts +104 -7
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +72 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
|
@@ -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 {
|
|
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:
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|