@cosmicdrift/kumiko-framework 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 (176) hide show
  1. package/README.md +5 -1
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  4. package/src/__tests__/error-contract.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  6. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  7. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  8. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  9. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  10. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  11. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  12. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  13. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  14. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  15. package/src/__tests__/full-stack.integration.test.ts +2 -2
  16. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  17. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  18. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  19. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  20. package/src/api/__tests__/api.test.ts +2 -2
  21. package/src/api/__tests__/body-limit.test.ts +1 -1
  22. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  23. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  24. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  25. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  26. package/src/api/server.ts +7 -20
  27. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  28. package/src/bun-db/query.ts +24 -21
  29. package/src/changes.json +99 -0
  30. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  31. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  32. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  33. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  34. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  35. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  36. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  37. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  38. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  39. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  40. package/src/db/apply-entity-event.ts +1 -1
  41. package/src/db/assert-exists-in.ts +1 -1
  42. package/src/db/event-store-executor-context.ts +5 -2
  43. package/src/db/event-store-executor-read.ts +6 -4
  44. package/src/db/event-store-executor-write.ts +24 -18
  45. package/src/db/queries/shadow-swap.ts +36 -0
  46. package/src/db/replay-migration-sql.ts +5 -5
  47. package/src/db/tenant-db-runner.ts +23 -0
  48. package/src/db/tenant-db.ts +97 -24
  49. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  50. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  51. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  52. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  53. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  54. package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
  55. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
  56. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  57. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  58. package/src/engine/__tests__/boot-validator.test.ts +69 -18
  59. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  60. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  61. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  62. package/src/engine/__tests__/engine.test.ts +39 -25
  63. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  64. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  65. package/src/engine/__tests__/entity-handlers.test.ts +47 -8
  66. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  67. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  68. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  69. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  70. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  71. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  72. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  73. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  74. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  75. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  76. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  77. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  78. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  79. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  80. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  81. package/src/engine/__tests__/registry.test.ts +19 -13
  82. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  83. package/src/engine/__tests__/screen.test.ts +176 -20
  84. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  85. package/src/engine/__tests__/steps-read.test.ts +204 -8
  86. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  87. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  88. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  89. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  90. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  91. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  92. package/src/engine/boot-validator/access-declarations.ts +3 -5
  93. package/src/engine/boot-validator/entity-handler.ts +21 -0
  94. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  95. package/src/engine/boot-validator/nav.ts +4 -3
  96. package/src/engine/boot-validator/parent-ref.ts +1 -1
  97. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  98. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  99. package/src/engine/boot-validator/screens.ts +154 -31
  100. package/src/engine/build-config-feature-schema.ts +12 -2
  101. package/src/engine/entity-handlers.ts +72 -20
  102. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  103. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  104. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  105. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  106. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  107. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  108. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  109. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  110. package/src/engine/feature-ast/patcher.ts +15 -3
  111. package/src/engine/feature-ast/patterns.ts +4 -0
  112. package/src/engine/feature-ast/render.ts +3 -0
  113. package/src/engine/feature-entity-handlers.ts +4 -4
  114. package/src/engine/index.ts +5 -0
  115. package/src/engine/pattern-library/shared-fields.ts +7 -3
  116. package/src/engine/registry-validate.ts +12 -4
  117. package/src/engine/schema-builder.ts +6 -0
  118. package/src/engine/screen-helpers.ts +52 -0
  119. package/src/engine/steps/_read-source.ts +12 -0
  120. package/src/engine/steps/read-find-many.ts +8 -2
  121. package/src/engine/steps/read-find-one.ts +11 -8
  122. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  123. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  124. package/src/engine/types/index.ts +8 -1
  125. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  126. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  127. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  128. package/src/errors/i18n/de.yaml +14 -0
  129. package/src/errors/i18n/en.yaml +13 -0
  130. package/src/errors/reasons.ts +4 -0
  131. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  132. package/src/i18n/required-surface-keys.ts +6 -2
  133. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  134. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  135. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  136. package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
  137. package/src/jobs/job-runner.ts +35 -5
  138. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  139. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  140. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  141. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  142. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  143. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  144. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  146. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  147. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  148. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  149. package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
  150. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  151. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  152. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  153. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  154. package/src/pipeline/dispatch-query.ts +27 -8
  155. package/src/pipeline/dispatch-shared.ts +141 -57
  156. package/src/pipeline/dispatch-stream.ts +1 -3
  157. package/src/pipeline/dispatch-write.ts +62 -14
  158. package/src/pipeline/dispatcher.ts +20 -0
  159. package/src/pipeline/escape-hatch-report.ts +127 -0
  160. package/src/pipeline/index.ts +8 -0
  161. package/src/pipeline/member-read-only-transaction.ts +75 -0
  162. package/src/pipeline/msp-rebuild.ts +2 -0
  163. package/src/pipeline/projection-rebuild.ts +2 -0
  164. package/src/pipeline/projections-runner.ts +15 -13
  165. package/src/pipeline/system-identity-switch.ts +121 -9
  166. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  167. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  168. package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
  169. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  170. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  171. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  172. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
  173. package/src/ui-types/index.ts +3 -0
  174. package/src/utils/__tests__/ids.test.ts +29 -1
  175. package/src/utils/ids.ts +10 -1
  176. package/src/utils/index.ts +1 -1
