@cosmicdrift/kumiko-bundled-features 0.198.0 → 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 (37) hide show
  1. package/package.json +7 -7
  2. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  3. package/src/compliance-profiles/feature.ts +2 -2
  4. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  5. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  6. package/src/config/__tests__/require-system-db.test.ts +46 -0
  7. package/src/config/feature.ts +28 -0
  8. package/src/config/handlers/cascade.query.ts +2 -2
  9. package/src/config/handlers/readiness.query.ts +19 -6
  10. package/src/config/handlers/reencrypt.job.ts +79 -21
  11. package/src/config/handlers/values.query.ts +2 -2
  12. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  13. package/src/delivery/__tests__/delivery.integration.test.ts +94 -3
  14. package/src/delivery/handlers/log.query.ts +10 -4
  15. package/src/delivery/handlers/preferences.query.ts +14 -1
  16. package/src/delivery/handlers/set-preference.write.ts +12 -1
  17. package/src/delivery/jobs.ts +20 -10
  18. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  19. package/src/feature-toggles/constants.ts +6 -0
  20. package/src/feature-toggles/handlers/list.query.ts +12 -1
  21. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  22. package/src/feature-toggles/handlers/set.write.ts +18 -3
  23. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  24. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  25. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +39 -0
  26. package/src/jobs/handlers/detail.query.ts +7 -1
  27. package/src/jobs/handlers/list.query.ts +8 -1
  28. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  29. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  30. package/src/jobs/handlers/retry.write.ts +7 -1
  31. package/src/readiness/handlers/status.query.ts +2 -1
  32. package/src/tenant/handlers/update.write.ts +30 -5
  33. package/src/user/__tests__/user.integration.test.ts +21 -0
  34. package/src/user/handlers/create.write.ts +10 -3
  35. package/src/user/handlers/find-for-auth.query.ts +9 -1
  36. package/src/user/handlers/me.query.ts +10 -1
  37. package/src/user/handlers/update.write.ts +12 -2
@@ -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
 
@@ -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,7 +1,12 @@
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,
@@ -10,6 +15,7 @@ import {
10
15
  type JobContext,
11
16
  type NotifyFn,
12
17
  qn,
18
+ type TenantId,
13
19
  } from "@cosmicdrift/kumiko-framework/engine";
14
20
  import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
15
21
  import {
@@ -17,6 +23,7 @@ import {
17
23
  setupTestStack,
18
24
  type TestStack,
19
25
  TestUsers,
26
+ testTenantId,
20
27
  unsafePushTables,
21
28
  } from "@cosmicdrift/kumiko-framework/stack";
22
29
  import { z } from "zod";
@@ -40,7 +47,7 @@ import { tenantEntity } from "../../tenant/schema/tenant";
40
47
  import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
41
48
  import { collectChannels, createDeliveryService } from "../delivery-service";
42
49
  import { createDeliveryFeature } from "../feature";
43
- import { deliveryRenderJob } from "../jobs";
50
+ import { deliveryRenderJob, deliverySendJob } from "../jobs";
44
51
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
45
52
  import { createDeliveryTestContext } from "../testing";
46
53
  import type { DeliveryService } from "../types";
@@ -1502,6 +1509,14 @@ describe("flow 17: async render→send pipeline", () => {
1502
1509
  expect(emailRows.some((r) => r["status"] === "sent")).toBe(true);
1503
1510
  });
1504
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
+
1505
1520
  test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
1506
1521
  const { runner, dispatched } = makeStubRunner();
1507
1522
  const attemptId = "00000000-0000-4000-8000-0000000000aa";
@@ -1518,7 +1533,12 @@ describe("flow 17: async render→send pipeline", () => {
1518
1533
  message: { notificationType: "app:notify:render-isolation", title: "X" },
1519
1534
  },
1520
1535
  // @cast-boundary test-seam — job throws before touching systemUser/log/write/queryAs
1521
- { db, registry: stack.registry, jobRunner: runner } as unknown as JobContext,
1536
+ {
1537
+ db,
1538
+ registry: stack.registry,
1539
+ jobRunner: runner,
1540
+ systemDb: makeSystemDb(admin.tenantId),
1541
+ } as unknown as JobContext,
1522
1542
  ),
1523
1543
  ).rejects.toThrow(/no render step/);
