@crvouga/mockingbird-service-google-ads 1.2.0 → 1.4.0

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/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog — @crvouga/mockingbird-service-google-ads
2
2
 
3
- ## 1.2.0 (2026-10-11)
3
+ ## 1.4.0 (2026-10-15)
4
+
5
+ ### Features
6
+
7
+ - unify the emulator control plane ([2068547](https://github.com/crvouga/mockingbird/commit/2068547bedde4e2e2331c1657c1a78120e84428b))
8
+
9
+ ## 1.3.0 (2026-10-10)
10
+
11
+ ### Features
12
+
13
+ - resolve reported emulator coverage gaps ([6edc643](https://github.com/crvouga/mockingbird/commit/6edc6438444853a51aca27849c4c8c0449109ea0))
14
+
15
+ ## 1.2.0 (2026-10-08)
4
16
 
5
17
  ### Features
6
18
 
package/DISCOVERY.md CHANGED
@@ -35,6 +35,7 @@ repository; live parity is an additional oracle check, not a substitute for the
35
35
 
36
36
  - `GET /__admin/health`
37
37
  - `GET /__admin`
38
+ - `GET /__admin/controls`
38
39
  - `GET /__admin/state`
39
40
  - `GET /__admin/requests`
40
41
  - `GET /__admin/metrics`
@@ -45,6 +46,28 @@ For HTTP services, use `x-mockingbird-namespace` (or the documented credential/p
45
46
  parallel tests do not share state. Admin state, journal, metrics and fault-preset endpoints are
46
47
  designed for assertions and diagnosis by consuming test suites.
47
48
 
49
+ ## Admin controls
50
+
51
+ Every HTTP emulator takes the same controls, and each means the same thing at boot
52
+ (`createRuntime({ adminControls })`, `serve --admin-controls`), over `/__admin`, and in a request header:
53
+
54
+ | Control | Admin API | Request header | Interface |
55
+ | --- | --- | --- | --- |
56
+ | Isolate state by key | `GET /__admin/namespaces` | `x-mockingbird-namespace: <key>` | [Namespaces](https://mockingbird.chrisvouga.dev/docs/admin-controls/namespaces) |
57
+ | Reset to boot | `POST /__admin/reset` | `x-mockingbird-reset: true` | [Reset](https://mockingbird.chrisvouga.dev/docs/admin-controls/reset) |
58
+ | Snapshot and restore | `POST /__admin/snapshots`, `POST /__admin/snapshots/<id>/restore` | `x-mockingbird-snapshot: <name>`, `x-mockingbird-restore: <name>` | [Snapshot and restore](https://mockingbird.chrisvouga.dev/docs/admin-controls/snapshot-and-restore) |
59
+ | Move the clock | `POST /__admin/clock` | `x-mockingbird-time: <instant>`, `x-mockingbird-advance: <duration>` | [Clock](https://mockingbird.chrisvouga.dev/docs/admin-controls/clock) |
60
+ | Read or branch from old state | `GET /__admin/timeline` | `x-mockingbird-at: <checkpoint>`, `x-mockingbird-branch: <name>` | [Checkpoints and branches](https://mockingbird.chrisvouga.dev/docs/admin-controls/checkpoints-and-branches) |
61
+ | Inject a fault | `POST /__admin/faults` | `x-mockingbird-fault: <fault>` | [Faults](https://mockingbird.chrisvouga.dev/docs/admin-controls/faults) |
62
+ | Latency and timeouts | `PUT /__admin/latency` | `x-mockingbird-delay: <duration>`, `x-mockingbird-timeout: <duration>` | [Latency and timeouts](https://mockingbird.chrisvouga.dev/docs/admin-controls/latency-and-timeouts) |
63
+ | Replay from a seed | `PUT /__admin/controls` | `x-mockingbird-seed: <seed>` | [Seed and determinism](https://mockingbird.chrisvouga.dev/docs/admin-controls/seed-and-determinism) |
64
+ | See what happened | `GET /__admin/requests`, `GET /__admin/state` | | [Journal, metrics and state](https://mockingbird.chrisvouga.dev/docs/admin-controls/journal-and-state) |
65
+
66
+ `GET /__admin` lists every route and header with a one-line summary, and `GET /__admin/controls`
67
+ is the namespace's current control document. The same seed and the same requests give the same
68
+ bytes. Setting controls as an emulator starts is [Boot](https://mockingbird.chrisvouga.dev/docs/admin-controls/boot); every route and header is in
69
+ the [Reference](https://mockingbird.chrisvouga.dev/docs/admin-controls/reference). The full guide: <https://mockingbird.chrisvouga.dev/docs/admin-controls>.
70
+
48
71
  ## Report a mismatch or missing capability
49
72
 
50
73
  Follow the [agent reporting contract](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md). Include package version,
package/README.md CHANGED
@@ -15,27 +15,24 @@ bun add @crvouga/mockingbird-service-google-ads
15
15
  ## Usage
16
16
 
17
17
  ```ts
18
- import {
19
- createRuntime,
20
- DEFAULT_CUSTOMER,
21
- DEFAULT_TOKEN,
22
- } from "@crvouga/mockingbird-service-google-ads"
23
-
24
- const emulator = createRuntime()
25
- const response = await emulator.fetch(
26
- new Request(`http://emulator.local/v25/customers/${DEFAULT_CUSTOMER}/googleAds:search`, {
27
- method: "POST",
28
- headers: {
29
- authorization: `Bearer ${DEFAULT_TOKEN}`,
30
- "content-type": "application/json",
31
- },
32
- body: JSON.stringify({
33
- query: "SELECT campaign.id, campaign.name, metrics.clicks FROM campaign",
34
- }),
35
- }),
36
- )
37
-
38
- console.log(await response.json())
18
+ import { OAuth2Client } from "google-auth-library"
19
+ import { DEFAULT_CUSTOMER, DEFAULT_TOKEN } from "@crvouga/mockingbird-service-google-ads"
20
+ import { createServer } from "@crvouga/mockingbird-service-google-ads/server"
21
+
22
+ const emulator = await createServer()
23
+
24
+ // Google publishes no Node client for the Ads API, so the application calls REST through
25
+ // google-auth-library. Only the base URL and the fixture token change.
26
+ const client = new OAuth2Client()
27
+ client.setCredentials({ access_token: DEFAULT_TOKEN })
28
+ const response = await client.request({
29
+ url: `${emulator.url}/v25/customers/${DEFAULT_CUSTOMER}/googleAds:search`,
30
+ method: "POST",
31
+ data: { query: "SELECT campaign.id, campaign.name, metrics.clicks FROM campaign" },
32
+ })
33
+ console.log(response.data)
34
+
35
+ await emulator.close()
39
36
  ```
40
37
 
41
38
  The Ads and Analytics Data routes use OAuth Bearer credentials. Measurement Protocol routes use
@@ -51,7 +48,7 @@ faults, and resets are isolated by namespace.
51
48
 
52
49
  | Operation | Route | Behavior |
53
50
  | --- | --- | --- |
54
- | SearchGoogleAds | `POST /v25/customers/{customerId}/googleAds:search` | GAQL selection, filtering, ordering, aggregation, summary rows, stable 10,000-row pages and validate-only requests |
51
+ | SearchGoogleAds | `POST /v25/customers/{customerId}/googleAds:search` | GAQL selection, filtering, ordering, aggregation, summary rows, fixed 10,000-row pages, two-hour page tokens bound to the query, and validate-only requests |
55
52
  | SearchStreamGoogleAds | `POST /v25/customers/{customerId}/googleAds:searchStream` | Deterministic JSON batches and optional summary rows |
56
53
  | MutateCampaignBudgets | `POST /v25/customers/{customerId}/campaignBudgets:mutate` | Create, update, remove, update masks, validate-only and atomic or partial failure |
57
54
  | UploadClickConversions | `POST /v25/customers/{customerId}:uploadClickConversions` | Click identifiers, conversion actions, duplicate detection, job IDs and partial failure |
@@ -63,6 +60,50 @@ faults, and resets are isolated by namespace.
63
60
  The exact request and response schemas are in [`openapi.yaml`](openapi.yaml), and the generated
64
61
  support matrix is in [`SUPPORT.md`](SUPPORT.md).
65
62
 
63
+ Campaign budgets are read back with GAQL (`FROM campaign_budget`); uploaded conversions and
64
+ collected events are read through the inspection routes below, and events also through the
65
+ Analytics Data reports. REST rows use protobuf JSON names (`costMicros`, `conversionsValue`,
66
+ `conversionActionCategory`) and int64 values are strings; GAQL field names stay snake_case.
67
+
68
+ Google Ads failures are a `google.rpc.Status` whose `details` hold a `GoogleAdsFailure` with the
69
+ per-operation `errorCode`, `message`, `location` and a `requestId` equal to the `request-id`
70
+ response header. Unparsable JSON and every Analytics Data failure are a bare status with no Ads
71
+ detail and no `request-id` header.
72
+
73
+ ## Where Google differs from a common assumption
74
+
75
+ These follow Google's documentation and the pinned `googleapis` v25 protos rather than what a
76
+ client written against an older contract may expect.
77
+
78
+ - **Search page size is fixed.** `Search` returns pages of up to 10,000 rows and a request that
79
+ sets `pageSize` is rejected with `requestError: PAGE_SIZE_NOT_SUPPORTED`. Continue by re-sending
80
+ the identical query with `pageToken`; a changed query is `INVALID_PAGE_TOKEN` and a token unused
81
+ for two hours is `EXPIRED_PAGE_TOKEN`
82
+ ([paging guide](https://developers.google.com/google-ads/api/docs/reporting/paging),
83
+ [`SearchGoogleAdsRequest`](https://github.com/googleapis/googleapis/blob/03a91044136a014466d4293eb1fe91f2b02075d2/google/ads/googleads/v25/services/google_ads_service.proto)).
84
+ To page a small fixture, lower the emulator's page with the `searchPageSize` setting; the
85
+ request still cannot choose it.
86
+ - **`/mp/collect` never reports validation errors over HTTP.** It answers an empty `204` for a
87
+ bad secret, an unparsable body or a rejected event alike. Inspect a rejection with
88
+ `POST /debug/mp/collect`, which returns `validationMessages` and stores nothing, or with
89
+ `GET /__admin/request-metadata`, which records `accepted`, `rejected`, `duplicate` and `reasons`
90
+ per request
91
+ ([validation guide](https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events)).
92
+ - **Timestamps older than 72 hours are overridden, not rejected,** unless the request sets
93
+ `validation_behavior: "ENFORCE_RECOMMENDATIONS"`
94
+ ([sending guide](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag)).
95
+ Google documents no bound for future timestamps, so the one-minute `futureToleranceMs` default
96
+ is a test policy of this emulator, not a vendor rule; set it to `null` to disable it.
97
+ - **A validate-only budget mutation returns errors only.** A valid preflight answers `{}` with no
98
+ `results` and writes nothing
99
+ ([`MutateCampaignBudgetsRequest`](https://github.com/googleapis/googleapis/blob/03a91044136a014466d4293eb1fe91f2b02075d2/google/ads/googleads/v25/services/campaign_budget_service.proto)).
100
+ - **Mutations take no idempotency key.** The duplicate guards are Google's own: a shared budget
101
+ name (`campaignBudgetError: DUPLICATE_NAME`), a conversion `orderId`
102
+ (`ORDER_ID_ALREADY_IN_USE`), a click and conversion time (`CLICK_CONVERSION_ALREADY_EXISTS`),
103
+ and a web `purchase` event's `transaction_id` per user.
104
+ - **`developer-token` is accepted and ignored.** The pinned protos mark the developer-token
105
+ errors as sunset; `login-customer-id` is still checked against the customer's manager.
106
+
66
107
  ## Controls and failures
67
108
 
68
109
  Service-specific controls are protected by the shared `x-mockingbird-admin-key` guard and live
@@ -70,21 +111,41 @@ beside the shared health, state, clock, journal, fault, reset, and Timeline endp
70
111
 
71
112
  | Control | Purpose |
72
113
  | --- | --- |
73
- | `GET /__admin/events` | Inspect stored Analytics events |
74
- | `GET /__admin/conversions` | Inspect uploaded conversions |
114
+ | `GET /__admin/events` | Inspect stored Analytics events, oldest first |
115
+ | `GET /__admin/conversions` | Inspect uploaded conversions, oldest first |
75
116
  | `GET /__admin/budgets` | Inspect campaign budgets |
76
- | `GET /__admin/budget-mutations` | Inspect the mutation audit trail |
117
+ | `GET /__admin/budget-mutations` | Inspect the mutation audit trail: request ID, `before` and `after` per write |
77
118
  | `GET /__admin/request-metadata` | Inspect redacted request metadata |
78
- | `POST /__admin/daily-metrics` | Seed synthetic daily Ads metrics |
119
+ | `POST /__admin/daily-metrics` | Seed synthetic daily Ads metrics (`availableAt` delays one row) |
79
120
  | `POST /__admin/page-tokens/expire` | Expire all current GAQL page tokens |
80
- | `GET /__admin/settings` | Read reporting lag, page-token TTL and future-timestamp policy |
121
+ | `GET /__admin/settings` | Read `reportingLagMs`, `pageTokenTtlMs`, `futureToleranceMs` and `searchPageSize` |
81
122
  | `PUT /__admin/settings` | Update those deterministic test settings |
82
123
 
83
- Fault presets include `quota_exhausted`, `server_error`, `network_reset`, `slow_response`,
84
- `ambiguous_budget_write`, `partial_budget_failure`, and `partial_conversion_failure`. The
85
- ambiguous-write preset commits the mutation before returning 503 so retry logic can be tested
86
- against an uncertain outcome. Sensitive event and conversion payloads are sealed in state;
87
- journals and inspection metadata contain only the fields needed for assertions.
124
+ | To | Use |
125
+ | --- | --- |
126
+ | Advance the reporting date | `POST /__admin/clock {"advance": "1d"}` or `{"set": "<ISO-8601>"}`; GAQL `DURING` ranges and Analytics `today`/`yesterday`/`NdaysAgo` follow it |
127
+ | Delay reporting | `PUT /__admin/settings {"reportingLagMs": n}` hides new events until the clock passes it |
128
+ | Page a small fixture | `PUT /__admin/settings {"searchPageSize": n}` (1 to 10,000) |
129
+ | Add latency | the `slow_response` preset, or `POST /__admin/faults {"latencyMs": n, "count": 1}` |
130
+ | Fail one request | the `server_error` or `network_reset` preset |
131
+ | Throttle | the `quota_exhausted` or `rate_limited` preset; `"count": n` throttles n requests |
132
+ | See what arrived | `GET /__admin/requests` (the journal) and `GET /__admin/request-metadata` |
133
+
134
+ Fault presets are `quota_exhausted`, `rate_limited`, `server_error`, `network_reset`,
135
+ `slow_response`, `ambiguous_budget_write`, `partial_budget_failure`, and
136
+ `partial_conversion_failure`. Each fires once unless `count` says otherwise, and is scoped to the
137
+ calling namespace. The two throttling presets answer `429 RESOURCE_EXHAUSTED` with
138
+ `quotaError: RESOURCE_EXHAUSTED` or `RESOURCE_TEMPORARILY_EXHAUSTED` and the backoff in
139
+ `details.quotaErrorDetails.retryDelay`, before anything is written.
140
+
141
+ `ambiguous_budget_write` commits the next executed budget mutation and then returns `503`, so
142
+ retry logic can be tested against an uncertain outcome. A validate-only preflight executes
143
+ nothing and does not spend it: the fault waits for the real call that follows. Reconcile before
144
+ replaying: the `503` carries a `request-id` header, and `GET /__admin/budget-mutations` lists the
145
+ write under that request ID with the budget's `before` and `after`.
146
+
147
+ Sensitive event and conversion payloads are sealed in state; journals and inspection metadata
148
+ contain only the fields needed for assertions.
88
149
 
89
150
  ## API
90
151
 
@@ -100,11 +161,19 @@ and the public fixture/runtime option types. The Node `./server` entry exports `
100
161
  Acceptance tests cover GAQL precision and paging, budget preflight and partial writes, conversion
101
162
  deduplication, GA4 event validation and reporting, OAuth scope/expiry behavior, encrypted state,
102
163
  namespaces, resets, fault recovery, and the unmodified `google-auth-library` 11.1.0 HTTP signing
103
- path. Property tests exercise every parity-enabled operation and prove that a divergent transport
104
- is detected deterministically.
105
-
106
- The emulator intentionally implements a bounded subset. It does not model the complete Google Ads
107
- resource graph, every GAQL function, every Analytics dimension or metric, real Google identity,
108
- production quota allocation, billing, dashboards, attribution processing, or undocumented
109
- backend behavior. Normal operation is local and does not contact Google; only the explicit parity
110
- command uses credentials supplied through `.env.local` or GitHub Actions secrets.
164
+ path. Runtime tests drive every control above over `/__admin`. Property tests exercise every
165
+ parity-enabled operation and prove that a divergent transport is detected deterministically.
166
+
167
+ `test/consumer.ts` is a port of the raw-fetch traffic described in the original service request.
168
+ It is not the requesting application's client, whose source is not in this repository, so no test
169
+ here runs that client unmodified.
170
+
171
+ ## Deliberately not modelled
172
+
173
+ The emulator implements a bounded subset. It does not model the complete Google Ads resource
174
+ graph, every GAQL function, GAQL field-compatibility rules between segments and metrics, every
175
+ Analytics dimension or metric, real Google identity, production quota allocation, billing,
176
+ dashboards, attribution processing, or undocumented backend behavior. Uploaded conversions do not
177
+ feed `metrics.conversions`; seed those with daily metrics. Normal operation is local and does not
178
+ contact Google; only the explicit parity command uses credentials supplied through `.env.local`
179
+ or GitHub Actions secrets.
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  createClock,
3
3
  createRuntime,
4
+ normalizeBootControls,
4
5
  parseDuration,
5
- resolveAdminPrefix
6
- } from "./chunk-DDJPMCRI.js";
6
+ parseFaultHeader,
7
+ resolveAdminPrefix,
8
+ restoreClock
9
+ } from "./chunk-SNVGFGLM.js";
7
10
 
8
11
  // ../../adapters/node/dist/cli.js
9
12
  import { readFile as readFile2 } from "node:fs/promises";
@@ -50,6 +53,8 @@ var serve = async (api, options = {}) => {
50
53
  req.socket.destroy();
51
54
  return;
52
55
  }
56
+ if (aborted.signal.aborted)
57
+ return;
53
58
  res.writeHead(500, { "content-type": "application/json" });
54
59
  res.end(JSON.stringify({
55
60
  error: {
@@ -81,6 +86,7 @@ var serve = async (api, options = {}) => {
81
86
  }
82
87
  res.end();
83
88
  } catch {
89
+ await new Promise((resolve2) => setImmediate(resolve2));
84
90
  res.destroy();
85
91
  } finally {
86
92
  reader.releaseLock();
@@ -164,11 +170,11 @@ var httpChild = async (target, entry, options) => {
164
170
  adminKey: entry.adminKey,
165
171
  seed: entry.seed,
166
172
  onLog: options.onLog,
173
+ ...entry.adminControls !== void 0 ? { adminControls: entry.adminControls } : {},
167
174
  ...entry.fixtures !== void 0 ? { fixtures: entry.fixtures } : {},
168
175
  ...entry.baseUrl !== void 0 ? { baseUrl: entry.baseUrl } : {}
169
176
  });
170
177
  const adminPrefix = resolveAdminPrefix(entry.adminPrefix);
171
- runtime.isolateNamespaces();
172
178
  const locked = /* @__PURE__ */ new Set();
173
179
  const active = /* @__PURE__ */ new Map();
174
180
  const api = {
@@ -430,19 +436,12 @@ async function startFleet(config, options) {
430
436
  return json(400, { error: "freeze must be boolean" });
431
437
  const response2 = await transaction(namespace, selected, async (_name, child) => {
432
438
  const childClock = child.clock(namespace);
433
- childClock.freeze();
434
- childClock.set(target);
435
- if (!frozen)
436
- childClock.unfreeze();
439
+ restoreClock(childClock, { now: target, frozen });
437
440
  await child.settle?.(namespace);
438
441
  return childClock.state();
439
442
  });
440
- if (response2.ok) {
441
- current.freeze();
442
- current.set(target);
443
- if (!frozen)
444
- current.unfreeze();
445
- }
443
+ if (response2.ok)
444
+ restoreClock(current, { now: target, frozen });
446
445
  return response2;
447
446
  }
448
447
  if (!route)
@@ -461,13 +460,8 @@ async function startFleet(config, options) {
461
460
  return json(404, { error: "no snapshot in this namespace" });
462
461
  const restored = new Map([...point.values].map(([name]) => [name, children.get(name)]));
463
462
  const response2 = await transaction(namespace, restored, async (name, child) => child.restore(namespace, point.values.get(name)));
464
- if (response2.ok) {
465
- const current = clock(namespace);
466
- current.freeze();
467
- current.set(point.clock.now);
468
- if (!point.clock.frozen)
469
- current.unfreeze();
470
- }
463
+ if (response2.ok)
464
+ restoreClock(clock(namespace), point.clock);
471
465
  return response2;
472
466
  }
473
467
  const snapshotId = randomUUID();
@@ -493,6 +487,8 @@ async function startFleet(config, options) {
493
487
  for (const [name, entry] of Object.entries(config.services)) {
494
488
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
495
489
  throw new Error("invalid service name");
490
+ if (config.adminControls !== void 0 || entry.adminControls !== void 0)
491
+ normalizeBootControls({ ...config.adminControls, ...entry.adminControls }, `services.${name}.adminControls`);
496
492
  try {
497
493
  const target = await options.load(name);
498
494
  if (entry.protocol && entry.protocol !== ("protocol" in target ? target.protocol : "http"))
@@ -501,7 +497,9 @@ async function startFleet(config, options) {
501
497
  const child = "start" in target ? await target.start(entry) : await httpChild(target, {
502
498
  ...entry,
503
499
  adminPrefix: resolveAdminPrefix(entry.adminPrefix ?? adminPrefix),
504
- ...adminKey !== void 0 ? { adminKey } : {}
500
+ ...adminKey !== void 0 ? { adminKey } : {},
501
+ // Fleet-wide controls, with the service's own keys replacing them.
502
+ ...config.adminControls !== void 0 || entry.adminControls !== void 0 ? { adminControls: { ...config.adminControls, ...entry.adminControls } } : {}
505
503
  }, options);
506
504
  children.set(name, child);
507
505
  if (!await child.ready())
@@ -634,7 +632,51 @@ var COMMON_SERVE_OPTIONS = {
634
632
  value: "<key>",
635
633
  description: "Require x-mockingbird-admin-key on /__admin/* (env MOCKINGBIRD_ADMIN_KEY)"
636
634
  },
637
- seed: { type: "string", value: "<seed>", description: "Seed for every random choice" },
635
+ seed: {
636
+ type: "string",
637
+ value: "<seed>",
638
+ description: "Seed for every random choice (env MOCKINGBIRD_SEED)"
639
+ },
640
+ "admin-controls": {
641
+ type: "string",
642
+ value: "<json|file>",
643
+ description: "Admin controls to boot with, as JSON or a JSON file: clock, latency, faults, snapshot, namespaces (env MOCKINGBIRD_ADMIN_CONTROLS)"
644
+ },
645
+ clock: {
646
+ type: "string",
647
+ value: "<instant>",
648
+ description: "Start the clock at this instant (epoch ms or ISO-8601)"
649
+ },
650
+ freeze: { type: "boolean", description: "Freeze the clock: it moves only when told to" },
651
+ "auto-advance": {
652
+ type: "string",
653
+ value: "<duration>",
654
+ description: "Step the clock this much before every request (e.g. 1s)"
655
+ },
656
+ latency: {
657
+ type: "string",
658
+ value: "<duration>",
659
+ description: "Baseline response time for every request (e.g. 250ms)"
660
+ },
661
+ jitter: {
662
+ type: "string",
663
+ value: "<duration>",
664
+ description: "Seeded extra latency between 0 and this"
665
+ },
666
+ "virtual-time": {
667
+ type: "boolean",
668
+ description: "Spend latency, delays and timeouts on the mock clock instead of really waiting"
669
+ },
670
+ fault: {
671
+ type: "string",
672
+ value: "<fault>",
673
+ description: 'A fault to boot with, as in x-mockingbird-fault: 503, rate_limited, "status=503; delay=2s"'
674
+ },
675
+ snapshot: {
676
+ type: "string",
677
+ value: "<file>",
678
+ description: "Start from a snapshot document (GET /__admin/snapshots/:id)"
679
+ },
638
680
  log: {
639
681
  type: "string",
640
682
  value: "<pretty|json|off>",
@@ -681,6 +723,68 @@ var formatLog = (format) => {
681
723
  };
682
724
  };
683
725
  var asString = (value) => typeof value === "string" ? value : void 0;
726
+ var readJsonFile = async (path, label) => {
727
+ try {
728
+ return JSON.parse(await readFile2(path, "utf8"));
729
+ } catch (error) {
730
+ throw new Error(`${label}: cannot read ${path}: ${error instanceof Error ? error.message : error}`);
731
+ }
732
+ };
733
+ var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
734
+ var resolveServeControls = async (values, env = process.env, base) => {
735
+ const source = asString(values["admin-controls"]) ?? env.MOCKINGBIRD_ADMIN_CONTROLS;
736
+ let document = isObject2(base) ? { ...base } : {};
737
+ if (source !== void 0 && source.trim() !== "") {
738
+ let parsed;
739
+ if (source.trim().startsWith("{")) {
740
+ try {
741
+ parsed = JSON.parse(source);
742
+ } catch {
743
+ throw new Error("--admin-controls: not valid JSON");
744
+ }
745
+ } else
746
+ parsed = await readJsonFile(source, "--admin-controls");
747
+ if (!isObject2(parsed))
748
+ throw new Error("--admin-controls: expected a JSON object");
749
+ document = { ...document, ...parsed };
750
+ }
751
+ const clock = isObject2(document.clock) ? { ...document.clock } : {};
752
+ const now = asString(values.clock);
753
+ if (now !== void 0)
754
+ clock.now = now;
755
+ if (values.freeze === true)
756
+ clock.frozen = true;
757
+ const autoAdvance = asString(values["auto-advance"]);
758
+ if (autoAdvance !== void 0)
759
+ clock.autoAdvance = autoAdvance;
760
+ if (Object.keys(clock).length > 0)
761
+ document.clock = clock;
762
+ const delay = asString(values.latency);
763
+ const jitter = asString(values.jitter);
764
+ if (delay !== void 0 || jitter !== void 0) {
765
+ const latency = isObject2(document.latency) ? { ...document.latency } : document.latency !== void 0 ? { delay: document.latency } : {};
766
+ if (delay !== void 0)
767
+ latency.delay = delay;
768
+ if (jitter !== void 0)
769
+ latency.jitter = jitter;
770
+ document.latency = latency;
771
+ }
772
+ if (values["virtual-time"] === true)
773
+ document.virtualTime = true;
774
+ const fault = asString(values.fault);
775
+ if (fault !== void 0)
776
+ document.faults = [
777
+ ...Array.isArray(document.faults) ? document.faults : [],
778
+ parseFaultHeader(fault, "--fault")
779
+ ];
780
+ const snapshot = asString(values.snapshot);
781
+ if (snapshot !== void 0)
782
+ document.snapshot = await readJsonFile(snapshot, "--snapshot");
783
+ if (Object.keys(document).length === 0)
784
+ return void 0;
785
+ normalizeBootControls(document, "adminControls");
786
+ return document;
787
+ };
684
788
  var loadTarget = async (name, own) => {
685
789
  if (name === own.name)
686
790
  return own;
@@ -707,7 +811,8 @@ var start = async (target, values, config) => {
707
811
  adminPrefix,
708
812
  adminKey: config.adminKey,
709
813
  seed: config.seed,
710
- onLog: formatLog(config.log)
814
+ onLog: formatLog(config.log),
815
+ ...config.adminControls !== void 0 ? { adminControls: config.adminControls } : {}
711
816
  });
712
817
  const listening = await listen(runtime, { port: config.port, host: config.host });
713
818
  target.listening?.(listening);
@@ -747,6 +852,9 @@ var serveCommand = (target) => ({
747
852
  const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
748
853
  if (adminKey2 !== void 0)
749
854
  config.adminKey = adminKey2;
855
+ const adminControls = await resolveServeControls(values, process.env, config.adminControls);
856
+ if (adminControls !== void 0)
857
+ config.adminControls = adminControls;
750
858
  const fleet = await startFleet(config, {
751
859
  load: (name) => loadTarget(name, target),
752
860
  onLog: formatLog(config.log ?? log),
@@ -772,10 +880,12 @@ var serveCommand = (target) => ({
772
880
  }
773
881
  const port = asString(values.port);
774
882
  const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
775
- const seed = asString(values.seed);
883
+ const seed = asString(values.seed) ?? process.env.MOCKINGBIRD_SEED;
776
884
  let listening;
777
885
  try {
886
+ const adminControls = await resolveServeControls(values);
778
887
  listening = await start(target, values, {
888
+ ...adminControls !== void 0 ? { adminControls } : {},
779
889
  adminPrefix: resolveAdminPrefix(asString(values["admin-prefix"]) ?? process.env.MOCKINGBIRD_ADMIN_PREFIX),
780
890
  port: port === void 0 ? target.defaultPort : Number.parseInt(port, 10),
781
891
  host: asString(values.host) ?? "127.0.0.1",
@@ -804,6 +914,7 @@ var serveTarget = {
804
914
  create: (_values, common) => createRuntime({
805
915
  ...common.adminPrefix !== void 0 ? { adminPrefix: common.adminPrefix } : {},
806
916
  ...common.adminKey !== void 0 ? { adminKey: common.adminKey } : {},
917
+ ...common.adminControls !== void 0 ? { adminControls: common.adminControls } : {},
807
918
  ...common.seed !== void 0 ? { seed: common.seed } : {},
808
919
  ...common.onLog ? { onLog: common.onLog } : {}
809
920
  }),
@@ -817,4 +928,4 @@ export {
817
928
  createServer2 as createServer,
818
929
  serveTarget
819
930
  };
820
- //# sourceMappingURL=chunk-KCDQPGFS.js.map
931
+ //# sourceMappingURL=chunk-ITE3L2DF.js.map