@@ -1,7 +1,8 @@
1
- // C1 — ctx.queryProjection: read projection tables by qualified name.
1
+ // C1/fw#2913 — ctx.queryProjection: read projection tables by qualified name.
2
2
  // Framework-level read surface so features don't have to import projection
3
3
  // drizzle-tables directly. Auto-filters by tenant_id when the projection
4
- // table carries that column.
4
+ // table carries that column; { unsafeAllTenants: true } opts out but needs a
5
+ // grant — r.systemScope() or a declared escapeHatch on the handler.
5
6
 
6
7
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
8
  import { z } from "zod";
@@ -11,10 +12,12 @@ import {
11
12
  text as pgText,
12
13
  uuid as pgUuid,
13
14
  } from "../../db/dialect";
15
+ import { defineUnmanagedTable } from "../../db/entity-table-meta";
14
16
  import { createEventStoreExecutor } from "../../db/event-store-executor";
15
17
  import { insertOne } from "../../db/query";
16
18
  import { buildEntityTable } from "../../db/table-builder";
17
19
  import { createEntity, createTextField, defineFeature } from "../../engine";
20
+ import type { EscapeHatchUseEvent, ProjectionTable } from "../../engine/types";
18
21
  import {
19
22
  resetEventStore,
20
23
  setupTestStack,
@@ -43,6 +46,19 @@ const systemScopedTable = pgTable("read_qp_widget_audit", {
43
46
  label: pgText("label").notNull(),
44
47
  });
45
48
 
49
+ // Plain EntityTableMeta table (no SchemaTable symbols) — exercises
50
+ // hasTenantColumn's meta-fallback branch instead of the drizzle-symbol path.
51
+ const entityMetaWidgetTable = defineUnmanagedTable({
52
+ tableName: "store_qp_widget_meta",
53
+ columns: [
54
+ { name: "widget_id", pgType: "uuid", notNull: true, primaryKey: true },
55
+ { name: "tenant_id", pgType: "uuid", notNull: true },
56
+ { name: "label", pgType: "text", notNull: true },
57
+ ],
58
+ });
59
+
60
+ const UNSAFE_ALL_TENANTS_REASON = "fw#2913 test — cross-tenant widget sweep";
61
+
46
62
  const qpFeature = defineFeature("qp", (r) => {
47
63
  r.entity("qp-widget", widgetEntity);
48
64
 
@@ -77,6 +93,24 @@ const qpFeature = defineFeature("qp", (r) => {
77
93
  },
78
94
  });
79
95
 
96
+ r.projection({
97
+ name: "widget-count-entitymeta",
98
+ source: "qp-widget",
99
+ // @cast-boundary test-fixture — a plain EntityTableMeta lacks SchemaTable's
100
+ // drizzle symbols; asEntityTableMeta()'s fallback branch reads it directly.
101
+ table: entityMetaWidgetTable as unknown as ProjectionTable,
102
+ apply: {
103
+ "qp-widget.created": async (event, tx) => {
104
+ const p = event.payload as { name?: string };
105
+ await insertOne(tx, entityMetaWidgetTable, {
106
+ widgetId: event.aggregateId,
107
+ tenantId: event.tenantId,
108
+ label: p.name ?? "?",
109
+ });
110
+ },
111
+ },
112
+ });
113
+
80
114
  const executor = createEventStoreExecutor(widgetTable, widgetEntity, {
81
115
  entityName: "qp-widget",
82
116
  });
@@ -92,7 +126,7 @@ const qpFeature = defineFeature("qp", (r) => {
92
126
  "widget:list-tenant",
93
127
  z.object({}),
94
128
  async (_query, ctx) => ctx.queryProjection("qp:projection:widget-count-tenant"),
95
- { access: { openToAll: true } },
129
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
96
130
  );
97
131
 
98
132
  r.queryHandler(
@@ -102,14 +136,56 @@ const qpFeature = defineFeature("qp", (r) => {
102
136
  ctx.queryProjection("qp:projection:widget-audit", {
103
137
  unsafeAllTenants: query.payload.unsafeAllTenants ?? false,
104
138
  }),
105
- { access: { openToAll: true } },
139
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
106
140
  );
107
141
 
108
142
  r.queryHandler(
109
143
  "widget:list-ghost",
110
144
  z.object({}),
111
145
  async (_query, ctx) => ctx.queryProjection("qp:projection:does-not-exist"),
112
- { access: { openToAll: true } },
146
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
147
+ );
148
+
149
+ r.queryHandler(
150
+ "widget:list-tenant-escape-hatch",
151
+ z.object({}),
152
+ async (_query, ctx) =>
153
+ ctx.queryProjection("qp:projection:widget-count-tenant", { unsafeAllTenants: true }),
154
+ {
155
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
156
+ escapeHatch: { reason: UNSAFE_ALL_TENANTS_REASON },
157
+ },
158
+ );
159
+
160
+ r.queryHandler(
161
+ "widget:list-entitymeta",
162
+ z.object({}),
163
+ async (_query, ctx) => ctx.queryProjection("qp:projection:widget-count-entitymeta"),
164
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
165
+ );
166
+
167
+ r.queryHandler(
168
+ "widget:list-tenant-untyped-payload",
169
+ z.object({ unsafeAllTenants: z.unknown() }),
170
+ async (query, ctx) =>
171
+ ctx.queryProjection("qp:projection:widget-count-tenant", {
172
+ // @cast-boundary test-fixture — deliberately not the literal boolean `true`,
173
+ // proving a truthy-but-not-strictly-true value still tenant-filters.
174
+ unsafeAllTenants: query.payload.unsafeAllTenants as boolean,
175
+ }),
176
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
177
+ );
178
+ });
179
+
180
+ const qpSystemFeature = defineFeature("qp-system", (r) => {
181
+ r.systemScope();
182
+
183
+ r.queryHandler(
184
+ "widget:list-tenant-systemscope",
185
+ z.object({}),
186
+ async (_query, ctx) =>
187
+ ctx.queryProjection("qp:projection:widget-count-tenant", { unsafeAllTenants: true }),
188
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
113
189
  );
114
190
  });
115
191
 
@@ -120,8 +196,25 @@ const otherTenantAdmin = {
120
196
  tenantId: "00000000-0000-4000-8000-0000000000b0" as const,
121
197
  };
122
198
 
199
+ function errorReason(details: unknown): string | undefined {
200
+ if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
201
+ return typeof details.reason === "string" ? details.reason : undefined;
202
+ }
203
+
204
+ function recordingSink(events: EscapeHatchUseEvent[]) {
205
+ return async (event: EscapeHatchUseEvent) => {
206
+ events.push(event);
207
+ };
208
+ }
209
+
210
+ const escapeHatchEvents: EscapeHatchUseEvent[] = [];
211
+
123
212
  beforeAll(async () => {
124
- stack = await setupTestStack({ features: [qpFeature], systemHooks: [] });
213
+ stack = await setupTestStack({
214
+ features: [qpFeature, qpSystemFeature],
215
+ systemHooks: [],
216
+ extraContext: { _escapeHatchAuditSink: recordingSink(escapeHatchEvents) },
217
+ });
125
218
  await unsafeCreateEntityTable(stack.db, widgetEntity, "qp-widget");
126
219
  });
127
220
 
@@ -130,10 +223,12 @@ afterAll(async () => {
130
223
  });
131
224
 
132
225
  afterEach(async () => {
226
+ escapeHatchEvents.length = 0;
133
227
  await resetEventStore(stack, [
134
228
  "read_qp_widgets",
135
229
  "read_qp_widget_count_tenant",
136
230
  "read_qp_widget_audit",
231
+ "store_qp_widget_meta",
137
232
  ]);
138
233
  });
139
234
 
@@ -173,24 +268,18 @@ describe("ctx.queryProjection", () => {
173
268
  expect(rows.map((r) => r.label).sort()).toEqual(["X", "Y"]);
174
269
  });
175
270
 
176
- test("unsafeAllTenants=true bypasses tenant filter on tenant-scoped projection", async () => {
177
- // Repurpose list-system by passing unsafeAllTenants=true — but list-system is
178
- // already no-tenant-column. The semantic matters when a projection HAS
179
- // tenant_id but the handler wants a cross-tenant sweep (audit). We
180
- // exercise that contract via a direct queryProjection call here.
271
+ test("unsafeAllTenants=true without a grant is denied — no cross-tenant read", async () => {
181
272
  await stack.http.writeOk("qp:write:widget:create", { name: "AA" }, admin);
182
273
  await stack.http.writeOk("qp:write:widget:create", { name: "BB" }, otherTenantAdmin);
183
274
 
184
- // The server exposes list-tenant with no opt-out, so assert the raw
185
- // helper path via a one-shot handler:
186
- // (We could add an "override" handler instead, but keeping the feature
187
- // surface small — assert against the two query handlers we have.)
188
- const sys = await stack.http.queryOk<Array<{ label: string }>>(
275
+ const err = await stack.http.queryErr(
189
276
  "qp:query:widget:list-system",
190
277
  { unsafeAllTenants: true },
191
278
  admin,
192
279
  );
193
- expect(sys).toHaveLength(2);
280
+ expect(err.httpStatus).toBe(403);
281
+ expect(err.code).toBe("access_denied");
282
+ expect(errorReason(err.details)).toBe("unsafe_all_tenants_denied");
194
283
  });
195
284
 
196
285
  test("unknown projection name throws with a helpful error", async () => {
@@ -199,4 +288,70 @@ describe("ctx.queryProjection", () => {
199
288
  const body = (await res.json()) as { error?: { message?: string } };
200
289
  expect(body.error?.message).toMatch(/projection not registered|does-not-exist/);
201
290
  });
291
+
292
+ test("escapeHatch grant lets unsafeAllTenants=true read cross-tenant, reporting exactly one unsafe-all-tenants event", async () => {
293
+ await stack.http.writeOk("qp:write:widget:create", { name: "AA" }, admin);
294
+ await stack.http.writeOk("qp:write:widget:create", { name: "BB" }, otherTenantAdmin);
295
+
296
+ const rows = await stack.http.queryOk<Array<{ label: string }>>(
297
+ "qp:query:widget:list-tenant-escape-hatch",
298
+ {},
299
+ admin,
300
+ );
301
+ expect(rows.map((r) => r.label).sort()).toEqual(["AA", "BB"]);
302
+
303
+ const matches = escapeHatchEvents.filter((e) => e.kind === "unsafe-all-tenants");
304
+ expect(matches).toEqual([
305
+ {
306
+ handler: "qp:query:widget:list-tenant-escape-hatch",
307
+ kind: "unsafe-all-tenants",
308
+ reason: UNSAFE_ALL_TENANTS_REASON,
309
+ tenantId: admin.tenantId,
310
+ actor: admin.id,
311
+ },
312
+ ]);
313
+ });
314
+
315
+ test("r.systemScope() grants unsafeAllTenants=true without a declared escapeHatch", async () => {
316
+ await stack.http.writeOk("qp:write:widget:create", { name: "CC" }, admin);
317
+ await stack.http.writeOk("qp:write:widget:create", { name: "DD" }, otherTenantAdmin);
318
+
319
+ const rows = await stack.http.queryOk<Array<{ label: string }>>(
320
+ "qp-system:query:widget:list-tenant-systemscope",
321
+ {},
322
+ admin,
323
+ );
324
+ expect(rows.map((r) => r.label).sort()).toEqual(["CC", "DD"]);
325
+ });
326
+
327
+ test("a plain EntityTableMeta projection table is tenant-filtered without unsafeAllTenants", async () => {
328
+ await stack.http.writeOk("qp:write:widget:create", { name: "EE" }, admin);
329
+ await stack.http.writeOk("qp:write:widget:create", { name: "FF" }, otherTenantAdmin);
330
+
331
+ const forAdmin = await stack.http.queryOk<Array<{ label: string }>>(
332
+ "qp:query:widget:list-entitymeta",
333
+ {},
334
+ admin,
335
+ );
336
+ expect(forAdmin.map((r) => r.label)).toEqual(["EE"]);
337
+
338
+ const forOther = await stack.http.queryOk<Array<{ label: string }>>(
339
+ "qp:query:widget:list-entitymeta",
340
+ {},
341
+ otherTenantAdmin,
342
+ );
343
+ expect(forOther.map((r) => r.label)).toEqual(["FF"]);
344
+ });
345
+
346
+ test("a truthy-but-not-true unsafeAllTenants payload still tenant-filters (no bypass, no grant)", async () => {
347
+ await stack.http.writeOk("qp:write:widget:create", { name: "GG" }, admin);
348
+ await stack.http.writeOk("qp:write:widget:create", { name: "HH" }, otherTenantAdmin);
349
+
350
+ const rows = await stack.http.queryOk<Array<{ label: string }>>(
351
+ "qp:query:widget:list-tenant-untyped-payload",
352
+ { unsafeAllTenants: "yes" },
353
+ admin,
354
+ );
355
+ expect(rows.map((r) => r.label)).toEqual(["GG"]);
356
+ });
202
357
  });
@@ -0,0 +1,118 @@
1
+ // fw#2861 — systemScope handlers get a default per-tenant rate limit unless
2
+ // they declare their own or opt out. Full-stack proof via setupTestStack +
3
+ // real HTTP (stack.http.write) — never createTestDispatcher. Modelled on
4
+ // rate-limit/__tests__/dispatcher-l3.integration.test.ts.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { createSystemUser, defineFeature } from "../../engine";
9
+ import type { RateLimitConfig, RateLimitDecision, RateLimitResolver } from "../../rate-limit";
10
+ import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
11
+
12
+ function fakeResolver(): {
13
+ readonly resolver: RateLimitResolver;
14
+ readonly calls: Array<{ bucket: string; config: RateLimitConfig }>;
15
+ } {
16
+ const calls: Array<{ bucket: string; config: RateLimitConfig }> = [];
17
+ const decision = (config: RateLimitConfig): RateLimitDecision => ({
18
+ allowed: true,
19
+ limit: config.limit,
20
+ remaining: config.limit - 1,
21
+ retryAfterSeconds: 0,
22
+ windowSeconds: config.windowSeconds,
23
+ // @cast-boundary test-fixture — resetAt is never read by the assertions below
24
+ resetAt: undefined as unknown as RateLimitDecision["resetAt"],
25
+ });
26
+ const resolver: RateLimitResolver = {
27
+ check: async (bucket, config) => {
28
+ calls.push({ bucket, config });
29
+ return decision(config);
30
+ },
31
+ enforce: async (bucket, config) => {
32
+ calls.push({ bucket, config });
33
+ return decision(config);
34
+ },
35
+ peek: async (bucket, config) => {
36
+ calls.push({ bucket, config: { ...config } });
37
+ return decision({ ...config, cost: undefined });
38
+ },
39
+ };
40
+ return { resolver, calls };
41
+ }
42
+
43
+ const rlDefaultFeature = defineFeature("rl-default", (r) => {
44
+ r.systemScope();
45
+
46
+ r.writeHandler("explicit", z.object({}), async () => ({ isSuccess: true as const, data: {} }), {
47
+ access: { roles: ["Admin"] },
48
+ rateLimit: { per: "tenant", limit: 2, windowSeconds: 60 },
49
+ });
50
+
51
+ r.writeHandler(
52
+ "implicit-default",
53
+ z.object({}),
54
+ async () => ({ isSuccess: true as const, data: {} }),
55
+ {
56
+ access: { roles: ["Admin"] },
57
+ },
58
+ );
59
+
60
+ r.writeHandler(
61
+ "as-system-probe",
62
+ z.object({}),
63
+ async (event, ctx) => {
64
+ const sysUser = createSystemUser(event.user.tenantId);
65
+ const res = await ctx.writeAs(sysUser, "rl-default:write:implicit-default", {});
66
+ return { isSuccess: true as const, data: res };
67
+ },
68
+ { access: { roles: ["Admin"] } },
69
+ );
70
+ });
71
+
72
+ let stack: TestStack;
73
+ let calls: Array<{ bucket: string; config: RateLimitConfig }>;
74
+ const TENANT = testTenantId(9201);
75
+ const admin = createTestUser({ tenantId: TENANT, roles: ["Admin"] });
76
+
77
+ beforeAll(async () => {
78
+ const { resolver, calls: recorded } = fakeResolver();
79
+ calls = recorded;
80
+ stack = await setupTestStack({
81
+ features: [rlDefaultFeature],
82
+ extraContext: { rateLimit: resolver },
83
+ });
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ describe("systemScope default rate limit (fw#2861)", () => {
91
+ test("explicit rateLimit still wins over the systemScope default", async () => {
92
+ calls.length = 0;
93
+ const res = await stack.http.write("rl-default:write:explicit", {}, admin);
94
+ expect(res.status).toBe(200);
95
+ expect(calls).toHaveLength(1);
96
+ expect(calls[0]?.bucket).toBe(`tenant:${TENANT}`);
97
+ expect(calls[0]?.config).toEqual({ limit: 2, windowSeconds: 60, cost: undefined });
98
+ });
99
+
100
+ test("no declared rateLimit on a systemScope handler -> the default {per:tenant+handler,limit:600,windowSeconds:60} fires", async () => {
101
+ calls.length = 0;
102
+ const res = await stack.http.write("rl-default:write:implicit-default", {}, admin);
103
+ expect(res.status).toBe(200);
104
+ expect(calls).toHaveLength(1);
105
+ expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:rl-default:write:implicit-default`);
106
+ expect(calls[0]?.config).toEqual({ limit: 600, windowSeconds: 60, cost: undefined });
107
+ });
108
+
109
+ test("a SYSTEM-identity caller (ctx.writeAs) never touches the resolver", async () => {
110
+ calls.length = 0;
111
+ const res = await stack.http.write("rl-default:write:as-system-probe", {}, admin);
112
+ expect(res.status).toBe(200);
113
+ // One call for the probe handler's own dispatch (bucket = admin's tenant+handler),
114
+ // NONE for the inner SYSTEM-identity writeAs to implicit-default.
115
+ expect(calls).toHaveLength(1);
116
+ expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:rl-default:write:as-system-probe`);
117
+ });
118
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Temporal } from "temporal-polyfill";
3
+ import { createSystemUser } from "../../engine";
4
+ import type { RateLimitConfig, RateLimitDecision, RateLimitResolver } from "../../rate-limit";
5
+ import { createTestUser, testTenantId } from "../../stack";
6
+ import type { DispatchContext } from "../dispatch-shared";
7
+ import { enforceRateLimit } from "../dispatch-shared";
8
+
9
+ const TENANT = testTenantId(9001);
10
+
11
+ function fakeResolver(): {
12
+ readonly resolver: RateLimitResolver;
13
+ readonly calls: Array<{ bucket: string; config: RateLimitConfig }>;
14
+ } {
15
+ const calls: Array<{ bucket: string; config: RateLimitConfig }> = [];
16
+ const decision: RateLimitDecision = {
17
+ allowed: true,
18
+ limit: 0,
19
+ remaining: 0,
20
+ retryAfterSeconds: 0,
21
+ windowSeconds: 0,
22
+ resetAt: {} as Temporal.Instant,
23
+ };
24
+ const resolver: RateLimitResolver = {
25
+ check: async (bucket, config) => {
26
+ calls.push({ bucket, config });
27
+ return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
28
+ },
29
+ enforce: async (bucket, config) => {
30
+ calls.push({ bucket, config });
31
+ return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
32
+ },
33
+ peek: async (bucket, config) => {
34
+ calls.push({ bucket, config: { ...config } });
35
+ return { ...decision, limit: config.limit, windowSeconds: config.windowSeconds };
36
+ },
37
+ };
38
+ return { resolver, calls };
39
+ }
40
+
41
+ function fakeCtx(rateLimit: RateLimitResolver | undefined): DispatchContext {
42
+ return {
43
+ appContext: { rateLimit },
44
+ } as unknown as DispatchContext; // @cast-boundary test-fixture — enforceRateLimit only reads ctx.appContext
45
+ }
46
+
47
+ describe("enforceRateLimit — systemScope default", () => {
48
+ test("systemScope handler, no declared rateLimit, resolver configured, regular user -> enforces the default per-tenant+handler limit", async () => {
49
+ const { resolver, calls } = fakeResolver();
50
+ const user = createTestUser({ tenantId: TENANT });
51
+
52
+ await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", user, true);
53
+
54
+ expect(calls).toHaveLength(1);
55
+ expect(calls[0]?.bucket).toBe(`tenant+handler:${TENANT}:some:write:handler`);
56
+ expect(calls[0]?.config).toEqual({ limit: 600, windowSeconds: 60, cost: undefined });
57
+ });
58
+
59
+ test("systemScope handler, SYSTEM-identity caller -> never touches the resolver", async () => {
60
+ const { resolver, calls } = fakeResolver();
61
+ const systemUser = createSystemUser(TENANT);
62
+
63
+ await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", systemUser, true);
64
+
65
+ expect(calls).toHaveLength(0);
66
+ });
67
+
68
+ test("systemScope handler, no resolver configured -> no-op, no throw", async () => {
69
+ const user = createTestUser({ tenantId: TENANT });
70
+
71
+ await expect(
72
+ enforceRateLimit(fakeCtx(undefined), undefined, "some:write:handler", user, true),
73
+ ).resolves.toBeUndefined();
74
+ });
75
+
76
+ test("systemScope handler with an explicit rateLimit -> that option is enforced, not the default", async () => {
77
+ const { resolver, calls } = fakeResolver();
78
+ const user = createTestUser({ tenantId: TENANT });
79
+
80
+ await enforceRateLimit(
81
+ fakeCtx(resolver),
82
+ { per: "user", limit: 3, windowSeconds: 10 },
83
+ "some:write:handler",
84
+ user,
85
+ true,
86
+ );
87
+
88
+ expect(calls).toHaveLength(1);
89
+ expect(calls[0]?.bucket).toBe(`user:${user.id}`);
90
+ expect(calls[0]?.config).toEqual({ limit: 3, windowSeconds: 10, cost: undefined });
91
+ });
92
+
93
+ test("systemScope handler with rateLimit: { disabled: true, reason } -> never enforced, no error", async () => {
94
+ const { resolver, calls } = fakeResolver();
95
+ const user = createTestUser({ tenantId: TENANT });
96
+
97
+ await expect(
98
+ enforceRateLimit(
99
+ fakeCtx(resolver),
100
+ { disabled: true, reason: "ops replay path" },
101
+ "some:write:handler",
102
+ user,
103
+ true,
104
+ ),
105
+ ).resolves.toBeUndefined();
106
+ expect(calls).toHaveLength(0);
107
+ });
108
+
109
+ test("non-systemScope handler, no declared rateLimit -> never enforced (unchanged baseline behavior)", async () => {
110
+ const { resolver, calls } = fakeResolver();
111
+ const user = createTestUser({ tenantId: TENANT });
112
+
113
+ await enforceRateLimit(fakeCtx(resolver), undefined, "some:write:handler", user, false);
114
+
115
+ expect(calls).toHaveLength(0);
116
+ });
117
+ });
@@ -69,13 +69,16 @@ const namedSearchFeature = defineFeature("named-search", (r) => {
69
69
  // executor-built table is write-locked to the executor) so the row
70
70
  // ends up with a value distinct from the event payload above. Proves
71
71
  // the search consumer reads the row, not the payload.
72
- await asRawClient(ctx.db).unsafe(
72
+ await asRawClient(ctx.db.unsafeRaw("test: raw row update bypassing the executor")).unsafe(
73
73
  `UPDATE read_named_search_notes SET label = $1 WHERE id = $2`,
74
74
  [ROW_LABEL, event.payload.id],
75
75
  );
76
76
  return { isSuccess: true as const, data: { id: event.payload.id } };
77
77
  },
78
- { access: { roles: ["Admin"] } },
78
+ {
79
+ access: { roles: ["Admin"] },
80
+ escapeHatch: { reason: "test: raw row update bypassing the executor" },
81
+ },
79
82
  );
80
83
 
81
84
  r.writeHandler(