@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.198.0",
3
+ "version": "0.199.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -125,12 +125,12 @@
125
125
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
126
126
  },
127
127
  "dependencies": {
128
- "@cosmicdrift/kumiko-dispatcher-live": "0.198.0",
129
- "@cosmicdrift/kumiko-framework": "0.198.0",
130
- "@cosmicdrift/kumiko-headless": "0.198.0",
131
- "@cosmicdrift/kumiko-renderer": "0.198.0",
132
- "@cosmicdrift/kumiko-renderer-web": "0.198.0",
133
- "@cosmicdrift/kumiko-types": "0.198.0",
128
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.0",
129
+ "@cosmicdrift/kumiko-framework": "0.199.0",
130
+ "@cosmicdrift/kumiko-headless": "0.199.0",
131
+ "@cosmicdrift/kumiko-renderer": "0.199.0",
132
+ "@cosmicdrift/kumiko-renderer-web": "0.199.0",
133
+ "@cosmicdrift/kumiko-types": "0.199.0",
134
134
  "@mollie/api-client": "^4.5.0",
135
135
  "@node-rs/argon2": "^2.0.2",
136
136
  "@types/mailparser": "^3.4.6",
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
5
  import {
5
6
  createTestUser,
@@ -306,3 +307,89 @@ describe("compliance-profiles :: needs-profile (S1.5 — Onboarding-Banner)", ()
306
307
  expect(result.status).toBe(403);
307
308
  });
308
309
  });