1524
1544
 
@@ -1529,6 +1549,77 @@ describe("flow 17: async render→send pipeline", () => {
1529
1549
  });
1530
1550
  expect(rows.some((r) => r["status"] === "failed")).toBe(true);
1531
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
+ });
1532
1623
  });
1533
1624
 
1534
1625
  // --- Flow 18: priority maps onto the BullMQ job priority ---
@@ -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,
@@ -7,11 +7,12 @@
7
7
 
8
8
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
9
9
  import type {
10
- AppContext,
10
+ JobContext,
11
11
  JobHandlerFn,
12
12
  Registry,
13
13
  TenantId,
14
14
  } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
15
16
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
16
17
  import { z } from "zod";
17
18
  import { appendAttemptEvent } from "./attempt-log";
@@ -44,13 +45,22 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
44
45
 
45
46
  type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
46
47
 
47
- function requireDeps(ctx: AppContext): { db: DbConnection; registry: Registry } {
48
+ function requireTenantScopedDeps(
49
+ ctx: JobContext,
50
+ tenantId: TenantId,
51
+ ): { db: DbConnection; registry: Registry } {
48
52
  const registry = ctx.registry;
49
53
  if (!registry) throw new Error("delivery job: missing registry in job context");
50
- // Job context provides the raw system connection (not tenant-scoped); the
51
- // append + projection layer scopes per event payload.
52
- const db = ctx.db as DbConnection; // @cast-boundary engine-bridge — job ctx db is the system connection
53
- if (!db) throw new Error("delivery job: missing db in job context");
54
+ // delivery is r.systemScope()'d (feature.ts), so JobContext.systemDb is
55
+ // guaranteed here. assertTenantMatch(tenantId).raw hands appendAttemptEvent/
56
+ // buildChannelContext the underlying DbConnection — safe because both
57
+ // filter by the same tenantId themselves (see JobContext.systemDb doc).
58
+ if (!ctx.systemDb) {
59
+ throw new InternalError({
60
+ message: "delivery job: ctx.systemDb missing on a system-scoped job",
61
+ });
62
+ }
63
+ const db = ctx.systemDb.assertTenantMatch(tenantId).raw as DbConnection; // @cast-boundary db-operator — TenantDb.raw is DbRunner, jobs never run inside a DbTx
54
64
  return { db, registry };
55
65
  }
56
66
 
@@ -96,9 +106,9 @@ function messageOf(err: unknown): string {
96
106
  // the send step is never reached, so a stuck render can't half-send.
97
107
  export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
98
108
  const p = renderJobPayloadSchema.parse(payload);
99
- const { db, registry } = requireDeps(ctx);
100
- const channel = resolveChannel(registry, p.channelName);
101
109
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
110
+ const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
111
+ const channel = resolveChannel(registry, p.channelName);
102
112
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
103
113
 
104
114
  try {
@@ -128,9 +138,9 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
128
138
  // the same already-rendered HTML — no re-render needed.
129
139
  export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
130
140
  const p = sendJobPayloadSchema.parse(payload);
131
- const { db, registry } = requireDeps(ctx);
132
- const channel = resolveChannel(registry, p.channelName);
133
141
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
142
+ const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
143
+ const channel = resolveChannel(registry, p.channelName);
134
144
  const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
135
145
 
136
146
  try {
@@ -0,0 +1,23 @@
1
+ // framework#2076: set.write / registered.query / list.query all read/write
2
+ // globalFeatureStateTable via ctx.systemDb.acknowledgeCrossTenant(...) instead
3
+ // of ctx.db directly. That only resolves correctly as long as the
4
+ // feature-toggles feature stays r.systemScope() (feature.ts) — buildHandlerContext
5
+ // only populates ctx.systemDb for system-scoped handlers (dispatch-shared.ts).
6
+ // This test pins that coupling so a future refactor that drops r.systemScope()
7
+ // fails loudly here instead of surfacing as "undefined.acknowledgeCrossTenant"
8
+ // deep inside a handler.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
13
+ import { createFeatureTogglesFeature } from "../feature";
14
+
15
+ describe("feature-toggles handlers stay r.systemScope()-coupled", () => {
16
+ const registry = createRegistry([createFeatureTogglesFeature()]);
17
+
18
+ test("set/list/registered are all system-scoped", () => {
19
+ expect(registry.isHandlerSystemScoped(FeatureToggleHandlers.set)).toBe(true);
20
+ expect(registry.isHandlerSystemScoped(FeatureToggleQueries.list)).toBe(true);
21
+ expect(registry.isHandlerSystemScoped(FeatureToggleQueries.registered)).toBe(true);
22
+ });
23
+ });
@@ -10,6 +10,12 @@ export const FEATURE_TOGGLE_SET_EVENT_NAME = "feature-toggles:event:toggle-set";
10
10
  // "feature-toggles" in its aggregate_type column.
11
11
  export const FEATURE_TOGGLE_AGGREGATE_TYPE = "feature-toggles";
12
12
 
13
+ // Shared acknowledgeCrossTenant() reason across all three handlers that read
14
+ // or write globalFeatureStateTable — one string so the three call sites
15
+ // can't drift (framework#2076).
16
+ export const FEATURE_TOGGLE_CROSS_TENANT_REASON =
17
+ "global feature-toggle state, no tenant scope by design";
18
+
13
19
  // Error reasons surfaced from feature-toggle handlers. Scoped to the
14
20
  // feature's namespace per the framework's reason-convention.
15
21
  export const FeatureToggleErrors = {
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
4
5
  import { globalFeatureStateTable } from "../global-feature-state-table";
5
6
 
6
7
  // List every row in the global_feature_state table — i.e. every feature
@@ -12,6 +13,16 @@ export const listQuery = defineQueryHandler({
12
13
  schema: z.object({}),
13
14
  access: { roles: ["SystemAdmin"] },
14
15
  handler: async (_event, ctx) => {
16
+ // ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
17
+ // declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
18
+ if (!ctx.systemDb) {
19
+ throw new Error(
20
+ "[feature-toggles] list-query requires ctx.systemDb — the feature-toggles " +
21
+ "feature must stay r.systemScope() (see feature.ts).",
22
+ );
23
+ }
24
+ const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
25
+
15
26
  type Row = {
16
27
  featureName: string;
17
28
  enabled: boolean;
@@ -19,7 +30,7 @@ export const listQuery = defineQueryHandler({
19
30
  updatedAt: Temporal.Instant;
20
31
  updatedBy: string;
21
32
  };
22
- const rows = await selectMany<Row>(ctx.db.raw, globalFeatureStateTable);
33
+ const rows = await selectMany<Row>(db.raw, globalFeatureStateTable);
23
34
  return {
24
35
  items: rows.map((r) => ({
25
36
  featureName: r.featureName,
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
4
5
  import { globalFeatureStateTable } from "../global-feature-state-table";
5
6
 
6
7
  // Inventory of every registered feature, annotated with toggle metadata
@@ -16,8 +17,18 @@ export const registeredQuery = defineQueryHandler({
16
17
  schema: z.object({}),
17
18
  access: { roles: ["SystemAdmin"] },
18
19
  handler: async (_event, ctx) => {
20
+ // ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
21
+ // declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
22
+ if (!ctx.systemDb) {
23
+ throw new Error(
24
+ "[feature-toggles] registered-query requires ctx.systemDb — the feature-toggles " +
25
+ "feature must stay r.systemScope() (see feature.ts).",
26
+ );
27
+ }
28
+ const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
29
+
19
30
  type OverrideRow = { featureName: string; enabled: boolean };
20
- const overrideRows = await selectMany<OverrideRow>(ctx.db.raw, globalFeatureStateTable);
31
+ const overrideRows = await selectMany<OverrideRow>(db.raw, globalFeatureStateTable);
21
32
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
22
33
 
23
34
  // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth