@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.273.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 (116) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-composition.boot.test.ts +25 -7
  3. package/src/__tests__/account-security-composition.integration.test.ts +23 -1
  4. package/src/__tests__/account-security-fixture.ts +3 -0
  5. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
  6. package/src/agent-tools/__tests__/feature.test.ts +10 -4
  7. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  8. package/src/audit/changes.json +12 -0
  9. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  10. package/src/audit/feature.ts +5 -1
  11. package/src/audit/index.ts +1 -0
  12. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  13. package/src/auth-email-password/handlers/invite-accept.write.ts +8 -1
  14. package/src/auth-mfa/handlers/disable.write.ts +7 -1
  15. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
  16. package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
  17. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
  18. package/src/auth-mfa/handlers/status.query.ts +7 -1
  19. package/src/auth-mfa/screens.ts +26 -3
  20. package/src/cap-counter/feature.ts +5 -3
  21. package/src/cap-overview/screens.ts +6 -0
  22. package/src/channel-in-app/handlers/inbox.query.ts +7 -1
  23. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
  24. package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
  25. package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
  26. package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
  27. package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
  28. package/src/config/__tests__/config.integration.test.ts +1 -1
  29. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  30. package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
  31. package/src/config/changes.json +6 -0
  32. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  33. package/src/config/db/queries/resolver.ts +22 -22
  34. package/src/config/handlers/cascade.query.ts +13 -1
  35. package/src/config/handlers/readiness.query.ts +13 -1
  36. package/src/config/handlers/reencrypt.job.ts +21 -2
  37. package/src/config/handlers/reset.write.ts +7 -1
  38. package/src/config/handlers/schema.query.ts +13 -1
  39. package/src/config/handlers/set.write.ts +7 -1
  40. package/src/config/handlers/values.query.ts +13 -1
  41. package/src/data-retention/handlers/policy-for.query.ts +8 -1
  42. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
  43. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
  44. package/src/delivery/__tests__/delivery.integration.test.ts +39 -5
  45. package/src/delivery/changes.json +6 -0
  46. package/src/delivery/delivery-service.ts +25 -3
  47. package/src/delivery/handlers/preferences.query.ts +13 -1
  48. package/src/delivery/handlers/set-preference.write.ts +7 -1
  49. package/src/delivery/testing.ts +8 -1
  50. package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
  51. package/src/folders/__tests__/feature.test.ts +14 -5
  52. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
  53. package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
  54. package/src/folders/__tests__/folders.integration.test.ts +6 -1
  55. package/src/folders/feature.ts +2 -2
  56. package/src/form-draft/constants.ts +7 -1
  57. package/src/jobs/handlers/list.query.ts +3 -1
  58. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  59. package/src/managed-pages/screens/branding-screen.ts +3 -0
  60. package/src/managed-pages/screens/page-screens.ts +3 -0
  61. package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +10 -2
  62. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  63. package/src/notes-history/feature.ts +2 -2
  64. package/src/notes-history/web/i18n.ts +3 -0
  65. package/src/notes-history/web/notes-section.tsx +61 -51
  66. package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
  67. package/src/personal-access-tokens/handlers/create.write.ts +7 -1
  68. package/src/personal-access-tokens/handlers/list.query.ts +7 -1
  69. package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
  70. package/src/personal-access-tokens/screens.ts +15 -2
  71. package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
  72. package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
  73. package/src/secrets/feature.ts +2 -2
  74. package/src/sessions/handlers/mine.query.ts +7 -1
  75. package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
  76. package/src/sessions/handlers/revoke.write.ts +7 -1
  77. package/src/sessions/screens.ts +9 -1
  78. package/src/tags/__tests__/feature.test.ts +25 -8
  79. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
  80. package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
  81. package/src/tags/__tests__/tags.integration.test.ts +6 -1
  82. package/src/tags/constants.ts +1 -1
  83. package/src/tags/feature.ts +2 -2
  84. package/src/tags/screens.ts +3 -0
  85. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  86. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
  87. package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
  88. package/src/tenant/changes.json +6 -0
  89. package/src/tenant/handlers/me.query.ts +13 -1
  90. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  91. package/src/tenant/screens.ts +3 -0
  92. package/src/tenant/seeding.ts +25 -19
  93. package/src/tier-engine/feature.ts +3 -0
  94. package/src/user/changes.json +6 -0
  95. package/src/user/handlers/me.query.ts +13 -1
  96. package/src/user/handlers/update-roles.ts +8 -5
  97. package/src/user/screens.ts +3 -0
  98. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  99. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  100. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +6 -1
  101. package/src/user-data-rights/feature.ts +11 -6
  102. package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
  103. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  104. package/src/user-data-rights/handlers/download-by-job.query.ts +7 -1
  105. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  106. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  107. package/src/user-data-rights/handlers/export-status.query.ts +7 -1
  108. package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
  109. package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
  110. package/src/user-data-rights/handlers/request-export.write.ts +7 -1
  111. package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
  112. package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
  113. package/src/user-data-rights/screens.ts +3 -0
  114. package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
  115. package/src/user-profile/feature.ts +11 -1
  116. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
