@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.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts +5 -0
  3. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  4. package/dist/assurance/.generated/conformance-verdicts.js +5 -0
  5. package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
  6. package/dist/assurance/capabilities.d.ts.map +1 -1
  7. package/dist/assurance/capabilities.js +24 -5
  8. package/dist/assurance/capabilities.js.map +1 -1
  9. package/dist/assurance/runner.d.ts.map +1 -1
  10. package/dist/assurance/runner.js +37 -50
  11. package/dist/assurance/runner.js.map +1 -1
  12. package/dist/assurance/types.d.ts +5 -0
  13. package/dist/assurance/types.d.ts.map +1 -1
  14. package/dist/assurance/types.js.map +1 -1
  15. package/dist/assurance/validation.d.ts +2 -16
  16. package/dist/assurance/validation.d.ts.map +1 -1
  17. package/dist/assurance/validation.js +121 -1
  18. package/dist/assurance/validation.js.map +1 -1
  19. package/dist/bridge/surface/arguments/assurance.d.ts +25 -0
  20. package/dist/bridge/surface/arguments/assurance.d.ts.map +1 -1
  21. package/dist/bridge/surface/arguments/assurance.js +1 -0
  22. package/dist/bridge/surface/arguments/assurance.js.map +1 -1
  23. package/dist/bridge/surface/methods/assurance/define.d.ts +2 -2
  24. package/dist/bridge/surface/methods/assurance/map.d.ts +23 -0
  25. package/dist/bridge/surface/methods/assurance/map.d.ts.map +1 -1
  26. package/dist/bridge/surface/methods/assurance/propose.d.ts +7 -0
  27. package/dist/bridge/surface/methods/assurance/propose.d.ts.map +1 -1
  28. package/dist/bridge/surface/render/discriminator-schemas.d.ts +2 -2
  29. package/dist/cli/database-rules.js +1 -1
  30. package/dist/cli/database-rules.js.map +1 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +13 -2
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/pyric-config.d.ts +8 -0
  35. package/dist/cli/pyric-config.d.ts.map +1 -1
  36. package/dist/cli/pyric-config.js.map +1 -1
  37. package/dist/cli/serve.d.ts.map +1 -1
  38. package/dist/cli/serve.js +5 -3
  39. package/dist/cli/serve.js.map +1 -1
  40. package/dist/cli/verify.d.ts +6 -0
  41. package/dist/cli/verify.d.ts.map +1 -1
  42. package/dist/cli/verify.js +240 -15
  43. package/dist/cli/verify.js.map +1 -1
  44. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  45. package/dist/conformance/.generated/can-i-use.js +1 -1
  46. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  47. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  48. package/dist/conformance/.generated/conformance-docs.js +1 -1
  49. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  50. package/dist/rtdb/inspect-deployed-rules.d.ts +3 -17
  51. package/dist/rtdb/inspect-deployed-rules.d.ts.map +1 -1
  52. package/dist/rtdb/inspect-deployed-rules.js +4 -54
  53. package/dist/rtdb/inspect-deployed-rules.js.map +1 -1
  54. package/dist/serve/repro/denials.d.ts.map +1 -1
  55. package/dist/serve/repro/denials.js +2 -1
  56. package/dist/serve/repro/denials.js.map +1 -1
  57. package/dist/serve/site-ui/_astro/{app.BpEphDR7.js → app.CiMHEvEW.js} +15 -15
  58. package/dist/serve/site-ui/_astro/{app.DFdWhpVY.js → app.DpzsBfos.js} +1 -1
  59. package/dist/serve/site-ui/_astro/{seed.vvXV1ioI.js → seed.BpXSRzLo.js} +1 -1
  60. package/dist/serve/site-ui/auth/index.html +1 -1
  61. package/dist/serve/site-ui/docs/database-compat/index.html +4 -0
  62. package/dist/serve/site-ui/docs/database-compat.md +4 -0
  63. package/dist/serve/site-ui/docs/get-started/start-building/index.html +1 -0
  64. package/dist/serve/site-ui/docs/get-started/start-building.md +2 -0
  65. package/dist/serve/site-ui/docs/index.json +35 -0
  66. package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api/index.html +23 -4
  67. package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api.md +11 -4
  68. package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api/index.html +15 -2
  69. package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api.md +11 -10
  70. package/dist/serve/site-ui/docs/pyric-rules-reference-api/index.html +372 -201
  71. package/dist/serve/site-ui/docs/pyric-rules-reference-api.md +148 -9
  72. package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript/index.html +56 -1
  73. package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript.md +33 -0
  74. package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  75. package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite.md +1 -1
  76. package/dist/serve/site-ui/docs/ship/ship-to-production/index.html +4 -1
  77. package/dist/serve/site-ui/docs/ship/ship-to-production.md +7 -1
  78. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  79. package/dist/serve/site-ui/firestore/index.html +1 -1
  80. package/dist/serve/site-ui/rtdb/index.html +1 -1
  81. package/dist/serve/site-ui/settings/index.html +1 -1
  82. package/dist/serve/site-ui/storage/index.html +1 -1
  83. package/dist/serve/site-ui/studio/index.html +1 -1
  84. package/dist/serve/site-ui/traffic/index.html +1 -1
  85. package/dist/serve/verify-hint.d.ts +2 -0
  86. package/dist/serve/verify-hint.d.ts.map +1 -0
  87. package/dist/serve/verify-hint.js +10 -0
  88. package/dist/serve/verify-hint.js.map +1 -0
  89. package/dist/serve/vite-generation-rules-watch.d.ts.map +1 -1
  90. package/dist/serve/vite-generation-rules-watch.js +5 -3
  91. package/dist/serve/vite-generation-rules-watch.js.map +1 -1
  92. package/dist/verify/index.d.ts +16 -2
  93. package/dist/verify/index.d.ts.map +1 -1
  94. package/dist/verify/index.js +100 -18
  95. package/dist/verify/index.js.map +1 -1
  96. package/dist/verify/staging/engine.d.ts +102 -0
  97. package/dist/verify/staging/engine.d.ts.map +1 -0
  98. package/dist/verify/staging/engine.js +371 -0
  99. package/dist/verify/staging/engine.js.map +1 -0
  100. package/dist/verify/staging/rest.d.ts +68 -0
  101. package/dist/verify/staging/rest.d.ts.map +1 -0
  102. package/dist/verify/staging/rest.js +93 -0
  103. package/dist/verify/staging/rest.js.map +1 -0
  104. package/dist/verify/staging/target.d.ts +39 -0
  105. package/dist/verify/staging/target.d.ts.map +1 -0
  106. package/dist/verify/staging/target.js +117 -0
  107. package/dist/verify/staging/target.js.map +1 -0
  108. package/package.json +4 -4
  109. package/src/assurance/.generated/conformance-verdicts.ts +5 -0
  110. package/src/assurance/capabilities.ts +22 -4
  111. package/src/assurance/runner.ts +40 -58
  112. package/src/assurance/types.ts +3 -0
  113. package/src/assurance/validation.ts +137 -0
  114. package/src/bridge/surface/arguments/assurance.ts +1 -0
  115. package/src/cli/database-rules.ts +1 -1
  116. package/src/cli/index.ts +13 -2
  117. package/src/cli/pyric-config.ts +5 -0
  118. package/src/cli/serve.ts +6 -3
  119. package/src/cli/verify.ts +244 -18
  120. package/src/conformance/.generated/can-i-use.ts +1 -1
  121. package/src/conformance/.generated/conformance-docs.ts +1 -1
  122. package/src/rtdb/inspect-deployed-rules.ts +5 -77
  123. package/src/serve/repro/denials.ts +2 -1
  124. package/src/serve/verify-hint.ts +8 -0
  125. package/src/serve/vite-generation-rules-watch.ts +6 -3
  126. package/src/verify/index.ts +119 -19
  127. package/src/verify/staging/engine.ts +455 -0
  128. package/src/verify/staging/rest.ts +129 -0
  129. 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.
@@ -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
- for (const divergence of failingDivergences(service.divergences)) {
92
- const where = divergence.instance === undefined ? service.service : `${service.service} ${divergence.instance}`;
93
- lines.push(` [${where}] ${formatDivergence(divergence)}`);
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
- results = await Promise.all(
182
- loaded.map((item) =>
183
- runFixture(item.name, item.fixture, rulesResolution.rules, {
184
- services,
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
- return 2;
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
- return `engine-drift: ${method}${divergence.path ?? ''}: sandbox=${divergence.sandbox}, rulesTestApi=${divergence.rulesTestApi}`;
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
  }