@cosmicdrift/kumiko-bundled-features 0.304.0 → 0.306.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/__tests__/extension-points-typed.test.ts +135 -0
- package/src/__tests__/use-extension-options.test.ts +278 -0
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +116 -0
- package/src/auth-email-password/changes.json +7 -0
- package/src/auth-email-password/i18n.ts +7 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +24 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +124 -2
- package/src/auth-email-password/web/__tests__/session-bootstrap-error.test.ts +32 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +1 -0
- package/src/auth-email-password/web/auth-client.ts +34 -2
- package/src/auth-email-password/web/auth-gate.tsx +5 -1
- package/src/auth-email-password/web/index.ts +4 -1
- package/src/auth-email-password/web/session-bootstrap-error.tsx +56 -0
- package/src/auth-email-password/web/session.tsx +32 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
- package/src/auth-foundation/types.ts +14 -4
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
- package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +8 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
- package/src/billing-foundation/types.ts +13 -1
- package/src/channel-email/feature.ts +2 -1
- package/src/channel-in-app/feature.ts +2 -1
- package/src/channel-push/feature.ts +2 -1
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
- package/src/config/__tests__/require-system-db.test.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +4 -8
- package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
- package/src/delivery/constants.ts +4 -0
- package/src/delivery/delivery-service.ts +45 -29
- package/src/delivery/feature.ts +2 -1
- package/src/delivery/index.ts +14 -11
- package/src/delivery/types.ts +24 -0
- package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
- package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
- package/src/document-ingest-foundation/changes.json +5 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
- package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/feature.ts +4 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
- package/src/inbound-mail-foundation/types.ts +10 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
- package/src/mail-foundation/feature.ts +15 -4
- package/src/mail-foundation/index.ts +1 -0
- package/src/mail-transport-inmemory/feature.ts +5 -4
- package/src/mail-transport-smtp/feature.ts +5 -4
- package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
- package/src/renderer-foundation/changes.json +8 -1
- package/src/renderer-foundation/constants.ts +4 -0
- package/src/renderer-foundation/feature.ts +10 -13
- package/src/renderer-foundation/index.ts +3 -0
- package/src/renderer-foundation/types.ts +23 -1
- package/src/renderer-simple/feature.ts +2 -1
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
- package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
- package/src/tenant-lifecycle/stages.ts +20 -18
- package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
- package/src/user-data-rights/lib/default-mailers.ts +2 -1
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
- package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createRegistry,
|
|
5
|
+
defineFeature,
|
|
6
|
+
isOpenToAllGranted,
|
|
7
|
+
validateBoot,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
9
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
10
|
import { createConfigFeature } from "../../config/feature";
|
|
5
11
|
import { createTenantFeature } from "../../tenant/feature";
|
|
6
|
-
import { DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
12
|
+
import { DELIVERY_CHANNEL_EXTENSION, DELIVERY_LOG_SCREEN_ID } from "../constants";
|
|
13
|
+
import { collectChannels } from "../delivery-service";
|
|
7
14
|
import { createDeliveryFeature } from "../feature";
|
|
8
15
|
|
|
9
16
|
describe("delivery screens + handler access alignment", () => {
|
|
@@ -38,6 +45,17 @@ describe("delivery screens + handler access alignment", () => {
|
|
|
38
45
|
expect(preferencesAccess !== undefined && isOpenToAllGranted(preferencesAccess)).toBe(true);
|
|
39
46
|
});
|
|
40
47
|
|
|
48
|
+
test("collectChannels fails loud on a registration with invalid options", () => {
|
|
49
|
+
const broken = defineFeature("broken-channel", (r) => {
|
|
50
|
+
// @ts-expect-error resolve/send are required — proves the guard rejects a malformed options bag instead of silently dropping the channel
|
|
51
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "bad-entity", { anything: 1 });
|
|
52
|
+
});
|
|
53
|
+
const registry = createRegistry([...features, broken]);
|
|
54
|
+
expect(() => collectChannels(registry)).toThrow(
|
|
55
|
+
`${DELIVERY_CHANNEL_EXTENSION} registration for "bad-entity" has invalid options`,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
41
59
|
test("boot-validates with a narrowed access option", () => {
|
|
42
60
|
expect(() =>
|
|
43
61
|
validateBoot([
|
|
@@ -4,8 +4,8 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
4
4
|
import {
|
|
5
5
|
buildEntityTable,
|
|
6
6
|
createEventStoreExecutor,
|
|
7
|
+
createSystemDbView,
|
|
7
8
|
createTenantDb,
|
|
8
|
-
createUncheckedSystemDb,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
10
|
import {
|
|
11
11
|
createEntity,
|
|
@@ -251,6 +251,65 @@ const ticketFeature = defineFeature("tickets", (r) => {
|
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
// A tenantUserIdsQuery handler NOT declared r.systemScope() must get the same
|
|
255
|
+
// tenant-filtered ctx.db / no ctx.systemDb a real dispatch would give it —
|
|
256
|
+
// captured here so the test can assert on it directly.
|
|
257
|
+
let capturedNonSystemQueryCtx: { hasSystemDb: boolean; dbMode: string } | undefined;
|
|
258
|
+
|
|
259
|
+
const nonSystemUserIdsQueryName = "test-non-system-broadcast:query:non-system-user-ids";
|
|
260
|
+
|
|
261
|
+
const nonSystemBroadcastFeature = defineFeature("test-non-system-broadcast", (r) => {
|
|
262
|
+
r.queryHandler({
|
|
263
|
+
name: "nonSystemUserIds",
|
|
264
|
+
schema: z.object({ tenantId: z.string() }),
|
|
265
|
+
access: { roles: ["Admin"] },
|
|
266
|
+
handler: async (_query, ctx) => {
|
|
267
|
+
capturedNonSystemQueryCtx = { hasSystemDb: ctx.systemDb !== undefined, dbMode: ctx.db.mode };
|
|
268
|
+
const rows = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {});
|
|
269
|
+
return rows.map((r2) => r2.userId);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw ungated —
|
|
275
|
+
// the grant comes from this feature's own r.systemScope() declaration, not from
|
|
276
|
+
// the delivery service caller. ctx.db stays ungated even here.
|
|
277
|
+
let capturedSystemQueryCtx:
|
|
278
|
+
| { systemDbUnsafeRawWorked: boolean; dbUnsafeRawThrew: boolean }
|
|
279
|
+
| undefined;
|
|
280
|
+
|
|
281
|
+
const systemUserIdsQueryName = "test-system-broadcast:query:system-user-ids";
|
|
282
|
+
|
|
283
|
+
const systemBroadcastFeature = defineFeature("test-system-broadcast", (r) => {
|
|
284
|
+
r.systemScope();
|
|
285
|
+
r.queryHandler({
|
|
286
|
+
name: "systemUserIds",
|
|
287
|
+
schema: z.object({ tenantId: z.string() }),
|
|
288
|
+
access: { roles: ["Admin"] },
|
|
289
|
+
handler: async (_query, ctx) => {
|
|
290
|
+
let dbUnsafeRawThrew = false;
|
|
291
|
+
try {
|
|
292
|
+
ctx.db.unsafeRaw("x");
|
|
293
|
+
} catch {
|
|
294
|
+
dbUnsafeRawThrew = true;
|
|
295
|
+
}
|
|
296
|
+
const runner = ctx.systemDb?.unsafeRaw(
|
|
297
|
+
"test: systemScope handler grants ctx.systemDb.unsafeRaw",
|
|
298
|
+
);
|
|
299
|
+
const rows = runner
|
|
300
|
+
? await selectMany<{ userId: string }>(runner, tenantMembershipsTable, {
|
|
301
|
+
tenantId: _query.payload.tenantId,
|
|
302
|
+
})
|
|
303
|
+
: undefined;
|
|
304
|
+
capturedSystemQueryCtx = {
|
|
305
|
+
systemDbUnsafeRawWorked: rows !== undefined,
|
|
306
|
+
dbUnsafeRawThrew,
|
|
307
|
+
};
|
|
308
|
+
return (rows ?? []).map((r2) => r2.userId);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
254
313
|
const configFeature = createConfigFeature();
|
|
255
314
|
const tenantFeature = createTenantFeature();
|
|
256
315
|
const templateResolverFeature = createTemplateResolverFeature();
|
|
@@ -279,6 +338,8 @@ const features = [
|
|
|
279
338
|
channelPushFeature,
|
|
280
339
|
appFeature,
|
|
281
340
|
ticketFeature,
|
|
341
|
+
nonSystemBroadcastFeature,
|
|
342
|
+
systemBroadcastFeature,
|
|
282
343
|
] as const;
|
|
283
344
|
|
|
284
345
|
beforeAll(async () => {
|
|
@@ -1327,6 +1388,76 @@ describe("flow 15b: escape-hatch audit for tenant broadcast", () => {
|
|
|
1327
1388
|
});
|
|
1328
1389
|
});
|
|
1329
1390
|
|
|
1391
|
+
// --- Flow 15c: tenantUserIdsQuery that is NOT r.systemScope() ---
|
|
1392
|
+
|
|
1393
|
+
describe("flow 15c: tenantUserIdsQuery handler without r.systemScope()", () => {
|
|
1394
|
+
test("gets a tenant-mode db and no systemDb, and the broadcast still resolves recipients", async () => {
|
|
1395
|
+
const nonSystemService = createDeliveryService({
|
|
1396
|
+
db,
|
|
1397
|
+
registry: stack.registry,
|
|
1398
|
+
channels: collectChannels(stack.registry),
|
|
1399
|
+
tenantUserIdsQuery: nonSystemUserIdsQueryName,
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
// Unique title — flow 8 already broadcasts app:notify:tenant-alert to the same
|
|
1403
|
+
// admin/user1/user2, so a plain notificationType filter would pass even if this
|
|
1404
|
+
// service delivered nothing new. Filtering by this test's own title isolates it.
|
|
1405
|
+
const nonSystemBroadcastTitle = "non-system-broadcast-flow-15c";
|
|
1406
|
+
await nonSystemService.notify(
|
|
1407
|
+
"app:notify:tenant-alert",
|
|
1408
|
+
{ to: { tenant: admin.tenantId }, data: { title: nonSystemBroadcastTitle, body: "X" } },
|
|
1409
|
+
admin,
|
|
1410
|
+
admin.tenantId,
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
expect(capturedNonSystemQueryCtx).toEqual({ hasSystemDb: false, dbMode: "tenant" });
|
|
1414
|
+
|
|
1415
|
+
const messages = await selectMany(db, inAppMessagesTable, {
|
|
1416
|
+
notificationType: "app:notify:tenant-alert",
|
|
1417
|
+
title: nonSystemBroadcastTitle,
|
|
1418
|
+
});
|
|
1419
|
+
const recipientIds = messages.map((m) => m["userId"]);
|
|
1420
|
+
expect(recipientIds).toContain(admin.id);
|
|
1421
|
+
expect(recipientIds).toContain(user1.id);
|
|
1422
|
+
expect(recipientIds).toContain(user2.id);
|
|
1423
|
+
});
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// --- Flow 15d: tenantUserIdsQuery that IS r.systemScope() ---
|
|
1427
|
+
|
|
1428
|
+
describe("flow 15d: tenantUserIdsQuery handler with r.systemScope()", () => {
|
|
1429
|
+
test("ctx.systemDb.unsafeRaw works, ctx.db.unsafeRaw is denied, broadcast still resolves recipients", async () => {
|
|
1430
|
+
const systemService = createDeliveryService({
|
|
1431
|
+
db,
|
|
1432
|
+
registry: stack.registry,
|
|
1433
|
+
channels: collectChannels(stack.registry),
|
|
1434
|
+
tenantUserIdsQuery: systemUserIdsQueryName,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
const systemBroadcastTitle = "system-broadcast-flow-15d";
|
|
1438
|
+
await systemService.notify(
|
|
1439
|
+
"app:notify:tenant-alert",
|
|
1440
|
+
{ to: { tenant: admin.tenantId }, data: { title: systemBroadcastTitle, body: "X" } },
|
|
1441
|
+
admin,
|
|
1442
|
+
admin.tenantId,
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
expect(capturedSystemQueryCtx).toEqual({
|
|
1446
|
+
systemDbUnsafeRawWorked: true,
|
|
1447
|
+
dbUnsafeRawThrew: true,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
const messages = await selectMany(db, inAppMessagesTable, {
|
|
1451
|
+
notificationType: "app:notify:tenant-alert",
|
|
1452
|
+
title: systemBroadcastTitle,
|
|
1453
|
+
});
|
|
1454
|
+
const recipientIds = messages.map((m) => m["userId"]);
|
|
1455
|
+
expect(recipientIds).toContain(admin.id);
|
|
1456
|
+
expect(recipientIds).toContain(user1.id);
|
|
1457
|
+
expect(recipientIds).toContain(user2.id);
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1330
1461
|
// --- Flow 16: Unsubscribe race — ON CONFLICT makes repeated clicks safe ---
|
|
1331
1462
|
|
|
1332
1463
|
describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
@@ -1545,10 +1676,16 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1545
1676
|
|
|
1546
1677
|
// Mirrors what job-runner.ts's handleJob really builds for a
|
|
1547
1678
|
// r.systemScope()'d job (framework#2105) — a real TenantDb wrapped by
|
|
1548
|
-
//
|
|
1679
|
+
// createSystemDbView, not a mock, so requireTenantScopedDeps'
|
|
1549
1680
|
// assertTenantMatch() call exercises the actual production code path.
|
|
1681
|
+
// The unsafeRaw grant mirrors the systemScope() grant that job-runner.ts's
|
|
1682
|
+
// real ctx.systemDb carries in production.
|
|
1550
1683
|
function makeSystemDb(tenantId: TenantId) {
|
|
1551
|
-
return
|
|
1684
|
+
return createSystemDbView(
|
|
1685
|
+
createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
|
|
1686
|
+
unsafeRaw: { reason: "test: job context mirrors systemScope() grant" },
|
|
1687
|
+
}),
|
|
1688
|
+
);
|
|
1552
1689
|
}
|
|
1553
1690
|
|
|
1554
1691
|
test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
|
|
@@ -2,6 +2,10 @@ import type { NotifyPriority } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { QnTypes, qn } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { DELIVERY_FEATURE, DeliveryJobNames } from "./public-names";
|
|
4
4
|
|
|
5
|
+
// Extension-point name for delivery-channel plugins (channel-email,
|
|
6
|
+
// channel-in-app, channel-push, ...).
|
|
7
|
+
export const DELIVERY_CHANNEL_EXTENSION = "deliveryChannel" as const;
|
|
8
|
+
|
|
5
9
|
export {
|
|
6
10
|
DELIVERY_ATTEMPT_EVENT,
|
|
7
11
|
DELIVERY_FEATURE,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import {
|
|
3
|
+
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type {
|
|
5
5
|
EscapeHatchAuditSink,
|
|
6
6
|
NotifyPriority,
|
|
@@ -16,14 +16,15 @@ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
|
16
16
|
import type { Redis } from "ioredis";
|
|
17
17
|
import { appendAttemptEvent, logAttempt } from "./attempt-log";
|
|
18
18
|
import { buildChannelContext } from "./channel-context";
|
|
19
|
-
import { DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
19
|
+
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants";
|
|
20
20
|
import { selectNotificationPreferences } from "./db/queries/preferences";
|
|
21
|
-
import
|
|
22
|
-
ChannelContext,
|
|
23
|
-
ChannelMessage,
|
|
24
|
-
DeliveryChannel,
|
|
25
|
-
DeliveryLogEntry,
|
|
26
|
-
DeliveryService,
|
|
21
|
+
import {
|
|
22
|
+
type ChannelContext,
|
|
23
|
+
type ChannelMessage,
|
|
24
|
+
type DeliveryChannel,
|
|
25
|
+
type DeliveryLogEntry,
|
|
26
|
+
type DeliveryService,
|
|
27
|
+
isDeliveryChannelPlugin,
|
|
27
28
|
} from "./types";
|
|
28
29
|
|
|
29
30
|
export type RateLimitConfig = {
|
|
@@ -58,22 +59,14 @@ export type DeliveryServiceOptions = {
|
|
|
58
59
|
|
|
59
60
|
// Build channel list from registry extension usages
|
|
60
61
|
export function collectChannels(registry: Registry): DeliveryChannel[] {
|
|
61
|
-
const usages = registry.getExtensionUsages(
|
|
62
|
+
const usages = registry.getExtensionUsages(DELIVERY_CHANNEL_EXTENSION);
|
|
62
63
|
return usages.map((usage) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
return {
|
|
71
|
-
name: usage.entityName,
|
|
72
|
-
mode: opts.mode,
|
|
73
|
-
resolve: opts.resolve,
|
|
74
|
-
render: opts.render,
|
|
75
|
-
send: opts.send,
|
|
76
|
-
};
|
|
64
|
+
if (!isDeliveryChannelPlugin(usage.options)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${DELIVERY_CHANNEL_EXTENSION} registration for "${usage.entityName}" has invalid options`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return { name: usage.entityName, ...usage.options };
|
|
77
70
|
});
|
|
78
71
|
}
|
|
79
72
|
|
|
@@ -177,18 +170,41 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
177
170
|
sink: escapeHatchAuditSink,
|
|
178
171
|
log,
|
|
179
172
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
173
|
+
// Mirrors buildHandlerContext (pipeline/dispatch-shared.ts): a non-systemScope
|
|
174
|
+
// tenantUserIdsQuery must get the same tenant-filtered db/no-systemDb context
|
|
175
|
+
// the real dispatcher would give it — handing it "system" mode + a systemDb
|
|
176
|
+
// view regardless would grant cross-tenant reach the dispatcher never would.
|
|
177
|
+
const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
|
|
178
|
+
const tenantDb = createTenantDb(
|
|
179
|
+
db,
|
|
180
|
+
tenantId,
|
|
181
|
+
isSystem ? "system" : "tenant",
|
|
182
|
+
undefined,
|
|
183
|
+
undefined,
|
|
184
|
+
undefined,
|
|
185
|
+
{ report },
|
|
186
|
+
);
|
|
187
|
+
// The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
|
|
188
|
+
// the grant comes from the handler's own r.systemScope() registration, not from this
|
|
189
|
+
// caller. ctx.db/dbOutsideTransaction stay on the grant-less tenantDb above; only the
|
|
190
|
+
// systemDb view's own source TenantDb carries the grant, so it can't leak onto ctx.db.
|
|
191
|
+
const systemDbSourceTenantDb = isSystem
|
|
192
|
+
? createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
|
|
193
|
+
report,
|
|
194
|
+
unsafeRaw: { reason: `r.systemScope() handler "${tenantUserIdsQuery}"` },
|
|
195
|
+
})
|
|
196
|
+
: undefined;
|
|
197
|
+
// Hand-built context, not routed through the dispatcher — this needs both
|
|
198
|
+
// db and dbOutsideTransaction wired directly.
|
|
185
199
|
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
186
200
|
return (await handler.handler(
|
|
187
201
|
{ type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
|
|
188
202
|
{
|
|
189
203
|
db: tenantDb,
|
|
190
204
|
dbOutsideTransaction: tenantDb,
|
|
191
|
-
systemDb:
|
|
205
|
+
systemDb: systemDbSourceTenantDb
|
|
206
|
+
? createSystemDbView(systemDbSourceTenantDb, undefined, report)
|
|
207
|
+
: undefined,
|
|
192
208
|
registry,
|
|
193
209
|
...bridgeStub(),
|
|
194
210
|
},
|
package/src/delivery/feature.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { z } from "zod";
|
|
9
9
|
import {
|
|
10
10
|
DELIVERY_ATTEMPT_EVENT,
|
|
11
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
11
12
|
DELIVERY_LOG_SCREEN_ID,
|
|
12
13
|
DELIVERY_STATUS_CELL_COMPONENT,
|
|
13
14
|
DeliveryJobNames,
|
|
@@ -118,7 +119,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
118
119
|
// eigenen mehr — channel-email nimmt renderer als direkte
|
|
119
120
|
// Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
|
|
120
121
|
// Usage. Migration 2026-05-19.
|
|
121
|
-
r.extendsRegistrar(
|
|
122
|
+
r.extendsRegistrar(DELIVERY_CHANNEL_EXTENSION, {
|
|
122
123
|
onRegister: () => {},
|
|
123
124
|
});
|
|
124
125
|
|
package/src/delivery/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { DeliveryStatusValue } from "./constants";
|
|
2
2
|
export {
|
|
3
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
3
4
|
DELIVERY_FEATURE,
|
|
4
5
|
DELIVERY_LOG_SCREEN_ID,
|
|
5
6
|
DeliveryErrors,
|
|
@@ -22,17 +23,19 @@ export {
|
|
|
22
23
|
notificationPreferencesTable,
|
|
23
24
|
} from "./tables";
|
|
24
25
|
export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing";
|
|
25
|
-
export
|
|
26
|
-
ChannelContext,
|
|
27
|
-
ChannelMessage,
|
|
28
|
-
ChannelResult,
|
|
29
|
-
DeliveryChannel,
|
|
30
|
-
DeliveryChannelMode,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
export {
|
|
27
|
+
type ChannelContext,
|
|
28
|
+
type ChannelMessage,
|
|
29
|
+
type ChannelResult,
|
|
30
|
+
type DeliveryChannel,
|
|
31
|
+
type DeliveryChannelMode,
|
|
32
|
+
type DeliveryChannelPlugin,
|
|
33
|
+
type DeliveryLogEntry,
|
|
34
|
+
type DeliveryService,
|
|
35
|
+
isDeliveryChannelPlugin,
|
|
36
|
+
type NotificationRenderer,
|
|
37
|
+
type RenderedMessage,
|
|
38
|
+
type RendererInput,
|
|
36
39
|
} from "./types";
|
|
37
40
|
export {
|
|
38
41
|
createUnsubscribeRoute,
|
package/src/delivery/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SessionUser,
|
|
8
8
|
TenantId,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { DELIVERY_CHANNEL_EXTENSION } from "./constants";
|
|
10
11
|
|
|
11
12
|
// --- Channel Interface ---
|
|
12
13
|
|
|
@@ -92,3 +93,26 @@ export type DeliveryService = {
|
|
|
92
93
|
tenantId: TenantId,
|
|
93
94
|
): Promise<void>;
|
|
94
95
|
};
|
|
96
|
+
|
|
97
|
+
// r.useExtension options-shape: `name` is NOT part of the registration
|
|
98
|
+
// payload — collectChannels derives it from the usage's entityName instead.
|
|
99
|
+
export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
|
|
100
|
+
|
|
101
|
+
export function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin {
|
|
102
|
+
return (
|
|
103
|
+
typeof o === "object" &&
|
|
104
|
+
o !== null &&
|
|
105
|
+
"mode" in o &&
|
|
106
|
+
"resolve" in o &&
|
|
107
|
+
typeof o.resolve === "function" &&
|
|
108
|
+
"send" in o &&
|
|
109
|
+
typeof o.send === "function"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// r.useExtension options-shape, co-located since the framework never imports upward.
|
|
114
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
115
|
+
interface KumikoExtensionOptionsMap {
|
|
116
|
+
[DELIVERY_CHANNEL_EXTENSION]: DeliveryChannelPlugin;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -25,7 +25,6 @@ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testin
|
|
|
25
25
|
import { createConfigFeature } from "../../config";
|
|
26
26
|
import { documentExtractEntity, documentExtractsTable } from "../entity";
|
|
27
27
|
import { readIngestPages, writeIngestPages } from "../pages";
|
|
28
|
-
import { documentExtractTenantDestroyHook } from "../tenant-destroy-hook";
|
|
29
28
|
|
|
30
29
|
let stack: TestStack;
|
|
31
30
|
let kms: InMemoryKmsAdapter;
|
|
@@ -110,29 +109,3 @@ describe("documentExtract.pages — tenant-subject encryption (#1621)", () => {
|
|
|
110
109
|
expect(readIngestPages(row?.["pages"])).toEqual([]);
|
|
111
110
|
});
|
|
112
111
|
});
|
|
113
|
-
|
|
114
|
-
describe("documentExtractTenantDestroyHook (#1621)", () => {
|
|
115
|
-
test("drops the tenant's extract rows and leaves other tenants alone", async () => {
|
|
116
|
-
const mine = await createExtract("file-mine");
|
|
117
|
-
const otherTenantId = testTenantId(2);
|
|
118
|
-
const otherResult = await executor.create(
|
|
119
|
-
{
|
|
120
|
-
fileRefId: "file-theirs",
|
|
121
|
-
storageKey: "s3://bucket/file-theirs",
|
|
122
|
-
pages: writeIngestPages(pages),
|
|
123
|
-
meta: {},
|
|
124
|
-
},
|
|
125
|
-
createSystemUser(otherTenantId),
|
|
126
|
-
createTenantDb(stack.db, otherTenantId),
|
|
127
|
-
);
|
|
128
|
-
if (!otherResult.isSuccess) throw new Error("setup create failed");
|
|
129
|
-
|
|
130
|
-
await documentExtractTenantDestroyHook({ db: stack.db, tenantId });
|
|
131
|
-
|
|
132
|
-
const remaining = (await asRawClient(stack.db).unsafe(
|
|
133
|
-
`SELECT id FROM read_document_extracts`,
|
|
134
|
-
)) as ReadonlyArray<{ id: string }>;
|
|
135
|
-
expect(remaining.map((row) => row.id)).not.toContain(mine);
|
|
136
|
-
expect(remaining).toHaveLength(1);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Drives the real tenant-lifecycle destruction sweep: a direct hook call with a raw DbRunner
|
|
2
|
+
// hides the TenantDb shape the "app-data" stage actually passes.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
|
+
import {
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
TestUsers,
|
|
14
|
+
unsafeCreateEntityTable,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { resetTestTables, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
18
|
+
import {
|
|
19
|
+
createComplianceProfilesFeature,
|
|
20
|
+
tenantComplianceProfileEntity,
|
|
21
|
+
tenantComplianceProfileTable,
|
|
22
|
+
} from "../../compliance-profiles";
|
|
23
|
+
import { createConfigFeature } from "../../config";
|
|
24
|
+
import { tenantMembershipEntity } from "../../tenant";
|
|
25
|
+
import { TenantHandlers } from "../../tenant/constants";
|
|
26
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
27
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
28
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
29
|
+
import {
|
|
30
|
+
TENANT_AGGREGATE_TYPE,
|
|
31
|
+
TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
32
|
+
} from "../../tenant-lifecycle/constants";
|
|
33
|
+
import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
|
|
34
|
+
import { documentExtractEntity, documentExtractsTable } from "../entity";
|
|
35
|
+
import { documentIngestFoundationFeature } from "../feature";
|
|
36
|
+
import { writeIngestPages } from "../pages";
|
|
37
|
+
|
|
38
|
+
const SET_PROFILE = "compliance-profiles:write:set-profile";
|
|
39
|
+
|
|
40
|
+
const documentExtractCrud = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
|
|
41
|
+
entityName: "document-extract",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let stack: TestStack;
|
|
45
|
+
let db: DbConnection;
|
|
46
|
+
|
|
47
|
+
const tenantA = TestUsers.admin;
|
|
48
|
+
const tenantB = TestUsers.otherTenant;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
stack = await setupTestStack({
|
|
52
|
+
features: [
|
|
53
|
+
createConfigFeature(),
|
|
54
|
+
createTenantFeature(),
|
|
55
|
+
createComplianceProfilesFeature(),
|
|
56
|
+
createTenantLifecycleFeature(),
|
|
57
|
+
documentIngestFoundationFeature,
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
db = stack.db;
|
|
61
|
+
|
|
62
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
63
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
64
|
+
await unsafeCreateEntityTable(db, tenantMembershipEntity);
|
|
65
|
+
await unsafeCreateEntityTable(db, documentExtractEntity);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await stack.cleanup();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
stack.events.reset();
|
|
74
|
+
await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
|
|
75
|
+
await stack.db.unsafe?.(
|
|
76
|
+
`TRUNCATE kumiko_events, read_document_extracts RESTART IDENTITY CASCADE`,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function seedTenant(user: typeof tenantA): Promise<void> {
|
|
81
|
+
await stack.http.writeOk(
|
|
82
|
+
TenantHandlers.create,
|
|
83
|
+
{ id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
|
|
84
|
+
TestUsers.systemAdmin,
|
|
85
|
+
);
|
|
86
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, user);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function seedDocumentExtract(tenantId: TenantId, fileRefId: string): Promise<string> {
|
|
90
|
+
const user = createSystemUser(tenantId);
|
|
91
|
+
const tdb = createTenantDb(db, tenantId);
|
|
92
|
+
const result = await documentExtractCrud.create(
|
|
93
|
+
{
|
|
94
|
+
fileRefId,
|
|
95
|
+
storageKey: `s3://bucket/${fileRefId}`,
|
|
96
|
+
pages: writeIngestPages([{ pageNumber: 1, text: "invoice text" }]),
|
|
97
|
+
meta: { provider: "test", ms: 1, needsOcr: false, pagesParsed: 1, totalPages: 1 },
|
|
98
|
+
},
|
|
99
|
+
user,
|
|
100
|
+
tdb,
|
|
101
|
+
);
|
|
102
|
+
if (!result.isSuccess) throw new Error(`seed failed: ${result.error.message}`);
|
|
103
|
+
return String(result.data.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sidesteps the `request-destruction` write handler (needs user/auth/sessions
|
|
107
|
+
// features wired) by seeding the same "destroying" state it would produce —
|
|
108
|
+
// same pattern as files-tenant-data's hooks.integration.test.ts.
|
|
109
|
+
async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
|
|
110
|
+
const now = getTemporal().Now.instant();
|
|
111
|
+
await updateRows(
|
|
112
|
+
db,
|
|
113
|
+
tenantTable,
|
|
114
|
+
{ status: "destroying", destroyStartedAt: now },
|
|
115
|
+
{ id: tenantId },
|
|
116
|
+
);
|
|
117
|
+
await append(db, {
|
|
118
|
+
aggregateId: tenantId,
|
|
119
|
+
aggregateType: TENANT_AGGREGATE_TYPE,
|
|
120
|
+
tenantId,
|
|
121
|
+
expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
|
|
122
|
+
type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
123
|
+
payload: { startedAt: now.toString() },
|
|
124
|
+
metadata: { userId: "system", requestId: "test:destruction-started" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
|
|
129
|
+
const farFuture = getTemporal()
|
|
130
|
+
.Now.instant()
|
|
131
|
+
.add({ hours: 24 * 3650 });
|
|
132
|
+
let status = "";
|
|
133
|
+
for (let i = 0; i < 20; i++) {
|
|
134
|
+
await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
|
|
135
|
+
const rows = await selectMany(db, tenantTable, { id: tenantId });
|
|
136
|
+
status = String(rows[0]?.["status"]);
|
|
137
|
+
if (status === "destroyed" || status === "destroyFailed") break;
|
|
138
|
+
}
|
|
139
|
+
return status;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("document-ingest-foundation :: tenant destroy (#3196)", () => {
|
|
143
|
+
test("purges documentExtract rows for the destroyed tenant, leaving another tenant's row untouched", async () => {
|
|
144
|
+
await seedTenant(tenantA);
|
|
145
|
+
await seedTenant(tenantB);
|
|
146
|
+
|
|
147
|
+
const idA = await seedDocumentExtract(tenantA.tenantId, "file-a");
|
|
148
|
+
await seedDocumentExtract(tenantB.tenantId, "file-b");
|
|
149
|
+
|
|
150
|
+
await seedDestroyingTenant(tenantA.tenantId);
|
|
151
|
+
|
|
152
|
+
const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
|
|
153
|
+
expect(finalStatus).toBe("destroyed");
|
|
154
|
+
|
|
155
|
+
const rowsA = await selectMany(db, documentExtractsTable, { tenantId: tenantA.tenantId });
|
|
156
|
+
expect(rowsA).toHaveLength(0);
|
|
157
|
+
|
|
158
|
+
const rowsB = await selectMany(db, documentExtractsTable, { tenantId: tenantB.tenantId });
|
|
159
|
+
expect(rowsB).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
// Proves this went through the executor (rebuild-safe forget), not a raw
|
|
162
|
+
// deleteMany — the forgotten row's aggregate carries a `.forgotten` event.
|
|
163
|
+
const events = await loadAggregate(db, idA, tenantA.tenantId);
|
|
164
|
+
expect(events.some((e) => e.type === "document-extract.forgotten")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|