@cosmicdrift/kumiko-bundled-features 0.270.0 → 0.274.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 (56) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-fixture.ts +3 -0
  3. package/src/agent-tools/tool-catalog.ts +1 -1
  4. package/src/agent-tools/tool-dispatch.ts +1 -1
  5. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  6. package/src/audit/changes.json +12 -0
  7. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  8. package/src/audit/feature.ts +5 -1
  9. package/src/audit/index.ts +1 -0
  10. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  11. package/src/auth-mfa/screens.ts +14 -10
  12. package/src/cap-counter/feature.ts +5 -3
  13. package/src/cap-overview/screens.ts +6 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  15. package/src/config/changes.json +6 -0
  16. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  17. package/src/config/db/queries/resolver.ts +22 -22
  18. package/src/config/handlers/cascade.query.ts +6 -0
  19. package/src/config/handlers/readiness.query.ts +6 -0
  20. package/src/config/handlers/reencrypt.job.ts +21 -2
  21. package/src/config/handlers/schema.query.ts +6 -0
  22. package/src/config/handlers/values.query.ts +6 -0
  23. package/src/delivery/__tests__/delivery.integration.test.ts +34 -0
  24. package/src/delivery/changes.json +6 -0
  25. package/src/delivery/delivery-service.ts +25 -3
  26. package/src/delivery/handlers/preferences.query.ts +6 -0
  27. package/src/delivery/testing.ts +8 -1
  28. package/src/jobs/handlers/list.query.ts +3 -1
  29. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  30. package/src/managed-pages/screens/branding-screen.ts +3 -0
  31. package/src/managed-pages/screens/page-screens.ts +3 -0
  32. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  33. package/src/notes-history/web/i18n.ts +3 -0
  34. package/src/notes-history/web/notes-section.tsx +61 -51
  35. package/src/personal-access-tokens/screens.ts +7 -7
  36. package/src/sessions/screens.ts +5 -3
  37. package/src/tags/screens.ts +3 -0
  38. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  39. package/src/tenant/changes.json +6 -0
  40. package/src/tenant/handlers/me.query.ts +6 -0
  41. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  42. package/src/tenant/screens.ts +3 -0
  43. package/src/tenant/seeding.ts +25 -19
  44. package/src/tier-engine/feature.ts +3 -0
  45. package/src/user/changes.json +6 -0
  46. package/src/user/handlers/me.query.ts +6 -0
  47. package/src/user/handlers/update-roles.ts +8 -5
  48. package/src/user/screens.ts +3 -0
  49. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  50. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  51. package/src/user-data-rights/feature.ts +3 -5
  52. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  53. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  54. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  55. package/src/user-data-rights/screens.ts +3 -0
  56. package/src/user-profile/feature.ts +3 -0
@@ -10,13 +10,6 @@ import {
10
10
  MFA_REGENERATE_RECOVERY_SCREEN_ID,
11
11
  } from "./constants";
12
12
 
13
- const MFA_ENABLE_OPEN_REASON =
14
- "each signed-in user may enroll their own account in MFA, mirroring the enable-start handler";
15
- const MFA_DISABLE_OPEN_REASON =
16
- "each signed-in user may turn off their own MFA, mirroring the disable handler";
17
- const MFA_REGENERATE_RECOVERY_OPEN_REASON =
18
- "each signed-in user may regenerate their own recovery codes, mirroring the regenerate-recovery handler";
19
-
20
13
  // Declarative TOTP-enrollment screen: mint (no input) -> reveal QR/secret/
21
14
  // recovery-codes -> confirm with a 6-digit code. `setupToken` is carried
22
15
  // from the mint payload into the confirm payload but never listed in
@@ -30,9 +23,13 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
30
23
  submitLabel: i18nKey("mfa.enable.start"),
31
24
  access: {
32
25
  openToAll: {
33
- reason: MFA_ENABLE_OPEN_REASON,
26
+ reason:
27
+ "each signed-in user may enroll their own account in MFA, mirroring the enable-start handler",
34
28
  },
35
29
  },
30
+ // Reached only via a direct link from account settings, never from a
31
+ // list — no nav area to resolve in isolation.
32
+ dormant: true,
36
33
  description:
37
34
  "Self-service screen where a signed-in user enrolls in TOTP two-factor authentication: it shows the QR code and manual secret, reveals the recovery codes once, and confirms enrollment with a code from their authenticator app.",
38
35
  reveal: {
@@ -86,9 +83,12 @@ export const mfaDisableScreen: ActionFormScreenDefinition = {
86
83
  cancelTarget: false,
87
84
  access: {
88
85
  openToAll: {
89
- reason: MFA_DISABLE_OPEN_REASON,
86
+ reason: "each signed-in user may turn off their own MFA, mirroring the disable handler",
90
87
  },
91
88
  },
89
+ // Reached only via a direct link from account settings, never from a
90
+ // list — no nav area to resolve in isolation.
91
+ dormant: true,
92
92
  description:
93
93
  "Self-service screen where a signed-in user turns two-factor authentication off by entering a code from their authenticator app or a recovery code; their other sessions and access tokens are signed out.",
94
94
  };
@@ -103,9 +103,13 @@ export const mfaRegenerateRecoveryScreen: SecretMintScreenDefinition = {
103
103
  cancelTarget: false,
104
104
  access: {
105
105
  openToAll: {
106
- reason: MFA_REGENERATE_RECOVERY_OPEN_REASON,
106
+ reason:
107
+ "each signed-in user may regenerate their own recovery codes, mirroring the regenerate-recovery handler",
107
108
  },
108
109
  },
110
+ // Reached only via a direct link from account settings, never from a
111
+ // list — no nav area to resolve in isolation.
112
+ dormant: true,
109
113
  description:
110
114
  "Self-service screen where a signed-in user with two-factor authentication replaces all recovery codes after confirming with a current code; the new codes are shown once.",
111
115
  reveal: {
@@ -66,8 +66,6 @@ import {
66
66
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
67
67
  import { CAP_COUNTER_I18N } from "./i18n";
68
68
 
69
- const sysadminAccess = { access: { roles: access.systemAdmin }, crossTenant: true } as const;
70
-
71
69
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
72
70
  r.describe(
73
71
  "Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
@@ -100,7 +98,11 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
100
98
  // über aggregate-id).
101
99
  r.queryHandler(
102
100
  defineEntityListHandler("cap-counter", capCounterEntity, {
103
- ...sysadminAccess,
101
+ access: { roles: access.systemAdmin },
102
+ escapeHatch: {
103
+ reason:
104
+ "SystemAdmin quota operator view lists usage counters of every tenant to see who consumes which cap",
105
+ },
104
106
  description:
105
107
  "Lists usage-counter rows across all tenants with cap name, current value, period start and soft-warn timestamp; use it as the operator view of who is consuming which quota.",
106
108
  }),
@@ -66,6 +66,9 @@ export function createTenantCapListScreen(
66
66
  ],
67
67
  defaultSort: { field: "name", dir: "asc" },
68
68
  access: { roles: ["SystemAdmin"] },
69
+ // No nav wiring yet (see feature.ts header) — no nav area to resolve
70
+ // in isolation.
71
+ dormant: true,
69
72
  };
70
73
  }
71
74
 
@@ -80,6 +83,9 @@ export const myCapsScreen: ScreenDefinition = {
80
83
  },
81
84
  ],
82
85
  access: { roles: MY_CAPS_ACCESS_ROLES },
86
+ // No nav wiring yet (see feature.ts header) — no nav area to resolve in
87
+ // isolation.
88
+ dormant: true,
83
89
  };
84
90
 
85
91
  export const platformTenantCapsScreen: ScreenDefinition = {
@@ -19,6 +19,7 @@ import {
19
19
  createTenantConfig,
20
20
  defineFeature,
21
21
  } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { ESCAPE_HATCH_USED_SIGNAL } from "@cosmicdrift/kumiko-framework/pipeline";
22
23
  import {
23
24
  createEnvelopeCipher,
24
25
  createEnvMasterKeyProvider,
@@ -158,7 +159,7 @@ describe("config KEK-rotation job — multi-tenant scan + write checks (kumiko-f
158
159
  // No warn output — an over-restrictive systemDb binding would reject a
159
160
  // row via assertRowsTenant, landing it in `failed` with a warn log
160
161
  // instead of `migrated`.
161
- expect(captured.warn).toEqual([]);
162
+ expect(captured.warn.filter((msg) => msg !== ESCAPE_HATCH_USED_SIGNAL)).toEqual([]);
162
163
 
163
164
  const completeLine = captured.info.find((line) =>
164
165
  line.includes("[config:reencrypt] complete:"),
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.271.0",
4
+ "type": "improvement",
5
+ "title": "systemScope handlers get a default per-tenant+handler rate limit; the four read queries opt out (fw#2861).",
6
+ "detail": "`config` mounts under `r.systemScope()`, so `config:write:set`/`config:write:reset` without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `config:query:cascade`, `config:query:values`, `config:query:schema` and `config:query:readiness` declare `rateLimit: { disabled: true, reason: \"...\" }` — each is a self-scoped read of the caller's own readable config, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
7
+ },
2
8
  {
3
9
  "version": "0.165.0",
4
10
  "type": "breaking",
@@ -1,27 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { createTenantDb, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
4
  import { selectConfigRowsForKeys, selectConfigRowsForScope } from "../resolver";
3
5
 
4
- function closedConnectionError(): Error {
5
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
6
- }
7
-
8
6
  type FakeClient = {
9
7
  unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
10
8
  begin: () => never;
11
9
  calls: number;
12
10
  };
13
11
 
14
- function fakeClient(failures: Error[]): FakeClient {
15
- const remaining = [...failures];
12
+ function fakeClient(rowsPerCall: readonly (readonly Record<string, unknown>[])[]): FakeClient {
16
13
  const client: FakeClient = {
17
14
  calls: 0,
18
15
  unsafe: async () => {
16
+ const rows = rowsPerCall[client.calls] ?? [];
19
17
  client.calls++;
20
- const err = remaining.shift();
21
- if (err) throw err;
22
- return [{ id: "r1", key: "k", value: "v", tenantId: "t1", userId: null }];
18
+ return rows;
23
19
  },
24
- // begin() present => pool client => retry path
25
20
  begin: () => {
26
21
  throw new Error("not used in test");
27
22
  },
@@ -29,44 +24,54 @@ function fakeClient(failures: Error[]): FakeClient {
29
24
  return client;
30
25
  }
31
26
 
32
- describe("config db/queries/resolver — closed-connection retry (#1163)", () => {
33
- test("selectConfigRowsForScope retries once and returns rows", async () => {
34
- const db = fakeClient([closedConnectionError()]);
27
+ describe("config db/queries/resolver — scope-bucket merge", () => {
28
+ test("selectConfigRowsForScope merges the scope bucket and the user bucket", async () => {
29
+ const db = fakeClient([
30
+ [{ id: "sys-1", key: "k1", value: "v-sys", tenantId: "system", userId: null }],
31
+ [{ id: "user-1", key: "k1", value: "v-user", tenantId: "t1", userId: "u1" }],
32
+ ]);
35
33
  const rows = await selectConfigRowsForScope(db as never, "system", "t1", "u1");
36
- expect(rows).toHaveLength(1);
37
- expect(rows[0]?.key).toBe("k");
38
34
  expect(db.calls).toBe(2);
35
+ expect(rows.map((r) => r.id).sort()).toEqual(["sys-1", "user-1"]);
39
36
  });
40
37
 
41
- test("selectConfigRowsForKeys retries once and returns rows", async () => {
42
- const db = fakeClient([closedConnectionError()]);
43
- const rows = await selectConfigRowsForKeys(db as never, ["k"], "system", "t1", "u1");
44
- expect(rows).toHaveLength(1);
38
+ test("selectConfigRowsForKeys merges the scope bucket and the user bucket", async () => {
39
+ const db = fakeClient([
40
+ [{ id: "sys-1", key: "k1", value: "v-sys", tenantId: "system", userId: null }],
41
+ [{ id: "user-1", key: "k1", value: "v-user", tenantId: "t1", userId: "u1" }],
42
+ ]);
43
+ const rows = await selectConfigRowsForKeys(db as never, ["k1"], "system", "t1", "u1");
45
44
  expect(db.calls).toBe(2);
45
+ expect(rows.map((r) => r.id).sort()).toEqual(["sys-1", "user-1"]);
46
46
  });
47
47
 
48
- test("gives up after the single retry when the connection stays closed", async () => {
49
- const db = fakeClient([closedConnectionError(), closedConnectionError()]);
50
- await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
51
- "connection was closed",
52
- );
53
- expect(db.calls).toBe(2);
48
+ test("selectConfigRowsForKeys returns [] without querying when keys is empty", async () => {
49
+ const db = fakeClient([]);
50
+ const rows = await selectConfigRowsForKeys(db as never, [], "system", "t1", "u1");
51
+ expect(rows).toEqual([]);
52
+ expect(db.calls).toBe(0);
54
53
  });
54
+ });
55
55
 
56
- test("does not retry when the client has no begin()", async () => {
57
- let calls = 0;
58
- const db = {
59
- unsafe: async () => {
60
- calls++;
61
- throw closedConnectionError();
56
+ describe("config db/queries/resolver — tenant-mode TenantDb narrowing", () => {
57
+ test("selectConfigRowsForScope's tenantId array survives readWhere's narrowing", async () => {
58
+ const captured: { sql: string; values: readonly unknown[] }[] = [];
59
+ const recordingRunner = {
60
+ unsafe: async (sql: string, values: readonly unknown[]) => {
61
+ captured.push({ sql, values });
62
+ return [] as unknown[];
62
63
  },
63
- savepoint: () => {
64
- throw new Error("not used");
64
+ begin: async () => {
65
+ throw new Error("not used in test");
65
66
  },
66
- };
67
- await expect(selectConfigRowsForScope(db as never, "system", "t1", "u1")).rejects.toThrow(
68
- "connection was closed",
69
- );
70
- expect(calls).toBe(1);
67
+ } as unknown as DbRunner;
68
+ const tdb = createTenantDb(recordingRunner, "t1");
69
+
70
+ await selectConfigRowsForScope(tdb, SYSTEM_TENANT_ID, "t1", "u1");
71
+
72
+ expect(captured).toHaveLength(2);
73
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
74
+ expect(captured[0]?.values).toContain(SYSTEM_TENANT_ID);
75
+ expect(captured[0]?.values).toContain("t1");
71
76
  });
72
77
  });
@@ -1,6 +1,7 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { configValuesTable } from "../../table";
4
5
 
5
6
  export type ConfigRow = {
6
7
  readonly id: string;
@@ -10,21 +11,21 @@ export type ConfigRow = {
10
11
  readonly userId: string | null;
11
12
  };
12
13
 
14
+ // Method-form so a tenant-mode TenantDb stays filtered.
13
15
  export async function selectConfigRowsForScope(
14
16
  db: DbRunner | TenantDb,
15
17
  systemTenantId: TenantId,
16
18
  tenantId: TenantId,
17
19
  userId: string,
18
20
  ): Promise<readonly ConfigRow[]> {
19
- return unsafeReadRetrying<ConfigRow>(
20
- db,
21
- `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
22
- FROM read_config_values
23
- WHERE (tenant_id = $1 AND user_id IS NULL)
24
- OR (tenant_id = $2 AND user_id IS NULL)
25
- OR (tenant_id = $2 AND user_id = $3)`,
26
- [systemTenantId, tenantId, userId],
27
- );
21
+ const [scopeRows, userRows] = await Promise.all([
22
+ selectMany<ConfigRow>(db, configValuesTable, {
23
+ tenantId: [systemTenantId, tenantId],
24
+ userId: null,
25
+ }),
26
+ selectMany<ConfigRow>(db, configValuesTable, { tenantId, userId }),
27
+ ]);
28
+ return [...scopeRows, ...userRows];
28
29
  }
29
30
 
30
31
  export async function selectConfigRowsForKeys(
@@ -34,16 +35,15 @@ export async function selectConfigRowsForKeys(
34
35
  tenantId: TenantId,
35
36
  userId: string,
36
37
  ): Promise<readonly ConfigRow[]> {
37
- return unsafeReadRetrying<ConfigRow>(
38
- db,
39
- `SELECT id, key, value, tenant_id AS "tenantId", user_id AS "userId"
40
- FROM read_config_values
41
- WHERE key = ANY($1)
42
- AND (
43
- (tenant_id = $2 AND user_id IS NULL)
44
- OR (tenant_id = $3 AND user_id IS NULL)
45
- OR (tenant_id = $3 AND user_id = $4)
46
- )`,
47
- [[...keys], systemTenantId, tenantId, userId],
48
- );
38
+ if (keys.length === 0) return [];
39
+ const keyFilter = { in: [...keys] };
40
+ const [scopeRows, userRows] = await Promise.all([
41
+ selectMany<ConfigRow>(db, configValuesTable, {
42
+ key: keyFilter,
43
+ tenantId: [systemTenantId, tenantId],
44
+ userId: null,
45
+ }),
46
+ selectMany<ConfigRow>(db, configValuesTable, { key: keyFilter, tenantId, userId }),
47
+ ]);
48
+ return [...scopeRows, ...userRows];
49
49
  }
@@ -23,6 +23,12 @@ export const cascadeQuery = defineQueryHandler({
23
23
  "only the config keys the caller's roles may read, before any value is returned",
24
24
  },
25
25
  },
26
+ rateLimit: {
27
+ disabled: true,
28
+ reason:
29
+ "self-scoped read of the caller's own readable config cascade, hit on every page load; " +
30
+ "per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
31
+ },
26
32
  handler: async (query, ctx) => {
27
33
  const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
28
34
  const registry = ctx.registry;
@@ -138,6 +138,12 @@ export const readinessQuery = defineQueryHandler({
138
138
  "candidate keys through hasConfigAccess so only keys the caller's roles may read are reported",
139
139
  },
140
140
  },
141
+ rateLimit: {
142
+ disabled: true,
143
+ reason:
144
+ "self-scoped read of the caller's own tenant's config readiness, hit on every page load; " +
145
+ "per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
146
+ },
141
147
  handler: async (query, ctx) => {
142
148
  const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
143
149
  const missing = await collectMissingRequiredConfig(
@@ -38,6 +38,7 @@ import {
38
38
  type TenantId,
39
39
  } from "@cosmicdrift/kumiko-framework/engine";
40
40
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
41
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
41
42
  import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
42
43
  import {
43
44
  type ChunkedMigrationStopReason,
@@ -107,6 +108,16 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
107
108
  ? Date.now() + payload.maxDurationMs
108
109
  : Number.POSITIVE_INFINITY;
109
110
 
111
+ function reportEscapeHatchFor(tenantId: TenantId) {
112
+ return createEscapeHatchReporter({
113
+ handler: "config:job:reencrypt",
114
+ tenantId,
115
+ actor: "system",
116
+ sink: ctx._escapeHatchAuditSink,
117
+ log: ctx.log,
118
+ });
119
+ }
120
+
110
121
  const tdbCache = new Map<TenantId, TenantDb>();
111
122
  function tdbFor(tenantId: TenantId): TenantDb {
112
123
  let existing = tdbCache.get(tenantId);
@@ -121,7 +132,11 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
121
132
  function sdbFor(tenantId: TenantId): UncheckedSystemDb {
122
133
  let existing = sdbCache.get(tenantId);
123
134
  if (!existing) {
124
- existing = createUncheckedSystemDb(tdbFor(tenantId));
135
+ existing = createUncheckedSystemDb(
136
+ tdbFor(tenantId),
137
+ undefined,
138
+ reportEscapeHatchFor(tenantId),
139
+ );
125
140
  sdbCache.set(tenantId, existing);
126
141
  }
127
142
  return existing;
@@ -131,7 +146,11 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
131
146
  // to acknowledge the cross-tenant scan below, never bound to a real
132
147
  // tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
133
148
  // write-path cache entry double as the scan's ack gate.
134
- const scanDb = createUncheckedSystemDb(createTenantDb(db, SYSTEM_TENANT_ID, "system"));
149
+ const scanDb = createUncheckedSystemDb(
150
+ createTenantDb(db, SYSTEM_TENANT_ID, "system"),
151
+ undefined,
152
+ reportEscapeHatchFor(SYSTEM_TENANT_ID),
153
+ );
135
154
 
136
155
  type ConfigRow = {
137
156
  id: string;
@@ -15,6 +15,12 @@ export const schemaQuery = defineQueryHandler({
15
15
  "only the config key definitions the caller's roles may read",
16
16
  },
17
17
  },
18
+ rateLimit: {
19
+ disabled: true,
20
+ reason:
21
+ "self-scoped read of the caller's own readable config schema, hit on every page load; " +
22
+ "per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
23
+ },
18
24
  handler: async (query, ctx) => {
19
25
  const registry = ctx.registry;
20
26
  const allKeys = registry.getAllConfigKeys();
@@ -23,6 +23,12 @@ export const valuesQuery = defineQueryHandler({
23
23
  "only the config keys the caller's roles may read, before any value is returned",
24
24
  },
25
25
  },
26
+ rateLimit: {
27
+ disabled: true,
28
+ reason:
29
+ "self-scoped read of the caller's own readable config values, hit on every page load; " +
30
+ "per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
31
+ },
26
32
  handler: async (query, ctx) => {
27
33
  const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
28
34
  const registry = ctx.registry;
@@ -1293,6 +1293,40 @@ describe("flow 15: idempotency requires Redis", () => {
1293
1293
  });
1294
1294
  });
1295
1295
 
1296
+ // --- Flow 15b: escape-hatch audit attribution for tenant-broadcast resolveUserIdsForTenant ---
1297
+
1298
+ describe("flow 15b: escape-hatch audit for tenant broadcast", () => {
1299
+ test("recording sink receives an acknowledge-cross-tenant event attributed to tenantUserIdsQuery", async () => {
1300
+ const recorded: import("@cosmicdrift/kumiko-framework/engine").EscapeHatchUseEvent[] = [];
1301
+ const auditedService = createDeliveryService({
1302
+ db,
1303
+ registry: stack.registry,
1304
+ channels: collectChannels(stack.registry),
1305
+ tenantUserIdsQuery: TenantQueries.resolveUserIds,
1306
+ escapeHatchAuditSink: async (event) => {
1307
+ recorded.push(event);
1308
+ },
1309
+ });
1310
+
1311
+ // Distinct tenantId (not used by any earlier test in this file) — the
1312
+ // module-level escape-hatch dedup window suppresses a repeat report for
1313
+ // an identical (handler, kind, reason, tenantId, actor) tuple within 60s,
1314
+ // which would otherwise make this assertion flaky depending on test order.
1315
+ const auditTenantId = testTenantId(915501);
1316
+
1317
+ await auditedService.notify(
1318
+ "app:notify:tenant-alert",
1319
+ { to: { tenant: auditTenantId }, data: { title: "X", body: "X" } },
1320
+ admin,
1321
+ admin.tenantId,
1322
+ );
1323
+
1324
+ const event = recorded.find((e) => e.kind === "acknowledge-cross-tenant");
1325
+ expect(event?.handler).toBe(TenantQueries.resolveUserIds);
1326
+ expect(event?.tenantId).toBe(auditTenantId);
1327
+ });
1328
+ });
1329
+
1296
1330
  // --- Flow 16: Unsubscribe race — ON CONFLICT makes repeated clicks safe ---
1297
1331
 
1298
1332
  describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.271.0",
4
+ "type": "improvement",
5
+ "title": "systemScope handlers get a default per-tenant+handler rate limit; delivery:preferences opts out (fw#2861).",
6
+ "detail": "`delivery` mounts under `r.systemScope()`, so `delivery:write:set-preference`/`delivery:query:log` without their own `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` once an app has a `RateLimitResolver` configured (framework change, see kumiko-framework changes.json 0.271.0). `delivery:query:preferences` declares `rateLimit: { disabled: true, reason: \"...\" }` — it is a self-scoped read of the caller's own notification preference rows, hit on every page load, and a per-tenant bucket would throttle the whole tenant's page loads instead of just that one handler's traffic."
7
+ },
2
8
  {
3
9
  "version": "0.208.2",
4
10
  "type": "improvement",
@@ -1,9 +1,16 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createTenantDb, createUncheckedSystemDb } from "@cosmicdrift/kumiko-framework/db";
4
- import type { NotifyPriority, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type {
5
+ EscapeHatchAuditSink,
6
+ NotifyPriority,
7
+ Registry,
8
+ TenantId,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
5
10
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
12
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
13
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
7
14
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
8
15
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
9
16
  import type { Redis } from "ioredis";
@@ -43,6 +50,10 @@ export type DeliveryServiceOptions = {
43
50
  // the delivery.render → delivery.send jobs. When absent, queued channels fall
44
51
  // back to synchronous inline delivery (job-less setups, unit tests).
45
52
  readonly jobRunner?: JobRunner;
53
+ // Attributed escape-hatch audit for resolveUserIdsForTenant's ctx.systemDb —
54
+ // absent means an unattributed warn log (see fallbackEscapeHatchReporter).
55
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
56
+ readonly log?: Logger;
46
57
  };
47
58
 
48
59
  // Build channel list from registry extension usages
@@ -82,6 +93,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
82
93
  isChannelKilled,
83
94
  idempotencyRedis,
84
95
  jobRunner,
96
+ escapeHatchAuditSink,
97
+ log,
85
98
  } = options;
86
99
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
87
100
 
@@ -157,7 +170,16 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
157
170
  throw new Error(`Tenant broadcast query "${tenantUserIdsQuery}" not found in registry`);
158
171
  }
159
172
  const systemUser = createSystemUser(tenantId);
160
- const tenantDb = createTenantDb(db, tenantId, "system");
173
+ const report = createEscapeHatchReporter({
174
+ handler: tenantUserIdsQuery,
175
+ tenantId,
176
+ actor: systemUser.id,
177
+ sink: escapeHatchAuditSink,
178
+ log,
179
+ });
180
+ const tenantDb = createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
181
+ report,
182
+ });
161
183
  // Hand-built context, not routed through the dispatcher — tenantUserIdsQuery is
162
184
  // typically an r.systemScope() handler, fail-closed on ctx.db, so this needs both.
163
185
  // @cast-boundary engine-payload — generic query-handler return for typed convention
@@ -166,7 +188,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
166
188
  {
167
189
  db: tenantDb,
168
190
  dbOutsideTransaction: tenantDb,
169
- systemDb: createUncheckedSystemDb(tenantDb),
191
+ systemDb: createUncheckedSystemDb(tenantDb, undefined, report),
170
192
  registry,
171
193
  ...bridgeStub(),
172
194
  },
@@ -14,6 +14,12 @@ export const preferencesQuery = defineQueryHandler({
14
14
  "query filters notificationPreferencesTable by the caller's own tenantId and userId",
15
15
  },
16
16
  },
17
+ rateLimit: {
18
+ disabled: true,
19
+ reason:
20
+ "self-scoped read of the caller's own notification preference rows, hit on every page " +
21
+ "load; per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
22
+ },
17
23
  description:
18
24
  "Returns the calling user's notification preference rows for their own tenant; use it to show which notification types and channels that user has enabled or muted.",
19
25
  handler: async (query, ctx) => {
@@ -1,6 +1,11 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type {
4
+ EscapeHatchAuditSink,
5
+ Registry,
6
+ TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
4
9
  import type { Redis } from "ioredis";
5
10
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service";
6
11
  import { collectChannels, createDeliveryService } from "./delivery-service";
@@ -10,6 +15,8 @@ export type CreateDeliveryTestContextOptions = {
10
15
  readonly tenantUserIdsQuery?: string;
11
16
  readonly rateLimit?: RateLimitConfig;
12
17
  readonly isChannelKilled?: KillSwitchResolver;
18
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
19
+ readonly log?: Logger;
13
20
  };
14
21
 
15
22
  /**
@@ -43,7 +43,9 @@ export const listQuery = defineQueryHandler({
43
43
  message: "jobs:query:list requires ctx.systemDb (feature must declare r.systemScope())",
44
44
  });
45
45
  }
46
- const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
46
+ const db = ctx.systemDb.unsafeRaw(
47
+ "cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
48
+ );
47
49
  const where: WhereObject = {};
48
50
  if (query.payload.jobName) where["jobName"] = query.payload.jobName;
49
51
  const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");