@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
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
5
  import { randomBytes } from "node:crypto";
6
+ import type { SchemaTable } from "@cosmicdrift/kumiko-framework/db";
7
+ import { asRawClient, entityTableFromRegistry, selectMany } from "@cosmicdrift/kumiko-framework/db";
6
8
  import type { HandlerContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
9
  import {
8
10
  createEntity,
@@ -67,6 +69,9 @@ const noteEntity = createEntity({
67
69
  },
68
70
  });
69
71
 
72
+ // Needs stack.registry — assigned in beforeAll.
73
+ let noteTable: SchemaTable;
74
+
70
75
  const hookNoteAEntity = createEntity({
71
76
  table: "qam_hook_note_a",
72
77
  fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
@@ -94,6 +99,13 @@ const TRIES_WRITE_QN = "queryasmemberprobe:query:tries-write";
94
99
  const TRIES_APPEND_EVENT_QN = "queryasmemberprobe:query:tries-append-event";
95
100
  const TRIES_FETCH_FOR_WRITING_QN = "queryasmemberprobe:query:tries-fetch-for-writing";
96
101
  const TRIES_JOB_RUNNER_QN = "queryasmemberprobe:query:tries-job-runner";
102
+ const TRIES_DB_WRITE_QN = "queryasmemberprobe:query:tries-db-write";
103
+ const TRIES_UNSAFE_RAW_WRITE_QN = "queryasmemberprobe:query:tries-unsafe-raw-write";
104
+ const TRIES_READ_WRITE_RESET_QN = "queryasmemberprobe:query:tries-read-write-reset";
105
+ const NESTED_DB_WRITE_QN = "queryasmemberprobe:query:nested-db-write";
106
+ const TRIES_SELECT_FOR_UPDATE_QN = "queryasmemberprobe:query:tries-select-for-update";
107
+ const TRIES_QUERY_AS_QN = "queryasmemberprobe:query:tries-query-as";
108
+ const READ_AS_MEMBER_QUERY_QN = "queryasmemberprobe:query:read-as-member-query";
97
109
  const SYSTEM_SCOPE_READ_AS_MEMBER_QN = "queryasmembersystemscope:write:read-as-member";
98
110
 
99
111
  const probeFeature = defineFeature("queryasmemberprobe", (r) => {
@@ -170,6 +182,88 @@ const probeFeature = defineFeature("queryasmemberprobe", (r) => {
170
182
  { access: { roles: ["Admin", "User"] } },
171
183
  );
172
184
 
185
+ // ctx.db stays available to a resolved member; these targets write through it.
186
+ r.queryHandler(
187
+ "tries-db-write",
188
+ z.object({}),
189
+ async (query, ctx) => {
190
+ await ctx.db.updateMany(noteTable, { body: "tampered" }, { tenantId: query.user.tenantId });
191
+ return { ok: true };
192
+ },
193
+ { access: { roles: ["Admin", "User"] } },
194
+ );
195
+ r.queryHandler(
196
+ "tries-unsafe-raw-write",
197
+ z.object({}),
198
+ async (_query, ctx) => {
199
+ await asRawClient(ctx.db.unsafeRaw("test: raw write probe")).unsafe(
200
+ "UPDATE qam_notes SET body = 'tampered'",
201
+ );
202
+ return { ok: true };
203
+ },
204
+ {
205
+ access: { roles: ["Admin", "User"] },
206
+ escapeHatch: { reason: "test: raw write probe" },
207
+ },
208
+ );
209
+ r.queryHandler(
210
+ "tries-read-write-reset",
211
+ z.object({}),
212
+ async (_query, ctx) => {
213
+ const raw = asRawClient(ctx.db.unsafeRaw("test: raw write probe"));
214
+ await raw.unsafe("SET TRANSACTION READ WRITE");
215
+ await raw.unsafe("UPDATE qam_notes SET body = 'tampered'");
216
+ return { ok: true };
217
+ },
218
+ {
219
+ access: { roles: ["Admin", "User"] },
220
+ escapeHatch: { reason: "test: raw write probe" },
221
+ },
222
+ );
223
+ r.queryHandler(
224
+ "nested-db-write",
225
+ z.object({}),
226
+ async (_query, ctx) => ctx.query(TRIES_DB_WRITE_QN, {}),
227
+ { access: { roles: ["Admin", "User"] } },
228
+ );
229
+ r.queryHandler(
230
+ "tries-select-for-update",
231
+ z.object({}),
232
+ async (_query, ctx) => {
233
+ const rows = await asRawClient(ctx.db.unsafeRaw("test: select-for-update probe")).unsafe(
234
+ "SELECT id FROM qam_notes FOR UPDATE",
235
+ );
236
+ return { ok: true, count: Array.isArray(rows) ? rows.length : 0 };
237
+ },
238
+ {
239
+ access: { roles: ["Admin", "User"] },
240
+ escapeHatch: { reason: "test: select-for-update probe" },
241
+ },
242
+ );
243
+ r.queryHandler(
244
+ "tries-query-as",
245
+ z.object({}),
246
+ async (query, ctx) => ctx.queryAs({ ...query.user, origin: undefined }, WHOAMI_QN, {}),
247
+ {
248
+ access: { roles: ["Admin", "User"] },
249
+ escapeHatch: { reason: "test: query-as identity switch probe" },
250
+ },
251
+ );
252
+ r.queryHandler(
253
+ "read-as-member-query",
254
+ z.object({
255
+ userId: z.string(),
256
+ targetQn: z.string(),
257
+ payload: z.record(z.string(), z.unknown()).default({}),
258
+ }),
259
+ async (query, ctx) =>
260
+ ctx.queryAsMember(query.payload.userId, query.payload.targetQn, query.payload.payload),
261
+ {
262
+ access: { roles: ["Admin"] },
263
+ escapeHatch: { reason: "test: reads as a stored member via a query caller" },
264
+ },
265
+ );
266
+
173
267
  // Caller-facing write handler that reads as a member — WITH and WITHOUT
174
268
  // escapeHatch (systemIdentitySwitchDenied gate).
175
269
  r.writeHandler(
@@ -226,6 +320,22 @@ const probeFeature = defineFeature("queryasmemberprobe", (r) => {
226
320
  },
227
321
  { access: { roles: ["Admin"] }, escapeHatch: { reason: "test: two-user cache check" } },
228
322
  );
323
+ r.writeHandler(
324
+ "read-as-member-then-write",
325
+ z.object({ userId: z.string() }),
326
+ async (event, ctx) => {
327
+ await ctx.queryAsMember(event.payload.userId, NOTE_LIST_QN, {});
328
+ const result = await ctx.write("queryasmemberprobe:write:note:create", {
329
+ ownerId: event.payload.userId,
330
+ body: "after-read",
331
+ });
332
+ if (!result.isSuccess) {
333
+ throw new Error(`nested write after queryAsMember failed: ${JSON.stringify(result.error)}`);
334
+ }
335
+ return { isSuccess: true as const, data: { ok: true } };
336
+ },
337
+ { access: { roles: ["Admin"] }, escapeHatch: { reason: "test: read-then-write probe" } },
338
+ );
229
339
 
230
340
  // authClaims — counter parity with cache + real-login-claims check.
231
341
  r.authClaims(async (user) => {
@@ -330,6 +440,7 @@ beforeAll(async () => {
330
440
  await unsafeCreateEntityTable(stack.db, tenantEntity);
331
441
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
332
442
  await unsafeCreateEntityTable(stack.db, noteEntity);
443
+ noteTable = entityTableFromRegistry(stack.registry, "note", noteEntity);
333
444
  await unsafeCreateEntityTable(stack.db, hookNoteAEntity);
334
445
  await unsafeCreateEntityTable(stack.db, hookNoteBEntity);
335
446
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
@@ -417,6 +528,27 @@ async function createNote(ownerId: string, body: string): Promise<void> {
417
528
  await stack.http.writeOk("queryasmemberprobe:write:note:create", { ownerId, body }, admin);
418
529
  }
419
530
 
531
+ async function readAsMemberViaQuery<T = unknown>(
532
+ userId: string,
533
+ targetQn: string,
534
+ payload: Record<string, unknown> = {},
535
+ ): Promise<T> {
536
+ return stack.http.queryOk<T>(READ_AS_MEMBER_QUERY_QN, { userId, targetQn, payload }, admin);
537
+ }
538
+
539
+ async function readAsMemberViaQueryErr(
540
+ userId: string,
541
+ targetQn: string,
542
+ payload: Record<string, unknown> = {},
543
+ ) {
544
+ return stack.http.queryErr(READ_AS_MEMBER_QUERY_QN, { userId, targetQn, payload }, admin);
545
+ }
546
+
547
+ async function readNoteBodies(): Promise<readonly string[]> {
548
+ const rows = await selectMany<{ body: string }>(stack.db, noteTable);
549
+ return [...rows.map((row) => row.body)].sort();
550
+ }
551
+
420
552
  // ── 1. Ownership — reading as a member applies THEIR row filters ──────────
421
553
 
422
554
  describe("ctx.queryAsMember — ownership rules of the resolved member apply", () => {
@@ -722,6 +854,16 @@ describe("ctx.queryAsMember — the resolved principal is read-only (no writeAsM
722
854
  expect(errorReason(err.details)).toBe("member_resolution_read_only");
723
855
  });
724
856
 
857
+ test("target query handler calling ctx.queryAs → denied with member_resolution_read_only", async () => {
858
+ await createTenant(TENANT_A);
859
+ const userId = await createUser("triesQueryAs@example.com", "pw-long-enough-33");
860
+ await addMembership(userId, TENANT_A);
861
+
862
+ const err = await readAsMemberErr(userId, TRIES_QUERY_AS_QN);
863
+ expect(err.code).toBe("access_denied");
864
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
865
+ });
866
+
725
867
  test("stack.dispatcher.write called directly with a resolved-like user (origin set) → denied", async () => {
726
868
  await createTenant(TENANT_A);
727
869
  const userId = await createUser("directdispatch@example.com", "pw-long-enough-21");
@@ -745,3 +887,164 @@ describe("ctx.queryAsMember — the resolved principal is read-only (no writeAsM
745
887
  }
746
888
  });
747
889
  });
890
+
891
+ // ── 7. Database-level read-only — the resolved principal's Postgres tx is
892
+ // itself READ ONLY, so even a raw ctx.db/unsafeRaw write is rejected ──
893
+
894
+ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)", () => {
895
+ beforeEach(async () => {
896
+ await resetTestTables(stack.db, [noteTable]);
897
+ });
898
+
899
+ test("a) tries-db-write via a write-caller (savepoint path) is denied, notes unchanged", async () => {
900
+ await createTenant(TENANT_A);
901
+ const userId = await createUser("dbwrite-write@example.com", "pw-long-enough-22");
902
+ await addMembership(userId, TENANT_A);
903
+ await createNote(userId, "note-1");
904
+ await createNote(userId, "note-2");
905
+
906
+ const err = await readAsMemberErr(userId, TRIES_DB_WRITE_QN);
907
+ expect(err.code).toBe("access_denied");
908
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
909
+ expect(await readNoteBodies()).toEqual(["note-1", "note-2"]);
910
+ });
911
+
912
+ test("b) tries-db-write via a query-caller (pool path) is denied, notes unchanged", async () => {
913
+ await createTenant(TENANT_A);
914
+ const userId = await createUser("dbwrite-query@example.com", "pw-long-enough-23");
915
+ await addMembership(userId, TENANT_A);
916
+ await createNote(userId, "note-1");
917
+
918
+ const err = await readAsMemberViaQueryErr(userId, TRIES_DB_WRITE_QN);
919
+ expect(err.code).toBe("access_denied");
920
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
921
+ expect(await readNoteBodies()).toEqual(["note-1"]);
922
+ });
923
+
924
+ test("c1) tries-unsafe-raw-write via a write-caller (savepoint path) is denied, notes unchanged", async () => {
925
+ await createTenant(TENANT_A);
926
+ const userId = await createUser("rawwrite-write@example.com", "pw-long-enough-24");
927
+ await addMembership(userId, TENANT_A);
928
+ await createNote(userId, "note-1");
929
+
930
+ const err = await readAsMemberErr(userId, TRIES_UNSAFE_RAW_WRITE_QN);
931
+ expect(err.code).toBe("access_denied");
932
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
933
+ expect(await readNoteBodies()).toEqual(["note-1"]);
934
+ });
935
+
936
+ test("c2) tries-unsafe-raw-write via a query-caller (pool path) is denied, notes unchanged", async () => {
937
+ await createTenant(TENANT_A);
938
+ const userId = await createUser("rawwrite-query@example.com", "pw-long-enough-25");
939
+ await addMembership(userId, TENANT_A);
940
+ await createNote(userId, "note-1");
941
+
942
+ const err = await readAsMemberViaQueryErr(userId, TRIES_UNSAFE_RAW_WRITE_QN);
943
+ expect(err.code).toBe("access_denied");
944
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
945
+ expect(await readNoteBodies()).toEqual(["note-1"]);
946
+ });
947
+
948
+ test("d1) tries-read-write-reset via a write-caller: request fails, notes unchanged", async () => {
949
+ await createTenant(TENANT_A);
950
+ const userId = await createUser("resetwrite-write@example.com", "pw-long-enough-26");
951
+ await addMembership(userId, TENANT_A);
952
+ await createNote(userId, "note-1");
953
+
954
+ // SQLSTATE 25001, not 25006 — surfaces as internal_error rather than member_resolution_read_only.
955
+ const err = await readAsMemberErr(userId, TRIES_READ_WRITE_RESET_QN);
956
+ expect(err.code).toBe("internal_error");
957
+ expect(await readNoteBodies()).toEqual(["note-1"]);
958
+ });
959
+
960
+ test("d2) tries-read-write-reset via a query-caller: request fails, notes unchanged", async () => {
961
+ await createTenant(TENANT_A);
962
+ const userId = await createUser("resetwrite-query@example.com", "pw-long-enough-27");
963
+ await addMembership(userId, TENANT_A);
964
+ await createNote(userId, "note-1");
965
+
966
+ const err = await readAsMemberViaQueryErr(userId, TRIES_READ_WRITE_RESET_QN);
967
+ expect(err.code).toBe("internal_error");
968
+ expect(await readNoteBodies()).toEqual(["note-1"]);
969
+ });
970
+
971
+ test("e) nested-db-write via a write-caller → denied with member_resolution_read_only, notes unchanged", async () => {
972
+ await createTenant(TENANT_A);
973
+ const userId = await createUser("nesteddbwrite@example.com", "pw-long-enough-28");
974
+ await addMembership(userId, TENANT_A);
975
+ await createNote(userId, "note-1");
976
+
977
+ const err = await readAsMemberErr(userId, NESTED_DB_WRITE_QN);
978
+ expect(err.code).toBe("access_denied");
979
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
980
+ expect(await readNoteBodies()).toEqual(["note-1"]);
981
+ });
982
+
983
+ test("e2) nested-db-write via a query-caller (pool path) → denied with member_resolution_read_only, notes unchanged", async () => {
984
+ await createTenant(TENANT_A);
985
+ const userId = await createUser("nesteddbwritequery@example.com", "pw-long-enough-34");
986
+ await addMembership(userId, TENANT_A);
987
+ await createNote(userId, "note-1");
988
+
989
+ const err = await readAsMemberViaQueryErr(userId, NESTED_DB_WRITE_QN);
990
+ expect(err.code).toBe("access_denied");
991
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
992
+ expect(await readNoteBodies()).toEqual(["note-1"]);
993
+ });
994
+
995
+ test("f) note:list via a query-caller (pool path) returns only the resolved member's own rows", async () => {
996
+ await createTenant(TENANT_A);
997
+ const userA = await createUser("dblist-a@example.com", "pw-long-enough-29");
998
+ const userB = await createUser("dblist-b@example.com", "pw-long-enough-30");
999
+ await addMembership(userA, TENANT_A);
1000
+ await addMembership(userB, TENANT_A);
1001
+ await createNote(userA, "a-note-1");
1002
+ await createNote(userB, "b-note-1");
1003
+
1004
+ const result = await readAsMemberViaQuery<{ rows: Array<{ ownerId: string }> }>(
1005
+ userA,
1006
+ NOTE_LIST_QN,
1007
+ );
1008
+ expect(result.rows).toHaveLength(1);
1009
+ expect(result.rows[0]?.ownerId).toBe(userA);
1010
+ });
1011
+
1012
+ test("g) tries-db-write via the normal dispatch path (no queryAsMember) succeeds, updates every row", async () => {
1013
+ await createTenant(TENANT_A);
1014
+ const userId = await createUser("directwrite@example.com", "pw-long-enough-31");
1015
+ await addMembership(userId, TENANT_A);
1016
+ await createNote(userId, "note-1");
1017
+ await createNote(userId, "note-2");
1018
+
1019
+ const result = await stack.http.queryOk<{ ok: boolean }>(TRIES_DB_WRITE_QN, {}, admin);
1020
+ expect(result.ok).toBe(true);
1021
+ expect(await readNoteBodies()).toEqual(["tampered", "tampered"]);
1022
+ });
1023
+
1024
+ test("h) read-as-member-then-write: a read via queryAsMember followed by a normal write succeeds", async () => {
1025
+ await createTenant(TENANT_A);
1026
+ const userId = await createUser("readthenwrite@example.com", "pw-long-enough-32");
1027
+ await addMembership(userId, TENANT_A);
1028
+ await createNote(userId, "existing-note");
1029
+
1030
+ const result = await stack.http.writeOk<{ ok: boolean }>(
1031
+ "queryasmemberprobe:write:read-as-member-then-write",
1032
+ { userId },
1033
+ admin,
1034
+ );
1035
+ expect(result.ok).toBe(true);
1036
+ expect(await readNoteBodies()).toEqual(["after-read", "existing-note"]);
1037
+ });
1038
+
1039
+ test("i) tries-select-for-update via a query-caller (pool path) is denied, notes unchanged", async () => {
1040
+ await createTenant(TENANT_A);
1041
+ const userId = await createUser("selectforupdate-query@example.com", "pw-long-enough-35");
1042
+ await addMembership(userId, TENANT_A);
1043
+ await createNote(userId, "note-1");
1044
+
1045
+ const err = await readAsMemberViaQueryErr(userId, TRIES_SELECT_FOR_UPDATE_QN);
1046
+ expect(err.code).toBe("access_denied");
1047
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1048
+ expect(await readNoteBodies()).toEqual(["note-1"]);
1049
+ });
1050
+ });
@@ -81,7 +81,14 @@ export function createInviteAcceptHandler() {
81
81
  // openToAll: any authenticated user (Branch 1). Branch 2+3 (anon)
82
82
  // nutzen `roles: ["all"]` weil dort GUEST_USER mit ["all"]-role
83
83
  // dispatched wird.
84
- access: { openToAll: true },
84
+ access: {
85
+ openToAll: {
86
+ reason:
87
+ "any signed-in user may accept an invitation; the token identifies the pending " +
88
+ "invitation and its tenant, and the handler verifies the caller's own email matches " +
89
+ "the invitation before adding membership",
90
+ },
91
+ },
85
92
  agent: { expose: false },
86
93
  escapeHatch: {
87
94
  reason: INVITE_ACCEPT_ESCAPE_HATCH_REASON,
@@ -30,7 +30,13 @@ export function createDisableHandler(opts: DisableOptions) {
30
30
  return defineWriteHandler({
31
31
  name: "disable",
32
32
  schema: z.object({ code: z.string().min(6).max(9) }),
33
- access: { openToAll: true },
33
+ access: {
34
+ openToAll: {
35
+ reason:
36
+ "each signed-in user may disable only their own MFA enrollment after proving " +
37
+ "possession of a TOTP or recovery code; the handler looks up and removes the caller's own user-mfa row",
38
+ },
39
+ },
34
40
  description:
35
41
  "Removes the caller's TOTP enrollment for good after they prove possession with a TOTP or recovery code, and signs their other sessions and access tokens out; use it when a user wants two-factor authentication turned off.",
36
42
  agent: { risk: "high" },
@@ -37,7 +37,13 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
37
37
  setupToken: z.string().min(1),
38
38
  code: z.string().length(6),
39
39
  }),
40
- access: { openToAll: true },
40
+ access: {
41
+ openToAll: {
42
+ reason:
43
+ "each signed-in user may complete their own MFA enrollment; the setup token is bound " +
44
+ "to the caller's id and any token minted for another user is rejected",
45
+ },
46
+ },
41
47
  description:
42
48
  "Completes TOTP enrollment for the signed-in user by checking a code against the secret carried in the setup token from enable-start, then storing the factor and signing every other session and access token out.",
43
49
  // Changes the caller's authentication state and signs their other sessions out.
@@ -29,7 +29,13 @@ export function createEnableStartHandler(opts: EnableStartOptions) {
29
29
  // input fields) — derived server-side from the caller's own email below.
30
30
  accountLabel: z.string().min(1).max(200).optional(),
31
31
  }),
32
- access: { openToAll: true },
32
+ access: {
33
+ openToAll: {
34
+ reason:
35
+ "each signed-in user may begin MFA enrollment for their own account only; nothing is " +
36
+ "persisted until enable-confirm and the label defaults from the caller's own session",
37
+ },
38
+ },
33
39
  description:
34
40
  "Begins TOTP enrollment for the signed-in user by generating a secret plus recovery codes and returning them as a short-lived setup token, an otpauth:// URI, the base32 secret for manual entry and the one-time plaintext recovery codes; nothing is persisted until enable-confirm. accountLabel defaults to the caller's own email.",
35
41
  // The result carries the TOTP secret, the otpauth:// URI and the plaintext
@@ -26,7 +26,13 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
26
26
  return defineWriteHandler({
27
27
  name: "regenerate-recovery",
28
28
  schema: z.object({ code: z.string().min(6).max(9) }),
29
- access: { openToAll: true },
29
+ access: {
30
+ openToAll: {
31
+ reason:
32
+ "each signed-in user may regenerate only their own recovery codes after proving " +
33
+ "possession of a TOTP or recovery code; the handler looks up the caller's own user-mfa row",
34
+ },
35
+ },
30
36
  description:
31
37
  "Destroys every existing recovery code of the caller, including unused ones, and returns a fresh set once in plaintext; use it when recovery codes may have leaked but TOTP itself should stay enrolled.",
32
38
  // Returns a fresh set of plaintext recovery codes — same transcript leak as
@@ -13,7 +13,13 @@ import { userMfaTable } from "../schema/user-mfa";
13
13
  export const mfaStatusQuery = defineQueryHandler({
14
14
  name: "user-mfa:status",
15
15
  schema: z.object({}),
16
- access: { openToAll: true },
16
+ access: {
17
+ openToAll: {
18
+ reason:
19
+ "each signed-in user reads only their own MFA status; the query filters " +
20
+ "userMfaTable by the caller's own id",
21
+ },
22
+ },
17
23
  description:
18
24
  "Reports whether the calling user has TOTP two-factor authentication enrolled; use it before offering either the enrollment flow or the disable and recovery-code actions.",
19
25
  handler: async (query, ctx) => {
@@ -21,7 +21,15 @@ export const mfaEnableScreen: SecretMintScreenDefinition = {
21
21
  fields: {},
22
22
  layout: { sections: [] },
23
23
  submitLabel: i18nKey("mfa.enable.start"),
24
- access: { openToAll: true },
24
+ access: {
25
+ openToAll: {
26
+ reason:
27
+ "each signed-in user may enroll their own account in MFA, mirroring the enable-start handler",
28
+ },
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,
25
33
  description:
26
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.",
27
35
  reveal: {
@@ -73,7 +81,14 @@ export const mfaDisableScreen: ActionFormScreenDefinition = {
73
81
  submitLabel: i18nKey("mfa.disable.submit"),
74
82
  submitStyle: "danger",
75
83
  cancelTarget: false,
76
- access: { openToAll: true },
84
+ access: {
85
+ openToAll: {
86
+ reason: "each signed-in user may turn off their own MFA, mirroring the disable handler",
87
+ },
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,
77
92
  description:
78
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.",
79
94
  };
@@ -86,7 +101,15 @@ export const mfaRegenerateRecoveryScreen: SecretMintScreenDefinition = {
86
101
  layout: { sections: [{ fields: ["code"] }] },
87
102
  submitLabel: i18nKey("mfa.regenerate.submit"),
88
103
  cancelTarget: false,
89
- access: { openToAll: true },
104
+ access: {
105
+ openToAll: {
106
+ reason:
107
+ "each signed-in user may regenerate their own recovery codes, mirroring the regenerate-recovery handler",
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,
90
113
  description:
91
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.",
92
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 = {
@@ -9,7 +9,13 @@ export const inboxQuery = defineQueryHandler({
9
9
  limit: z.number().min(1).max(100).default(50),
10
10
  unreadOnly: z.boolean().default(false),
11
11
  }),
12
- access: { openToAll: true },
12
+ access: {
13
+ openToAll: {
14
+ reason:
15
+ "each signed-in user reads only their own in-app inbox; the query filters " +
16
+ "inAppMessagesTable by the caller's own id",
17
+ },
18
+ },
13
19
  description:
14
20
  "Lists the calling user's own in-app notification messages newest-first, optionally only the unread ones; use it to show or inspect a user's notification inbox.",
15
21
  handler: async (query, ctx) => {
@@ -6,7 +6,13 @@ import { inAppMessagesTable } from "../tables";
6
6
  export const markAllReadWrite = defineWriteHandler({
7
7
  name: "markAllRead",
8
8
  schema: z.object({}),
9
- access: { openToAll: true },
9
+ access: {
10
+ openToAll: {
11
+ reason:
12
+ "each signed-in user marks only their own unread in-app messages as read; the " +
13
+ "update is scoped to the caller's own userId",
14
+ },
15
+ },
10
16
  description:
11
17
  "Marks every unread in-app message of the calling user as read and returns how many rows were touched; use it for a mark-all-as-read action.",
12
18
  handler: async (event, ctx) => {
@@ -10,7 +10,13 @@ export const markReadWrite = defineWriteHandler({
10
10
  // inAppMessages.id is a serial integer (table is infra, not an ES aggregate).
11
11
  id: z.number().int(),
12
12
  }),
13
- access: { openToAll: true },
13
+ access: {
14
+ openToAll: {
15
+ reason:
16
+ "each signed-in user marks only their own in-app message as read; the update " +
17
+ "requires id + the caller's own userId to match, otherwise it fails not-found",
18
+ },
19
+ },
14
20
  description:
15
21
  "Marks one in-app message of the calling user as read by its numeric id, failing with not-found when no such message belongs to the caller; use it when a user opens a single notification.",
16
22
  handler: async (event, ctx) => {
@@ -6,7 +6,13 @@ import { inAppMessagesTable } from "../tables";
6
6
  export const unreadCountQuery = defineQueryHandler({
7
7
  name: "unreadCount",
8
8
  schema: z.object({}),
9
- access: { openToAll: true },
9
+ access: {
10
+ openToAll: {
11
+ reason:
12
+ "each signed-in user counts only their own unread in-app messages; the query " +
13
+ "filters inAppMessagesTable by the caller's own id",
14
+ },
15
+ },
10
16
  description:
11
17
  "Counts the calling user's unread in-app notification messages; use it for an inbox badge or to check whether a user has pending notifications.",
12
18
  handler: async (query, ctx) => {
@@ -19,7 +19,13 @@ import { tenantComplianceProfileTable } from "../schema/profile-selection";
19
19
  export const forTenantQuery = defineQueryHandler({
20
20
  name: "for-tenant",
21
21
  schema: z.object({}),
22
- access: { openToAll: true },
22
+ access: {
23
+ openToAll: {
24
+ reason:
25
+ "any signed-in tenant member may read their own tenant's effective compliance " +
26
+ "profile; the result carries policy metadata, never PII",
27
+ },
28
+ },
23
29
  description:
24
30
  "Returns the effective compliance profile for the caller's tenant with any tenant override merged in, falling back to minimal-no-region plus a no-profile-selected warning when the tenant has not picked one yet.",
25
31
  handler: async (query, ctx): Promise<EffectiveComplianceProfile> => {
@@ -16,7 +16,13 @@ import { z } from "zod";
16
16
  export const listProfilesQuery = defineQueryHandler({
17
17
  name: "list-profiles",
18
18
  schema: z.object({}),
19
- access: { openToAll: true },
19
+ access: {
20
+ openToAll: {
21
+ reason:
22
+ "any signed-in user may list the selectable compliance profiles; the data is " +
23
+ "static, deployment-wide reference information, not tenant- or user-specific",
24
+ },
25
+ },
20
26
  description:
21
27
  "Lists the compliance profiles a tenant can choose from (key, region, label, supervisory-authority contact, notification languages), for rendering the onboarding profile picker; the minimal-no-region fallback is excluded because it is not selectable.",
22
28
  handler: async (): Promise<{ profiles: readonly ComplianceProfileSummary[] }> => {
@@ -173,7 +173,7 @@ const probeFeature = defineFeature("probe", (r) => {
173
173
  data: { orders: probe.orders, push: probe.push, betaEnabled: probe.betaEnabled },
174
174
  };
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