310
+
311
+ describe("compliance-profiles :: needs-profile mit access.systemAdmin-Narrowing (#2063)", () => {
312
+ let narrowedStack: TestStack;
313
+ let narrowedDb: DbConnection;
314
+
315
+ beforeAll(async () => {
316
+ const narrowedFeature = createComplianceProfilesFeature({ access: access.systemAdmin });
317
+ narrowedStack = await setupTestStack({ features: [narrowedFeature] });
318
+ narrowedDb = narrowedStack.db;
319
+ await unsafeCreateEntityTable(narrowedDb, tenantComplianceProfileEntity);
320
+ await createEventsTable(narrowedDb);
321
+ });
322
+
323
+ afterAll(async () => {
324
+ await narrowedStack.cleanup();
325
+ });
326
+
327
+ // TenantAdmin can no longer reach the picker screen (access.systemAdmin
328
+ // excludes TenantAdmin) — needsSelection must stop pointing them at a
329
+ // screen they can't open.
330
+ test("TenantAdmin ohne Picker-Zugriff → needsSelection bleibt false, reason=picker_not_accessible_for_role", async () => {
331
+ const excludedTenantAdmin = createIsolatedTenantAdmin(200);
332
+ const result = await narrowedStack.http.queryOk<{
333
+ needsSelection: boolean;
334
+ currentProfile: string | null;
335
+ reason?: string;
336
+ }>(NEEDS_PROFILE, {}, excludedTenantAdmin);
337
+ expect(result.needsSelection).toBe(false);
338
+ expect(result.currentProfile).toBeNull();
339
+ expect(result.reason).toBe("picker_not_accessible_for_role");
340
+ });
341
+
342
+ // A user holding both roles can still reach the picker via SystemAdmin —
343
+ // the gate must intersect the caller's actual roles, not just check
344
+ // whether TenantAdmin (the query's fixed call-access role) is among the
345
+ // feature's configured picker roles.
346
+ test("TenantAdmin+SystemAdmin dual-role → needsSelection bleibt true (Picker via SystemAdmin erreichbar)", async () => {
347
+ const dualRoleUser = createTestUser({
348
+ id: 204,
349
+ tenantId: testTenantId(204),
350
+ roles: ["TenantAdmin", "SystemAdmin"],
351
+ });
352
+ const result = await narrowedStack.http.queryOk<{
353
+ needsSelection: boolean;
354
+ currentProfile: string | null;
355
+ reason?: string;
356
+ }>(NEEDS_PROFILE, {}, dualRoleUser);
357
+ expect(result.needsSelection).toBe(true);
358
+ expect(result.reason).toBe("no_profile_selected");
359
+ });
360
+
361
+ // The needs-profile call access itself stays TenantAdmin-only — the
362
+ // `access` option is not passed 1:1 to the query dispatcher (otherwise
363
+ // SystemAdmin could now call an endpoint only TenantAdmin used to be
364
+ // allowed to call — a widening #2063 does not ask for).
365
+ test("SystemAdmin darf needs-profile weiterhin nicht aufrufen (Call-Access unveraendert)", async () => {
366
+ const systemAdmin = createTestUser({
367
+ id: 201,
368
+ tenantId: testTenantId(201),
369
+ roles: ["SystemAdmin"],
370
+ });
371
+ const result = await narrowedStack.http.query(NEEDS_PROFILE, {}, systemAdmin);
372
+ expect(result.status).toBe(403);
373
+ });
374
+
375
+ test("TenantAdmin mit bereits gesetztem Profile → needsSelection bleibt false, kein reason-Sonderfall", async () => {
376
+ const setupAdmin = createIsolatedTenantAdmin(202);
377
+ // set-profile is also narrowed to access.systemAdmin — set up via
378
+ // SystemAdmin to create the profile row for the read test.
379
+ const systemAdminSetup = createTestUser({
380
+ id: 203,
381
+ tenantId: setupAdmin.tenantId,
382
+ roles: ["SystemAdmin"],
383
+ });
384
+ await narrowedStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, systemAdminSetup);
385
+
386
+ const result = await narrowedStack.http.queryOk<{
387
+ needsSelection: boolean;
388
+ currentProfile: string | null;
389
+ reason?: string;
390
+ }>(NEEDS_PROFILE, {}, setupAdmin);
391
+ expect(result.needsSelection).toBe(false);
392
+ expect(result.currentProfile).toBe("eu-dsgvo");
393
+ expect(result.reason).toBeUndefined();
394
+ });
395
+ });
@@ -6,7 +6,7 @@ import {
6
6
  import { COMPLIANCE_PROFILE_SCREEN_ID } from "./constants";
7
7
  import { forTenantQuery } from "./handlers/for-tenant.query";
8
8
  import { listProfilesQuery } from "./handlers/list-profiles.query";
9
- import { needsProfileQuery } from "./handlers/needs-profile.query";
9
+ import { createNeedsProfileQuery } from "./handlers/needs-profile.query";
10
10
  import { setProfileWrite } from "./handlers/set-profile.write";
11
11
  import { subProcessorsQuery } from "./handlers/sub-processors.query";
12
12
  import { COMPLIANCE_PROFILES_I18N } from "./i18n";
@@ -70,7 +70,7 @@ export function createComplianceProfilesFeature(
70
70
  forTenant: r.queryHandler(forTenantQuery),
71
71
  listProfiles: r.queryHandler(listProfilesQuery),
72
72
  subProcessors: r.queryHandler(subProcessorsQuery),
73
- needsProfile: r.queryHandler(needsProfileQuery),
73
+ needsProfile: r.queryHandler(createNeedsProfileQuery(resolvedAccess)),
74
74
  };
75
75
 
76
76
  r.screen({
@@ -5,49 +5,69 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { z } from "zod";
6
6
  import { tenantComplianceProfileTable } from "../schema/profile-selection";
7
7
 
8
- // Onboarding-Banner-Trigger fuer Tenant-Admin.
8
+ // Onboarding banner trigger for tenant admin.
9
9
  //
10
- // Sprint 1.5 — minimaler API-Endpoint, UI-Banner kommt in einem
11
- // spaeteren UI-Sprint. Reine Read-Query: gibt es einen Eintrag in
12
- // tenantComplianceProfile fuer den aktuellen Tenant?
10
+ // Sprint 1.5 — minimal API endpoint, the UI banner comes in a later UI
11
+ // sprint. Pure read query: does an entry exist in tenantComplianceProfile
12
+ // for the current tenant?
13
13
  //
14
- // Wenn neinTenant-Admin muss Profile waehlen (Pflicht beim
15
- // Onboarding). Bis zur Wahl laeuft minimal-no-region mit warning,
16
- // das in der Tenant-Dashboard-Banner sichtbar gemacht werden soll.
14
+ // If nottenant admin must pick a profile (mandatory during onboarding).
15
+ // Until picked, minimal-no-region runs with a warning that should surface
16
+ // in the tenant dashboard banner.
17
17
  //
18
- // Access: TenantAdmin only — der Banner ist nur fuer Tenant-Admins
19
- // relevant, nicht fuer normale Member.
20
- export const needsProfileQuery = defineQueryHandler({
21
- name: "needs-profile",
22
- schema: z.object({}),
23
- access: { roles: [ROLES.TenantAdmin] },
24
- handler: async (query, ctx): Promise<NeedsProfileResponse> => {
25
- const row = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
26
- tenantId: query.user.tenantId,
27
- })) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
18
+ // Access: TenantAdmin only — the banner is only relevant to tenant admins,
19
+ // not regular members. Stays hard-coded to TenantAdmin (not the `access`
20
+ // option of createComplianceProfilesFeature) — passing the option through
21
+ // would widen call access by default to all three access.admin roles
22
+ // (including SystemAdmin). Instead the handler takes `pickerReachableRoles`
23
+ // and deliberately resolves the #2063 special case: if the *caller's*
24
+ // roles no longer intersect the picker's `access` option, the query stops
25
+ // reporting needsSelection otherwise a tenant admin would be told to
26
+ // open a screen they can't reach.
27
+ export function createNeedsProfileQuery(pickerReachableRoles: readonly string[]) {
28
+ return defineQueryHandler({
29
+ name: "needs-profile",
30
+ schema: z.object({}),
31
+ access: { roles: [ROLES.TenantAdmin] },
32
+ handler: async (query, ctx): Promise<NeedsProfileResponse> => {
33
+ const row = (await fetchOne(ctx.db, tenantComplianceProfileTable, {
34
+ tenantId: query.user.tenantId,
35
+ })) as { profileKey: ComplianceProfileKey } | null; // @cast-boundary db-runner
36
+
37
+ if (row) {
38
+ // S1.7 X1: minimal-no-region is no longer settable via set-profile
39
+ // (Zod). If Sprint 2 ships a seedComplianceProfile helper that
40
+ // reintroduces the migration edge case, a defensive path belongs
41
+ // here again — until then, every existing entry is a deliberately
42
+ // chosen production profile.
43
+ return {
44
+ needsSelection: false,
45
+ currentProfile: row.profileKey,
46
+ };
47
+ }
48
+
49
+ const callerCanReachPicker = query.user.roles.some((role) =>
50
+ pickerReachableRoles.includes(role),
51
+ );
52
+ if (!callerCanReachPicker) {
53
+ return {
54
+ needsSelection: false,
55
+ currentProfile: null,
56
+ reason: "picker_not_accessible_for_role",
57
+ };
58
+ }
28
59
 
29
- if (!row) {
30
60
  return {
31
61
  needsSelection: true,
32
62
  currentProfile: null,
33
63
  reason: "no_profile_selected",
34
64
  };
35
- }
36
-
37
- // S1.7 X1: minimal-no-region ist via set-profile (Zod) nicht mehr
38
- // setzbar. Wenn Sprint 2 einen seedComplianceProfile-Helper liefert
39
- // der den Migration-Edge-Case einführt, kommt hier wieder ein
40
- // defensiver Pfad rein — bis dahin: jeder existierende Eintrag ist
41
- // ein bewusst gewähltes Production-Profile.
42
- return {
43
- needsSelection: false,
44
- currentProfile: row.profileKey,
45
- };
46
- },
47
- });
65
+ },
66
+ });
67
+ }
48
68
 
49
69
  interface NeedsProfileResponse {
50
70
  readonly needsSelection: boolean;
51
71
  readonly currentProfile: ComplianceProfileKey | null;
52
- readonly reason?: "no_profile_selected";
72
+ readonly reason?: "no_profile_selected" | "picker_not_accessible_for_role";
53
73
  }
@@ -0,0 +1,185 @@
1
+ // Regression guard for kumiko-framework#2072: the KEK-rotation job's
2
+ // systemScope() fail-closed checks — createUncheckedSystemDb().
3
+ // acknowledgeCrossTenant() gating the initial cross-tenant scan,
4
+ // assertRowsTenant() re-verifying each per-tenant slice before it's served
5
+ // to the write loop — must not block legitimate multi-tenant rotation. A
6
+ // systemDb accidentally bound to the wrong tenant (e.g. always
7
+ // SYSTEM_TENANT_ID instead of the scanned row's own tenant) would make
8
+ // every non-system-tenant row fail closed via assertRowsTenant, landing in
9
+ // `failed` (not thrown — chunked-entity-migration.ts routes migrateRow
10
+ // throws through onRowError) rather than `migrated`.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { randomBytes } from "node:crypto";
14
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
+ import {
17
+ access,
18
+ createSystemUser,
19
+ createTenantConfig,
20
+ defineFeature,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import {
23
+ createEnvelopeCipher,
24
+ createEnvMasterKeyProvider,
25
+ } from "@cosmicdrift/kumiko-framework/secrets";
26
+ import {
27
+ setupTestStack,
28
+ type TestStack,
29
+ testTenantId,
30
+ unsafePushTables,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
33
+ import { createConfigFeature } from "../feature";
34
+ import { reencryptJob } from "../handlers/reencrypt.job";
35
+ import { createConfigResolver } from "../resolver";
36
+ import { configValueEntity, configValuesTable } from "../table";
37
+
38
+ const KEY = "tenant-check-rot:config:secret-pass";
39
+ const TENANT_A = testTenantId(201);
40
+ const TENANT_B = testTenantId(202);
41
+
42
+ const v1Key = randomBytes(32).toString("base64");
43
+ const v2Key = randomBytes(32).toString("base64");
44
+ const mutableProvider = createMutableMasterKeyProvider(
45
+ createEnvMasterKeyProvider({
46
+ env: {
47
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
48
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
49
+ },
50
+ }),
51
+ );
52
+ const cipher = createEnvelopeCipher(mutableProvider, {});
53
+ const resolver = createConfigResolver({ cipher });
54
+
55
+ const keyDef = createTenantConfig("text", {
56
+ encrypted: true,
57
+ read: access.admin,
58
+ write: access.admin,
59
+ });
60
+
61
+ const tenantCheckFeature = defineFeature("tenant-check-rot", (r) => {
62
+ r.requires("config");
63
+ r.config({ keys: { "secret-pass": keyDef } });
64
+ });
65
+
66
+ const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
67
+ entityName: "config-value",
68
+ });
69
+
70
+ let stack: TestStack;
71
+
72
+ async function seedRow(tenantId: string, plaintext: string): Promise<void> {
73
+ const envelope = await cipher.encrypt(JSON.stringify(plaintext), { tenantId });
74
+ const systemUser = createSystemUser(tenantId);
75
+ const tdb = createTenantDb(stack.db, tenantId, "system");
76
+ const result = await executor.create(
77
+ { key: KEY, value: envelope, tenantId, userId: null },
78
+ systemUser,
79
+ tdb,
80
+ );
81
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
82
+ }
83
+
84
+ type RawConfigRow = { value: string; tenantId: string };
85
+
86
+ async function readRawValues(): Promise<readonly RawConfigRow[]> {
87
+ return await selectMany<RawConfigRow>(stack.db, configValuesTable, { key: KEY });
88
+ }
89
+
90
+ type CapturedLog = { info: string[]; warn: string[] };
91
+
92
+ type TestJobLog = {
93
+ info: (msg: string) => void;
94
+ warn: (msg: string) => void;
95
+ error: () => void;
96
+ debug: () => void;
97
+ child: () => TestJobLog;
98
+ };
99
+
100
+ function capturingLog(captured: CapturedLog): TestJobLog {
101
+ const log: TestJobLog = {
102
+ info: (msg) => captured.info.push(msg),
103
+ warn: (msg) => captured.warn.push(msg),
104
+ error: () => {},
105
+ debug: () => {},
106
+ child: () => log,
107
+ };
108
+ return log;
109
+ }
110
+
111
+ function jobCtx(captured: CapturedLog): Parameters<typeof reencryptJob>[1] {
112
+ return {
113
+ db: stack.db,
114
+ registry: stack.registry,
115
+ masterKeyProvider: mutableProvider,
116
+ configEncryption: cipher,
117
+ log: capturingLog(captured),
118
+ } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
119
+ }
120
+
121
+ beforeAll(async () => {
122
+ stack = await setupTestStack({
123
+ features: [createConfigFeature(), tenantCheckFeature],
124
+ masterKeyProvider: mutableProvider,
125
+ extraContext: { configResolver: resolver, configEncryption: cipher },
126
+ });
127
+ await unsafePushTables(stack.db, { configValuesTable });
128
+ await seedRow(TENANT_A, "tenant-a-secret");
129
+ await seedRow(TENANT_B, "tenant-b-secret");
130
+ });
131
+
132
+ afterAll(async () => {
133
+ await stack.cleanup();
134
+ });
135
+
136
+ describe("config KEK-rotation job — multi-tenant scan + write checks (kumiko-framework#2072)", () => {
137
+ test("acknowledgeCrossTenant scan + per-tenant assertRowsTenant checks still rotate every tenant's row", async () => {
138
+ const beforeRows = await readRawValues();
139
+ expect(beforeRows).toHaveLength(2);
140
+ for (const row of beforeRows) {
141
+ expect(JSON.parse(row.value).kekVersion).toBe(1);
142
+ }
143
+
144
+ // "ops added a new master key version and flipped CURRENT=2".
145
+ mutableProvider.replace(
146
+ createEnvMasterKeyProvider({
147
+ env: {
148
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
149
+ KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
150
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
151
+ },
152
+ }),
153
+ );
154
+
155
+ const captured: CapturedLog = { info: [], warn: [] };
156
+ await reencryptJob({}, jobCtx(captured));
157
+
158
+ // No warn output — an over-restrictive systemDb binding would reject a
159
+ // row via assertRowsTenant, landing it in `failed` with a warn log
160
+ // instead of `migrated`.
161
+ expect(captured.warn).toEqual([]);
162
+
163
+ const completeLine = captured.info.find((line) =>
164
+ line.includes("[config:reencrypt] complete:"),
165
+ );
166
+ if (!completeLine) throw new Error("job did not log a completion summary");
167
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
168
+ migrated: number;
169
+ failed: number;
170
+ alreadyCurrent: number;
171
+ };
172
+ expect(result.migrated).toBe(2);
173
+ expect(result.failed).toBe(0);
174
+ expect(result.alreadyCurrent).toBe(0);
175
+
176
+ const afterRows = await readRawValues();
177
+ expect(afterRows).toHaveLength(2);
178
+ for (const row of afterRows) {
179
+ expect(JSON.parse(row.value).kekVersion).toBe(2);
180
+ }
181
+
182
+ expect(await resolver.get(KEY, keyDef, TENANT_A, "u1", stack.db)).toBe("tenant-a-secret");
183
+ expect(await resolver.get(KEY, keyDef, TENANT_B, "u1", stack.db)).toBe("tenant-b-secret");
184
+ });
185
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { requireSystemDb } from "../feature";
7
+
8
+ // Minimal TenantDb stub — only .tenantId is read by assertTenantMatch /
9
+ // createUncheckedSystemDb; the real db-query methods are never called by
10
+ // requireSystemDb itself.
11
+ function fakeTenantDb(tenantId: TenantId): TenantDb {
12
+ return { tenantId, mode: "system" } as unknown as TenantDb;
13
+ }
14
+
15
+ // Minimal HandlerContext stub — only .db / .systemDb are read by requireSystemDb.
16
+ function contextStub(db: TenantDb, withSystemDb: boolean): HandlerContext {
17
+ return {
18
+ db,
19
+ systemDb: withSystemDb ? createUncheckedSystemDb(db) : undefined,
20
+ // biome-ignore lint/suspicious/noExplicitAny: rest of HandlerContext isn't touched by requireSystemDb.
21
+ } as any;
22
+ }
23
+
24
+ const TENANT_A = "tenant-a" as TenantId;
25
+ const TENANT_B = "tenant-b" as TenantId;
26
+
27
+ describe("requireSystemDb", () => {
28
+ test("throws InternalError when ctx.systemDb is missing", () => {
29
+ const ctx = contextStub(fakeTenantDb(TENANT_A), false);
30
+ expect(() => requireSystemDb(ctx, "config:query:test", TENANT_A)).toThrow(
31
+ /ctx\.systemDb missing/,
32
+ );
33
+ });
34
+
35
+ test("returns the underlying TenantDb when tenantId matches", () => {
36
+ const db = fakeTenantDb(TENANT_A);
37
+ const ctx = contextStub(db, true);
38
+ expect(requireSystemDb(ctx, "config:query:test", TENANT_A)).toBe(db);
39
+ });
40
+
41
+ test("fails closed with AccessDeniedError when tenantId does not match ctx.db's own tenant", () => {
42
+ const db = fakeTenantDb(TENANT_A);
43
+ const ctx = contextStub(db, true);
44
+ expect(() => requireSystemDb(ctx, "config:query:test", TENANT_B)).toThrow(AccessDeniedError);
45
+ });
46
+ });
@@ -120,6 +120,34 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
120
120
  return ctx.configResolver;
121
121
  }
