@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -1,3 +1,4 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  type ConfigKeyType,
3
4
  type ConfigScope,
@@ -7,7 +8,7 @@ import {
7
8
  toKebab,
8
9
  } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { z } from "zod";
10
- import { requireConfigResolver } from "../feature";
11
+ import { requireConfigResolver, requireSystemDb } from "../feature";
11
12
  import { hasConfigAccess } from "../write-helpers";
12
13
 
13
14
  export type ReadinessMissingKey = {
@@ -32,18 +33,23 @@ export type RequiredKeyGate = (qualifiedName: string) => boolean;
32
33
  // Builds the per-tenant gate from r.extensionSelector declarations: resolve
33
34
  // each selector through the config cascade, mark every provider-feature
34
35
  // registered under that point as counted/uncounted. Features without a
35
- // selector-gated registration always count.
36
+ // selector-gated registration always count. `db` is the caller's own scoped
37
+ // handle — config's own handlers pass requireSystemDb's result, the plain
38
+ // tenant-scoped readiness feature passes its own ctx.db. This function makes
39
+ // no scope decision itself; see readiness/handlers/status.query.ts and
40
+ // config/handlers/readiness.query.ts's own handler for the two call sites.
36
41
  export async function buildProviderSelectionGate(
37
42
  ctx: HandlerContext,
38
43
  callerQn: string,
39
44
  user: SessionUser,
45
+ db: TenantDb,
40
46
  ): Promise<RequiredKeyGate> {
41
47
  const resolver = requireConfigResolver(ctx, callerQn);
42
48
  const countsByFeature = new Map<string, boolean>();
43
49
  for (const [extensionName, selectorKey] of ctx.registry.getAllExtensionSelectors()) {
44
50
  const keyDef = ctx.registry.getConfigKey(selectorKey);
45
51
  if (!keyDef) continue; // registry-build already failed this; defensive
46
- const value = await resolver.get(selectorKey, keyDef, user.tenantId, user.id, ctx.db);
52
+ const value = await resolver.get(selectorKey, keyDef, user.tenantId, user.id, db);
47
53
  const selected = typeof value === "string" ? value.trim() : "";
48
54
  for (const usage of ctx.registry.getExtensionUsages(extensionName)) {
49
55
  if (usage.featureName === undefined) continue;
@@ -65,6 +71,7 @@ export async function collectMissingRequiredConfig(
65
71
  ctx: HandlerContext,
66
72
  callerQn: string,
67
73
  user: SessionUser,
74
+ db: TenantDb,
68
75
  gate?: RequiredKeyGate,
69
76
  options?: {
70
77
  /** Verdict-Pfade (Rollup, selbst role-gated) MÜSSEN ungefiltert zählen:
@@ -75,7 +82,7 @@ export async function collectMissingRequiredConfig(
75
82
  },
76
83
  ): Promise<ReadinessMissingKey[]> {
77
84
  const resolver = requireConfigResolver(ctx, callerQn);
78
- const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user));
85
+ const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user, db));
79
86
  // Kandidaten erst sammeln, dann EIN Batch-Resolve — die sequentielle
80
87
  // resolver.get-Schleife war ein N+1 über alle required Keys (272/1).
81
88
  type KeyDef =
@@ -98,7 +105,7 @@ export async function collectMissingRequiredConfig(
98
105
  candidates,
99
106
  user.tenantId,
100
107
  user.id,
101
- ctx.db,
108
+ db,
102
109
  ctx.secrets,
103
110
  );
104
111
  for (const [qualifiedKey, keyDef] of candidates) {
@@ -124,7 +131,13 @@ export const readinessQuery = defineQueryHandler({
124
131
  // Per-key read access enforced via hasConfigAccess inside the handler.
125
132
  access: { openToAll: true },
126
133
  handler: async (query, ctx) => {
127
- const missing = await collectMissingRequiredConfig(ctx, "config:query:readiness", query.user);
134
+ const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
135
+ const missing = await collectMissingRequiredConfig(
136
+ ctx,
137
+ "config:query:readiness",
138
+ query.user,
139
+ db,
140
+ );
128
141
  return { missing };
129
142
  },
130
143
  });
@@ -15,15 +15,28 @@
15
15
  // so each rotation appends a normal `.updated` event whose payload carries
16
16
  // the NEW envelope: after a full run even a from-scratch rebuild only
17
17
  // ever sees the current-KEK envelope.
18
+ //
19
+ // systemScope() fail-closed pattern (framework#2056): the initial scan is
20
+ // intentionally cross-tenant (system-wide key rotation), acknowledged via
21
+ // UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
22
+ // and assertRowsTenant() re-verifies each row against its bucket's tenant
23
+ // right before the write — both checks are built locally from the exported
24
+ // createUncheckedSystemDb().
18
25
 
19
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
20
26
  import {
21
27
  createEventStoreExecutor,
22
28
  createTenantDb,
29
+ createUncheckedSystemDb,
23
30
  type DbConnection,
24
31
  type TenantDb,
32
+ type UncheckedSystemDb,
25
33
  } from "@cosmicdrift/kumiko-framework/db";
26
- import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
34
+ import {
35
+ type JobHandlerFn,
36
+ type SessionUser,
37
+ SYSTEM_TENANT_ID,
38
+ type TenantId,
39
+ } from "@cosmicdrift/kumiko-framework/engine";
27
40
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
28
41
  import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
29
42
  import {
@@ -104,6 +117,22 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
104
117
  return existing;
105
118
  }
106
119
 
120
+ const sdbCache = new Map<TenantId, UncheckedSystemDb>();
121
+ function sdbFor(tenantId: TenantId): UncheckedSystemDb {
122
+ let existing = sdbCache.get(tenantId);
123
+ if (!existing) {
124
+ existing = createUncheckedSystemDb(tdbFor(tenantId));
125
+ sdbCache.set(tenantId, existing);
126
+ }
127
+ return existing;
128
+ }
129
+
130
+ // Standalone from sdbCache/tdbCache on purpose: this one is only ever used
131
+ // to acknowledge the cross-tenant scan below, never bound to a real
132
+ // tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
133
+ // write-path cache entry double as the scan's ack gate.
134
+ const scanDb = createUncheckedSystemDb(createTenantDb(db, SYSTEM_TENANT_ID, "system"));
135
+
107
136
  type ConfigRow = {
108
137
  id: string;
109
138
  key: string;
@@ -115,27 +144,51 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
115
144
  let alreadyCurrent = 0;
116
145
  const targetVersion = provider.currentVersion();
117
146
 
147
+ type BucketedRow = { tenantId: TenantId; row: ConfigRow };
148
+
118
149
  // ponytail: one full candidate scan — config rows are operator-scale
119
150
  // (tenants × encrypted keys), cursor pagination when that ever changes.
120
- // Served to the shared loop in slices so its deadline/signal/failure
121
- // checks run between chunks, not only once.
122
- let pending: ConfigRow[] | undefined;
123
- async function nextBatch(): Promise<readonly ConfigRow[]> {
124
- if (pending === undefined) {
125
- pending =
126
- encryptedKeys.length === 0
127
- ? []
128
- : [
129
- ...(await selectMany<ConfigRow>(db, configValuesTable, {
130
- key: { in: encryptedKeys },
131
- })),
132
- ];
151
+ // Bucketed by tenant so each row keeps its bucket's tenant attached
152
+ // (assertRowsTenant below re-checks that against the row's own field
153
+ // right before the write) and the shared loop's deadline/signal/failure
154
+ // checks still run between chunks, not only once.
155
+ let buckets: Map<TenantId, ConfigRow[]> | undefined;
156
+ async function loadBuckets(): Promise<Map<TenantId, ConfigRow[]>> {
157
+ if (buckets) return buckets;
158
+ const grouped = new Map<TenantId, ConfigRow[]>();
159
+ if (encryptedKeys.length > 0) {
160
+ const scanned = await scanDb
161
+ .acknowledgeCrossTenant("system-wide key rotation")
162
+ .selectMany<ConfigRow>(configValuesTable, { key: { in: encryptedKeys } });
163
+ for (const row of scanned) {
164
+ const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
165
+ const bucket = grouped.get(tenantId);
166
+ if (bucket) bucket.push(row);
167
+ else grouped.set(tenantId, [row]);
168
+ }
169
+ }
170
+ buckets = grouped;
171
+ return buckets;
172
+ }
173
+
174
+ async function nextBatch(): Promise<readonly BucketedRow[]> {
175
+ const grouped = await loadBuckets();
176
+ for (const [tenantId, rows] of grouped) {
177
+ if (rows.length === 0) {
178
+ grouped.delete(tenantId);
179
+ continue;
180
+ }
181
+ const slice = rows.splice(0, SCAN_SLICE_SIZE);
182
+ if (rows.length === 0) grouped.delete(tenantId);
183
+ return slice.map((row) => ({ tenantId, row }));
133
184
  }
134
- const slice = pending;
135
- return slice.splice(0, SCAN_SLICE_SIZE);
185
+ return [];
136
186
  }
137
187
 
138
- async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
188
+ async function migrateRow({
189
+ tenantId,
190
+ row,
191
+ }: BucketedRow): Promise<"migrated" | "skipped" | "failed"> {
139
192
  if (row.value === null || row.value === undefined) return "skipped";
140
193
  const classification = classifyStoredEnvelope(row.value, targetVersion);
141
194
  if (classification === "current") {
@@ -154,7 +207,12 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
154
207
  return "failed";
155
208
  }
156
209
 
157
- const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
210
+ // Re-verify the row's own tenantId against the bucket it was scanned
211
+ // into, right before the write (framework#2072) — a mismatch here
212
+ // fails this row only (via onRowError below), it does not abort the
213
+ // whole job.
214
+ sdbFor(tenantId).assertRowsTenant([row], "tenantId");
215
+
158
216
  const plaintext = await cipher.decrypt(row.value, { tenantId });
159
217
  const reencrypted = await cipher.encrypt(plaintext, { tenantId });
160
218
 
@@ -177,13 +235,13 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
177
235
  return "migrated";
178
236
  }
179
237
 
180
- const outcome = await runChunkedMigration<ConfigRow>({
238
+ const outcome = await runChunkedMigration<BucketedRow>({
181
239
  nextBatch,
182
240
  migrateRow,
183
241
  maxFailures,
184
242
  deadlineAt: deadline,
185
243
  signal: ctx.signal,
186
- onRowError: (row, err) => {
244
+ onRowError: ({ row }, err) => {
187
245
  ctx.log?.warn?.(`[config:reencrypt] failed to re-encrypt row ${row.id}`, { err });
188
246
  },
189
247
  });
@@ -6,7 +6,7 @@ import {
6
6
  isEncryptedAtRest,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { z } from "zod";
9
- import { requireConfigResolver } from "../feature";
9
+ import { requireConfigResolver, requireSystemDb } from "../feature";
10
10
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
11
11
  import { hasConfigAccess } from "../write-helpers";
12
12
 
@@ -16,7 +16,7 @@ export const valuesQuery = defineQueryHandler({
16
16
  // Per-key read access enforced via hasConfigAccess inside the handler.
17
17
  access: { openToAll: true },
18
18
  handler: async (query, ctx) => {
19
- const db = ctx.db;
19
+ const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
20
20
  const registry = ctx.registry;
21
21
  const resolver = requireConfigResolver(ctx, "config:query:values");
22
22
 
@@ -15,13 +15,32 @@ describe("delivery screens + handler access alignment", () => {
15
15
  const delivery = createDeliveryFeature();
16
16
  const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
17
17
  expect(screen?.type).toBe("custom");
18
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
19
- expect(screen.access.roles).toEqual(access.admin);
20
- }
18
+ expect(rolesOf(screen?.access)).toEqual([...access.admin]);
21
19
  });
22
20
 
23
21
  test("delivery log handler shares access.admin", () => {
24
22
  const delivery = createDeliveryFeature();
25
23
  expect(rolesOf(delivery.queryHandlers["log"]?.access)).toEqual([...access.admin]);
26
24
  });
25
+
26
+ test("access option narrows the delivery-log screen and its log handler together (#2033)", () => {
27
+ const delivery = createDeliveryFeature({ access: access.systemAdmin });
28
+ const screen = delivery.screens[DELIVERY_LOG_SCREEN_ID];
29
+ expect(rolesOf(screen?.access)).toEqual([...access.systemAdmin]);
30
+ expect(rolesOf(delivery.queryHandlers["log"]?.access)).toEqual([...access.systemAdmin]);
31
+ });
32
+
33
+ test("access option leaves the preferences handler openToAll — that's per-user, not an admin surface", () => {
34
+ const delivery = createDeliveryFeature({ access: access.systemAdmin });
35
+ const preferencesAccess = delivery.queryHandlers["preferences"]?.access;
36
+ expect(
37
+ preferencesAccess && "openToAll" in preferencesAccess && preferencesAccess.openToAll,
38
+ ).toBe(true);
39
+ });
40
+
41
+ test("boot-validates with a narrowed access option", () => {
42
+ expect(() =>
43
+ validateBoot([createDeliveryFeature({ access: access.systemAdmin })]),
44
+ ).not.toThrow();
45
+ });
27
46
  });
@@ -9,7 +9,7 @@ import {
9
9
  unsafePushTables,
10
10
  } from "@cosmicdrift/kumiko-framework/stack";
11
11
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
12
- import { DELIVERY_LOG_SCREEN_ID, DeliveryQueries } from "../constants";
12
+ import { DELIVERY_LOG_SCREEN_ID, DeliveryHandlers, DeliveryQueries } from "../constants";
13
13
  import { createDeliveryFeature } from "../feature";
14
14
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
15
15
 
@@ -104,3 +104,46 @@ describe("delivery log tenant isolation", () => {
104
104
  expect(res.rows.some((r) => r.tenantId === tenantB)).toBe(false);
105
105
  });
106
106
  });
107
+
108
+ describe("delivery preferences tenant isolation", () => {
109
+ // A userId is not tenant-unique — the same person can be a member of
110
+ // several tenants (tenant:write:add-member takes {userId, tenantId}).
111
+ // delivery runs in system-scope, so the preferences query handler must
112
+ // filter by tenantId too — otherwise setting a preference in tenant A
113
+ // leaks into (or gets read from) tenant B for the same userId.
114
+ test("user only sees their own tenant's preference for a shared userId", async () => {
115
+ const tenantA = testTenantId(31);
116
+ const tenantB = testTenantId(32);
117
+ const sharedUser = 41;
118
+ const userInTenantA = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantA });
119
+ const userInTenantB = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantB });
120
+ expect(userInTenantA.id).toBe(userInTenantB.id);
121
+
122
+ await stack.http.writeOk(
123
+ DeliveryHandlers.setPreference,
124
+ { notificationType: "welcome", channel: "email", enabled: false },
125
+ userInTenantA,
126
+ );
127
+ await stack.http.writeOk(
128
+ DeliveryHandlers.setPreference,
129
+ { notificationType: "welcome", channel: "push", enabled: false },
130
+ userInTenantB,
131
+ );
132
+
133
+ const resA = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
134
+ DeliveryQueries.preferences,
135
+ {},
136
+ userInTenantA,
137
+ );
138
+ expect(resA.rows.length).toBeGreaterThan(0);
139
+ expect(resA.rows.every((r) => r.channel === "email")).toBe(true);
140
+
141
+ const resB = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
142
+ DeliveryQueries.preferences,
143
+ {},
144
+ userInTenantB,
145
+ );
146
+ expect(resB.rows.length).toBeGreaterThan(0);
147
+ expect(resB.rows.every((r) => r.channel === "push")).toBe(true);
148
+ });
149
+ });
@@ -1,14 +1,21 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
- import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ buildEntityTable,
6
+ createEventStoreExecutor,
7
+ createTenantDb,
8
+ createUncheckedSystemDb,
9
+ } from "@cosmicdrift/kumiko-framework/db";
5
10
  import {
6
11
  createEntity,
7
12
  createTextField,
8
13
  defineFeature,
9
14
  defineWriteHandler,
15
+ type JobContext,
10
16
  type NotifyFn,
11
17
  qn,
18
+ type TenantId,
12
19
  } from "@cosmicdrift/kumiko-framework/engine";
13
20
  import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
14
21
  import {
@@ -16,6 +23,7 @@ import {
16
23
  setupTestStack,
17
24
  type TestStack,
18
25
  TestUsers,
26
+ testTenantId,
19
27
  unsafePushTables,
20
28
  } from "@cosmicdrift/kumiko-framework/stack";
21
29
  import { z } from "zod";
@@ -39,7 +47,7 @@ import { tenantEntity } from "../../tenant/schema/tenant";
39
47
  import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
40
48
  import { collectChannels, createDeliveryService } from "../delivery-service";
41
49
  import { createDeliveryFeature } from "../feature";
42
- import { deliveryRenderJob } from "../jobs";
50
+ import { deliveryRenderJob, deliverySendJob } from "../jobs";
43
51
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
44
52
  import { createDeliveryTestContext } from "../testing";
45
53
  import type { DeliveryService } from "../types";
@@ -1501,6 +1509,14 @@ describe("flow 17: async render→send pipeline", () => {
1501
1509
  expect(emailRows.some((r) => r["status"] === "sent")).toBe(true);
1502
1510
  });
1503
1511
 
1512
+ // Mirrors what job-runner.ts's handleJob really builds for a
1513
+ // r.systemScope()'d job (framework#2105) — a real TenantDb wrapped by
1514
+ // createUncheckedSystemDb, not a mock, so requireTenantScopedDeps'
1515
+ // assertTenantMatch() call exercises the actual production code path.
1516
+ function makeSystemDb(tenantId: TenantId) {
1517
+ return createUncheckedSystemDb(createTenantDb(db, tenantId, "system"));
1518
+ }
1519
+
1504
1520
  test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
1505
1521
  const { runner, dispatched } = makeStubRunner();
1506
1522
  const attemptId = "00000000-0000-4000-8000-0000000000aa";
@@ -1516,7 +1532,13 @@ describe("flow 17: async render→send pipeline", () => {
1516
1532
  priority: "normal",
1517
1533
  message: { notificationType: "app:notify:render-isolation", title: "X" },
1518
1534
  },
1519
- { db, registry: stack.registry, jobRunner: runner },
1535
+ // @cast-boundary test-seam — job throws before touching systemUser/log/write/queryAs
1536
+ {
1537
+ db,
1538
+ registry: stack.registry,
1539
+ jobRunner: runner,
1540
+ systemDb: makeSystemDb(admin.tenantId),
1541
+ } as unknown as JobContext,
1520
1542
  ),
1521
1543
  ).rejects.toThrow(/no render step/);
1522
1544
 
@@ -1527,6 +1549,77 @@ describe("flow 17: async render→send pipeline", () => {
1527
1549
  });
1528
1550
  expect(rows.some((r) => r["status"] === "failed")).toBe(true);
1529
1551
  });
1552
+
1553
+ // The regression this diff exists to prevent: a payload.tenantId that
1554
+ // doesn't match the systemDb's own tenant must be rejected, not silently
1555
+ // used to read/write another tenant's stream (what ctx.db without
1556
+ // assertTenantMatch would have allowed).
1557
+ test("job handlers throw when payload.tenantId doesn't match ctx.systemDb's tenant", async () => {
1558
+ const { runner, dispatched } = makeStubRunner();
1559
+ const foreignTenantId = testTenantId(999);
1560
+ const attemptId = "00000000-0000-4000-8000-0000000000cc";
1561
+ // @cast-boundary test-seam — mirrors job-runner.ts's real systemDb shape
1562
+ const ctxWithMismatchedSystemDb = {
1563
+ db,
1564
+ registry: stack.registry,
1565
+ jobRunner: runner,
1566
+ systemDb: makeSystemDb(admin.tenantId),
1567
+ } as unknown as JobContext;
1568
+
1569
+ await expect(
1570
+ deliveryRenderJob(
1571
+ {
1572
+ channelName: "email",
1573
+ address: testEmail(asyncRecipient),
1574
+ tenantId: foreignTenantId,
1575
+ recipientId: String(asyncRecipient),
1576
+ notificationType: "app:notify:cross-tenant",
1577
+ deliveryAttemptId: attemptId,
1578
+ priority: "normal",
1579
+ message: { notificationType: "app:notify:cross-tenant", title: "X" },
1580
+ },
1581
+ ctxWithMismatchedSystemDb,
1582
+ ),
1583
+ ).rejects.toThrow(/tenant self-check failed/);
1584
+
1585
+ expect(dispatched).toHaveLength(0);
1586
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1587
+ notificationType: "app:notify:cross-tenant",
1588
+ });
1589
+ expect(rows).toHaveLength(0);
1590
+ });
1591
+
1592
+ // Unreachable via real dispatch (job-runner.ts only omits systemDb for jobs
1593
+ // whose feature is NOT r.systemScope()'d, and delivery always is) — this
1594
+ // exercises requireTenantScopedDeps' own defensive guard directly, the
1595
+ // same way the missing-render-step case above exercises resolveChannel's.
1596
+ test("job handlers throw when ctx.systemDb is missing on a system-scoped job", async () => {
1597
+ const { runner } = makeStubRunner();
1598
+ const attemptId = "00000000-0000-4000-8000-0000000000bb";
1599
+ const payload = {
1600
+ channelName: "email",
1601
+ address: testEmail(asyncRecipient),
1602
+ tenantId: admin.tenantId,
1603
+ recipientId: String(asyncRecipient),
1604
+ notificationType: "app:notify:missing-systemdb",
1605
+ deliveryAttemptId: attemptId,
1606
+ priority: "normal" as const,
1607
+ message: { notificationType: "app:notify:missing-systemdb", title: "X" },
1608
+ };
1609
+ // @cast-boundary test-seam — no systemDb, unlike what job-runner.ts actually wires
1610
+ const ctxWithoutSystemDb = {
1611
+ db,
1612
+ registry: stack.registry,
1613
+ jobRunner: runner,
1614
+ } as unknown as JobContext;
1615
+
1616
+ await expect(deliveryRenderJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
1617
+ /ctx\.systemDb missing/,
1618
+ );
1619
+ await expect(deliverySendJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
1620
+ /ctx\.systemDb missing/,
1621
+ );
1622
+ });
1530
1623
  });
1531
1624
 
1532
1625
  // --- Flow 18: priority maps onto the BullMQ job priority ---
@@ -19,7 +19,17 @@ import {
19
19
  notificationPreferencesTable,
20
20
  } from "./tables";
21
21
 
22
- export function createDeliveryFeature(): FeatureDefinition {
22
+ export type DeliveryFeatureOptions = {
23
+ // Access gate for the delivery-log screen and the `log` query it reads —
24
+ // both move together so the nav entry a role sees always matches a
25
+ // callable handler (#2033). Default access.admin includes TenantAdmin,
26
+ // who in a line-of-business app is the operator, not the platform — pass
27
+ // access.systemAdmin to keep this platform-only and out of their nav.
28
+ readonly access?: readonly string[];
29
+ };
30
+
31
+ export function createDeliveryFeature(options?: DeliveryFeatureOptions): FeatureDefinition {
32
+ const resolvedAccess = options?.access ?? access.admin;
23
33
  return defineFeature("delivery", (r) => {
24
34
  r.describe(
25
35
  "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
@@ -118,7 +128,7 @@ export function createDeliveryFeature(): FeatureDefinition {
118
128
  };
119
129
 
120
130
  const queries = {
121
- log: r.queryHandler(logQuery),
131
+ log: r.queryHandler({ ...logQuery, access: { roles: resolvedAccess } }),
122
132
  preferences: r.queryHandler(preferencesQuery),
123
133
  };
124
134
 
@@ -126,7 +136,7 @@ export function createDeliveryFeature(): FeatureDefinition {
126
136
  id: DELIVERY_LOG_SCREEN_ID,
127
137
  type: "custom",
128
138
  renderer: { react: { __component: "DeliveryLogScreen" } },
129
- access: { roles: access.admin },
139
+ access: { roles: resolvedAccess },
130
140
  });
131
141
  r.nav({
132
142
  id: "delivery-log",
@@ -1,5 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { decryptStoredPii } from "../../shared";
5
6
  import { deliveryAttemptsTable } from "../tables";
@@ -11,11 +12,16 @@ export const logQuery = defineQueryHandler({
11
12
  }),
12
13
  access: { roles: access.admin },
13
14
  handler: async (query, ctx) => {
14
- // delivery runs in system-scope (feature.ts r.systemScope()), so ctx.db is
15
- // NOT auto-tenant-filtered — filter explicitly or a tenant admin reads every
16
- // tenant's attempts (with decrypted recipient PII below).
15
+ if (!ctx.systemDb) {
16
+ throw new InternalError({ message: "delivery log handler requires ctx.systemDb" });
17
+ }
18
+ // delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
19
+ // unfiltered). assertTenantMatch is a self-check on the caller's own
20
+ // tenantId, not a query filter — it returns that same unfiltered db, so
21
+ // the explicit `where` below still does the actual tenant scoping.
22
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
17
23
  const rows = await selectMany(
18
- ctx.db,
24
+ db,
19
25
  deliveryAttemptsTable,
20
26
  { tenantId: query.user.tenantId },
21
27
  {
@@ -1,5 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
5
  import { notificationPreferencesTable } from "../tables";
5
6
 
@@ -8,7 +9,19 @@ export const preferencesQuery = defineQueryHandler({
8
9
  schema: z.object({}),
9
10
  access: { openToAll: true },
10
11
  handler: async (query, ctx) => {
11
- const rows = await selectMany(ctx.db, notificationPreferencesTable, { userId: query.user.id });
12
+ // delivery runs in system-scope, so ctx.db is not auto-tenant-filtered —
13
+ // userId alone is not tenant-unique, so a userId-only filter here leaked preferences cross-tenant.
14
+ if (!ctx.systemDb) {
15
+ throw new InternalError({
16
+ message: "preferences: ctx.systemDb missing on a system-scoped handler",
17
+ });
18
+ }
19
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
20
+
21
+ const rows = await selectMany(db, notificationPreferencesTable, {
22
+ tenantId: query.user.tenantId,
23
+ userId: query.user.id,
24
+ });
12
25
 
13
26
  return { rows };
14
27
  },
@@ -1,4 +1,5 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { z } from "zod";
3
4
  import { upsertPreference } from "../upsert-preference";
4
5
 
@@ -15,7 +16,17 @@ export const setPreferenceWrite = defineWriteHandler({
15
16
  const { notificationType, channel, enabled } = event.payload;
16
17
  const { id: userId, tenantId } = event.user;
17
18
 
18
- const result = await upsertPreference(ctx.db, event.user, {
19
+ // delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
20
+ // is guaranteed there. No client-supplied userId exists on this payload;
21
+ // assertTenantMatch is the enforcement path that keeps it that way.
22
+ if (!ctx.systemDb) {
23
+ throw new InternalError({
24
+ message: "setPreference: ctx.systemDb missing on a system-scoped handler",
25
+ });
26
+ }
27
+ const db = ctx.systemDb.assertTenantMatch(tenantId);
28
+
29
+ const result = await upsertPreference(db, event.user, {
19
30
  tenantId,
20
31
  userId,
21
32
  notificationType,
@@ -15,7 +15,7 @@ export {
15
15
  type KillSwitchResolver,
16
16
  type RateLimitConfig,
17
17
  } from "./delivery-service";
18
- export { createDeliveryFeature } from "./feature";
18
+ export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature";
19
19
  export {
20
20
  deliveryAttemptsTable,
21
21
  notificationPreferenceEntity,