@cosmicdrift/kumiko-bundled-features 0.270.0 → 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 (54) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/account-security-fixture.ts +3 -0
  3. package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +96 -0
  4. package/src/audit/changes.json +12 -0
  5. package/src/audit/escape-hatch-audit-sink.ts +52 -0
  6. package/src/audit/feature.ts +5 -1
  7. package/src/audit/index.ts +1 -0
  8. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +303 -0
  9. package/src/auth-mfa/screens.ts +14 -10
  10. package/src/cap-counter/feature.ts +5 -3
  11. package/src/cap-overview/screens.ts +6 -0
  12. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +2 -1
  13. package/src/config/changes.json +6 -0
  14. package/src/config/db/queries/__tests__/resolver.test.ts +43 -38
  15. package/src/config/db/queries/resolver.ts +22 -22
  16. package/src/config/handlers/cascade.query.ts +6 -0
  17. package/src/config/handlers/readiness.query.ts +6 -0
  18. package/src/config/handlers/reencrypt.job.ts +21 -2
  19. package/src/config/handlers/schema.query.ts +6 -0
  20. package/src/config/handlers/values.query.ts +6 -0
  21. package/src/delivery/__tests__/delivery.integration.test.ts +34 -0
  22. package/src/delivery/changes.json +6 -0
  23. package/src/delivery/delivery-service.ts +25 -3
  24. package/src/delivery/handlers/preferences.query.ts +6 -0
  25. package/src/delivery/testing.ts +8 -1
  26. package/src/jobs/handlers/list.query.ts +3 -1
  27. package/src/ledger/__tests__/ledger.integration.test.ts +57 -0
  28. package/src/managed-pages/screens/branding-screen.ts +3 -0
  29. package/src/managed-pages/screens/page-screens.ts +3 -0
  30. package/src/notes-history/__tests__/notes-section.test.tsx +23 -0
  31. package/src/notes-history/web/i18n.ts +3 -0
  32. package/src/notes-history/web/notes-section.tsx +61 -51
  33. package/src/personal-access-tokens/screens.ts +7 -7
  34. package/src/sessions/screens.ts +5 -3
  35. package/src/tags/screens.ts +3 -0
  36. package/src/tenant/__tests__/members-facet-i18n.test.ts +5 -5
  37. package/src/tenant/changes.json +6 -0
  38. package/src/tenant/handlers/me.query.ts +6 -0
  39. package/src/tenant/handlers/update-member-roles.write.ts +10 -1
  40. package/src/tenant/screens.ts +3 -0
  41. package/src/tenant/seeding.ts +25 -19
  42. package/src/tier-engine/feature.ts +3 -0
  43. package/src/user/changes.json +6 -0
  44. package/src/user/handlers/me.query.ts +6 -0
  45. package/src/user/handlers/update-roles.ts +8 -5
  46. package/src/user/screens.ts +3 -0
  47. package/src/user-data-rights/__tests__/boot-checks.test.ts +16 -0
  48. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +1 -1
  49. package/src/user-data-rights/feature.ts +3 -5
  50. package/src/user-data-rights/handlers/download-attempt-list.query.ts +4 -1
  51. package/src/user-data-rights/handlers/export-job-detail.query.ts +4 -1
  52. package/src/user-data-rights/handlers/export-job-list.query.ts +4 -1
  53. package/src/user-data-rights/screens.ts +3 -0
  54. package/src/user-profile/feature.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.270.0",
3
+ "version": "0.273.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.270.0",
133
- "@cosmicdrift/kumiko-framework": "0.270.0",
134
- "@cosmicdrift/kumiko-headless": "0.270.0",
135
- "@cosmicdrift/kumiko-renderer": "0.270.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.270.0",
137
- "@cosmicdrift/kumiko-types": "0.270.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.273.0",
133
+ "@cosmicdrift/kumiko-framework": "0.273.0",
134
+ "@cosmicdrift/kumiko-headless": "0.273.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.273.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.273.0",
137
+ "@cosmicdrift/kumiko-types": "0.273.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.270.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.270.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.273.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.273.0"
168
168
  }
169
169
  }