122
122
 
123
+ // Config declares r.systemScope() (raw ctx.db is unfiltered across tenants
124
+ // in that mode), so config's own query handlers must go through ctx.systemDb's
125
+ // explicit self-check instead of touching ctx.db directly. Only valid for
126
+ // handlers dispatched as config:query:* — those always run under config's
127
+ // own system-scoped context, so ctx.systemDb is never actually missing here;
128
+ // the throw documents that invariant instead of silently degrading.
129
+ // buildProviderSelectionGate / collectMissingRequiredConfig (readiness.query.ts)
130
+ // are shared with the separate, plain tenant-scoped readiness feature, so they
131
+ // take the already-scoped db as an explicit parameter instead of resolving it
132
+ // themselves — each call site (config's own handler here, readiness's
133
+ // status.query.ts) makes its own scope decision instead of this function
134
+ // guessing from ctx.systemDb's presence.
135
+ export function requireSystemDb(
136
+ ctx: HandlerContext,
137
+ handlerName: string,
138
+ tenantId: TenantId,
139
+ ): TenantDb {
140
+ if (!ctx.systemDb) {
141
+ throw new InternalError({
142
+ message:
143
+ `[${handlerName}] ctx.systemDb missing — only present for r.systemScope() handlers; ` +
144
+ `the config feature declares r.systemScope() in feature.ts, so this points at a ` +
145
+ `pipeline wiring bug rather than a missing feature declaration.`,
146
+ });
147
+ }
148
+ return ctx.systemDb.assertTenantMatch(tenantId);
149
+ }
150
+
123
151
  // Mirror of requireConfigResolver for the encryption round-trip side.
124
152
  // Only keys declared `encrypted: true` need this — the setter calls it
125
153
  // lazily so apps that never wire encryption still boot (and only crash
@@ -5,7 +5,7 @@ import {
5
5
  isEncryptedAtRest,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
- import { requireConfigResolver } from "../feature";
8
+ import { requireConfigResolver, requireSystemDb } from "../feature";
9
9
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
10
10
  import { hasConfigAccess } from "../write-helpers";
11
11
 
@@ -16,7 +16,7 @@ export const cascadeQuery = defineQueryHandler({
16
16
  }),
17
17
  access: { openToAll: true },
18
18
  handler: async (query, ctx) => {
19
- const db = ctx.db;
19
+ const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
20
20
  const registry = ctx.registry;
21
21
  const resolver = requireConfigResolver(ctx, "config:query:cascade");
22
22
 
@@ -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
  });