@@ -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:"),
@@ -38,7 +38,7 @@ const probeFeature = defineFeature("probe", (r) => {
38
38
  isSuccess: true,
39
39
  data: { tenant: ctx.tz.tenant, user: ctx.tz.user },
40
40
  }),
41
- { access: { openToAll: true } },
41
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
42
42
  );
43
43
  });
44
44
 
@@ -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
  }
@@ -16,7 +16,19 @@ export const cascadeQuery = defineQueryHandler({
16
16
  schema: z.object({
17
17
  keys: z.array(z.string()).optional(),
18
18
  }),
19
- access: { openToAll: true },
19
+ access: {
20
+ openToAll: {
21
+ reason:
22
+ "any signed-in user may call cascade; hasConfigAccess filters the result to " +
23
+ "only the config keys the caller's roles may read, before any value is returned",
24
+ },
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
+ },
20
32
  handler: async (query, ctx) => {
21
33
  const db = requireSystemDb(ctx, "config:query:cascade", query.user.tenantId);
22
34
  const registry = ctx.registry;
@@ -131,7 +131,19 @@ export const readinessQuery = defineQueryHandler({
131
131
  "Lists the required config keys that are still unset for the caller's tenant, each with its scope and type; use it to find out what is missing before a feature can run.",
132
132
  schema: z.object({}),
133
133
  // Per-key read access enforced via hasConfigAccess inside the handler.
134
- access: { openToAll: true },
134
+ access: {
135
+ openToAll: {
136
+ reason:
137
+ "any signed-in user may call readiness; collectMissingRequiredConfig filters " +
138
+ "candidate keys through hasConfigAccess so only keys the caller's roles may read are reported",
139
+ },
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
+ },
135
147
  handler: async (query, ctx) => {
136
148
  const db = requireSystemDb(ctx, "config:query:readiness", query.user.tenantId);
137
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;
@@ -26,7 +26,13 @@ export const resetWrite = defineWriteHandler({
26
26
  scope: scopeEnum.optional(),
27
27
  }),
28
28
  // Per-key access enforcement lives inside the handler via checkWriteAccess.
29
- access: { openToAll: true },
29
+ access: {
30
+ openToAll: {
31
+ reason:
32
+ "any signed-in user may call reset; prepareConfigWrite enforces the per-key " +
33
+ "write access rule before any value is touched",
34
+ },
35
+ },
30
36
  handler: async (event, ctx) => {
31
37
  const db = requireSystemDb(ctx, "config:write:reset", event.user.tenantId);
32
38
 
@@ -8,7 +8,19 @@ export const schemaQuery = defineQueryHandler({
8
8
  "Returns the definitions of all config keys the caller may read (scope, type, default, bounds, required flag) without any values; use it to discover which settings exist and how they may be set.",
9
9
  schema: z.object({}),
10
10
  // Per-key read access enforced via hasConfigAccess inside the handler.
11
- access: { openToAll: true },
11
+ access: {
12
+ openToAll: {
13
+ reason:
14
+ "any signed-in user may call schema; hasConfigAccess filters the result to " +
15
+ "only the config key definitions the caller's roles may read",
16
+ },
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
+ },
12
24
  handler: async (query, ctx) => {
13
25
  const registry = ctx.registry;
14
26
  const allKeys = registry.getAllConfigKeys();
@@ -44,7 +44,13 @@ export const setWrite = defineWriteHandler({
44
44
  scope: scopeEnum.optional(),
45
45
  }),
46
46
  // Per-key access enforcement lives inside the handler via checkWriteAccess.
47
- access: { openToAll: true },
47
+ access: {
48
+ openToAll: {
49
+ reason:
50
+ "any signed-in user may call set; prepareConfigWrite enforces the per-key " +
51
+ "write access rule before any value is stored",
52
+ },
53
+ },
48
54
  handler: async (event, ctx) => {
49
55
  const db = requireSystemDb(ctx, "config:write:set", event.user.tenantId);
50
56
 
@@ -16,7 +16,19 @@ export const valuesQuery = defineQueryHandler({
16
16
  "Returns the effective value, scope and winning source for every config key the caller may read, with encrypted and secret-backed values masked; use it to inspect the current settings.",
17
17
  schema: z.object({}),
18
18
  // Per-key read access enforced via hasConfigAccess inside the handler.
19
- access: { openToAll: true },
19
+ access: {
20
+ openToAll: {
21
+ reason:
22
+ "any signed-in user may call values; hasConfigAccess filters the result to " +
23
+ "only the config keys the caller's roles may read, before any value is returned",
24
+ },
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
+ },
20
32
  handler: async (query, ctx) => {
21
33
  const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
22
34
  const registry = ctx.registry;
@@ -22,7 +22,14 @@ export const policyForQuery = defineQueryHandler({
22
22
  schema: z.object({
23
23
  entityName: z.string().min(1).max(100),
24
24
  }),
25
- access: { openToAll: true },
25
+ access: {
26
+ openToAll: {
27
+ reason:
28
+ "any signed-in tenant member (and other features calling cross-feature) may " +
29
+ "resolve the retention policy for an entity name in their own tenant; the result " +
30
+ "carries only policy metadata, no PII",
31
+ },
32
+ },
26
33
  description:
27
34
  "Resolves the effective retention policy for one entity name in the caller's tenant (keep-for duration plus delete or anonymize strategy) by layering the entity default, tenant preset and tenant override, so a forget flow or cleanup job knows how that data may be removed.",
28
35
  handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
@@ -65,7 +65,7 @@ const appFeature = defineFeature("app", (r) => {
65
65
  });
66
66
  return { isSuccess: true, data: { sent: true } };
67
67
  },
68
- access: { openToAll: true },
68
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
69
69
  }),
70
70
  );
71
71
 
@@ -82,7 +82,7 @@ const appFeature = defineFeature("app", (r) => {
82
82
  });
83
83
  return { isSuccess: true, data: { sent: true } };
84
84
  },
85
- access: { openToAll: true },
85
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
86
86
  }),
87
87
  );
88
88
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, isOpenToAllGranted, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { DELIVERY_LOG_SCREEN_ID } from "../constants";
5
5
  import { createDeliveryFeature } from "../feature";
@@ -33,9 +33,7 @@ describe("delivery screens + handler access alignment", () => {
33
33
  test("access option leaves the preferences handler openToAll — that's per-user, not an admin surface", () => {
34
34
  const delivery = createDeliveryFeature({ access: access.systemAdmin });
35
35
  const preferencesAccess = delivery.queryHandlers["preferences"]?.access;
36
- expect(
37
- preferencesAccess && "openToAll" in preferencesAccess && preferencesAccess.openToAll,
38
- ).toBe(true);
36
+ expect(preferencesAccess !== undefined && isOpenToAllGranted(preferencesAccess)).toBe(true);
39
37
  });
40
38
 
41
39
  test("boot-validates with a narrowed access option", () => {
@@ -101,7 +101,7 @@ const appFeature = defineFeature("app", (r) => {
101
101
 
102
102
  return { isSuccess: true, data: { assigned: true } };
103
103
  },
104
- access: { openToAll: true },
104
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
105
105
  }),
106
106
  );
107
107
 
@@ -124,7 +124,7 @@ const appFeature = defineFeature("app", (r) => {
124
124
 
125
125
  return { isSuccess: true, data: { sent: true } };
126
126
  },
127
- access: { openToAll: true },
127
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
128
128
  }),
129
129
  );
130
130
 
@@ -141,7 +141,7 @@ const appFeature = defineFeature("app", (r) => {
141
141
  title: z.string(),
142
142
  body: z.string(),
143
143
  }),
144
- access: { openToAll: true },
144
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
145
145
  handler: async (event, ctx) => {
146
146
  const notify = ctx.notify as NotifyFn;
147
147
  await notify(event.payload.notificationType, {
@@ -173,7 +173,7 @@ const appFeature = defineFeature("app", (r) => {
173
173
 
174
174
  return { isSuccess: true, data: { sent: true } };
175
175
  },
176
- access: { openToAll: true },
176
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
177
177
  }),
178
178
  );
179
179
  });
@@ -206,7 +206,7 @@ const ticketFeature = defineFeature("tickets", (r) => {
206
206
  status: z.string(),
207
207
  }),
208
208
  async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db),
209
- { access: { openToAll: true } },
209
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
210
210
  );
211
211
 
212
212
  // Declarative: notify assignee when ticket is created with assigneeId
@@ -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
  },
@@ -7,7 +7,19 @@ import { notificationPreferencesTable } from "../tables";
7
7
  export const preferencesQuery = defineQueryHandler({
8
8
  name: "preferences",
9
9
  schema: z.object({}),
10
- access: { openToAll: true },
10
+ access: {
11
+ openToAll: {
12
+ reason:
13
+ "each signed-in user reads only their own notification preference rows; the " +
14
+ "query filters notificationPreferencesTable by the caller's own tenantId and userId",
15
+ },
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
+ },
11
23
  description:
12
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.",
13
25
  handler: async (query, ctx) => {
@@ -11,7 +11,13 @@ export const setPreferenceWrite = defineWriteHandler({
11
11
  enabled: z.boolean(),
12
12
  }),
13
13
  // Every user manages their own preferences; tenant+user scoping is on the WHERE.
14
- access: { openToAll: true },
14
+ access: {
15
+ openToAll: {
16
+ reason:
17
+ "each signed-in user changes only their own notification preferences; the " +
18
+ "write is scoped to the caller's own tenantId and userId via assertTenantMatch",
19
+ },
20
+ },
15
21
  description:
16
22
  "Enables or disables one notification type and channel combination for the calling user, where either side may be the wildcard * as a catch-all; use it when a user changes their own notification settings.",
17
23
  handler: async (event, ctx) => {