pyric 0.1.0-alpha.20 → 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 (225) 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 +49 -0
  7. package/dist/ai/broker/broker.d.ts.map +1 -1
  8. package/dist/ai/broker/broker.js +90 -0
  9. package/dist/ai/broker/broker.js.map +1 -1
  10. package/dist/ai/broker/gemini-engine.d.ts +14 -1
  11. package/dist/ai/broker/gemini-engine.d.ts.map +1 -1
  12. package/dist/ai/broker/gemini-engine.js +30 -9
  13. package/dist/ai/broker/gemini-engine.js.map +1 -1
  14. package/dist/ai/broker/index.d.ts +2 -2
  15. package/dist/ai/broker/index.d.ts.map +1 -1
  16. package/dist/ai/broker/index.js +2 -2
  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 +24 -0
  27. package/dist/ai/broker/types.d.ts.map +1 -1
  28. package/dist/ai/internal.d.ts +5 -0
  29. package/dist/ai/internal.d.ts.map +1 -1
  30. package/dist/ai/internal.js +5 -0
  31. package/dist/ai/internal.js.map +1 -1
  32. package/dist/ai/response-helpers.d.ts.map +1 -1
  33. package/dist/ai/response-helpers.js +6 -24
  34. package/dist/ai/response-helpers.js.map +1 -1
  35. package/dist/analytics/index.d.ts +40 -0
  36. package/dist/analytics/index.d.ts.map +1 -0
  37. package/dist/analytics/index.js +25 -0
  38. package/dist/analytics/index.js.map +1 -0
  39. package/dist/app-check/index.d.ts +31 -0
  40. package/dist/app-check/index.d.ts.map +1 -0
  41. package/dist/app-check/index.js +20 -0
  42. package/dist/app-check/index.js.map +1 -0
  43. package/dist/database/controls.d.ts +1 -1
  44. package/dist/database/controls.js +1 -1
  45. package/dist/database/sandbox/rules-eval.d.ts +1 -1
  46. package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
  47. package/dist/database/sandbox/rules-eval.js +2 -2
  48. package/dist/database/sandbox/rules-eval.js.map +1 -1
  49. package/dist/database/sandbox/write-plane.js +2 -2
  50. package/dist/database/sandbox/write-plane.js.map +1 -1
  51. package/dist/database/sandbox-controls.d.ts +12 -5
  52. package/dist/database/sandbox-controls.d.ts.map +1 -1
  53. package/dist/database/sandbox-controls.js +20 -9
  54. package/dist/database/sandbox-controls.js.map +1 -1
  55. package/dist/deferred/entry.d.ts +75 -0
  56. package/dist/deferred/entry.d.ts.map +1 -0
  57. package/dist/deferred/entry.js +143 -0
  58. package/dist/deferred/entry.js.map +1 -0
  59. package/dist/firestore/lite.d.ts +72 -0
  60. package/dist/firestore/lite.d.ts.map +1 -0
  61. package/dist/firestore/lite.js +22 -0
  62. package/dist/firestore/lite.js.map +1 -0
  63. package/dist/firestore/persistence.d.ts +2 -2
  64. package/dist/firestore/persistence.js +1 -1
  65. package/dist/functions/index.d.ts +29 -0
  66. package/dist/functions/index.d.ts.map +1 -0
  67. package/dist/functions/index.js +20 -0
  68. package/dist/functions/index.js.map +1 -0
  69. package/dist/performance/index.d.ts +22 -0
  70. package/dist/performance/index.d.ts.map +1 -0
  71. package/dist/performance/index.js +19 -0
  72. package/dist/performance/index.js.map +1 -0
  73. package/dist/remote-config/index.d.ts +40 -0
  74. package/dist/remote-config/index.d.ts.map +1 -0
  75. package/dist/remote-config/index.js +25 -0
  76. package/dist/remote-config/index.js.map +1 -0
  77. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
  78. package/dist/rules/grammar/FirestoreValidator.js +43 -27
  79. package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
  80. package/dist/rules/grammar/document-access-count.d.ts +24 -0
  81. package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
  82. package/dist/rules/grammar/document-access-count.js +100 -0
  83. package/dist/rules/grammar/document-access-count.js.map +1 -0
  84. package/dist/rules/linter/ast-utils.d.ts +2 -6
  85. package/dist/rules/linter/ast-utils.d.ts.map +1 -1
  86. package/dist/rules/linter/ast-utils.js +2 -63
  87. package/dist/rules/linter/ast-utils.js.map +1 -1
  88. package/dist/rules/linter/hallucinations.d.ts +8 -0
  89. package/dist/rules/linter/hallucinations.d.ts.map +1 -1
  90. package/dist/rules/linter/hallucinations.js +121 -31
  91. package/dist/rules/linter/hallucinations.js.map +1 -1
  92. package/dist/rules/linter/linter.d.ts +10 -1
  93. package/dist/rules/linter/linter.d.ts.map +1 -1
  94. package/dist/rules/linter/linter.js +23 -9
  95. package/dist/rules/linter/linter.js.map +1 -1
  96. package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
  97. package/dist/rules/rtdb/grammar/simulator.js +13 -13
  98. package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
  99. package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
  100. package/dist/rules/rtdb/simulation/handler.js +96 -35
  101. package/dist/rules/rtdb/simulation/handler.js.map +1 -1
  102. package/dist/rules/simulator/document-lookups.d.ts +4 -0
  103. package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
  104. package/dist/rules/simulator/document-lookups.js +42 -3
  105. package/dist/rules/simulator/document-lookups.js.map +1 -1
  106. package/dist/rules/simulator/eval-error.d.ts +11 -0
  107. package/dist/rules/simulator/eval-error.d.ts.map +1 -1
  108. package/dist/rules/simulator/eval-error.js +14 -0
  109. package/dist/rules/simulator/eval-error.js.map +1 -1
  110. package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
  111. package/dist/rules/simulator/evaluation-builtins.js +33 -8
  112. package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
  113. package/dist/rules/simulator/evaluation-context.d.ts +12 -0
  114. package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
  115. package/dist/rules/simulator/evaluator.d.ts +1 -1
  116. package/dist/rules/simulator/evaluator.d.ts.map +1 -1
  117. package/dist/rules/simulator/evaluator.js +16 -4
  118. package/dist/rules/simulator/evaluator.js.map +1 -1
  119. package/dist/rules/simulator/handler.d.ts.map +1 -1
  120. package/dist/rules/simulator/handler.js +52 -5
  121. package/dist/rules/simulator/handler.js.map +1 -1
  122. package/dist/rules/simulator/lookup-budget.d.ts +37 -0
  123. package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
  124. package/dist/rules/simulator/lookup-budget.js +96 -0
  125. package/dist/rules/simulator/lookup-budget.js.map +1 -0
  126. package/dist/rules/stdlib-modules.d.ts +7 -0
  127. package/dist/rules/stdlib-modules.d.ts.map +1 -1
  128. package/dist/rules/stdlib-modules.js +11 -11
  129. package/dist/rules/stdlib-modules.js.map +1 -1
  130. package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
  131. package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
  132. package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
  133. package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
  134. package/dist/sandbox/index.d.ts +1 -1
  135. package/dist/sandbox/index.d.ts.map +1 -1
  136. package/dist/sandbox/index.js +1 -1
  137. package/dist/sandbox/index.js.map +1 -1
  138. package/dist/sandbox/persistence/types.d.ts +1 -1
  139. package/dist/sandbox/remote.d.ts +2 -2
  140. package/dist/sandbox/sandbox-context.d.ts +8 -1
  141. package/dist/sandbox/sandbox-context.d.ts.map +1 -1
  142. package/dist/sandbox/sandbox-context.js +59 -8
  143. package/dist/sandbox/sandbox-context.js.map +1 -1
  144. package/dist/sandbox/types/auth-state.d.ts +1 -0
  145. package/dist/sandbox/types/auth-state.d.ts.map +1 -1
  146. package/dist/sandbox/types/events.d.ts +2 -1
  147. package/dist/sandbox/types/events.d.ts.map +1 -1
  148. package/dist/sandbox/types/operation.d.ts +1 -0
  149. package/dist/sandbox/types/operation.d.ts.map +1 -1
  150. package/dist/storage/enforce.d.ts +20 -1
  151. package/dist/storage/enforce.d.ts.map +1 -1
  152. package/dist/storage/enforce.js +47 -14
  153. package/dist/storage/enforce.js.map +1 -1
  154. package/dist/storage/index.d.ts +1 -1
  155. package/dist/storage/index.d.ts.map +1 -1
  156. package/dist/storage/list.js +1 -1
  157. package/dist/storage/list.js.map +1 -1
  158. package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
  159. package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
  160. package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
  161. package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
  162. package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
  163. package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
  164. package/dist/storage/sandbox/rules-evaluator.js +105 -33
  165. package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
  166. package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
  167. package/dist/storage/sandbox/rules-methods.js +17 -7
  168. package/dist/storage/sandbox/rules-methods.js.map +1 -1
  169. package/dist/storage/service.d.ts +23 -2
  170. package/dist/storage/service.d.ts.map +1 -1
  171. package/dist/storage/service.js +36 -26
  172. package/dist/storage/service.js.map +1 -1
  173. package/package.json +41 -1
  174. package/src/ai/blocked.ts +97 -0
  175. package/src/ai/broker/broker.ts +88 -0
  176. package/src/ai/broker/gemini-engine.ts +36 -9
  177. package/src/ai/broker/index.ts +2 -1
  178. package/src/ai/broker/openai-engine.ts +31 -3
  179. package/src/ai/broker/synthesizer.ts +34 -0
  180. package/src/ai/broker/types.ts +25 -0
  181. package/src/ai/internal.ts +5 -0
  182. package/src/ai/response-helpers.ts +6 -25
  183. package/src/analytics/index.ts +53 -0
  184. package/src/app/dispatch.test.ts +3 -1
  185. package/src/app-check/index.ts +41 -0
  186. package/src/database/controls.ts +1 -1
  187. package/src/database/sandbox/rules-eval.ts +2 -2
  188. package/src/database/sandbox/write-plane.ts +2 -2
  189. package/src/database/sandbox-controls.ts +29 -9
  190. package/src/deferred/entry.ts +167 -0
  191. package/src/firestore/lite.ts +91 -0
  192. package/src/firestore/persistence.ts +2 -2
  193. package/src/functions/index.ts +38 -0
  194. package/src/performance/index.ts +31 -0
  195. package/src/remote-config/index.ts +53 -0
  196. package/src/rules/grammar/FirestoreValidator.ts +44 -28
  197. package/src/rules/grammar/document-access-count.ts +75 -0
  198. package/src/rules/linter/ast-utils.ts +2 -36
  199. package/src/rules/linter/hallucinations.ts +123 -25
  200. package/src/rules/linter/linter.ts +32 -10
  201. package/src/rules/rtdb/grammar/simulator.ts +12 -13
  202. package/src/rules/rtdb/simulation/handler.ts +149 -46
  203. package/src/rules/simulator/document-lookups.ts +46 -3
  204. package/src/rules/simulator/eval-error.ts +16 -0
  205. package/src/rules/simulator/evaluation-builtins.ts +35 -8
  206. package/src/rules/simulator/evaluation-context.ts +12 -0
  207. package/src/rules/simulator/evaluator.ts +14 -3
  208. package/src/rules/simulator/handler.ts +68 -4
  209. package/src/rules/simulator/lookup-budget.ts +101 -0
  210. package/src/rules/stdlib-modules.ts +19 -11
  211. package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
  212. package/src/sandbox/index.ts +5 -1
  213. package/src/sandbox/persistence/types.ts +1 -1
  214. package/src/sandbox/remote.ts +2 -2
  215. package/src/sandbox/sandbox-context.ts +69 -9
  216. package/src/sandbox/types/auth-state.ts +1 -1
  217. package/src/sandbox/types/events.ts +2 -1
  218. package/src/sandbox/types/operation.ts +1 -1
  219. package/src/storage/enforce.ts +53 -14
  220. package/src/storage/index.ts +1 -1
  221. package/src/storage/list.ts +1 -1
  222. package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
  223. package/src/storage/sandbox/rules-evaluator.ts +109 -32
  224. package/src/storage/sandbox/rules-methods.ts +21 -7
  225. package/src/storage/service.ts +92 -27
