pyric 0.1.0-alpha.19 → 0.1.0-alpha.21

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 (281) hide show
  1. package/README.md +64 -89
  2. package/dist/ai/blocked.d.ts +52 -0
  3. package/dist/ai/blocked.d.ts.map +1 -0
  4. package/dist/ai/blocked.js +83 -0
  5. package/dist/ai/blocked.js.map +1 -0
  6. package/dist/ai/broker/broker.d.ts +50 -0
  7. package/dist/ai/broker/broker.d.ts.map +1 -1
  8. package/dist/ai/broker/broker.js +157 -21
  9. package/dist/ai/broker/broker.js.map +1 -1
  10. package/dist/ai/broker/gemini-engine.d.ts +56 -0
  11. package/dist/ai/broker/gemini-engine.d.ts.map +1 -0
  12. package/dist/ai/broker/gemini-engine.js +235 -0
  13. package/dist/ai/broker/gemini-engine.js.map +1 -0
  14. package/dist/ai/broker/index.d.ts +2 -1
  15. package/dist/ai/broker/index.d.ts.map +1 -1
  16. package/dist/ai/broker/index.js +2 -1
  17. package/dist/ai/broker/index.js.map +1 -1
  18. package/dist/ai/broker/openai-engine.d.ts +10 -1
  19. package/dist/ai/broker/openai-engine.d.ts.map +1 -1
  20. package/dist/ai/broker/openai-engine.js +26 -4
  21. package/dist/ai/broker/openai-engine.js.map +1 -1
  22. package/dist/ai/broker/synthesizer.d.ts +14 -0
  23. package/dist/ai/broker/synthesizer.d.ts.map +1 -1
  24. package/dist/ai/broker/synthesizer.js +31 -0
  25. package/dist/ai/broker/synthesizer.js.map +1 -1
  26. package/dist/ai/broker/types.d.ts +29 -0
  27. package/dist/ai/broker/types.d.ts.map +1 -1
  28. package/dist/ai/instances.js +1 -1
  29. package/dist/ai/instances.js.map +1 -1
  30. package/dist/ai/internal.d.ts +5 -0
  31. package/dist/ai/internal.d.ts.map +1 -1
  32. package/dist/ai/internal.js +5 -0
  33. package/dist/ai/internal.js.map +1 -1
  34. package/dist/ai/response-helpers.d.ts.map +1 -1
  35. package/dist/ai/response-helpers.js +6 -24
  36. package/dist/ai/response-helpers.js.map +1 -1
  37. package/dist/analytics/index.d.ts +40 -0
  38. package/dist/analytics/index.d.ts.map +1 -0
  39. package/dist/analytics/index.js +25 -0
  40. package/dist/analytics/index.js.map +1 -0
  41. package/dist/app-check/index.d.ts +31 -0
  42. package/dist/app-check/index.d.ts.map +1 -0
  43. package/dist/app-check/index.js +20 -0
  44. package/dist/app-check/index.js.map +1 -0
  45. package/dist/auth/instances.js +1 -1
  46. package/dist/auth/instances.js.map +1 -1
  47. package/dist/database/controls.d.ts +1 -1
  48. package/dist/database/controls.js +1 -1
  49. package/dist/database/instances.js +1 -1
  50. package/dist/database/instances.js.map +1 -1
  51. package/dist/database/sandbox/backend.d.ts +1 -0
  52. package/dist/database/sandbox/backend.d.ts.map +1 -1
  53. package/dist/database/sandbox/backend.js +1 -0
  54. package/dist/database/sandbox/backend.js.map +1 -1
  55. package/dist/database/sandbox/rules-eval.d.ts +6 -2
  56. package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
  57. package/dist/database/sandbox/rules-eval.js +15 -2
  58. package/dist/database/sandbox/rules-eval.js.map +1 -1
  59. package/dist/database/sandbox/write-plane.d.ts +1 -0
  60. package/dist/database/sandbox/write-plane.d.ts.map +1 -1
  61. package/dist/database/sandbox/write-plane.js +7 -2
  62. package/dist/database/sandbox/write-plane.js.map +1 -1
  63. package/dist/database/sandbox-controls.d.ts +12 -5
  64. package/dist/database/sandbox-controls.d.ts.map +1 -1
  65. package/dist/database/sandbox-controls.js +20 -9
  66. package/dist/database/sandbox-controls.js.map +1 -1
  67. package/dist/database/sandbox-namespace.d.ts +19 -1
  68. package/dist/database/sandbox-namespace.d.ts.map +1 -1
  69. package/dist/database/sandbox-namespace.js +24 -1
  70. package/dist/database/sandbox-namespace.js.map +1 -1
  71. package/dist/deferred/entry.d.ts +75 -0
  72. package/dist/deferred/entry.d.ts.map +1 -0
  73. package/dist/deferred/entry.js +143 -0
  74. package/dist/deferred/entry.js.map +1 -0
  75. package/dist/firestore/instances.js +1 -1
  76. package/dist/firestore/instances.js.map +1 -1
  77. package/dist/firestore/lite.d.ts +72 -0
  78. package/dist/firestore/lite.d.ts.map +1 -0
  79. package/dist/firestore/lite.js +22 -0
  80. package/dist/firestore/lite.js.map +1 -0
  81. package/dist/firestore/persistence.d.ts +2 -2
  82. package/dist/firestore/persistence.js +1 -1
  83. package/dist/firestore/sandbox-controls.d.ts +2 -0
  84. package/dist/firestore/sandbox-controls.d.ts.map +1 -1
  85. package/dist/firestore/sandbox-controls.js +8 -0
  86. package/dist/firestore/sandbox-controls.js.map +1 -1
  87. package/dist/functions/index.d.ts +29 -0
  88. package/dist/functions/index.d.ts.map +1 -0
  89. package/dist/functions/index.js +20 -0
  90. package/dist/functions/index.js.map +1 -0
  91. package/dist/messaging/client.d.ts +2 -1
  92. package/dist/messaging/client.d.ts.map +1 -1
  93. package/dist/messaging/client.js +2 -2
  94. package/dist/messaging/client.js.map +1 -1
  95. package/dist/messaging/instance.d.ts +2 -2
  96. package/dist/messaging/instance.d.ts.map +1 -1
  97. package/dist/messaging/instance.js +40 -6
  98. package/dist/messaging/instance.js.map +1 -1
  99. package/dist/messaging/sw.d.ts +2 -1
  100. package/dist/messaging/sw.d.ts.map +1 -1
  101. package/dist/messaging/sw.js +2 -2
  102. package/dist/messaging/sw.js.map +1 -1
  103. package/dist/performance/index.d.ts +22 -0
  104. package/dist/performance/index.d.ts.map +1 -0
  105. package/dist/performance/index.js +19 -0
  106. package/dist/performance/index.js.map +1 -0
  107. package/dist/remote-config/index.d.ts +40 -0
  108. package/dist/remote-config/index.d.ts.map +1 -0
  109. package/dist/remote-config/index.js +25 -0
  110. package/dist/remote-config/index.js.map +1 -0
  111. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
  112. package/dist/rules/grammar/FirestoreValidator.js +43 -27
  113. package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
  114. package/dist/rules/grammar/document-access-count.d.ts +24 -0
  115. package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
  116. package/dist/rules/grammar/document-access-count.js +100 -0
  117. package/dist/rules/grammar/document-access-count.js.map +1 -0
  118. package/dist/rules/linter/ast-utils.d.ts +2 -6
  119. package/dist/rules/linter/ast-utils.d.ts.map +1 -1
  120. package/dist/rules/linter/ast-utils.js +2 -63
  121. package/dist/rules/linter/ast-utils.js.map +1 -1
  122. package/dist/rules/linter/hallucinations.d.ts +8 -0
  123. package/dist/rules/linter/hallucinations.d.ts.map +1 -1
  124. package/dist/rules/linter/hallucinations.js +121 -31
  125. package/dist/rules/linter/hallucinations.js.map +1 -1
  126. package/dist/rules/linter/linter.d.ts +10 -1
  127. package/dist/rules/linter/linter.d.ts.map +1 -1
  128. package/dist/rules/linter/linter.js +23 -9
  129. package/dist/rules/linter/linter.js.map +1 -1
  130. package/dist/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
  131. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts +1 -1
  132. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts.map +1 -1
  133. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js +5 -2
  134. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js.map +1 -1
  135. package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
  136. package/dist/rules/rtdb/grammar/simulator.js +61 -15
  137. package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
  138. package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
  139. package/dist/rules/rtdb/simulation/handler.js +96 -35
  140. package/dist/rules/rtdb/simulation/handler.js.map +1 -1
  141. package/dist/rules/simulator/document-lookups.d.ts +4 -0
  142. package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
  143. package/dist/rules/simulator/document-lookups.js +42 -3
  144. package/dist/rules/simulator/document-lookups.js.map +1 -1
  145. package/dist/rules/simulator/eval-error.d.ts +11 -0
  146. package/dist/rules/simulator/eval-error.d.ts.map +1 -1
  147. package/dist/rules/simulator/eval-error.js +14 -0
  148. package/dist/rules/simulator/eval-error.js.map +1 -1
  149. package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
  150. package/dist/rules/simulator/evaluation-builtins.js +33 -8
  151. package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
  152. package/dist/rules/simulator/evaluation-context.d.ts +12 -0
  153. package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
  154. package/dist/rules/simulator/evaluator.d.ts +1 -1
  155. package/dist/rules/simulator/evaluator.d.ts.map +1 -1
  156. package/dist/rules/simulator/evaluator.js +16 -4
  157. package/dist/rules/simulator/evaluator.js.map +1 -1
  158. package/dist/rules/simulator/handler.d.ts.map +1 -1
  159. package/dist/rules/simulator/handler.js +52 -5
  160. package/dist/rules/simulator/handler.js.map +1 -1
  161. package/dist/rules/simulator/lookup-budget.d.ts +37 -0
  162. package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
  163. package/dist/rules/simulator/lookup-budget.js +96 -0
  164. package/dist/rules/simulator/lookup-budget.js.map +1 -0
  165. package/dist/rules/stdlib-modules.d.ts +7 -0
  166. package/dist/rules/stdlib-modules.d.ts.map +1 -1
  167. package/dist/rules/stdlib-modules.js +11 -11
  168. package/dist/rules/stdlib-modules.js.map +1 -1
  169. package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
  170. package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
  171. package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
  172. package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
  173. package/dist/sandbox/index.d.ts +1 -1
  174. package/dist/sandbox/index.d.ts.map +1 -1
  175. package/dist/sandbox/index.js +1 -1
  176. package/dist/sandbox/index.js.map +1 -1
  177. package/dist/sandbox/persistence/types.d.ts +1 -1
  178. package/dist/sandbox/remote.d.ts +2 -2
  179. package/dist/sandbox/sandbox-context.d.ts +8 -1
  180. package/dist/sandbox/sandbox-context.d.ts.map +1 -1
  181. package/dist/sandbox/sandbox-context.js +59 -8
  182. package/dist/sandbox/sandbox-context.js.map +1 -1
  183. package/dist/sandbox/types/auth-state.d.ts +1 -0
  184. package/dist/sandbox/types/auth-state.d.ts.map +1 -1
  185. package/dist/sandbox/types/events.d.ts +2 -1
  186. package/dist/sandbox/types/events.d.ts.map +1 -1
  187. package/dist/sandbox/types/operation.d.ts +1 -0
  188. package/dist/sandbox/types/operation.d.ts.map +1 -1
  189. package/dist/storage/enforce.d.ts +20 -1
  190. package/dist/storage/enforce.d.ts.map +1 -1
  191. package/dist/storage/enforce.js +47 -14
  192. package/dist/storage/enforce.js.map +1 -1
  193. package/dist/storage/index.d.ts +1 -1
  194. package/dist/storage/index.d.ts.map +1 -1
  195. package/dist/storage/list.js +1 -1
  196. package/dist/storage/list.js.map +1 -1
  197. package/dist/storage/persistence.d.ts.map +1 -1
  198. package/dist/storage/persistence.js +66 -14
  199. package/dist/storage/persistence.js.map +1 -1
  200. package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
  201. package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
  202. package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
  203. package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
  204. package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
  205. package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
  206. package/dist/storage/sandbox/rules-evaluator.js +105 -33
  207. package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
  208. package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
  209. package/dist/storage/sandbox/rules-methods.js +17 -7
  210. package/dist/storage/sandbox/rules-methods.js.map +1 -1
  211. package/dist/storage/service.d.ts +23 -2
  212. package/dist/storage/service.d.ts.map +1 -1
  213. package/dist/storage/service.js +37 -26
  214. package/dist/storage/service.js.map +1 -1
  215. package/package.json +42 -2
  216. package/src/ai/blocked.ts +97 -0
  217. package/src/ai/broker/broker.ts +153 -22
  218. package/src/ai/broker/gemini-engine.ts +311 -0
  219. package/src/ai/broker/index.ts +2 -0
  220. package/src/ai/broker/openai-engine.ts +31 -3
  221. package/src/ai/broker/synthesizer.ts +34 -0
  222. package/src/ai/broker/types.ts +27 -1
  223. package/src/ai/instances.ts +1 -1
  224. package/src/ai/internal.ts +5 -0
  225. package/src/ai/response-helpers.ts +6 -25
  226. package/src/analytics/index.ts +53 -0
  227. package/src/app/dispatch.test.ts +3 -1
  228. package/src/app-check/index.ts +41 -0
  229. package/src/auth/instances.ts +1 -1
  230. package/src/database/controls.ts +1 -1
  231. package/src/database/instances.ts +1 -1
  232. package/src/database/sandbox/backend.ts +1 -0
  233. package/src/database/sandbox/rules-eval.ts +18 -2
  234. package/src/database/sandbox/write-plane.ts +8 -2
  235. package/src/database/sandbox-controls.ts +29 -9
  236. package/src/database/sandbox-namespace.ts +28 -1
  237. package/src/deferred/entry.ts +167 -0
  238. package/src/firestore/instances.ts +1 -1
  239. package/src/firestore/lite.ts +91 -0
  240. package/src/firestore/persistence.ts +2 -2
  241. package/src/firestore/sandbox-controls.ts +10 -0
  242. package/src/functions/index.ts +38 -0
  243. package/src/messaging/client.ts +3 -2
  244. package/src/messaging/instance.ts +44 -7
  245. package/src/messaging/sw.ts +3 -2
  246. package/src/performance/index.ts +31 -0
  247. package/src/remote-config/index.ts +53 -0
  248. package/src/rules/grammar/FirestoreValidator.ts +44 -28
  249. package/src/rules/grammar/document-access-count.ts +75 -0
  250. package/src/rules/linter/ast-utils.ts +2 -36
  251. package/src/rules/linter/hallucinations.ts +123 -25
  252. package/src/rules/linter/linter.ts +32 -10
  253. package/src/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
  254. package/src/rules/rtdb/grammar/RtdbExpr.ohm.generated.ts +5 -2
  255. package/src/rules/rtdb/grammar/simulator.ts +41 -15
  256. package/src/rules/rtdb/simulation/handler.ts +149 -46
  257. package/src/rules/simulator/document-lookups.ts +46 -3
  258. package/src/rules/simulator/eval-error.ts +16 -0
  259. package/src/rules/simulator/evaluation-builtins.ts +35 -8
  260. package/src/rules/simulator/evaluation-context.ts +12 -0
  261. package/src/rules/simulator/evaluator.ts +14 -3
  262. package/src/rules/simulator/handler.ts +68 -4
  263. package/src/rules/simulator/lookup-budget.ts +101 -0
  264. package/src/rules/stdlib-modules.ts +19 -11
  265. package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
  266. package/src/sandbox/index.ts +5 -1
  267. package/src/sandbox/persistence/types.ts +1 -1
  268. package/src/sandbox/remote.ts +2 -2
  269. package/src/sandbox/sandbox-context.ts +69 -9
  270. package/src/sandbox/types/auth-state.ts +1 -1
  271. package/src/sandbox/types/events.ts +2 -1
  272. package/src/sandbox/types/operation.ts +1 -1
  273. package/src/storage/enforce.ts +53 -14
  274. package/src/storage/index.ts +1 -1
  275. package/src/storage/list.ts +1 -1
  276. package/src/storage/persistence.ts +74 -16
  277. package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
  278. package/src/storage/sandbox/rules-evaluator.ts +109 -32
  279. package/src/storage/sandbox/rules-methods.ts +21 -7
  280. package/src/storage/service.ts +93 -27
  281. package/README.md.orig +0 -53