@@ -24,6 +24,9 @@ export const accountSecurityFeature = defineFeature("account-security", (r) => {
24
24
  r.screen({
25
25
  id: ACCOUNT_SECURITY_SCREEN_ID,
26
26
  type: "dashboard",
27
+ // Test fixture demonstrating composition — a real app would nav this,
28
+ // but that's out of scope here.
29
+ dormant: true,
27
30
  panels: [
28
31
  {
29
32
  kind: "screen",
@@ -0,0 +1,96 @@
1
+ // fw#2861 — createEscapeHatchAuditSink persists an escape-hatch-use event as
2
+ // audit:event:escape-hatch-used, visible through the existing audit:query:list
3
+ // handler. Real HTTP calls + setupTestStack — never createTestDispatcher.
4
+ // Modelled on audit.integration.test.ts for users/tenant setup + resetEventStore.
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { defineFeature, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ createTestUser,
10
+ resetEventStore,
11
+ setupTestStack,
12
+ type TestStack,
13
+ testTenantId,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { z } from "zod";
16
+ import { createConfigFeature } from "../../config";
17
+ import { createTenantFeature } from "../../tenant";
18
+ import { AuditQueries } from "../constants";
19
+ import { createEscapeHatchAuditSink, ESCAPE_HATCH_USED_EVENT } from "../escape-hatch-audit-sink";
20
+ import { createAuditFeature } from "../feature";
21
+
22
+ const UNSAFE_RAW_REASON = "fw#2861 bundled-features integration test — declared unsafeRaw write";
23
+
24
+ const probeFeature = defineFeature("escape-hatch-audit-sink-probe", (r) => {
25
+ r.writeHandler(
26
+ "unsafe-raw-write",
27
+ z.object({}),
28
+ async (_event, ctx) => {
29
+ ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
30
+ return { isSuccess: true as const, data: { ok: true } };
31
+ },
32
+ { access: { roles: ["User"] }, escapeHatch: { reason: UNSAFE_RAW_REASON } },
33
+ );
34
+ });
35
+
36
+ let stack: TestStack;
37
+
38
+ const tenantId = testTenantId(1);
39
+ const user: SessionUser = createTestUser({ id: 9, tenantId, roles: ["User"] });
40
+ const adminOfSameTenant: SessionUser = createTestUser({ id: 10, tenantId, roles: ["Admin"] });
41
+
42
+ beforeAll(async () => {
43
+ stack = await setupTestStack({
44
+ features: [probeFeature, createConfigFeature(), createTenantFeature(), createAuditFeature()],
45
+ extraContext: ({ db }) => ({ _escapeHatchAuditSink: createEscapeHatchAuditSink({ db }) }),
46
+ });
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await stack.cleanup();
51
+ });
52
+
53
+ beforeEach(async () => {
54
+ await resetEventStore(stack);
55
+ });
56
+
57
+ type AuditRow = {
58
+ id: string;
59
+ type: string;
60
+ createdBy: string;
61
+ payload: Record<string, unknown>;
62
+ };
63
+
64
+ type AuditResponse = { rows: AuditRow[]; nextBefore: string | null };
65
+
66
+ async function pollForEscapeHatchRow(): Promise<AuditRow> {
67
+ const deadline = Date.now() + 2000;
68
+ while (Date.now() < deadline) {
69
+ const res = await stack.http.queryOk<AuditResponse>(
70
+ AuditQueries.list,
71
+ { eventType: ESCAPE_HATCH_USED_EVENT },
72
+ adminOfSameTenant,
73
+ );
74
+ const row = res.rows[0];
75
+ if (row) return row;
76
+ await new Promise((resolve) => setTimeout(resolve, 50));
77
+ }
78
+ throw new Error("escape-hatch-used audit row did not appear within 2s");
79
+ }
80
+
81
+ describe("createEscapeHatchAuditSink — persists audit:event:escape-hatch-used", () => {
82
+ test("an unsafeRaw use is queryable via audit:query:list", async () => {
83
+ await stack.http.writeOk("escape-hatch-audit-sink-probe:write:unsafe-raw-write", {}, user);
84
+
85
+ const row = await pollForEscapeHatchRow();
86
+
87
+ expect(row.type).toBe(ESCAPE_HATCH_USED_EVENT);
88
+ expect(row.createdBy).toBe(user.id);
89
+ expect(row.payload).toEqual({
90
+ handler: "escape-hatch-audit-sink-probe:write:unsafe-raw-write",
91
+ kind: "unsafe-raw",
92
+ reason: UNSAFE_RAW_REASON,
93
+ actor: user.id,
94
+ });
95
+ });
96
+ });
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.271.0",
4
+ "type": "improvement",
5
+ "title": "escapeHatchUsedSchema.kind accepts the new unsafe-all-tenants EscapeHatchKind (fw#2913).",
6
+ "detail": "`escapeHatchUsedSchema.kind` (`audit/escape-hatch-audit-sink.ts`) now also accepts `\"unsafe-all-tenants\"`, keeping the `satisfies readonly EscapeHatchKind[]` tuple in sync with the framework's new `EscapeHatchKind` member for a granted `ctx.queryProjection(qn, { unsafeAllTenants: true })` call — see the framework changelog for fw#2913. No consumer action needed; existing sinks already accept any `EscapeHatchKind`."
7
+ },
8
+ {
9
+ "version": "0.271.0",
10
+ "type": "improvement",
11
+ "title": "New escape-hatch-used audit sink: createEscapeHatchAuditSink records security:escape-hatch-used as audit:event:escape-hatch-used (fw#2861).",
12
+ "detail": "New `createEscapeHatchAuditSink({ db })` (`@cosmicdrift/kumiko-bundled-features/audit`) implements the framework's `EscapeHatchAuditSink` — it parses each escape-hatch-use event against the new `escapeHatchUsedSchema` and appends it as an `audit:event:escape-hatch-used` event (`r.defineEvent(\"escape-hatch-used\", ...)`, `piiFields: \"none\"`) on the unbound pool, so the entry survives a rollback of the handler that triggered it. `server-runtime`'s `buildBootExtraContext` wires it as `AppContext._escapeHatchAuditSink` automatically whenever the `audit` feature is mounted; without the feature, escape-hatch uses fall back to a structured warn log. Events show up in the existing `audit:query:list`/`audit-log` screen like any other event."
13
+ },
2
14
  {
3
15
  "version": "0.241.0",
4
16
  "type": "breaking",
@@ -0,0 +1,52 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { EscapeHatchAuditSink, EscapeHatchKind } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
+ import { z } from "zod";
6
+
7
+ export const ESCAPE_HATCH_USED_EVENT = "audit:event:escape-hatch-used";
8
+ export const ESCAPE_HATCH_USE_AGGREGATE_TYPE = "escapeHatchUse";
9
+
10
+ // Kept as a tuple so `satisfies` catches a kind added/removed/renamed on
11
+ // EscapeHatchKind without this schema following along.
12
+ const escapeHatchKinds = [
13
+ "unsafe-raw",
14
+ "acknowledge-cross-tenant",
15
+ "global-write",
16
+ "identity-switch",
17
+ "unsafe-all-tenants",
18
+ ] as const satisfies readonly EscapeHatchKind[];
19
+
20
+ export const escapeHatchUsedSchema = z.object({
21
+ handler: z.string(),
22
+ kind: z.enum(escapeHatchKinds),
23
+ reason: z.string(),
24
+ actor: z.string(),
25
+ targetUserId: z.string().optional(),
26
+ targetTenantId: z.string().optional(),
27
+ });
28
+
29
+ export function createEscapeHatchAuditSink(opts: {
30
+ readonly db: DbConnection;
31
+ }): EscapeHatchAuditSink {
32
+ return async (event) => {
33
+ const payload = escapeHatchUsedSchema.parse({
34
+ handler: event.handler,
35
+ kind: event.kind,
36
+ reason: event.reason,
37
+ actor: event.actor,
38
+ ...(event.target && { targetUserId: event.target.id, targetTenantId: event.target.tenantId }),
39
+ });
40
+ // Appends on the unbound pool, never the handler tx — an audit entry must
41
+ // survive a rollback of the handler that used the hatch.
42
+ await append(opts.db, {
43
+ aggregateId: generateId(),
44
+ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
45
+ tenantId: event.tenantId,
46
+ expectedVersion: 0,
47
+ type: ESCAPE_HATCH_USED_EVENT,
48
+ payload,
49
+ metadata: { userId: event.actor },
50
+ });
51
+ };
52
+ }
@@ -5,6 +5,7 @@ import {
5
5
  i18nKey,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants";
8
+ import { escapeHatchUsedSchema } from "./escape-hatch-audit-sink";
8
9
  import { detailsQuery } from "./handlers/details.query";
9
10
  import { listQuery } from "./handlers/list.query";
10
11
  import { AUDIT_I18N } from "./i18n";
@@ -24,7 +25,7 @@ import { AUDIT_I18N } from "./i18n";
24
25
  export function createAuditFeature(): FeatureDefinition {
25
26
  return defineFeature("audit", (r) => {
26
27
  r.describe(
27
- "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range.",
28
+ "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).",
28
29
  );
29
30
  r.uiHints({
30
31
  displayLabel: "Audit Log",
@@ -35,6 +36,9 @@ export function createAuditFeature(): FeatureDefinition {
35
36
  // Screens resolve actor names via tenant:query:members.
36
37
  r.requires("tenant");
37
38
 
39
+ // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
40
+ r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
41
+
38
42
  const queries = {
39
43
  list: r.queryHandler(listQuery),
40
44
  details: r.queryHandler(detailsQuery),
@@ -1,2 +1,3 @@
1
1
  export { AUDIT_FEATURE, AuditQueries } from "./constants";
2
+ export { createEscapeHatchAuditSink, ESCAPE_HATCH_USED_EVENT } from "./escape-hatch-audit-sink";
2
3
  export { createAuditFeature } from "./feature";
@@ -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
+ });
@@ -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: {