@@ -2,10 +2,43 @@ import type { SimulationContext, SimResource } from './evaluation-context.js';
2
2
  import { EvalError } from './eval-error.js';
3
3
  import { Path } from './wrappers/path.js';
4
4
 
5
+ /**
6
+ * Check if a path or segment list points to a document (non-empty, even segment count).
7
+ */
8
+ export function isDocumentPath(segmentsOrPath: string | readonly string[]): boolean {
9
+ const segments = typeof segmentsOrPath === 'string'
10
+ ? segmentsOrPath.split('/').filter(Boolean)
11
+ : segmentsOrPath;
12
+ return segments.length > 0 && segments.length % 2 === 0;
13
+ }
14
+
5
15
  export function normalizeDocumentPath(rawPath: string): string {
6
- return rawPath
7
- .replace(/\$\(database\)/g, '(default)')
8
- .replace(/^\/databases\/\(default\)\/documents\//, '');
16
+ let cleaned = rawPath.replace(/\$\(database\)/g, '(default)');
17
+ const dbPrefix = '/databases/(default)/documents/';
18
+ if (cleaned.startsWith(dbPrefix)) {
19
+ cleaned = cleaned.slice(dbPrefix.length);
20
+ } else if (cleaned.startsWith('/databases/(default)/documents')) {
21
+ cleaned = cleaned.slice('/databases/(default)/documents'.length);
22
+ }
23
+ if (cleaned.startsWith('/')) {
24
+ cleaned = cleaned.slice(1);
25
+ }
26
+
27
+ const rawSegments = cleaned.split('/').filter((s) => s.length > 0 && s !== '.');
28
+ const stack: string[] = [];
29
+
30
+ for (const seg of rawSegments) {
31
+ if (seg === '..') {
32
+ if (stack.length > 1) {
33
+ stack.pop();
34
+ }
35
+ // When stack.length <= 1, .. is clamped to prevent escaping collection or document root.
36
+ } else {
37
+ stack.push(seg);
38
+ }
39
+ }
40
+
41
+ return stack.join('/');
9
42
  }
10
43
 
11
44
  /** Build the identity-bearing value returned by a real document lookup. */
@@ -20,6 +53,12 @@ export function makeGetResource(relPath: string, data: Record<string, unknown>):
20
53
 
21
54
  export function resolveGet(rawPath: string, context: SimulationContext): SimResource {
22
55
  const path = normalizeDocumentPath(rawPath);
56
+ const segments = path.split('/').filter(Boolean);
57
+ if (!isDocumentPath(segments)) {
58
+ throw new EvalError(
59
+ `get() requires a path pointing to a document (even segment count), got '${path}'`,
60
+ );
61
+ }
23
62
  let document = context.mockDocuments.get(path);
24
63
  if (!document && context.getDoc) {
25
64
  const loaded = context.getDoc(path);
@@ -38,6 +77,10 @@ export function resolveGet(rawPath: string, context: SimulationContext): SimReso
38
77
 
39
78
  export function resolveExists(rawPath: string, context: SimulationContext): boolean {
40
79
  const path = normalizeDocumentPath(rawPath);
80
+ const segments = path.split('/').filter(Boolean);
81
+ if (!isDocumentPath(segments)) {
82
+ return false;
83
+ }
41
84
  if (context.mockDocuments.has(path)) return true;
42
85
  if (context.getDoc) {
43
86
  const loaded = context.getDoc(path);
@@ -6,3 +6,19 @@ export class EvalError extends Error {
6
6
  this.name = 'EvalError';
7
7
  }
8
8
  }
9
+
10
+ /**
11
+ * A resource limit the whole request ran into: the per-request document
12
+ * access budget today, and any future per-request cap. Production fails the
13
+ * entire request closed on these instead of producing a CEL error value, so
14
+ * they are never absorbed by a determining `&&`/`||` operand and never
15
+ * confined to the allow rule that raised them.
16
+ */
17
+ export class ResourceLimitError extends EvalError {
18
+ constructor(message: string, expr?: Expression) {
19
+ super(message, expr);
20
+ this.name = 'ResourceLimitError';
21
+ }
22
+ }
23
+
24
+ export { EvalError as RuleEvalError };
@@ -11,7 +11,8 @@ import { Path } from './wrappers/path.js';
11
11
  import { RulesFloat } from './wrappers/float.js';
12
12
  import { EvalError } from './eval-error.js';
13
13
  import { UnsupportedError } from './unsupported-error.js';
14
- import { makeGetResource, normalizeDocumentPath, resolveExists, resolveGet } from './document-lookups.js';
14
+ import { isDocumentPath, makeGetResource, normalizeDocumentPath, resolveExists, resolveGet } from './document-lookups.js';
15
+ import { chargeLookup } from './lookup-budget.js';
15
16
  import type { SimulationContext } from './evaluation-context.js';
16
17
  import { evaluate, isKnownGlobal, resolveIdentifier } from './evaluator.js';
17
18
  import { evaluateHashingMethod } from './hashing-builtins.js';
@@ -53,10 +54,14 @@ export function evaluateFunctionCall(
53
54
  switch (name) {
54
55
  case 'get': {
55
56
  const path = String(evaluate(args[0], ctx, scope));
57
+ // Charge the per-request lookup budget BEFORE resolving so the 11th
58
+ // distinct access fails closed even when the doc would resolve.
59
+ chargeLookup(ctx, 'doc', path);
56
60
  return resolveGet(path, ctx);
57
61
  }
58
62
  case 'exists': {
59
63
  const path = String(evaluate(args[0], ctx, scope));
64
+ chargeLookup(ctx, 'doc', path); // exists() shares get()'s per-path slot
60
65
  return resolveExists(path, ctx);
61
66
  }
62
67
  case 'getAfter': {
@@ -71,15 +76,27 @@ export function evaluateFunctionCall(
71
76
  // doesn't touch.
72
77
  const argVal = evaluate(args[0], ctx, scope);
73
78
  const pathStr = String(argVal);
79
+ const normalized = normalizeDocumentPath(pathStr);
80
+ const segments = normalized.split('/').filter(Boolean);
81
+ if (!isDocumentPath(segments)) {
82
+ throw new EvalError(
83
+ `getAfter() requires a path pointing to a document (even segment count), got '${normalized}'`,
84
+ );
85
+ }
86
+ // getAfter() is a document access call and counts against the
87
+ // per-request budget. 'after' keying: the post-write projection is a
88
+ // different snapshot than get()'s pre-write read, so a path read by
89
+ // both charges two slots. The fall-through resolveGet below does NOT
90
+ // re-charge, because charging lives only here.
91
+ chargeLookup(ctx, 'after', normalized);
74
92
  if (pathStr === ctx.afterStatePath.toString()) {
75
93
  // RULES-B8: getAfter() of a doc that won't exist post-write (delete,
76
94
  // or projected-null) ERRORS like get() — guard with existsAfter().
77
95
  if (ctx.afterState === null) {
78
96
  throw new EvalError(`getAfter() of non-existent document '${pathStr}' (guard with existsAfter() first)`);
79
97
  }
80
- return makeGetResource(normalizeDocumentPath(pathStr), ctx.afterState);
98
+ return makeGetResource(normalized, ctx.afterState);
81
99
  }
82
- const normalized = normalizeDocumentPath(pathStr);
83
100
  if (ctx.batchProjection?.has(normalized)) {
84
101
  const projected = ctx.batchProjection.get(normalized)!;
85
102
  if (projected === null) {
@@ -96,19 +113,29 @@ export function evaluateFunctionCall(
96
113
  // through to exists().
97
114
  const argVal = evaluate(args[0], ctx, scope);
98
115
  const pathStr = String(argVal);
116
+ const normalized = normalizeDocumentPath(pathStr);
117
+ const segments = normalized.split('/').filter(Boolean);
118
+ if (!isDocumentPath(segments)) {
119
+ return false;
120
+ }
121
+ // existsAfter() counts against the per-request lookup budget and
122
+ // shares getAfter()'s post-write slot for the same path.
123
+ chargeLookup(ctx, 'after', normalized);
99
124
  if (pathStr === ctx.afterStatePath.toString()) {
100
125
  return ctx.existsAfter;
101
126
  }
102
- const normalized = normalizeDocumentPath(pathStr);
103
127
  if (ctx.batchProjection?.has(normalized)) {
104
128
  return ctx.batchProjection.get(normalized) !== null;
105
129
  }
106
130
  return resolveExists(pathStr, ctx);
107
131
  }
108
- case 'debug': {
109
- const val = evaluate(args[0], ctx, scope);
110
- return val; // debug() returns its argument
111
- }
132
+ // NOTE: `debug()` is deliberately NOT handled here. Production Firestore
133
+ // rejects it at compile ("Function not found error: Name: [debug]", the
134
+ // captured acceptance probe for firestore.function.debug), so the local
135
+ // evaluator must fail it the same way. Falling through to the
136
+ // user-defined lookup below yields `EvalError('Unknown function: debug')`,
137
+ // the exact message the conformance rejection-signature normalizer maps
138
+ // to `function-not-found:debug`.
112
139
  // RULES-B5 / RULES-B6: type-conversion builtins follow CEL's strict
113
140
  // semantics, not JS coercion. `String()` routes a RulesFloat through its
114
141
  // `.toString()` so `string(1.0)` → "1.0" (decimal preserved); bare ints
@@ -1,4 +1,5 @@
1
1
  import type { FunctionDef } from '../grammar/FirestoreAST.js';
2
+ import type { LookupBudget } from './lookup-budget.js';
2
3
  import type { TraceRecorder } from './trace-recorder.js';
3
4
  import { Path } from './wrappers/path.js';
4
5
  import { Timestamp } from './wrappers/timestamp.js';
@@ -108,6 +109,17 @@ export interface SimulationContext {
108
109
  * through to `get()` exactly as before.
109
110
  */
110
111
  batchProjection?: Map<string, Record<string, unknown> | null>;
112
+ /**
113
+ * Per-request document access budget (production: 10 distinct
114
+ * get/exists/getAfter/existsAfter reads per single-document request; the
115
+ * 11th fails the evaluation closed). The handler creates one per test
116
+ * case and shares it across every match block and allow rule evaluated
117
+ * for that request; a fresh budget per test case is the "resets between
118
+ * requests" semantics. Optional so non-handler evaluation paths are
119
+ * unaffected. See LookupBudget in lookup-budget.ts for the counting
120
+ * semantics and the in-repo production evidence.
121
+ */
122
+ lookupBudget?: LookupBudget;
111
123
  /** Optional per-rule expression-trace recorder. When set, the evaluator
112
124
  * wraps every `evaluate()` call and records the sub-expression tree;
113
125
  * when absent (the default), the evaluator is unchanged. The handler
@@ -22,10 +22,10 @@ import { Timestamp } from './wrappers/timestamp.js';
22
22
  import { Bytes } from './wrappers/bytes.js';
23
23
  import { Path } from './wrappers/path.js';
24
24
  import { RulesFloat } from './wrappers/float.js';
25
- import { EvalError } from './eval-error.js';
25
+ import { EvalError, ResourceLimitError } from './eval-error.js';
26
26
  import { UnsupportedError } from './unsupported-error.js';
27
27
 
28
- export { EvalError } from './eval-error.js';
28
+ export { EvalError, EvalError as RuleEvalError } from './eval-error.js';
29
29
  export { UnsupportedError } from './unsupported-error.js';
30
30
 
31
31
  // ═══ Simulation Context ═══
@@ -71,7 +71,9 @@ function evaluateExpr(expr: Expression, ctx: SimulationContext, scope: Record<st
71
71
  return evaluateBinaryOp(expr.op, expr.left, expr.right, ctx, scope);
72
72
 
73
73
  case 'unaryOp': {
74
- if (expr.op === '!') return !evaluate(expr.operand, ctx, scope);
74
+ if (expr.op === '!') {
75
+ return !requireBoolean(evaluate(expr.operand, ctx, scope), expr.operand);
76
+ }
75
77
  if (expr.op === '-') {
76
78
  const v = evaluate(expr.operand, ctx, scope);
77
79
  // RULES-B5: negating a float stays a float (`-1.5 is float`); negating
@@ -363,6 +365,13 @@ function evaluateBinaryOp(
363
365
  if (op === '&&') {
364
366
  let lv: unknown, lErr: unknown;
365
367
  try { lv = requireBoolean(evaluate(left, ctx, scope), left); } catch (e) { lErr = e; }
368
+ // A resource-limit failure (the document access budget) fails the WHOLE
369
+ // request, it is not a CEL error value: a determining RHS must not
370
+ // absorb it into an ALLOW. Fail closed immediately.
371
+ // TODO(unverified): confirm against a live production capture that the
372
+ // 11th-access error is not absorbable. The Rules Test API does not
373
+ // enforce the budget, so the capture has to be a deployed ruleset.
374
+ if (lErr instanceof ResourceLimitError) throw lErr;
366
375
  if (lErr === undefined && lv === false) {
367
376
  // LHS is false → determines the result; RHS need not be evaluated.
368
377
  ctx.trace?.skip(right);
@@ -380,6 +389,8 @@ function evaluateBinaryOp(
380
389
  if (op === '||') {
381
390
  let lv: unknown, lErr: unknown;
382
391
  try { lv = requireBoolean(evaluate(left, ctx, scope), left); } catch (e) { lErr = e; }
392
+ // See the `&&` branch: a resource-limit failure is not absorbable.
393
+ if (lErr instanceof ResourceLimitError) throw lErr;
383
394
  if (lErr === undefined && lv === true) {
384
395
  // LHS is true → determines the result; RHS need not be evaluated.
385
396
  ctx.trace?.skip(right);
@@ -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;