@@ -24,6 +24,8 @@ import { evaluate, UnsupportedError, TraceRecorder, type SimulationContext } fro
24
24
 
25
25
  import { Timestamp } from './wrappers/timestamp.js';
26
26
  import { Path } from './wrappers/path.js';
27
+ import { LookupBudget } from './lookup-budget.js';
28
+ import { ResourceLimitError } from './eval-error.js';
27
29
  import { projectAfterState } from './project-after-state.js';
28
30
  import {
29
31
  requestQuery,
@@ -75,12 +77,27 @@ function methodToOperations(method: string): string[] {
75
77
 
76
78
  // ═══ Rule evaluation ═══
77
79
 
80
+ /**
81
+ * One match block's contribution to the request verdict. `resourceLimit`
82
+ * carries a per-request resource limit (the document access budget) that
83
+ * the block ran into: it is not this block's private failure, so the caller
84
+ * stops evaluating siblings and denies the whole request.
85
+ */
86
+ interface RuleBlockOutcome {
87
+ decision: Decision;
88
+ trace: RuleEvaluation[];
89
+ notes: string[];
90
+ resourceLimit?: string;
91
+ }
92
+
78
93
  /**
79
94
  * Evaluate all allow rules in a match block for a given operation.
80
95
  * Uses OR semantics — if any matching rule allows, access is granted.
81
96
  *
82
97
  * Decision logic:
83
98
  * - any rule ALLOW → ALLOW (short-circuit)
99
+ * - else if any rule hit a per-request resource limit → DENY, and the
100
+ * caller stops: the limit ends the request, not just this block
84
101
  * - else if any rule threw UnsupportedError → UNSUPPORTED (sim abstains)
85
102
  * - else → DENY
86
103
  *
@@ -93,7 +110,7 @@ function evaluateRules(
93
110
  operation: string,
94
111
  ctx: SimulationContext,
95
112
  source?: string,
96
- ): { decision: Decision; trace: RuleEvaluation[]; notes: string[] } {
113
+ ): RuleBlockOutcome {
97
114
  const ops = methodToOperations(operation);
98
115
  const trace: RuleEvaluation[] = [];
99
116
  const notes: string[] = [];
@@ -140,6 +157,27 @@ function evaluateRules(
140
157
  trace.push(entry);
141
158
  } catch (e) {
142
159
  entry.expressionTrace = recorder.entries;
160
+ // A per-request resource limit (the document access budget) is not a
161
+ // failure of THIS rule: production stops the whole request there, so
162
+ // no sibling allow rule and no other match block gets a chance to
163
+ // grant. Abandon the loop and report the limit up to the caller.
164
+ const isResourceLimit = e instanceof ResourceLimitError;
165
+ if (isResourceLimit) {
166
+ entry.verdict = 'ERROR';
167
+ entry.message = (e as ResourceLimitError).message;
168
+ trace.push(entry);
169
+ ctx.trace = priorRecorder;
170
+ notes.push(
171
+ `Request denied by a rules resource limit: ${(e as ResourceLimitError).message}. `
172
+ + 'The limit is per request, so no other allow rule or match block was evaluated.',
173
+ );
174
+ return {
175
+ decision: 'DENY',
176
+ trace,
177
+ notes,
178
+ resourceLimit: (e as ResourceLimitError).message,
179
+ };
180
+ }
143
181
  const isUnsupported = e instanceof UnsupportedError;
144
182
  if (isUnsupported) {
145
183
  sawUnsupported = true;
@@ -220,6 +258,7 @@ function buildContext(
220
258
  pathVariables: Record<string, string>,
221
259
  getDoc?: (path: string) => Record<string, unknown> | null,
222
260
  batchProjection?: Map<string, Record<string, unknown> | null>,
261
+ lookupBudget?: LookupBudget,
223
262
  ): SimulationContext {
224
263
  const fnMap = new Map<string, FunctionDef>();
225
264
  for (const fn of functions) fnMap.set(fn.name, fn);
@@ -366,6 +405,12 @@ function buildContext(
366
405
  // getafter-batch fix — shared batch/transaction projection, when the
367
406
  // caller supplied one. Absent for single-op evaluation.
368
407
  ...(batchProjection ? { batchProjection } : {}),
408
+ // Per-request document access budget (10 distinct lookups). The SAME
409
+ // instance is threaded into every match block's context for one test
410
+ // case, because production's budget spans overlapping match blocks and
411
+ // OR'd allow rules within one request evaluation. An absent budget and
412
+ // an `undefined` one mean the same thing here: no budget is enforced.
413
+ lookupBudget,
369
414
  };
370
415
  }
371
416
 
@@ -538,9 +583,18 @@ export class SimulateFirestoreRulesHandler {
538
583
  const notes: string[] = [];
539
584
  let sawUnsupported = false;
540
585
  let grantingBlockPath: string | undefined;
586
+ let hitResourceLimit = false;
587
+ // One lookup budget per test case (one request evaluation), shared
588
+ // across every matching block below and reset here between requests.
589
+ // Production's single-request budget is 10 distinct document
590
+ // accesses; transactions and batched writes additionally get a
591
+ // 20-access aggregate that is NOT modeled, because each per-op
592
+ // simulate() call in a batch (see WriteRuntime.buildBatchProjection)
593
+ // gets its own fresh per-op budget of 10.
594
+ const lookupBudget = new LookupBudget();
541
595
  for (const match of matches) {
542
596
  const pathVars = { ...rootBindings, ...match.pathVariables };
543
- const ctx = buildContext(tc, match.functions, pathVars, opts?.getDoc, opts?.batchProjection);
597
+ const ctx = buildContext(tc, match.functions, pathVars, opts?.getDoc, opts?.batchProjection, lookupBudget);
544
598
 
545
599
  const blockPath = renderMatchBlockPath(match.block);
546
600
  const res = evaluateRules(match.block, tc.method, ctx, source);
@@ -550,6 +604,14 @@ export class SimulateFirestoreRulesHandler {
550
604
  trace.push(...res.trace);
551
605
  notes.push(...res.notes);
552
606
 
607
+ // A per-request resource limit ends the request: production stops
608
+ // the whole evaluation, so a later match block cannot grant.
609
+ const isResourceLimit = res.resourceLimit !== undefined;
610
+ if (isResourceLimit) {
611
+ decision = 'DENY';
612
+ hitResourceLimit = true;
613
+ break;
614
+ }
553
615
  const isResAllow = res.decision === 'ALLOW';
554
616
  if (isResAllow) {
555
617
  decision = 'ALLOW';
@@ -561,8 +623,10 @@ export class SimulateFirestoreRulesHandler {
561
623
  sawUnsupported = true;
562
624
  }
563
625
  }
564
- const isNotAllow = decision !== 'ALLOW';
565
- if (isNotAllow) {
626
+ // A resource limit is a definite production DENY, so it outranks an
627
+ // UNSUPPORTED abstention recorded by an earlier block.
628
+ const isEscalatable = decision !== 'ALLOW' && !hitResourceLimit;
629
+ if (isEscalatable) {
566
630
  if (sawUnsupported) {
567
631
  decision = 'UNSUPPORTED';
568
632
  }
@@ -0,0 +1,101 @@
1
+ import type { SimulationContext } from './evaluation-context.js';
2
+ import { ResourceLimitError } from './eval-error.js';
3
+ import { isDocumentPath, normalizeDocumentPath } from './document-lookups.js';
4
+
5
+ // ═══ Per-request document-lookup budget ═══
6
+ //
7
+ // Production hard-limits security-rule document access calls, get(),
8
+ // exists(), getAfter() and existsAfter(), to 10 per single-document request
9
+ // or query evaluation. Multi-document transactions and batched writes get
10
+ // an AGGREGATE budget of 20 on top of a per-operation 10; only the per-
11
+ // operation 10 is modeled here (each batched op's simulate() call gets its
12
+ // own fresh LookupBudget, mirroring how WriteRuntime.buildBatchProjection
13
+ // shares one projection map across per-op simulate() calls but each op is
14
+ // its own rules evaluation).
15
+ //
16
+ // Counting semantics: distinct accesses, not raw calls. In-repo production
17
+ // evidence:
18
+ // - site-docs secure/firestore-rules-limits.md ("More than 10 document
19
+ // access calls"): "Repeated reads of the same path are cached; reads of
20
+ // different paths are not."
21
+ // - rules/stdlib-modules.ts spaces guidance: "get() is cached per request,
22
+ // so all rules on the request share a single read of the 10-get budget."
23
+ // - The Storage sibling limit (storage/sandbox/rules-methods.ts) uses a
24
+ // per-request distinct-path set, verified by real-resource capture.
25
+ //
26
+ // What is implemented, exactly: the budget keys on (kind, path), where kind
27
+ // is 'doc' for pre-write reads (get/exists) and 'after' for post-write
28
+ // projections (getAfter/existsAfter). So get() and exists() on one path
29
+ // share a slot, getAfter() and existsAfter() on one path share a slot, and
30
+ // a path read by BOTH a pre-write and a post-write call charges TWO slots.
31
+ // The two calls read different snapshots, so charging them separately is the
32
+ // fail-closed direction while production's cache granularity is uncaptured.
33
+ //
34
+ // TODO(unverified): a credentialed production capture is needed to pin
35
+ // (a) whether get() and exists() on one path really share one cache slot,
36
+ // (b) whether getAfter() on a path already read by get() charges a second
37
+ // slot or reuses the first, and (c) whether exhausting the budget is
38
+ // absorbable by a determining CEL operand (modeled: not absorbable). The
39
+ // capture is a ruleset whose condition performs 10 lookups and then an
40
+ // 11th, deployed and probed against a live project. The Rules Test API does
41
+ // not enforce the budget, so it cannot produce this evidence.
42
+
43
+ /** Production's per-evaluation document access budget (single request). */
44
+ const DOCUMENT_LOOKUP_LIMIT = 10;
45
+
46
+ /**
47
+ * Budget exhaustion. A {@link ResourceLimitError}, not a plain EvalError,
48
+ * because it fails the WHOLE request closed: CEL error absorption
49
+ * (`error || true`, `error && false`) must not swallow it into an ALLOW,
50
+ * and a sibling allow rule or match block must not evaluate past it.
51
+ */
52
+ export class LookupBudgetError extends ResourceLimitError {
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = 'LookupBudgetError';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Per-request lookup counter. The handler creates ONE per test case
61
+ * (request evaluation) and every match block and allow rule evaluated for
62
+ * that request shares it: production's budget spans OR'd rules and
63
+ * overlapping match blocks alike (the linter's SHARED_GATE "cross-rule
64
+ * budget exhaustion" hazard).
65
+ */
66
+ export class LookupBudget {
67
+ private readonly accessed = new Set<string>();
68
+
69
+ /**
70
+ * Charge one document access. `kind` is 'doc' for pre-write reads
71
+ * (get/exists) and 'after' for post-write projections (getAfter/
72
+ * existsAfter). A repeat access to the same (kind, path) is cached and
73
+ * free; the 11th DISTINCT access throws {@link LookupBudgetError}, which
74
+ * fails the request closed.
75
+ */
76
+ charge(kind: 'doc' | 'after', normalizedPath: string): void {
77
+ const key = `${kind}:${normalizedPath}`;
78
+ if (this.accessed.has(key)) return; // cached, does not count against the budget
79
+ if (this.accessed.size >= DOCUMENT_LOOKUP_LIMIT) {
80
+ throw new LookupBudgetError(
81
+ `document access limit exceeded: this request already read ${DOCUMENT_LOOKUP_LIMIT} distinct documents `
82
+ + `via get()/exists()/getAfter()/existsAfter() (production allows 10 per request; `
83
+ + `the 11th distinct access fails the evaluation)`,
84
+ );
85
+ }
86
+ this.accessed.add(key);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The single charging seam for all four document access builtins. Charges
92
+ * the budget for a raw (unnormalized) lookup path. Malformed paths charge
93
+ * nothing: no document read happens, and the caller's own validation
94
+ * produces the error or false result.
95
+ */
96
+ export function chargeLookup(ctx: SimulationContext, kind: 'doc' | 'after', rawPath: string): void {
97
+ if (!ctx.lookupBudget) return;
98
+ const normalized = normalizeDocumentPath(rawPath);
99
+ if (!isDocumentPath(normalized)) return;
100
+ ctx.lookupBudget.charge(kind, normalized);
101
+ }
@@ -46,6 +46,13 @@ export type RulesService = 'firestore' | 'storage';
46
46
  export interface StdlibEntry {
47
47
  /** Surface signature, e.g. `math.ceil(x: number): int`. */
48
48
  signature: string;
49
+ /**
50
+ * Whether production accepts a ruleset that calls this entry. Omitted
51
+ * means accepted. `'rejected'` documents a name that Firebase reference
52
+ * material lists but production's compiler refuses, so the catalog can
53
+ * carry the correction while the signature field stays a signature.
54
+ */
55
+ acceptance?: 'rejected';
49
56
  /** One-line summary the agent reads first. */
50
57
  description: string;
51
58
  /** ~1–3 lines of real rule fragments. Optional. */
@@ -85,9 +92,9 @@ const BUILTINS_MODULE: StdlibModuleDefinition = {
85
92
  key: 'builtins',
86
93
  kind: 'language-namespace',
87
94
  description:
88
- 'Top-level functions always in scope inside rules — get, exists, getAfter, debug. Called bare, no namespace prefix.',
95
+ 'Top-level functions always in scope inside rules: get, exists, getAfter. Called bare, no namespace prefix. (debug() is docs-only: production rejects it at compile.)',
89
96
  purpose:
90
- 'The four built-in functions Firestore Rules ships with at the top level. They are NOT inside a namespace — call `get(path)`, not `firestore.get(path)`. Used to read other documents (cross-document gates), check existence, peek at post-write state, and print debug values.',
97
+ 'The built-in functions Firestore Rules ships with at the top level. They are NOT inside a namespace: call `get(path)`, not `firestore.get(path)`. Used to read other documents (cross-document gates), check existence, and peek at post-write state.',
91
98
  whenToUse:
92
99
  'Reach for `builtins` any time a rule needs information that isn\'t already on `request` or `resource`. Most common: `get(path)` to read another document\'s fields inside a predicate.',
93
100
  entries: [
@@ -117,10 +124,11 @@ const BUILTINS_MODULE: StdlibModuleDefinition = {
117
124
  },
118
125
  {
119
126
  signature: 'debug(value: any): bool',
127
+ acceptance: 'rejected',
120
128
  description:
121
- 'Print `value` to the Firestore emulator/test runner log and return true. No-op in production. Useful for narrowing why a rule denies during local development.',
129
+ 'DO NOT USE. Although Firebase reference docs list `debug()`, production Firestore REJECTS rulesets that call it at compile time (`Function not found error: Name: [debug]`). The linter and local simulator reject it for the same reason.',
122
130
  notes:
123
- 'Always returns true — wrapping it in a predicate (`if debug(resource.data.x) && otherCondition`) shows the value without changing rule outcome.',
131
+ 'Remove any debug() wrapper before deploying and evaluate the inner expression directly. There is no deployable logging primitive in rules.',
124
132
  },
125
133
  ],
126
134
  relatedKeys: ['request', 'resource', 'path'],
@@ -132,11 +140,11 @@ const MATH: StdlibModuleDefinition = {
132
140
  key: 'math',
133
141
  kind: 'language-namespace',
134
142
  description:
135
- 'Numeric helpers — abs, ceil, floor, round, sqrt, pow, isInfinite, isNaN. Use when rules need to coerce or bound numbers.',
143
+ 'Numeric helpers: abs, ceil, floor, round, sqrt, pow, isNaN. Use when rules need to coerce or bound numbers.',
136
144
  purpose:
137
145
  'The `math` namespace exposes a small set of numeric utilities for use inside rule predicates. Functions are called as `math.<fn>(...)`. Always in scope; no import needed.',
138
146
  whenToUse:
139
- 'Reach for `math` whenever a rule needs to round, bound, or guard against non-finite numbers in a user-supplied field.',
147
+ 'Reach for `math` whenever a rule needs to round, bound, or guard against NaN in a user-supplied field.',
140
148
  entries: [
141
149
  { signature: 'math.abs(n: number): number', description: 'Absolute value of `n`.' },
142
150
  { signature: 'math.ceil(n: number): int', description: 'Smallest integer >= `n`.' },
@@ -145,15 +153,15 @@ const MATH: StdlibModuleDefinition = {
145
153
  { signature: 'math.sqrt(n: number): number', description: 'Square root of `n`. Negative input returns NaN.' },
146
154
  { signature: 'math.pow(base: number, exp: number): number', description: '`base` raised to `exp`.' },
147
155
  {
148
- signature: 'math.isInfinite(n: number): bool',
149
- description: 'True if `n` is +Infinity or -Infinity.',
150
- notes: 'Pairs with `math.isNaN` for safe numeric guards before arithmetic.',
156
+ signature: 'math.isNaN(n: number): bool',
157
+ description: 'True if `n` is NaN.',
158
+ notes:
159
+ 'There is NO isInfinite function in the math namespace. Production Firestore rejects it at compile time (`Function not found error: Name: [math.isInfinite]`), despite it appearing in some Firebase reference material.',
151
160
  },
152
- { signature: 'math.isNaN(n: number): bool', description: 'True if `n` is NaN.' },
153
161
  ],
154
162
  examples: [
155
163
  `allow update: if math.ceil(request.resource.data.score) <= 100;`,
156
- `allow create: if !math.isNaN(request.resource.data.lat) && !math.isInfinite(request.resource.data.lat);`,
164
+ `allow create: if !math.isNaN(request.resource.data.lat);`,
157
165
  ],
158
166
  relatedKeys: ['hashing'],
159
167
  };
@@ -67,7 +67,23 @@ export function getFirestore(ctx: SandboxContext): SandboxFirestore {
67
67
  * arm pins on every op/sub. Never absent — see {@link getFirestore}. */
68
68
  export function lensForAuth(auth: AuthState): AuthLens {
69
69
  if (auth === null || auth === undefined) return { mode: 'anon' };
70
- return auth.token === undefined
71
- ? { mode: 'as', uid: auth.uid }
72
- : { mode: 'as', uid: auth.uid, token: auth.token };
70
+ const lens: Extract<AuthLens, { mode: 'as' }> = { mode: 'as', uid: auth.uid };
71
+ if (auth.token !== undefined) lens.token = auth.token;
72
+ if (auth.tenant !== undefined) lens.tenant = auth.tenant;
73
+ return lens;
73
74
  }
75
+
76
+ export function authStateForLens(actAs: Extract<AuthLens, { mode: 'as' }>): AuthState {
77
+ const state: Extract<AuthState, { uid: string }> = { uid: actAs.uid };
78
+ if (actAs.token !== undefined) state.token = actAs.token;
79
+ if (actAs.tenant !== undefined) state.tenant = actAs.tenant;
80
+ return state;
81
+ }
82
+
83
+ export function lensCacheKey(actAs: Extract<AuthLens, { mode: 'as' }>): string {
84
+ const parts = [actAs.uid];
85
+ if (actAs.tenant !== undefined) parts.push(`tenant:${actAs.tenant}`);
86
+ if (actAs.token !== undefined) parts.push(JSON.stringify(actAs.token));
87
+ return parts.join(':');
88
+ }
89
+
@@ -51,7 +51,11 @@ export type {
51
51
  WriteSandboxEvent,
52
52
  } from './types/index.js';
53
53
  export { SandboxError } from './types/index.js';
54
- export { SandboxContextImpl } from './sandbox-context.js';
54
+ export {
55
+ SandboxContextImpl,
56
+ normalizeAuthState,
57
+ validateAuthState,
58
+ } from './sandbox-context.js';
55
59
  export {
56
60
  isOperationEvent,
57
61
  operationContextFor,
@@ -15,7 +15,7 @@
15
15
  /**
16
16
  * Minimal web-storage-like contract the session persistence controller
17
17
  * reads/writes. Matches the `localStorage` / `sessionStorage` browser
18
- * API subset that `pyric dev`'s `SessionStore` already uses, so
18
+ * API subset that `pyric sandbox`'s `SessionStore` already uses, so
19
19
  * browsers pass real storages and tests pass in-memory Map-backed fakes.
20
20
  *
21
21
  * Why the minimal subset (get/set/remove) instead of the full
@@ -78,7 +78,7 @@ export interface RemoteSandboxChannel {
78
78
  */
79
79
  export interface RemoteSandbox extends Sandbox {
80
80
  readonly [REMOTE_SANDBOX]: true;
81
- /** Base URL of the `pyric dev` this handle is attached to (used in
81
+ /** Base URL of the `pyric sandbox` this handle is attached to (used in
82
82
  * error guidance: "open <serveUrl> in a browser and retry"). */
83
83
  readonly serveUrl: string;
84
84
  /** The raw worker op/sub relay channel. */
@@ -100,7 +100,7 @@ export const REMOTE_SANDBOX_FACTORY = Symbol.for('pyric.remote.sandboxFactory');
100
100
 
101
101
  /** Options accepted by the ambient remote-sandbox factory. */
102
102
  export interface RemoteSandboxFactoryOptions {
103
- /** Explicit `pyric dev` base URL (from `PYRIC_SANDBOX=remote:<url>`).
103
+ /** Explicit `pyric sandbox` base URL (from `PYRIC_SANDBOX=remote:<url>`).
104
104
  * When omitted the factory discovers the running host itself (the
105
105
  * `.pyric/serve.json` locator protocol). */
106
106
  url?: string;
@@ -27,32 +27,84 @@ import { SandboxError } from './types/errors.js';
27
27
  import type { Sandbox } from './types/service.js';
28
28
  import { immutableOperationContext } from './operation-record.js';
29
29
 
30
+ /**
31
+ * Normalize AuthState during SandboxContextImpl context assembly.
32
+ * Automatically projects top-level `tenant` into `token.firebase.tenant`
33
+ * while preserving custom claims and explicit nested token overrides.
34
+ * Never mutates original input.
35
+ */
36
+ export function normalizeAuthState(auth: AuthState): AuthState {
37
+ if (auth === null) return null;
38
+
39
+ if (auth.tenant === undefined) {
40
+ if (auth.token === undefined) {
41
+ return { uid: auth.uid };
42
+ }
43
+ return {
44
+ uid: auth.uid,
45
+ token: structuredClone(auth.token),
46
+ };
47
+ }
48
+
49
+ const token = auth.token !== undefined ? structuredClone(auth.token) : {};
50
+ const existingFirebase = token.firebase;
51
+
52
+ let normalizedFirebase: Record<string, unknown>;
53
+ if (
54
+ typeof existingFirebase === 'object' &&
55
+ existingFirebase !== null &&
56
+ !Array.isArray(existingFirebase)
57
+ ) {
58
+ const fbObj = existingFirebase as Record<string, unknown>;
59
+ normalizedFirebase = {
60
+ ...fbObj,
61
+ tenant: fbObj.tenant !== undefined ? fbObj.tenant : auth.tenant,
62
+ };
63
+ } else {
64
+ normalizedFirebase = { tenant: auth.tenant };
65
+ }
66
+
67
+ token.firebase = normalizedFirebase;
68
+
69
+ return {
70
+ uid: auth.uid,
71
+ tenant: auth.tenant,
72
+ token,
73
+ };
74
+ }
75
+
30
76
  function authLensFor(auth: AuthState): AuthLens {
31
77
  if (auth === null) return { mode: 'anon' };
32
- return auth.token === undefined
33
- ? { mode: 'as', uid: auth.uid }
34
- : { mode: 'as', uid: auth.uid, token: auth.token };
78
+ const lens: Extract<AuthLens, { mode: 'as' }> = { mode: 'as', uid: auth.uid };
79
+ if (auth.token !== undefined) lens.token = auth.token;
80
+ if (auth.tenant !== undefined) lens.tenant = auth.tenant;
81
+ return lens;
35
82
  }
36
83
 
37
84
  export class SandboxContextImpl implements SandboxContext {
85
+ public readonly auth: AuthState;
86
+ public readonly operationContext: OperationContext;
87
+
38
88
  constructor(
39
89
  public readonly sandbox: Sandbox,
40
- public readonly auth: AuthState,
90
+ auth: AuthState,
41
91
  operationContext?: OperationContext,
42
92
  ) {
93
+ validateAuthState(auth);
94
+ const normalized = normalizeAuthState(auth);
95
+ this.auth = normalized;
43
96
  this.operationContext = immutableOperationContext(operationContext ?? {
44
97
  source: { kind: 'unattributed' },
45
- authLens: authLensFor(auth),
98
+ authLens: authLensFor(normalized),
46
99
  });
47
100
  }
48
101
 
49
- public readonly operationContext: OperationContext;
50
-
51
102
  withAuth(auth: AuthState): SandboxContext {
52
103
  validateAuthState(auth);
53
- return new SandboxContextImpl(this.sandbox, auth, {
104
+ const normalized = normalizeAuthState(auth);
105
+ return new SandboxContextImpl(this.sandbox, normalized, {
54
106
  source: this.operationContext.source,
55
- authLens: authLensFor(auth),
107
+ authLens: authLensFor(normalized),
56
108
  ...(this.operationContext.planId === undefined
57
109
  ? {}
58
110
  : { planId: this.operationContext.planId }),
@@ -106,6 +158,14 @@ export function validateAuthState(auth: unknown): asserts auth is AuthState {
106
158
  'withAuth() requires `uid` to be a non-empty string.',
107
159
  );
108
160
  }
161
+ if ('tenant' in obj && obj.tenant !== undefined) {
162
+ if (typeof obj.tenant !== 'string' || obj.tenant.length === 0) {
163
+ throw new SandboxError(
164
+ 'invalid-argument',
165
+ 'withAuth() requires `tenant` (when present) to be a non-empty string.',
166
+ );
167
+ }
168
+ }
109
169
  if ('token' in obj && obj.token !== undefined) {
110
170
  if (obj.token === null || typeof obj.token !== 'object' || Array.isArray(obj.token)) {
111
171
  throw new SandboxError(
@@ -18,5 +18,5 @@
18
18
  * names should reflect that.
19
19
  */
20
20
  export type AuthState =
21
- | { uid: string; token?: Record<string, unknown> }
21
+ | { uid: string; token?: Record<string, unknown>; tenant?: string }
22
22
  | null;
@@ -379,7 +379,8 @@ export interface ServiceMutationEvent {
379
379
  * - storage: `object_put` | `object_delete` | `metadata_update`
380
380
  * - rtdb: `set` | `update` | `remove` | `transaction`
381
381
  * - ai: `generate_content` | `stream_generate_content` |
382
- * `count_tokens` | `request_rejected`
382
+ * `count_tokens` | `request_rejected` | `response_blocked` |
383
+ * `model_substituted`
383
384
  * New ops can be added without a breaking change (consumers switch with a
384
385
  * default branch).
385
386
  */
@@ -15,7 +15,7 @@ export type EventActor =
15
15
  /** The identity/rules lens an operation actually ran under. */
16
16
  export type AuthLens =
17
17
  | { mode: 'admin' }
18
- | { mode: 'as'; uid: string; token?: Record<string, unknown> }
18
+ | { mode: 'as'; uid: string; tenant?: string; token?: Record<string, unknown> }
19
19
  | { mode: 'app-session' }
20
20
  | { mode: 'anon' };
21
21
 
@@ -4,10 +4,9 @@
4
4
  * grow their own copy of the same dispatch.
5
5
  *
6
6
  * Behavior:
7
- * - No rules configured → allow. The v1 scope's session-archive
8
- * ruleset is opt-in; bare `getStorage` with no `rules` option
9
- * keeps the open-by-default semantics consistent with the
10
- * pre-Slice-8 surface.
7
+ * - No rules configured → deny. Matches Firebase production fail-closed
8
+ * security invariants: bare `getStorage` with no `rules` option
9
+ * rejects client operations with `storage/unauthorized`.
11
10
  * - Rules configured → call `evaluateStorageRules`. Throw
12
11
  * `storage/unauthorized` with the evaluator's `reasons` joined
13
12
  * into the message on denial.
@@ -16,9 +15,8 @@
16
15
  * permission governs both download and list, so `list.ts` enforces a
17
16
  * `read` check on the scanned prefix path. A previous v1 scope build
18
17
  * silently bypassed list — that contradicted the rules-enforcement
19
- * contract (a denied tree was still enumerable). With no rules
20
- * configured the check is a no-op (open-by-default), so the
21
- * session-archive demo is unaffected.
18
+ * contract (a denied tree was still enumerable). When no rules are
19
+ * configured the check fails closed (default-deny).
22
20
  *
23
21
  * Studio observability (storage-denial-events): every enforcement
24
22
  * decision — allow, deny, AND admin-plane bypass — lands on the
@@ -44,10 +42,12 @@ import { emitSandboxEvent, makeSandboxOperationEvent } from 'pyric/sandbox/inter
44
42
  import type { EventProvenance } from 'pyric/sandbox';
45
43
  import {
46
44
  storageOperationProvenance,
45
+ type CrossServiceIam,
47
46
  type StorageService,
48
47
  type Target,
49
48
  } from './service.js';
50
49
  import { evaluateStorageRules } from './sandbox/rules-evaluator.js';
50
+ import { RuleEvalError } from './sandbox/rules-evaluation-error.js';
51
51
  import type { EvaluationInput, FirestoreLookup } from './sandbox/rules.js';
52
52
  import { unauthorized } from './errors.js';
53
53
 
@@ -71,18 +71,16 @@ export function enforceRules(
71
71
  return;
72
72
  }
73
73
  if (!service.rules) {
74
- // Open-by-default: no rules configured, no evaluation happened.
75
- // Still emit `allow` for parity — an unrestricted op is legitimately
76
- // "allowed", just never evaluated.
77
- emitOperation(target, input, 'allow', undefined, 'user', false, boundProvenance);
78
- return;
74
+ const reasons = ['No Storage rules configured; default deny.'];
75
+ emitOperation(target, input, 'deny', reasons, 'user', false, boundProvenance);
76
+ throw unauthorized(input.request.method, input.request.path, ' — No Storage rules configured; default deny.');
79
77
  }
80
78
  const evaluationInput = target ? withCanonicalRulesPath(input, target.bucket) : input;
81
79
  const result = evaluateStorageRules(
82
80
  service.rules,
83
81
  evaluationInput,
84
82
  undefined,
85
- firestoreLookupFor(target),
83
+ firestoreLookupFor(target, service.crossServiceIam),
86
84
  );
87
85
  if (!result.allowed) {
88
86
  emitOperation(target, input, 'deny', result.reasons, 'user', true, boundProvenance);
@@ -164,12 +162,53 @@ function emitOperation(
164
162
  *
165
163
  * Calls without a target get no lookup — a rule that reaches for
166
164
  * `firestore.*` there denies "unsupported" rather than false-allowing.
165
+ *
166
+ * `crossServiceIam: 'denied'` models the production project state WITHOUT
167
+ * `roles/firebaserules.firestoreServiceAgent` on the Storage service agent:
168
+ * the lookup capability is still injected (so path validation and laziness
169
+ * behave identically), but every EXECUTED get/exists fails. See
170
+ * {@link crossServiceIamDeniedLookup}.
167
171
  */
168
- function firestoreLookupFor(target?: Target): FirestoreLookup | undefined {
172
+ function firestoreLookupFor(
173
+ target: Target | undefined,
174
+ crossServiceIam: CrossServiceIam,
175
+ ): FirestoreLookup | undefined {
169
176
  if (!target) return undefined;
177
+ if (crossServiceIam === 'denied') return crossServiceIamDeniedLookup();
170
178
  const admin = target.sandbox.admin;
171
179
  return {
172
180
  get: (path) => admin.getDocument(path) as Record<string, unknown> | null,
173
181
  exists: (path) => admin.getDocument(path) !== null,
174
182
  };
175
183
  }
184
+
185
+ /**
186
+ * The `crossServiceIam: 'denied'` lookup: every executed
187
+ * `firestore.get()/exists()` throws a {@link RuleEvalError} naming the
188
+ * missing service-agent role, so the rule denies with that reason,
189
+ * mirroring production's IAM-disabled boundary captured by conformance
190
+ * observation `stdlib-realstorage-p3-lookup-budget` (row storage-rules#134):
191
+ * every lookup-executing family DENIES while a short-circuited lookup is
192
+ * never invoked and its rule still ALLOWS. The capture pins only that
193
+ * executed-lookup/short-circuit boundary; how an IAM failure interacts
194
+ * with CEL `&&`/`||` error absorption is NOT pinned by it (no absorption
195
+ * family ran IAM-disabled), so this uses the same absorbable
196
+ * {@link RuleEvalError} class as the existing no-capability deny path.
197
+ *
198
+ * Exported for the conformance replay test
199
+ * (`packages/conformance/test/src/storage-stdlib-real-replay.test.ts`),
200
+ * which runs the captured IAM-disabled matrix against the evaluator with
201
+ * THIS production lookup, not a hand-rolled twin.
202
+ */
203
+ export function crossServiceIamDeniedLookup(): FirestoreLookup {
204
+ const fail = (method: 'get' | 'exists'): never => {
205
+ throw new RuleEvalError(
206
+ `firestore.${method}() failed: cross-service Firestore access is not authorized: ` +
207
+ "the Storage service agent lacks roles/firebaserules.firestoreServiceAgent (crossServiceIam: 'denied')",
208
+ );
209
+ };
210
+ return {
211
+ get: () => fail('get'),
212
+ exists: () => fail('exists'),
213
+ };
214
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  export { getStorageSandbox, TARGET_SYMBOL } from './service.js';
25
- export type { FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
25
+ export type { CrossServiceIam, FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
26
26
  export { getStorage, connectStorageEmulator } from './instances.js';
27
27
 
28
28
  export { StorageError } from './errors.js';