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.
- package/README.md +64 -89
- package/dist/ai/blocked.d.ts +52 -0
- package/dist/ai/blocked.d.ts.map +1 -0
- package/dist/ai/blocked.js +83 -0
- package/dist/ai/blocked.js.map +1 -0
- package/dist/ai/broker/broker.d.ts +49 -0
- package/dist/ai/broker/broker.d.ts.map +1 -1
- package/dist/ai/broker/broker.js +90 -0
- package/dist/ai/broker/broker.js.map +1 -1
- package/dist/ai/broker/gemini-engine.d.ts +14 -1
- package/dist/ai/broker/gemini-engine.d.ts.map +1 -1
- package/dist/ai/broker/gemini-engine.js +30 -9
- package/dist/ai/broker/gemini-engine.js.map +1 -1
- package/dist/ai/broker/index.d.ts +2 -2
- package/dist/ai/broker/index.d.ts.map +1 -1
- package/dist/ai/broker/index.js +2 -2
- package/dist/ai/broker/index.js.map +1 -1
- package/dist/ai/broker/openai-engine.d.ts +10 -1
- package/dist/ai/broker/openai-engine.d.ts.map +1 -1
- package/dist/ai/broker/openai-engine.js +26 -4
- package/dist/ai/broker/openai-engine.js.map +1 -1
- package/dist/ai/broker/synthesizer.d.ts +14 -0
- package/dist/ai/broker/synthesizer.d.ts.map +1 -1
- package/dist/ai/broker/synthesizer.js +31 -0
- package/dist/ai/broker/synthesizer.js.map +1 -1
- package/dist/ai/broker/types.d.ts +24 -0
- package/dist/ai/broker/types.d.ts.map +1 -1
- package/dist/ai/internal.d.ts +5 -0
- package/dist/ai/internal.d.ts.map +1 -1
- package/dist/ai/internal.js +5 -0
- package/dist/ai/internal.js.map +1 -1
- package/dist/ai/response-helpers.d.ts.map +1 -1
- package/dist/ai/response-helpers.js +6 -24
- package/dist/ai/response-helpers.js.map +1 -1
- package/dist/analytics/index.d.ts +40 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/index.js +25 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/app-check/index.d.ts +31 -0
- package/dist/app-check/index.d.ts.map +1 -0
- package/dist/app-check/index.js +20 -0
- package/dist/app-check/index.js.map +1 -0
- package/dist/database/controls.d.ts +1 -1
- package/dist/database/controls.js +1 -1
- package/dist/database/sandbox/rules-eval.d.ts +1 -1
- package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
- package/dist/database/sandbox/rules-eval.js +2 -2
- package/dist/database/sandbox/rules-eval.js.map +1 -1
- package/dist/database/sandbox/write-plane.js +2 -2
- package/dist/database/sandbox/write-plane.js.map +1 -1
- package/dist/database/sandbox-controls.d.ts +12 -5
- package/dist/database/sandbox-controls.d.ts.map +1 -1
- package/dist/database/sandbox-controls.js +20 -9
- package/dist/database/sandbox-controls.js.map +1 -1
- package/dist/deferred/entry.d.ts +75 -0
- package/dist/deferred/entry.d.ts.map +1 -0
- package/dist/deferred/entry.js +143 -0
- package/dist/deferred/entry.js.map +1 -0
- package/dist/firestore/lite.d.ts +72 -0
- package/dist/firestore/lite.d.ts.map +1 -0
- package/dist/firestore/lite.js +22 -0
- package/dist/firestore/lite.js.map +1 -0
- package/dist/firestore/persistence.d.ts +2 -2
- package/dist/firestore/persistence.js +1 -1
- package/dist/functions/index.d.ts +29 -0
- package/dist/functions/index.d.ts.map +1 -0
- package/dist/functions/index.js +20 -0
- package/dist/functions/index.js.map +1 -0
- package/dist/performance/index.d.ts +22 -0
- package/dist/performance/index.d.ts.map +1 -0
- package/dist/performance/index.js +19 -0
- package/dist/performance/index.js.map +1 -0
- package/dist/remote-config/index.d.ts +40 -0
- package/dist/remote-config/index.d.ts.map +1 -0
- package/dist/remote-config/index.js +25 -0
- package/dist/remote-config/index.js.map +1 -0
- package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
- package/dist/rules/grammar/FirestoreValidator.js +43 -27
- package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
- package/dist/rules/grammar/document-access-count.d.ts +24 -0
- package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
- package/dist/rules/grammar/document-access-count.js +100 -0
- package/dist/rules/grammar/document-access-count.js.map +1 -0
- package/dist/rules/linter/ast-utils.d.ts +2 -6
- package/dist/rules/linter/ast-utils.d.ts.map +1 -1
- package/dist/rules/linter/ast-utils.js +2 -63
- package/dist/rules/linter/ast-utils.js.map +1 -1
- package/dist/rules/linter/hallucinations.d.ts +8 -0
- package/dist/rules/linter/hallucinations.d.ts.map +1 -1
- package/dist/rules/linter/hallucinations.js +121 -31
- package/dist/rules/linter/hallucinations.js.map +1 -1
- package/dist/rules/linter/linter.d.ts +10 -1
- package/dist/rules/linter/linter.d.ts.map +1 -1
- package/dist/rules/linter/linter.js +23 -9
- package/dist/rules/linter/linter.js.map +1 -1
- package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
- package/dist/rules/rtdb/grammar/simulator.js +13 -13
- package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
- package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
- package/dist/rules/rtdb/simulation/handler.js +96 -35
- package/dist/rules/rtdb/simulation/handler.js.map +1 -1
- package/dist/rules/simulator/document-lookups.d.ts +4 -0
- package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
- package/dist/rules/simulator/document-lookups.js +42 -3
- package/dist/rules/simulator/document-lookups.js.map +1 -1
- package/dist/rules/simulator/eval-error.d.ts +11 -0
- package/dist/rules/simulator/eval-error.d.ts.map +1 -1
- package/dist/rules/simulator/eval-error.js +14 -0
- package/dist/rules/simulator/eval-error.js.map +1 -1
- package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
- package/dist/rules/simulator/evaluation-builtins.js +33 -8
- package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
- package/dist/rules/simulator/evaluation-context.d.ts +12 -0
- package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
- package/dist/rules/simulator/evaluator.d.ts +1 -1
- package/dist/rules/simulator/evaluator.d.ts.map +1 -1
- package/dist/rules/simulator/evaluator.js +16 -4
- package/dist/rules/simulator/evaluator.js.map +1 -1
- package/dist/rules/simulator/handler.d.ts.map +1 -1
- package/dist/rules/simulator/handler.js +52 -5
- package/dist/rules/simulator/handler.js.map +1 -1
- package/dist/rules/simulator/lookup-budget.d.ts +37 -0
- package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
- package/dist/rules/simulator/lookup-budget.js +96 -0
- package/dist/rules/simulator/lookup-budget.js.map +1 -0
- package/dist/rules/stdlib-modules.d.ts +7 -0
- package/dist/rules/stdlib-modules.d.ts.map +1 -1
- package/dist/rules/stdlib-modules.js +11 -11
- package/dist/rules/stdlib-modules.js.map +1 -1
- package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
- package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
- package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
- package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/persistence/types.d.ts +1 -1
- package/dist/sandbox/remote.d.ts +2 -2
- package/dist/sandbox/sandbox-context.d.ts +8 -1
- package/dist/sandbox/sandbox-context.d.ts.map +1 -1
- package/dist/sandbox/sandbox-context.js +59 -8
- package/dist/sandbox/sandbox-context.js.map +1 -1
- package/dist/sandbox/types/auth-state.d.ts +1 -0
- package/dist/sandbox/types/auth-state.d.ts.map +1 -1
- package/dist/sandbox/types/events.d.ts +2 -1
- package/dist/sandbox/types/events.d.ts.map +1 -1
- package/dist/sandbox/types/operation.d.ts +1 -0
- package/dist/sandbox/types/operation.d.ts.map +1 -1
- package/dist/storage/enforce.d.ts +20 -1
- package/dist/storage/enforce.d.ts.map +1 -1
- package/dist/storage/enforce.js +47 -14
- package/dist/storage/enforce.js.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/list.js +1 -1
- package/dist/storage/list.js.map +1 -1
- package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
- package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
- package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
- package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
- package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-evaluator.js +105 -33
- package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
- package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-methods.js +17 -7
- package/dist/storage/sandbox/rules-methods.js.map +1 -1
- package/dist/storage/service.d.ts +23 -2
- package/dist/storage/service.d.ts.map +1 -1
- package/dist/storage/service.js +36 -26
- package/dist/storage/service.js.map +1 -1
- package/package.json +41 -1
- package/src/ai/blocked.ts +97 -0
- package/src/ai/broker/broker.ts +88 -0
- package/src/ai/broker/gemini-engine.ts +36 -9
- package/src/ai/broker/index.ts +2 -1
- package/src/ai/broker/openai-engine.ts +31 -3
- package/src/ai/broker/synthesizer.ts +34 -0
- package/src/ai/broker/types.ts +25 -0
- package/src/ai/internal.ts +5 -0
- package/src/ai/response-helpers.ts +6 -25
- package/src/analytics/index.ts +53 -0
- package/src/app/dispatch.test.ts +3 -1
- package/src/app-check/index.ts +41 -0
- package/src/database/controls.ts +1 -1
- package/src/database/sandbox/rules-eval.ts +2 -2
- package/src/database/sandbox/write-plane.ts +2 -2
- package/src/database/sandbox-controls.ts +29 -9
- package/src/deferred/entry.ts +167 -0
- package/src/firestore/lite.ts +91 -0
- package/src/firestore/persistence.ts +2 -2
- package/src/functions/index.ts +38 -0
- package/src/performance/index.ts +31 -0
- package/src/remote-config/index.ts +53 -0
- package/src/rules/grammar/FirestoreValidator.ts +44 -28
- package/src/rules/grammar/document-access-count.ts +75 -0
- package/src/rules/linter/ast-utils.ts +2 -36
- package/src/rules/linter/hallucinations.ts +123 -25
- package/src/rules/linter/linter.ts +32 -10
- package/src/rules/rtdb/grammar/simulator.ts +12 -13
- package/src/rules/rtdb/simulation/handler.ts +149 -46
- package/src/rules/simulator/document-lookups.ts +46 -3
- package/src/rules/simulator/eval-error.ts +16 -0
- package/src/rules/simulator/evaluation-builtins.ts +35 -8
- package/src/rules/simulator/evaluation-context.ts +12 -0
- package/src/rules/simulator/evaluator.ts +14 -3
- package/src/rules/simulator/handler.ts +68 -4
- package/src/rules/simulator/lookup-budget.ts +101 -0
- package/src/rules/stdlib-modules.ts +19 -11
- package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
- package/src/sandbox/index.ts +5 -1
- package/src/sandbox/persistence/types.ts +1 -1
- package/src/sandbox/remote.ts +2 -2
- package/src/sandbox/sandbox-context.ts +69 -9
- package/src/sandbox/types/auth-state.ts +1 -1
- package/src/sandbox/types/events.ts +2 -1
- package/src/sandbox/types/operation.ts +1 -1
- package/src/storage/enforce.ts +53 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/list.ts +1 -1
- package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
- package/src/storage/sandbox/rules-evaluator.ts +109 -32
- package/src/storage/sandbox/rules-methods.ts +21 -7
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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(
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 === '!')
|
|
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
|
-
):
|
|
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
|
-
|
|
565
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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.
|
|
149
|
-
description: 'True if `n` is
|
|
150
|
-
notes:
|
|
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)
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
|
package/src/sandbox/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
package/src/sandbox/remote.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|