@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.
- package/package.json +9 -9
- package/src/agent-tools/__tests__/kms-erase-gate.integration.test.ts +127 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- 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/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- 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/tenant/__tests__/members-facet-i18n.test.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +40 -3
- 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 = {
|
package/src/delivery/tables.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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,
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
24
|
-
key
|
|
25
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
},
|