@pyric/cli 0.1.0-alpha.25 → 0.1.0-alpha.27
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 +1 -1
- package/dist/assurance/.generated/conformance-verdicts.d.ts +5 -0
- package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
- package/dist/assurance/.generated/conformance-verdicts.js +5 -0
- package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
- package/dist/assurance/capabilities.d.ts.map +1 -1
- package/dist/assurance/capabilities.js +24 -5
- package/dist/assurance/capabilities.js.map +1 -1
- package/dist/assurance/runner.d.ts.map +1 -1
- package/dist/assurance/runner.js +37 -50
- package/dist/assurance/runner.js.map +1 -1
- package/dist/assurance/types.d.ts +5 -0
- package/dist/assurance/types.d.ts.map +1 -1
- package/dist/assurance/types.js.map +1 -1
- package/dist/assurance/validation.d.ts +2 -16
- package/dist/assurance/validation.d.ts.map +1 -1
- package/dist/assurance/validation.js +121 -1
- package/dist/assurance/validation.js.map +1 -1
- package/dist/bridge/surface/arguments/assurance.d.ts +25 -0
- package/dist/bridge/surface/arguments/assurance.d.ts.map +1 -1
- package/dist/bridge/surface/arguments/assurance.js +1 -0
- package/dist/bridge/surface/arguments/assurance.js.map +1 -1
- package/dist/bridge/surface/methods/assurance/define.d.ts +2 -2
- package/dist/bridge/surface/methods/assurance/map.d.ts +23 -0
- package/dist/bridge/surface/methods/assurance/map.d.ts.map +1 -1
- package/dist/bridge/surface/methods/assurance/propose.d.ts +7 -0
- package/dist/bridge/surface/methods/assurance/propose.d.ts.map +1 -1
- package/dist/bridge/surface/render/discriminator-schemas.d.ts +2 -2
- package/dist/cli/database-rules.js +1 -1
- package/dist/cli/database-rules.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +13 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pyric-config.d.ts +8 -0
- package/dist/cli/pyric-config.d.ts.map +1 -1
- package/dist/cli/pyric-config.js.map +1 -1
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +5 -3
- package/dist/cli/serve.js.map +1 -1
- package/dist/cli/verify.d.ts +6 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +240 -15
- package/dist/cli/verify.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +1 -1
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
- package/dist/conformance/.generated/conformance-docs.js +1 -1
- package/dist/conformance/.generated/conformance-docs.js.map +1 -1
- package/dist/rtdb/inspect-deployed-rules.d.ts +3 -17
- package/dist/rtdb/inspect-deployed-rules.d.ts.map +1 -1
- package/dist/rtdb/inspect-deployed-rules.js +4 -54
- package/dist/rtdb/inspect-deployed-rules.js.map +1 -1
- package/dist/serve/repro/denials.d.ts.map +1 -1
- package/dist/serve/repro/denials.js +2 -1
- package/dist/serve/repro/denials.js.map +1 -1
- package/dist/serve/site-ui/_astro/{app.BpEphDR7.js → app.CiMHEvEW.js} +15 -15
- package/dist/serve/site-ui/_astro/{app.DFdWhpVY.js → app.DpzsBfos.js} +1 -1
- package/dist/serve/site-ui/_astro/{seed.vvXV1ioI.js → seed.BpXSRzLo.js} +1 -1
- package/dist/serve/site-ui/auth/index.html +1 -1
- package/dist/serve/site-ui/docs/database-compat/index.html +4 -0
- package/dist/serve/site-ui/docs/database-compat.md +4 -0
- package/dist/serve/site-ui/docs/get-started/start-building/index.html +1 -0
- package/dist/serve/site-ui/docs/get-started/start-building.md +2 -0
- package/dist/serve/site-ui/docs/index.json +35 -0
- package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api/index.html +23 -4
- package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api.md +11 -4
- package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api/index.html +15 -2
- package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api.md +11 -10
- package/dist/serve/site-ui/docs/pyric-rules-reference-api/index.html +372 -201
- package/dist/serve/site-ui/docs/pyric-rules-reference-api.md +148 -9
- package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript/index.html +56 -1
- package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript.md +33 -0
- package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
- package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite.md +1 -1
- package/dist/serve/site-ui/docs/ship/ship-to-production/index.html +4 -1
- package/dist/serve/site-ui/docs/ship/ship-to-production.md +7 -1
- package/dist/serve/site-ui/examples/chess/index.html +1 -1
- package/dist/serve/site-ui/firestore/index.html +1 -1
- package/dist/serve/site-ui/rtdb/index.html +1 -1
- package/dist/serve/site-ui/settings/index.html +1 -1
- package/dist/serve/site-ui/storage/index.html +1 -1
- package/dist/serve/site-ui/studio/index.html +1 -1
- package/dist/serve/site-ui/traffic/index.html +1 -1
- package/dist/serve/verify-hint.d.ts +2 -0
- package/dist/serve/verify-hint.d.ts.map +1 -0
- package/dist/serve/verify-hint.js +10 -0
- package/dist/serve/verify-hint.js.map +1 -0
- package/dist/serve/vite-generation-rules-watch.d.ts.map +1 -1
- package/dist/serve/vite-generation-rules-watch.js +5 -3
- package/dist/serve/vite-generation-rules-watch.js.map +1 -1
- package/dist/verify/index.d.ts +16 -2
- package/dist/verify/index.d.ts.map +1 -1
- package/dist/verify/index.js +100 -18
- package/dist/verify/index.js.map +1 -1
- package/dist/verify/staging/engine.d.ts +102 -0
- package/dist/verify/staging/engine.d.ts.map +1 -0
- package/dist/verify/staging/engine.js +371 -0
- package/dist/verify/staging/engine.js.map +1 -0
- package/dist/verify/staging/rest.d.ts +68 -0
- package/dist/verify/staging/rest.d.ts.map +1 -0
- package/dist/verify/staging/rest.js +93 -0
- package/dist/verify/staging/rest.js.map +1 -0
- package/dist/verify/staging/target.d.ts +39 -0
- package/dist/verify/staging/target.d.ts.map +1 -0
- package/dist/verify/staging/target.js +117 -0
- package/dist/verify/staging/target.js.map +1 -0
- package/package.json +4 -4
- package/src/assurance/.generated/conformance-verdicts.ts +5 -0
- package/src/assurance/capabilities.ts +22 -4
- package/src/assurance/runner.ts +40 -58
- package/src/assurance/types.ts +3 -0
- package/src/assurance/validation.ts +137 -0
- package/src/bridge/surface/arguments/assurance.ts +1 -0
- package/src/cli/database-rules.ts +1 -1
- package/src/cli/index.ts +13 -2
- package/src/cli/pyric-config.ts +5 -0
- package/src/cli/serve.ts +6 -3
- package/src/cli/verify.ts +244 -18
- package/src/conformance/.generated/can-i-use.ts +1 -1
- package/src/conformance/.generated/conformance-docs.ts +1 -1
- package/src/rtdb/inspect-deployed-rules.ts +5 -77
- package/src/serve/repro/denials.ts +2 -1
- package/src/serve/verify-hint.ts +8 -0
- package/src/serve/vite-generation-rules-watch.ts +6 -3
- package/src/verify/index.ts +119 -19
- package/src/verify/staging/engine.ts +455 -0
- package/src/verify/staging/rest.ts +129 -0
- package/src/verify/staging/target.ts +137 -0
|
@@ -13,13 +13,17 @@
|
|
|
13
13
|
* `observations[0]`, `probes[2]`. A caller that validates one record on its
|
|
14
14
|
* own passes nothing and gets the record's kind instead.
|
|
15
15
|
*/
|
|
16
|
+
import { resolveDatabaseInstance } from "pyric/sandbox/internal";
|
|
17
|
+
import { rtdbRulesSourceRejection } from "pyric/rules/internal/rtdb";
|
|
16
18
|
import {
|
|
19
|
+
ASSURANCE_CAMPAIGN_SCHEMA,
|
|
17
20
|
ASSURANCE_TARGET_SCHEMA,
|
|
18
21
|
AssuranceInputError,
|
|
19
22
|
type AssuranceActor,
|
|
20
23
|
type AssuranceObservation,
|
|
21
24
|
type AssuranceProbe,
|
|
22
25
|
type FirebaseOperation,
|
|
26
|
+
type AuthorizationCampaignSpec,
|
|
23
27
|
type LocalFirebaseTarget,
|
|
24
28
|
type MutationDimension,
|
|
25
29
|
type SecurityInvariant,
|
|
@@ -225,13 +229,56 @@ export function assertTarget(
|
|
|
225
229
|
if (target.rules.rtdb !== undefined) {
|
|
226
230
|
requiredRecord(target.rules.rtdb, `${where}.rules.rtdb`);
|
|
227
231
|
requiredRecord(target.rules.rtdb.rules, `${where}.rules.rtdb.rules`);
|
|
232
|
+
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const declaredRuleInstances = new Map<string, string>();
|
|
236
|
+
if (target.rules.rtdbInstances !== undefined) {
|
|
237
|
+
requiredRecord(target.rules.rtdbInstances, `${where}.rules.rtdbInstances`);
|
|
238
|
+
for (const [instance, rules] of Object.entries(target.rules.rtdbInstances)) {
|
|
239
|
+
const at = `${where}.rules.rtdbInstances[${JSON.stringify(instance)}]`;
|
|
240
|
+
requiredRecord(rules, at);
|
|
241
|
+
requiredRecord((rules as any).rules, `${at}.rules`);
|
|
242
|
+
try {
|
|
243
|
+
const resolvedName = resolveDatabaseInstance(instance)!.name;
|
|
244
|
+
if (declaredRuleInstances.has(resolvedName)) {
|
|
245
|
+
throw new AssuranceInputError(`${at} declares instance '${resolvedName}' which is already declared by key '${declaredRuleInstances.get(resolvedName)}'`);
|
|
246
|
+
}
|
|
247
|
+
declaredRuleInstances.set(resolvedName, instance);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
if (e instanceof AssuranceInputError) throw e;
|
|
250
|
+
throw new AssuranceInputError(`${at} names a malformed RTDB instance: ${e instanceof Error ? e.message : String(e)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
228
253
|
}
|
|
254
|
+
|
|
229
255
|
if (target.state.firestore !== undefined) {
|
|
230
256
|
requiredRecord(target.state.firestore, `${where}.state.firestore`);
|
|
231
257
|
for (const [docPath, docData] of Object.entries(target.state.firestore)) {
|
|
232
258
|
requiredRecord(docData, `${where}.state.firestore[${docPath}]`);
|
|
233
259
|
}
|
|
234
260
|
}
|
|
261
|
+
if (target.state.rtdbInstances !== undefined) {
|
|
262
|
+
requiredRecord(target.state.rtdbInstances, `${where}.state.rtdbInstances`);
|
|
263
|
+
const declaredStateInstances = new Map<string, string>();
|
|
264
|
+
for (const [instance] of Object.entries(target.state.rtdbInstances)) {
|
|
265
|
+
const at = `${where}.state.rtdbInstances[${JSON.stringify(instance)}]`;
|
|
266
|
+
try {
|
|
267
|
+
const resolvedName = resolveDatabaseInstance(instance)!.name;
|
|
268
|
+
if (!declaredRuleInstances.has(resolvedName)) {
|
|
269
|
+
throw new AssuranceInputError(`${at} provides state for instance '${resolvedName}' which has no declared rules`);
|
|
270
|
+
}
|
|
271
|
+
if (declaredStateInstances.has(resolvedName)) {
|
|
272
|
+
throw new AssuranceInputError(`${at} provides state for instance '${resolvedName}' which is already provided by key '${declaredStateInstances.get(resolvedName)}'`);
|
|
273
|
+
}
|
|
274
|
+
declaredStateInstances.set(resolvedName, instance);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
if (e instanceof AssuranceInputError) throw e;
|
|
277
|
+
throw new AssuranceInputError(`${at} names a malformed RTDB instance: ${e instanceof Error ? e.message : String(e)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
235
282
|
if (target.state.storage !== undefined) {
|
|
236
283
|
if (!Array.isArray(target.state.storage)) {
|
|
237
284
|
throw new AssuranceInputError(
|
|
@@ -367,6 +414,9 @@ function assertOperation(
|
|
|
367
414
|
): asserts operation is FirebaseOperation {
|
|
368
415
|
requiredRecord(operation, where);
|
|
369
416
|
enumValue(operation.service, OPERATION_SERVICES, where, "service");
|
|
417
|
+
if (operation.service === "rtdb" && (operation as any).instance !== undefined) {
|
|
418
|
+
requiredString((operation as any).instance, where, "instance");
|
|
419
|
+
}
|
|
370
420
|
requiredString(operation.path, where, "path");
|
|
371
421
|
enumValue(
|
|
372
422
|
operation.method,
|
|
@@ -507,3 +557,90 @@ export function changedDimensions(
|
|
|
507
557
|
}
|
|
508
558
|
return changed;
|
|
509
559
|
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
export function assertCampaign(spec: AuthorizationCampaignSpec): void {
|
|
563
|
+
if (spec.schema !== ASSURANCE_CAMPAIGN_SCHEMA) {
|
|
564
|
+
throw new AssuranceInputError(
|
|
565
|
+
`campaign schema must be '${ASSURANCE_CAMPAIGN_SCHEMA}'.`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
if (!spec.id.trim())
|
|
569
|
+
throw new AssuranceInputError("campaign id is required.");
|
|
570
|
+
assertTarget(spec.target);
|
|
571
|
+
if (!Array.isArray(spec.actors)) {
|
|
572
|
+
throw new AssuranceInputError("campaign actors must be an array.");
|
|
573
|
+
}
|
|
574
|
+
if (!Array.isArray(spec.invariants)) {
|
|
575
|
+
throw new AssuranceInputError("campaign invariants must be an array.");
|
|
576
|
+
}
|
|
577
|
+
if (!Array.isArray(spec.probes)) {
|
|
578
|
+
throw new AssuranceInputError("campaign probes must be an array.");
|
|
579
|
+
}
|
|
580
|
+
for (const actor of spec.actors) assertActor(actor);
|
|
581
|
+
for (const invariant of spec.invariants) assertInvariant(invariant);
|
|
582
|
+
for (const probe of spec.probes) assertProbe(probe);
|
|
583
|
+
uniqueIds(spec.actors, "actor");
|
|
584
|
+
uniqueIds(spec.invariants, "invariant");
|
|
585
|
+
uniqueIds(spec.probes, "probe");
|
|
586
|
+
|
|
587
|
+
const declaredRtdbInstances = spec.target.rules.rtdbInstances ? Object.keys(spec.target.rules.rtdbInstances).map(k => resolveDatabaseInstance(k)!.name) : [];
|
|
588
|
+
for (const probe of spec.probes) {
|
|
589
|
+
for (const op of [probe.control, probe.mutation.operation]) {
|
|
590
|
+
// test what happens to unknown fields (unrecognized keys)
|
|
591
|
+
const allowedKeys = new Set(["service", "method", "path", "data", "query", "dataBase64", "contentType", "customMetadata", "instance"]);
|
|
592
|
+
for (const key of Object.keys(op)) {
|
|
593
|
+
if (!allowedKeys.has(key)) {
|
|
594
|
+
throw new AssuranceInputError(`operation names unknown field '${key}'`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (op.service === "rtdb" && op.instance !== undefined && op.instance !== "default") {
|
|
599
|
+
let resolvedName;
|
|
600
|
+
try {
|
|
601
|
+
resolvedName = resolveDatabaseInstance(op.instance)!.name;
|
|
602
|
+
} catch (e) {
|
|
603
|
+
throw new AssuranceInputError(`invalid RTDB instance '${op.instance}': ${e instanceof Error ? e.message : String(e)}`);
|
|
604
|
+
}
|
|
605
|
+
if (!declaredRtdbInstances.includes(resolvedName)) {
|
|
606
|
+
throw new AssuranceInputError(`probe '${probe.id}' names undeclared RTDB instance '${op.instance}'. Declared instances: ${declaredRtdbInstances.length > 0 ? declaredRtdbInstances.join(", ") : "none"}.`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
for (const probe of spec.probes) {
|
|
613
|
+
const actor = spec.actors.find((item) => item.id === probe.actorId);
|
|
614
|
+
const invariant = spec.invariants.find(
|
|
615
|
+
(item) => item.id === probe.invariantId,
|
|
616
|
+
);
|
|
617
|
+
if (!actor)
|
|
618
|
+
throw new AssuranceInputError(
|
|
619
|
+
`probe '${probe.id}' references unknown actor '${probe.actorId}'.`,
|
|
620
|
+
);
|
|
621
|
+
if (!invariant) {
|
|
622
|
+
throw new AssuranceInputError(
|
|
623
|
+
`probe '${probe.id}' references unknown invariant '${probe.invariantId}'.`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (
|
|
627
|
+
invariant.service !== "cross-service" &&
|
|
628
|
+
invariant.service !== probe.control.service
|
|
629
|
+
) {
|
|
630
|
+
throw new AssuranceInputError(
|
|
631
|
+
`probe '${probe.id}' service does not match invariant '${invariant.id}'.`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function uniqueIds(items: ReadonlyArray<{ id: string }>, label: string): void {
|
|
638
|
+
const seen = new Set<string>();
|
|
639
|
+
for (const item of items) {
|
|
640
|
+
if (!item.id.trim())
|
|
641
|
+
throw new AssuranceInputError(`${label} id is required.`);
|
|
642
|
+
if (seen.has(item.id))
|
|
643
|
+
throw new AssuranceInputError(`duplicate ${label} id '${item.id}'.`);
|
|
644
|
+
seen.add(item.id);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
@@ -82,6 +82,7 @@ export const OPERATION_METHOD_VALUES: readonly string[] = [
|
|
|
82
82
|
export const firebaseOperation = z
|
|
83
83
|
.object({
|
|
84
84
|
service: z.enum(OPERATION_SERVICES).describe('The service the operation runs against.'),
|
|
85
|
+
instance: z.string().optional().describe('The named RTDB instance, if the service is rtdb.'),
|
|
85
86
|
method: z
|
|
86
87
|
.enum(OPERATION_METHOD_VALUES as [string, ...string[]])
|
|
87
88
|
.describe('The request method, from the set the named service evaluates.'),
|
|
@@ -236,6 +236,6 @@ export async function runDatabaseRulesGenerate(
|
|
|
236
236
|
await mkdirFn(dirname(resolvedOut), { recursive: true });
|
|
237
237
|
await writeFileFn(resolvedOut, `${JSON.stringify(rulesJson, null, 2)}\n`, 'utf-8');
|
|
238
238
|
|
|
239
|
-
out.write(`pyric database rules generate: wrote ${resolvedOut}\n`);
|
|
239
|
+
out.write(`pyric database rules generate: wrote ${resolvedOut}\n Test before deploy: pyric verify --service rtdb --rules rtdb=${outPath}\n`);
|
|
240
240
|
return 0;
|
|
241
241
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -63,9 +63,11 @@ USAGE
|
|
|
63
63
|
pyric init [dir] [--template=web|node]
|
|
64
64
|
pyric sandbox salvage --source=DIR --out=NEW_DIR
|
|
65
65
|
pyric snapshot [--out=DIR]
|
|
66
|
-
pyric verify [fixture|dir] [--engine sandbox|rules-test-api|both]
|
|
66
|
+
pyric verify [fixture|dir] [--engine sandbox|rules-test-api|staging|both]
|
|
67
67
|
pyric can-i-use <feature> [--json]
|
|
68
68
|
pyric verify cases [fixture] [--service firestore] [--out FILE]
|
|
69
|
+
pyric verify staging init
|
|
70
|
+
pyric verify staging restore [--force]
|
|
69
71
|
pyric firestore rules validate <path>
|
|
70
72
|
pyric firestore rules resolve <path> [--out <path>]
|
|
71
73
|
pyric firestore indexes generate <path...> [--out <path>]
|
|
@@ -110,9 +112,12 @@ COMMANDS
|
|
|
110
112
|
for the Firestore/RTDB services present in the fixture.
|
|
111
113
|
No arg replays the latest \`pyric sandbox\` capture
|
|
112
114
|
(.pyric/last-session.json). --service filters services.
|
|
113
|
-
--engine sandbox (default), rules-test-api, or both.
|
|
115
|
+
--engine sandbox (default), rules-test-api, staging, or both.
|
|
114
116
|
Hosted Rules Test API verification is Firestore-only
|
|
115
117
|
and uses --project plus the configured Google credentials.
|
|
118
|
+
--engine staging replays RTDB against your own dedicated
|
|
119
|
+
instance (pyric.json verify.staging) so production decides
|
|
120
|
+
each operation; repeat --engine to compare with sandbox.
|
|
116
121
|
--rules service=path overrides firebase.json resolution
|
|
117
122
|
(repeat for mixed captures). Each RTDB instance the session
|
|
118
123
|
used is checked against its own rules; --rules
|
|
@@ -123,6 +128,12 @@ COMMANDS
|
|
|
123
128
|
Exact and fuzzy queries use the same model as MCP. --json.
|
|
124
129
|
verify cases [fixture] Derive Firestore Rules Test API cases from a captured
|
|
125
130
|
fixture and print JSON, or write with --out FILE.
|
|
131
|
+
verify staging init Enroll the empty RTDB instance pyric.json verify.staging
|
|
132
|
+
names ({ project, rtdb }) for --engine staging. Refuses an
|
|
133
|
+
instance that holds data or that firebase.json deploys to.
|
|
134
|
+
verify staging restore Put the staging instance back after an interrupted run, from
|
|
135
|
+
the rules kept in .pyric/staging-restore.json; --force
|
|
136
|
+
deploys deny-all rules with the marker when none were kept.
|
|
126
137
|
firestore rules validate Validate Firestore rules structure against a file.
|
|
127
138
|
firestore rules resolve Resolve Firestore 2+modules imports to one ruleset.
|
|
128
139
|
firestore indexes generate Generate firestore.indexes.json from application source.
|
package/src/cli/pyric-config.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface PyricConfig {
|
|
|
31
31
|
rules?: string | PyricRulesConfig;
|
|
32
32
|
/** Project ID label for emulation. */
|
|
33
33
|
project?: string;
|
|
34
|
+
/** `pyric verify` settings. */
|
|
35
|
+
verify?: {
|
|
36
|
+
/** The dedicated RTDB instance `pyric verify --engine staging` replays against. */
|
|
37
|
+
staging?: { project: string; rtdb: string };
|
|
38
|
+
};
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export async function readPyricConfig(cwd: string = process.cwd()): Promise<PyricConfig> {
|
package/src/cli/serve.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
type SandboxSession,
|
|
72
72
|
} from '../serve/sandbox-session.js';
|
|
73
73
|
import { resolveAvatarsConfig } from '../serve/avatars-config.js';
|
|
74
|
+
import { createVerifyHint } from '../serve/verify-hint.js';
|
|
74
75
|
|
|
75
76
|
interface HostingConfig {
|
|
76
77
|
public?: string;
|
|
@@ -552,6 +553,8 @@ async function startServeRuntime(opts: {
|
|
|
552
553
|
// a failed reload asked for, so fixing or creating one reloads. The source
|
|
553
554
|
// is the path the rules load from, watched whether or not it exists at
|
|
554
555
|
// startup, so a rules file created later loads.
|
|
556
|
+
const getVerifyHint = createVerifyHint();
|
|
557
|
+
|
|
555
558
|
const isWatchEnabled = opts.watch ?? true;
|
|
556
559
|
if (isWatchEnabled) {
|
|
557
560
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -567,7 +570,7 @@ async function startServeRuntime(opts: {
|
|
|
567
570
|
const isReloaded = result.kind === 'reloaded';
|
|
568
571
|
const isRejected = result.kind === 'rejected';
|
|
569
572
|
if (isReloaded) {
|
|
570
|
-
logger.note(` ↻ rules reloaded (hash ${result.rulesHash}) → ${result.clients} page(s)`);
|
|
573
|
+
logger.note(` ↻ rules reloaded (hash ${result.rulesHash}) → ${result.clients} page(s)${getVerifyHint()}`);
|
|
571
574
|
if (result.fixtureCheck && result.fixtureCheck.ran > 0) {
|
|
572
575
|
for (const line of result.fixtureCheck.lines) logger.note(line);
|
|
573
576
|
}
|
|
@@ -610,7 +613,7 @@ async function startServeRuntime(opts: {
|
|
|
610
613
|
void session.reloadDatabaseRules(changed).then((results) => {
|
|
611
614
|
for (const { instance, file: source, result } of results) {
|
|
612
615
|
if (result.kind === 'reloaded') {
|
|
613
|
-
logger.note(` ↻ rtdb rules reloaded for ${instance} (hash ${result.rulesHash}) → ${result.clients} page(s)`);
|
|
616
|
+
logger.note(` ↻ rtdb rules reloaded for ${instance} (hash ${result.rulesHash}) → ${result.clients} page(s)${getVerifyHint()}`);
|
|
614
617
|
if (result.fixtureCheck && result.fixtureCheck.ran > 0) {
|
|
615
618
|
for (const line of result.fixtureCheck.lines) logger.note(line);
|
|
616
619
|
}
|
|
@@ -650,7 +653,7 @@ async function startServeRuntime(opts: {
|
|
|
650
653
|
debounceStorage = setTimeout(() => {
|
|
651
654
|
void session.reloadStorageRules().then((result) => {
|
|
652
655
|
if (result.kind === 'reloaded') {
|
|
653
|
-
logger.note(` ↻ storage rules reloaded (hash ${result.rulesHash}) → ${result.clients} page(s)`);
|
|
656
|
+
logger.note(` ↻ storage rules reloaded (hash ${result.rulesHash}) → ${result.clients} page(s)${getVerifyHint()}`);
|
|
654
657
|
} else if (result.kind === 'rejected') {
|
|
655
658
|
logger.note(` ⚠ storage rules NOT reloaded (last-good stays live): ${result.error.message}`);
|
|
656
659
|
} else if (result.kind === 'removed') {
|
package/src/cli/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { basename, join, resolve } from 'node:path';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
deriveRulesTestCases,
|
|
5
5
|
fixtureVerifiableServices,
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
type VerifyRulesInput,
|
|
16
16
|
type VerifyServiceResult,
|
|
17
17
|
} from '../verify/index.js';
|
|
18
|
-
import { readFirebaseJson, type FirebaseJson } from './firebase-json.js';
|
|
18
|
+
import { readFirebaseJson, readFirebaseRcSync, type FirebaseJson } from './firebase-json.js';
|
|
19
|
+
import { resolveFirebaseProject } from './firebase-project.js';
|
|
20
|
+
import { readPyricConfig } from './pyric-config.js';
|
|
21
|
+
import { markedProject, restoreStagingInstance, StagingSafetyError, stagingMarker } from '../verify/staging/engine.js';
|
|
22
|
+
import { RtdbRestClient } from '../verify/staging/rest.js';
|
|
23
|
+
import { assertNotAppInstance, stagingTargetFrom, stagingToken, type StagingTarget } from '../verify/staging/target.js';
|
|
19
24
|
import type { FlagValue, ParsedArgs } from './parse-args.js';
|
|
20
25
|
import { resolveScope } from '../credentials/node/scope.js';
|
|
21
26
|
import { CAPTURE_RELATIVE_PATH } from '../serve/capture-store.js';
|
|
@@ -37,6 +42,8 @@ export interface FixtureResult {
|
|
|
37
42
|
|
|
38
43
|
export interface VerifyCliDeps {
|
|
39
44
|
resolveScope?: typeof resolveScope;
|
|
45
|
+
/** The staging instance's REST client; tests pass one backed by a local sandbox. */
|
|
46
|
+
stagingClient?: (target: StagingTarget, token: () => Promise<string>) => RtdbRestClient;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export function loadFixture(path: string): Fixture {
|
|
@@ -88,9 +95,16 @@ export function formatResults(results: FixtureResult[]): string {
|
|
|
88
95
|
lines.push(`${marker} ${result.name}${desc} - ${serviceSummary}`);
|
|
89
96
|
for (const service of Object.values(result.result.services)) {
|
|
90
97
|
if (!service) continue;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
const engines = Object.values(service.engines ?? {}).filter((engine) => engine !== undefined);
|
|
99
|
+
// With more than one engine, each line names the engine that reported it.
|
|
100
|
+
const groups = engines.length > 1
|
|
101
|
+
? engines.map((engine) => ({ label: ` ${engine.engine}`, divergences: engine.divergences }))
|
|
102
|
+
: [{ label: '', divergences: service.divergences }];
|
|
103
|
+
for (const group of groups) {
|
|
104
|
+
for (const divergence of failingDivergences(group.divergences)) {
|
|
105
|
+
const instance = divergence.instance === undefined ? '' : ` ${divergence.instance}`;
|
|
106
|
+
lines.push(` [${service.service}${instance}${group.label}] ${formatDivergence(divergence)}`);
|
|
107
|
+
}
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
110
|
}
|
|
@@ -104,6 +118,9 @@ export async function runVerify(parsed: ParsedArgs, deps: VerifyCliDeps = {}): P
|
|
|
104
118
|
if (parsed.positional[0] === 'cases') {
|
|
105
119
|
return runVerifyCases(parsed, cwd);
|
|
106
120
|
}
|
|
121
|
+
if (parsed.positional[0] === 'staging') {
|
|
122
|
+
return runVerifyStaging(parsed, cwd, deps);
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
const target = parsed.positional[0];
|
|
109
126
|
const inputPath = resolve(cwd, target ?? CAPTURE_RELATIVE_PATH);
|
|
@@ -149,6 +166,23 @@ export async function runVerify(parsed: ParsedArgs, deps: VerifyCliDeps = {}): P
|
|
|
149
166
|
return 2;
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
// The staging target is checked before anything else reads the project: an
|
|
170
|
+
// instance the app uses is refused before a request is made.
|
|
171
|
+
let staging: VerifyFixtureOptions['staging'];
|
|
172
|
+
let stopStaging: (() => void) | undefined;
|
|
173
|
+
if (engines.includes('staging')) {
|
|
174
|
+
try {
|
|
175
|
+
const opened = await openStagingInstance(cwd, stringFlag(parsed.flags.get('project')), deps);
|
|
176
|
+
process.stderr.write(`pyric verify: staging instance ${opened.target.databaseUrl} (project ${opened.projectId}, credentials: ${opened.source})\n`);
|
|
177
|
+
const guard = stagingRunGuard(cwd, opened.target);
|
|
178
|
+
stopStaging = guard.stop;
|
|
179
|
+
staging = { client: opened.client, projectId: opened.projectId, ...guard.hooks };
|
|
180
|
+
} catch (e) {
|
|
181
|
+
process.stderr.write(`pyric verify: ${messageOf(e)}\n`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
152
186
|
let rulesResolution: { rules: VerifyRulesInput; paths: Record<string, string> };
|
|
153
187
|
try {
|
|
154
188
|
rulesResolution = await resolveCandidateRules({
|
|
@@ -177,19 +211,24 @@ export async function runVerify(parsed: ParsedArgs, deps: VerifyCliDeps = {}): P
|
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
let results: FixtureResult[];
|
|
214
|
+
const options = {
|
|
215
|
+
services,
|
|
216
|
+
engines,
|
|
217
|
+
...(rulesTestApi ? { rulesTestApi } : {}),
|
|
218
|
+
...(staging ? { staging } : {}),
|
|
219
|
+
};
|
|
180
220
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
engines,
|
|
186
|
-
...(rulesTestApi ? { rulesTestApi } : {}),
|
|
187
|
-
})),
|
|
188
|
-
);
|
|
221
|
+
// The staging engine holds one instance, so its fixtures run one at a time.
|
|
222
|
+
results = staging
|
|
223
|
+
? await pipelineFixtures(loaded, (item) => runFixture(item.name, item.fixture, rulesResolution.rules, options))
|
|
224
|
+
: await Promise.all(loaded.map((item) => runFixture(item.name, item.fixture, rulesResolution.rules, options)));
|
|
189
225
|
} catch (e) {
|
|
190
|
-
const prefix = e instanceof VerifyInputError ? 'invalid input' : 'failed to replay';
|
|
226
|
+
const prefix = e instanceof VerifyInputError ? 'invalid input' : e instanceof StagingSafetyError ? 'staging instance' : 'failed to replay';
|
|
191
227
|
process.stderr.write(`pyric verify: ${prefix}: ${messageOf(e)}\n`);
|
|
192
|
-
|
|
228
|
+
stopStaging?.();
|
|
229
|
+
return e instanceof StagingAbortedError || (e as { name?: string }).name === 'AbortError' ? 130 : 2;
|
|
230
|
+
} finally {
|
|
231
|
+
stopStaging?.();
|
|
193
232
|
}
|
|
194
233
|
const allOk = results.every((result) => result.ok);
|
|
195
234
|
|
|
@@ -259,6 +298,190 @@ function runVerifyCases(parsed: ParsedArgs, cwd: string): number {
|
|
|
259
298
|
return result.ok ? 0 : 1;
|
|
260
299
|
}
|
|
261
300
|
|
|
301
|
+
/** The staging instance from `pyric.json`, refused when the app uses it, with credentials for its project. */
|
|
302
|
+
async function openStagingInstance(
|
|
303
|
+
cwd: string,
|
|
304
|
+
appProjectOption: string | undefined,
|
|
305
|
+
deps: VerifyCliDeps,
|
|
306
|
+
): Promise<{ client: RtdbRestClient; projectId: string; target: StagingTarget; source: string }> {
|
|
307
|
+
const target = stagingTargetFrom(await readPyricConfig(cwd));
|
|
308
|
+
const firebaseJson = await readFirebaseJsonOrNull(cwd);
|
|
309
|
+
const appProject = resolveFirebaseProject({ projectDir: cwd, ...(appProjectOption ? { option: appProjectOption } : {}) });
|
|
310
|
+
assertNotAppInstance(target, {
|
|
311
|
+
firebaseJson,
|
|
312
|
+
...(appProject.projectId ? { appProjectId: appProject.projectId } : {}),
|
|
313
|
+
targetInstances: databaseTargetInstances(cwd, appProject.projectId),
|
|
314
|
+
});
|
|
315
|
+
const credentials = await stagingToken(target.projectId, deps.resolveScope ? { resolve: deps.resolveScope } : {});
|
|
316
|
+
const client = deps.stagingClient?.(target, credentials.token) ?? new RtdbRestClient(target.databaseUrl, credentials.token);
|
|
317
|
+
return { client, projectId: target.projectId, target, source: credentials.source };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Every instance a `.firebaserc` database target maps for the app's project. */
|
|
321
|
+
function databaseTargetInstances(cwd: string, projectId: string | undefined): string[] {
|
|
322
|
+
if (!projectId) return [];
|
|
323
|
+
try {
|
|
324
|
+
const rc = readFirebaseRcSync(cwd) as { targets?: Record<string, { database?: Record<string, string[]> }> } | null;
|
|
325
|
+
return Object.values(rc?.targets?.[projectId]?.database ?? {}).flat();
|
|
326
|
+
} catch {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function pipelineFixtures<T>(items: T[], run: (item: T) => Promise<FixtureResult>): Promise<FixtureResult[]> {
|
|
332
|
+
const out: FixtureResult[] = [];
|
|
333
|
+
for (const item of items) out.push(await run(item));
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
class StagingAbortedError extends Error {}
|
|
338
|
+
|
|
339
|
+
/** Where a staging run keeps the prior rules while it runs, for `pyric verify staging restore`. */
|
|
340
|
+
export const STAGING_RESTORE_RELATIVE_PATH = join('.pyric', 'staging-restore.json');
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* While a staging run is active: keep the prior rules on disk, and turn
|
|
344
|
+
* SIGINT/SIGTERM into an abort between steps so the run still cleans up. A
|
|
345
|
+
* second signal exits at once, naming the recovery command.
|
|
346
|
+
*/
|
|
347
|
+
function stagingRunGuard(cwd: string, target: StagingTarget): { hooks: Pick<NonNullable<VerifyFixtureOptions['staging']>, 'onBeforeChange' | 'onRestored' | 'signal'>; stop: () => void } {
|
|
348
|
+
const controller = new AbortController();
|
|
349
|
+
const restorePath = join(cwd, STAGING_RESTORE_RELATIVE_PATH);
|
|
350
|
+
let signals = 0;
|
|
351
|
+
const onSignal = (signal: NodeJS.Signals) => {
|
|
352
|
+
signals += 1;
|
|
353
|
+
if (signals === 1) {
|
|
354
|
+
process.stderr.write(`\npyric verify: ${signal}: stopping after the current step and restoring ${target.databaseUrl}. Press again to quit without restoring.\n`);
|
|
355
|
+
controller.abort(new StagingAbortedError(`interrupted by ${signal}`));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
process.stderr.write(`pyric verify: quitting without restoring ${target.databaseUrl}. Run \`pyric verify staging restore\` to put it back.\n`);
|
|
359
|
+
process.exit(130);
|
|
360
|
+
};
|
|
361
|
+
process.on('SIGINT', onSignal);
|
|
362
|
+
process.on('SIGTERM', onSignal);
|
|
363
|
+
let stopped = false;
|
|
364
|
+
return {
|
|
365
|
+
hooks: {
|
|
366
|
+
signal: controller.signal,
|
|
367
|
+
onBeforeChange: (priorRules) => {
|
|
368
|
+
mkdirSync(dirname(restorePath), { recursive: true });
|
|
369
|
+
writeFileSync(restorePath, `${JSON.stringify({ databaseUrl: target.databaseUrl, projectId: target.projectId, rules: priorRules, startedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
370
|
+
},
|
|
371
|
+
onRestored: () => rmSync(restorePath, { force: true }),
|
|
372
|
+
},
|
|
373
|
+
stop: () => {
|
|
374
|
+
if (stopped) return;
|
|
375
|
+
stopped = true;
|
|
376
|
+
process.off('SIGINT', onSignal);
|
|
377
|
+
process.off('SIGTERM', onSignal);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `pyric verify staging restore`: put the configured instance back after a run
|
|
384
|
+
* that could not, from the prior rules the run kept in
|
|
385
|
+
* `.pyric/staging-restore.json`; with `--force` and no such file, enroll it
|
|
386
|
+
* afresh with deny-all rules. Its data is deleted either way.
|
|
387
|
+
*/
|
|
388
|
+
async function runVerifyStagingRestore(parsed: ParsedArgs, cwd: string, deps: VerifyCliDeps): Promise<number> {
|
|
389
|
+
let opened: Awaited<ReturnType<typeof openStagingInstance>>;
|
|
390
|
+
try {
|
|
391
|
+
opened = await openStagingInstance(cwd, stringFlag(parsed.flags.get('project')), deps);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
process.stderr.write(`pyric verify staging restore: ${messageOf(e)}\n`);
|
|
394
|
+
return 2;
|
|
395
|
+
}
|
|
396
|
+
const { client, target } = opened;
|
|
397
|
+
const restorePath = join(cwd, STAGING_RESTORE_RELATIVE_PATH);
|
|
398
|
+
let rules: string | undefined;
|
|
399
|
+
if (existsSync(restorePath)) {
|
|
400
|
+
const kept = JSON.parse(readFileSync(restorePath, 'utf8')) as { databaseUrl?: string; rules?: string };
|
|
401
|
+
if (kept.databaseUrl !== target.databaseUrl) {
|
|
402
|
+
process.stderr.write(`pyric verify staging restore: ${STAGING_RESTORE_RELATIVE_PATH} is for ${kept.databaseUrl}, not ${target.databaseUrl}.\n`);
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
405
|
+
rules = kept.rules;
|
|
406
|
+
} else if (parsed.flags.get('force') === true) {
|
|
407
|
+
rules = `${stagingMarker(target.projectId)}\n{\n "rules": {\n ".read": false,\n ".write": false\n }\n}`;
|
|
408
|
+
} else {
|
|
409
|
+
const current = await client.getRulesText().catch(() => '');
|
|
410
|
+
if (markedProject(current) === target.projectId && (await client.get('/', { shallow: true })).body === null) {
|
|
411
|
+
process.stdout.write(`✓ ${target.databaseUrl} is enrolled and empty; nothing to restore.\n`);
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
process.stderr.write(
|
|
415
|
+
`pyric verify staging restore: no ${STAGING_RESTORE_RELATIVE_PATH} holds the rules ${target.databaseUrl} had. ` +
|
|
416
|
+
'Pass --force to deploy deny-all rules with the enrollment marker and delete its data.\n',
|
|
417
|
+
);
|
|
418
|
+
return 2;
|
|
419
|
+
}
|
|
420
|
+
if (typeof rules !== 'string' || markedProject(rules) !== target.projectId) {
|
|
421
|
+
process.stderr.write(`pyric verify staging restore: the kept rules do not carry the enrollment marker for ${target.projectId}; refusing to deploy them.\n`);
|
|
422
|
+
return 2;
|
|
423
|
+
}
|
|
424
|
+
const result = await restoreStagingInstance(client, rules);
|
|
425
|
+
if (!result.rulesRestored || !result.dataRemoved) {
|
|
426
|
+
process.stderr.write(`pyric verify staging restore: ${target.databaseUrl} is not restored (${result.errors.join('; ')}).\n`);
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
rmSync(restorePath, { force: true });
|
|
430
|
+
process.stdout.write(`✓ restored ${target.databaseUrl}: enrolled rules deployed, data deleted.\n`);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** `pyric verify staging init`: enroll the configured instance, once. */
|
|
435
|
+
async function runVerifyStaging(parsed: ParsedArgs, cwd: string, deps: VerifyCliDeps): Promise<number> {
|
|
436
|
+
if (parsed.positional[1] === 'restore') return runVerifyStagingRestore(parsed, cwd, deps);
|
|
437
|
+
if (parsed.positional[1] !== 'init') {
|
|
438
|
+
process.stderr.write('pyric verify staging: use `init` to enroll the instance pyric.json names, or `restore` to put it back after an interrupted run.\n');
|
|
439
|
+
return 2;
|
|
440
|
+
}
|
|
441
|
+
let opened: Awaited<ReturnType<typeof openStagingInstance>>;
|
|
442
|
+
try {
|
|
443
|
+
opened = await openStagingInstance(cwd, stringFlag(parsed.flags.get('project')), deps);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
process.stderr.write(`pyric verify staging init: ${messageOf(e)}\n`);
|
|
446
|
+
return 2;
|
|
447
|
+
}
|
|
448
|
+
const { client, target } = opened;
|
|
449
|
+
try {
|
|
450
|
+
const rules = await client.getRulesText();
|
|
451
|
+
const marked = markedProject(rules);
|
|
452
|
+
if (marked === target.projectId) {
|
|
453
|
+
process.stdout.write(`✓ ${target.databaseUrl} is already enrolled for project ${target.projectId}.\n`);
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
if (marked !== undefined) {
|
|
457
|
+
process.stderr.write(`pyric verify staging init: ${target.databaseUrl} is enrolled for project ${marked}, not ${target.projectId}.\n`);
|
|
458
|
+
return 2;
|
|
459
|
+
}
|
|
460
|
+
const root = await client.get('/', { shallow: true });
|
|
461
|
+
if (root.status !== 200 || root.body !== null) {
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
`pyric verify staging init: refusing to enroll ${target.databaseUrl}: it holds data (or could not be read, HTTP ${root.status}). ` +
|
|
464
|
+
'The staging engine replaces an instance\'s rules and data, so enroll an empty instance created for it.\n',
|
|
465
|
+
);
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
const enrolled = `${stagingMarker(target.projectId)}\n${rules.trimStart()}`;
|
|
469
|
+
const dry = await client.putRules(enrolled, { dryRun: true });
|
|
470
|
+
if (dry.status !== 200) throw new Error(`production refused the marked rules: HTTP ${dry.status} ${dry.text.slice(0, 200)}`);
|
|
471
|
+
const put = await client.putRules(enrolled);
|
|
472
|
+
if (put.status !== 200) throw new Error(`deploying the marked rules failed: HTTP ${put.status} ${put.text.slice(0, 200)}`);
|
|
473
|
+
if (markedProject(await client.getRulesText()) !== target.projectId) throw new Error('the marker did not read back.');
|
|
474
|
+
} catch (e) {
|
|
475
|
+
process.stderr.write(`pyric verify staging init: ${messageOf(e)}\n`);
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
process.stdout.write(
|
|
479
|
+
`✓ enrolled ${target.databaseUrl} (project ${target.projectId}, credentials: ${opened.source}).\n` +
|
|
480
|
+
' Run `pyric verify --engine staging` to replay a captured session against it; add `--engine sandbox` to compare engines.\n',
|
|
481
|
+
);
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
262
485
|
function loadFixturesFromDirectory(dir: string): Array<{ name: string; fixture: Fixture }> {
|
|
263
486
|
return readdirSync(dir)
|
|
264
487
|
.filter((f) => f.endsWith('.json'))
|
|
@@ -296,8 +519,10 @@ function parseEngines(flag: FlagValue | undefined): VerifyEngine[] {
|
|
|
296
519
|
pushUnique(out, 'sandbox');
|
|
297
520
|
} else if (item === 'rules-test-api' || item === 'rulesTestApi') {
|
|
298
521
|
pushUnique(out, 'rulesTestApi');
|
|
522
|
+
} else if (item === 'staging') {
|
|
523
|
+
pushUnique(out, 'staging');
|
|
299
524
|
} else {
|
|
300
|
-
throw new Error(`unsupported verify engine '${item}'. Supported engines: sandbox, rules-test-api, both.`);
|
|
525
|
+
throw new Error(`unsupported verify engine '${item}'. Supported engines: sandbox, rules-test-api, staging, both.`);
|
|
301
526
|
}
|
|
302
527
|
}
|
|
303
528
|
return out;
|
|
@@ -447,7 +672,8 @@ function formatDivergence(divergence: VerifyDivergence): string {
|
|
|
447
672
|
}
|
|
448
673
|
if (divergence.kind === 'engine-drift') {
|
|
449
674
|
const method = divergence.method ? `${divergence.method} ` : '';
|
|
450
|
-
|
|
675
|
+
const other = divergence.staging !== undefined ? `staging=${divergence.staging}` : `rulesTestApi=${divergence.rulesTestApi}`;
|
|
676
|
+
return `engine-drift: ${method}${divergence.path ?? ''}: sandbox=${divergence.sandbox}, ${other}`;
|
|
451
677
|
}
|
|
452
678
|
return `${divergence.drift}: ${divergence.path ?? ''}`;
|
|
453
679
|
}
|