@crvouga/mockingbird-service-flex 0.1.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 ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog — @crvouga/mockingbird-service-flex
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ Initial release.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # @crvouga/mockingbird-service-flex
2
+
3
+ Stateful mock of the **Flex** (withflex.com) HSA/FSA payments API for test suites: products
4
+ (answered from a recorded catalog corpus), checkout sessions in `payment`, `off_session` and
5
+ `setup` modes, customers, setup intents, refunds, the **hosted checkout page**, and the
6
+ Svix-signed webhooks Flex posts back. A UI checkout that drove the real
7
+ `checkout.withflex.com` page and then waited on a 5-minute reconciler settles here in
8
+ milliseconds: the page is local, and the signed webhook reaches the app as soon as the card is
9
+ accepted.
10
+
11
+ - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/flex/SUPPORT.md)
12
+ - Flex publishes no machine-readable spec: the contract (`openapi.yaml`) is hand-authored from
13
+ the wire shapes our consumer reads and writes (`B/billing/flex/`), and every field its zod
14
+ schemas require is served.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -D @crvouga/mockingbird-service-flex
20
+ ```
21
+
22
+ ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
23
+ `npx mockingbird-flex serve`, `createServer` from `./server` (Node), or `createRuntime` with any
24
+ Fetch server.
25
+
26
+ ## Usage
27
+
28
+ Point the app at the mock:
29
+
30
+ | Env | Value |
31
+ | --- | --- |
32
+ | `FLEX_API_BASE_URL` | `http://127.0.0.1:8792` (or `…/ns/<namespace>`) |
33
+ | `FLEX_API_KEY` | any `fsk_test_…` key (test mode); `fsk_…` is live mode; other formats get 401 |
34
+ | `FLEX_WEBHOOK_SECRET` | the same value as `--webhook-secret`: `fwhsec_<base64>` or `whsec_<base64>` |
35
+
36
+ ```bash
37
+ npx mockingbird-flex serve --port 8792 \
38
+ --webhook-url http://127.0.0.1:3000/billing/webhooks/flex \
39
+ --webhook-secret "$FLEX_WEBHOOK_SECRET"
40
+ ```
41
+
42
+ ```ts
43
+ import { createRuntime } from "@crvouga/mockingbird-service-flex"
44
+
45
+ const flex = createRuntime({
46
+ webhooks: {
47
+ url: "http://127.0.0.1:3000/billing/webhooks/flex",
48
+ secret: "fwhsec_ZmxleC1tb2NrLXNpZ25pbmcta2V5",
49
+ },
50
+ })
51
+ const post = (path: string, body: unknown) =>
52
+ flex.fetch(
53
+ new Request(`http://flex.test${path}`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", authorization: "Bearer fsk_test_suite" },
56
+ body: JSON.stringify(body),
57
+ }),
58
+ )
59
+
60
+ const { checkout_session } = (await (
61
+ await post("/v1/checkout/sessions", {
62
+ checkout_session: {
63
+ success_url: "https://app.test/done?session_id={CHECKOUT_SESSION_ID}",
64
+ cancel_url: "https://app.test/cart",
65
+ client_reference_id: "attempt-1",
66
+ line_items: [
67
+ { price_data: { product: "fprod_01m0tgysj4ahvf8fas60c2ef2d", unit_amount: 4500 }, quantity: 1 },
68
+ ],
69
+ },
70
+ })
71
+ ).json()) as { checkout_session: { checkout_session_id: string; url: string } }
72
+ // …open checkout_session.url in the browser and pay with 4000 0512 3000 0072, or:
73
+ await post(`/__admin/sessions/${checkout_session.checkout_session_id}/complete`, {})
74
+ ```
75
+
76
+ ### Routes
77
+
78
+ All API bodies are wrapped: `{product: {…}}`, `{checkout_session: {…}}`, `{customer: {…}}`,
79
+ `{setup_intent: {…}}`, `{products: […], has_more}`, `{checkout_sessions: […], has_more}`.
80
+ Errors are `{error: {type, message, param?}}`.
81
+
82
+ | Route | Behaviour |
83
+ | --- | --- |
84
+ | `GET /v1/products?limit=&starting_after=` | Oldest first; `limit` 1–100 (default 10). The corpus comes first, then created products. |
85
+ | `POST /v1/products` | `{product: {name, description?, url?, client_reference_id?, metadata?}}`. New products are active, `hsa_fsa_eligibility: null` until classified (`PUT /__admin/products/:id`). |
86
+ | `GET /v1/products/{id}` | `product_id, name, description, url, client_reference_id, hsa_fsa_eligibility, visit_type, active, test_mode, metadata, created_at`. |
87
+ | `PATCH /v1/products/{id}` | `{product: {active?, name?, description?, url?, metadata?}}`; emits `product.updated`. |
88
+ | `POST /v1/checkout/sessions` | `Idempotency-Key` honoured. `mode` `payment` (≥1 line item), `setup` (a customer and no line items, else 400), `off_session` (customer + a saved `payment_method`, charged before answering: the response is already `complete`, or its expanded payment intent is `requires_payment_method` / `requires_action` per `offSessionOutcome`). Unknown or inactive products, customers and payment methods are 400. `redirect_url` and `url` are the hosted page. |
89
+ | `GET /v1/checkout/sessions/{id}?expand_customer=true&expand_payment_intent=true` | The session; expansions return `customer` / `payment_intent` objects instead of ids. |
90
+ | `GET /v1/checkout/sessions?client_reference_id=&limit=&starting_after=` | Newest first (our ambiguous-create recovery). |
91
+ | `POST /v1/checkout/sessions/{id}/refund` | `Idempotency-Key` honoured. `{checkout_session: {}}` (full) or `{checkout_session: {amount}}`; 400 when unpaid or over-refunded. |
92
+ | `POST /v1/customers` | `Idempotency-Key` honoured. `{customer: {first_name, last_name, email, phone}}` (all required). |
93
+ | `GET /v1/setup_intents/{id}?expand=customer,payment_method` | `setup_intent_id, status, customer, payment_method`. |
94
+
95
+ Auth: `Authorization: Bearer fsk_test_…` or `fsk_…`; a missing key, or any other format
96
+ (`sk_test_…`, `whsec_…`), is 401 `authentication_error`. `test_mode` on created objects follows
97
+ the key. Same key + same body replays the stored response; same key + a different body is 400
98
+ `idempotency_error`; a concurrent request with an in-flight key is 409.
99
+
100
+ ### Hosted page
101
+
102
+ `GET /pay/{sessionId}` renders a plain form (no scripts) with `data-testid`s
103
+ `flex-mock-email`, `-first-name`, `-last-name`, `-phone`, `-card`, `-exp`, `-cvc`, `-zip`,
104
+ `-pay`, `-cancel`, `-error`, `-amount`, and on the letter step `-lmn-submit`. Inputs also carry
105
+ the `name`s and placeholders our codecept locators look for (`cardNumber`, `expiry`, `cvc`,
106
+ `postalCode`, `email`, …). `POST /pay/{sessionId}` submits it:
107
+
108
+ | Card | Result |
109
+ | --- | --- |
110
+ | `4000 0512 3000 0072` | HSA card: succeeds. |
111
+ | `4242 4242 4242 4242` (or any other valid card) | Succeeds; if a line item's product is `letter_of_medical_necessity`, the session gets `next_action: {type: "collect_letter_of_medical_necessity", collect_letter_of_medical_necessity: {url}}` and the browser goes to that step; submitting it completes the payment. |
112
+ | `4000 0000 0000 0002` | Declines: 402 page with `<div role="alert">Your card was declined.</div>`; the payment intent is `requires_payment_method`. |
113
+
114
+ Success 302s to `success_url` with `{CHECKOUT_SESSION_ID}` substituted (raw and
115
+ `%7BCHECKOUT_SESSION_ID%7D`); `GET /pay/{id}/cancel` (the Cancel link) 302s to `cancel_url`,
116
+ leaving the session open. A contact email on a session without a customer creates one. In a
117
+ namespace the page URL carries `/ns/<name>` (the browser sends no headers); `publicUrl`
118
+ overrides the origin.
119
+
120
+ ### Webhooks
121
+
122
+ Svix-signed (`svix-id`, `svix-timestamp` = wall clock, `svix-signature: v1,<base64
123
+ HMAC-SHA256(key, "<id>.<ts>.<body>")>`, key = base64-decoded secret after `fwhsec_`/`whsec_`).
124
+ Body: `{event: {event_id, event_type, object, event_dt, test_mode, created_at}}`.
125
+ Checkout events carry the session (so `object.checkout_session_id`), payment-intent events the
126
+ intent plus `checkout_session_id`, refund events `checkout_session` and `payment_intent`, and
127
+ `product.updated` the product (`object.product_id`).
128
+
129
+ | Event | When |
130
+ | --- | --- |
131
+ | `payment_intent.succeeded`, then `checkout.session.completed` | the page (or `…/complete`, or an off-session charge) settles a session |
132
+ | `checkout.session.async_payment_succeeded` | settling a session whose intent was `processing` |
133
+ | `checkout.session.async_payment_failed` | a decline (page, admin, off-session) |
134
+ | `checkout.session.expired` | `…/expire`, or `expires_at` passing on the mock clock (default 24 h) |
135
+ | `refund.created`, `charge.refunded`, `checkout.session.refunded`, `refund.updated`, `charge.refund.updated` | each refund |
136
+ | `product.updated` | `PATCH /v1/products/{id}` and `PUT /__admin/products/:id` |
137
+ | `checkout_session.completed`, `checkout_session.expired` | the aliases, with `PUT /__admin/settings {"eventNaming": "underscored"}` |
138
+
139
+ `POST /__admin/events {type, session | product}` emits any type on demand. Non-2xx answers are
140
+ retried (immediately, 5 s, 5 min, 30 min, 2 h); `GET /__admin/webhooks`, `…/events`,
141
+ `POST /__admin/webhooks/flush`, `…/:id/replay`, `PUT /__admin/webhook-endpoints` as usual.
142
+
143
+ ### Admin (beyond the standard contract)
144
+
145
+ | Route | Effect |
146
+ | --- | --- |
147
+ | `PUT /__admin/products/:id` | `{hsa_fsa_eligibility?, active?, test_mode?, visit_type?, client_reference_id?, name?, metadata?}`; emits `product.updated`. |
148
+ | `POST /__admin/sessions/:id/complete` | `{card?}`: settle as if paid (HSA card unless `card` is `4242…`). |
149
+ | `POST /__admin/sessions/:id/decline` | Payment intent → `requires_payment_method`. |
150
+ | `POST /__admin/sessions/:id/expire` | Session → `expired`. |
151
+ | `POST /__admin/sessions/:id/require_action` | `{next_action_type?}`: `collect_letter_of_medical_necessity` (default), `provide_second_payment_method`, `provide_alternative_payment_method`, `payment_failed`. |
152
+ | `PUT /__admin/sessions/:id/payment-intent` | `{status, amount_received?}`: `requires_payment_method`, `requires_action`, `processing`, `succeeded`, `canceled`. |
153
+ | `GET /__admin/sessions`, `GET /__admin/sessions/:id` | The namespace's sessions. |
154
+ | `POST /__admin/events` | Emit any event type for a session or product. |
155
+ | `GET/PUT /__admin/settings` | `{eventNaming, offSessionOutcome, sessionTtlSeconds, lmnOnRegularCard, publicUrl}`. |
156
+ | `POST /__admin/tick` | Expire due sessions now (the served mock ticks every 100 ms). |
157
+
158
+ Every orchestrator state is reachable: pending (open), action_required (`require_action`, or an
159
+ intent `requires_action`), processing, canceled (intent `canceled`, or expired), failed
160
+ (`decline`), succeeded, refunded (full refund), quarantined (partial refund,
161
+ `amount_mismatch`, duplicate sessions).
162
+
163
+ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /__admin/faults/presets`):
164
+ `create_4xx` (400, nothing created), `create_5xx` (creates, then 500: recovery adopts it),
165
+ `create_5xx_not_created`, `timeout` (creates, answers after 16 s, past the client's 15 s abort;
166
+ `params.delayMs` overrides), `invalid_shape` (no `redirect_url`/`url`), `amount_mismatch`
167
+ (`amount_total` + 100), `duplicate_sessions_for_client_reference` (two sessions, then 500),
168
+ `refund_4xx`, `server_error`, `webhook_duplicate`, `webhook_reorder`, `webhook_drop`.
169
+
170
+ ### Namespaces
171
+
172
+ `x-mockingbird-namespace`, a `/ns/<name>` prefix on `FLEX_API_BASE_URL`, or by API key:
173
+ `PUT /__admin/credentials {"credentials": {"<FLEX_API_KEY>": "<namespace>"}}`.
174
+
175
+ ### Corpus
176
+
177
+ `src/corpus/products.ts` is the product side of every row of the consumer's
178
+ `flexCatalogMappings` reference fixture (663 rows, regenerated with
179
+ `bun scripts/corpus.ts <fixture.json>`): product id, client reference, the `geviti_purpose` /
180
+ `geviti_merchant_product_id` / `geviti_client_reference_id` metadata our catalog validation
181
+ compares, eligibility and visit type. Every product is active and test-mode, so our validation
182
+ reproduces each mapping row's own `active` flag. No sandbox recording exists (no credentials),
183
+ so product names are synthesised.
184
+
185
+ ### Deliberately not modelled
186
+
187
+ - Real card processing, Stripe iframes and split-tender payments: the page is a plain form, and
188
+ split payment is reachable only as `next_action: provide_second_payment_method`.
189
+ - The letter-of-medical-necessity questionnaire: one submit button stands in for it.
190
+ - Test/live data separation: a live key sees the same objects (only `test_mode` differs).
191
+ - Subscriptions, coupons, promotion codes (`allow_promotion_codes` is echoed only), partial
192
+ captures, disputes.
193
+ - Flex's exact error texts and ids: shapes follow what our consumer reads; ids look like
194
+ `fprod_01z…`, `fcs_01z…`, `fcus_…`, `fpi_…`, `fseti_…`, `fpm_…`, `fevt_…`.
195
+
196
+ ## API
197
+
198
+ | Export | Kind | Description |
199
+ | --- | --- | --- |
200
+ | `FlexAPI` | class | The in-process mock: `fetch(request)`, `reset()`, `settle(id)`, `decline(id)`, `expire(id)`, `requireAction(id, type)`, `setPaymentIntent(id, patch)`, `applyRefund(id, amount)`, `putProduct(product)`, `emitFor(type, target)`, `tick()`, `sessions()`, `present(session, view)`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `products`, `settings`, `onEvent`. |
201
+ | `createRuntime` | function | The mock with the full service contract. Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `products`, `settings`, `tickMs`, `clock`, `seed`, `adminKey`, `onLog`. |
202
+ | `FLEX_PRESETS` | object | Every named fault preset. |
203
+ | `FLEX_NAMESPACE` | string | The service name, `"flex"`. |
204
+ | `FLEX_EVENT_TYPES` | array | Every webhook event type, aliases included. |
205
+ | `keyMode` | function | `"test"` for `fsk_test_…`, `"live"` for `fsk_…`, otherwise `undefined`. |
206
+ | `isNextActionType` | function | Whether a string is a next-action type. |
207
+ | `CARDS`, `classifyCard`, `substituteSessionId` | values | The hosted page's test cards, its card classifier, and the `{CHECKOUT_SESSION_ID}` substitution. |
208
+ | `CORPUS_ROWS`, `corpusProduct` | values | The recorded product corpus and its row → product mapping. |
209
+ | `DEFAULT_SETTINGS`, `ELIGIBILITIES`, `NEXT_ACTION_TYPES`, `PAYMENT_INTENT_STATUSES` | values | Defaults and enums. |
210
+ | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
211
+ | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http` (expiry ticks every 100 ms); the `serve` CLI target (`--webhook-url`, `--webhook-secret`, `--public-url`, `--event-naming`); port 8792. |
212
+
213
+ Part of [mockingbird](https://github.com/crvouga/mockingbird).
@@ -0,0 +1,380 @@
1
+ import {
2
+ createRuntime
3
+ } from "./chunk-RB3RIE5G.js";
4
+
5
+ // ../../adapters/node/dist/cli.js
6
+ import { readFile } from "node:fs/promises";
7
+ import { parseArgs } from "node:util";
8
+
9
+ // ../../adapters/node/dist/serve.js
10
+ import { createServer } from "node:http";
11
+ var serve = async (api, options = {}) => {
12
+ const server = createServer(async (req, res) => {
13
+ const chunks = [];
14
+ for await (const chunk of req) {
15
+ chunks.push(Buffer.from(chunk));
16
+ }
17
+ const body = Buffer.concat(chunks);
18
+ const address = server.address();
19
+ const port = typeof address === "object" && address !== null ? address.port : void 0;
20
+ const base = `http://${req.headers.host ?? `localhost:${port ?? 80}`}`;
21
+ const raw = req.url ?? "/";
22
+ const url = new URL(raw.replace(/^\/+/, "/"), base);
23
+ const method = req.method ?? "GET";
24
+ const init = { method, headers: req.headers };
25
+ if (method !== "GET" && method !== "HEAD" && body.length > 0) {
26
+ init.body = body;
27
+ }
28
+ const aborted = new AbortController();
29
+ res.once("close", () => {
30
+ if (!res.writableFinished)
31
+ aborted.abort();
32
+ });
33
+ init.signal = aborted.signal;
34
+ const request = new Request(url, init);
35
+ let response;
36
+ try {
37
+ response = await api.fetch(request);
38
+ } catch (error) {
39
+ if (error.code === "MOCKINGBIRD_DROP") {
40
+ req.socket.destroy();
41
+ return;
42
+ }
43
+ res.writeHead(500, { "content-type": "application/json" });
44
+ res.end(JSON.stringify({
45
+ error: {
46
+ type: "mockingbird_internal",
47
+ message: error instanceof Error ? error.message : String(error)
48
+ }
49
+ }));
50
+ return;
51
+ }
52
+ const headers = Object.fromEntries(response.headers);
53
+ const cookies = response.headers.getSetCookie();
54
+ if (cookies.length > 0)
55
+ headers["set-cookie"] = cookies;
56
+ if (!response.body) {
57
+ res.writeHead(response.status, headers);
58
+ res.end();
59
+ return;
60
+ }
61
+ res.writeHead(response.status, headers);
62
+ res.flushHeaders();
63
+ const reader = response.body.getReader();
64
+ try {
65
+ for (; ; ) {
66
+ const { done, value } = await reader.read();
67
+ if (done)
68
+ break;
69
+ if (!res.write(value))
70
+ await new Promise((resolve) => res.once("drain", resolve));
71
+ }
72
+ res.end();
73
+ } catch {
74
+ res.destroy();
75
+ } finally {
76
+ reader.releaseLock();
77
+ }
78
+ });
79
+ await new Promise((resolve, reject) => {
80
+ server.once("error", reject);
81
+ server.listen(options.port ?? 0, options.host, resolve);
82
+ });
83
+ return server;
84
+ };
85
+
86
+ // ../../adapters/node/dist/listen.js
87
+ var listen = async (api, options = {}) => {
88
+ const host = options.host ?? "127.0.0.1";
89
+ const server = await serve(api, { port: options.port ?? 0, host });
90
+ const address = server.address();
91
+ const port = typeof address === "object" && address !== null ? address.port : options.port ?? 0;
92
+ const shown = host.includes(":") ? `[${host}]` : host;
93
+ return {
94
+ url: `http://${shown}:${port}`,
95
+ port,
96
+ host,
97
+ server,
98
+ close: () => new Promise((resolve, reject) => {
99
+ server.close((error) => {
100
+ if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
101
+ reject(error);
102
+ else
103
+ resolve();
104
+ });
105
+ server.closeAllConnections?.();
106
+ })
107
+ };
108
+ };
109
+
110
+ // ../../adapters/node/dist/cli.js
111
+ var optionHelp = (options) => Object.entries(options).map(([name, option]) => {
112
+ const flag = `--${name}${option.type === "string" ? ` ${option.value ?? "<value>"}` : ""}`;
113
+ const fallback = option.default !== void 0 ? ` (default: ${String(option.default)})` : "";
114
+ return ` ${flag.padEnd(30)} ${option.description}${fallback}`;
115
+ });
116
+ var help = (spec) => [
117
+ `${spec.bin} \u2014 ${spec.description}`,
118
+ "",
119
+ "Usage:",
120
+ ` ${spec.bin} <command> [options]`,
121
+ "",
122
+ "Commands:",
123
+ ...Object.entries(spec.commands).map(([name, c]) => ` ${name.padEnd(30)} ${c.summary}`),
124
+ "",
125
+ `Run \`${spec.bin} <command> --help\` for a command's options.`
126
+ ].join("\n");
127
+ var commandHelp = (spec, name, command) => [
128
+ `${spec.bin} ${name} \u2014 ${command.summary}`,
129
+ "",
130
+ "Usage:",
131
+ ` ${command.usage ?? `${spec.bin} ${name} [options]`}`,
132
+ ...command.options ? ["", "Options:", ...optionHelp(command.options)] : []
133
+ ].join("\n");
134
+ var runCli = async (spec, argv) => {
135
+ const pair = argv.length >= 2 ? `${argv[0]} ${argv[1]}` : void 0;
136
+ const words = pair !== void 0 && spec.commands[pair] ? 2 : 1;
137
+ const name = words === 2 ? pair : argv[0];
138
+ const rest = argv.slice(words);
139
+ if (name === void 0 || name === "--help" || name === "-h" || name === "help") {
140
+ console.log(help(spec));
141
+ return 0;
142
+ }
143
+ const command = spec.commands[name];
144
+ if (!command) {
145
+ console.error(`${spec.bin}: unknown command ${JSON.stringify(name)}
146
+
147
+ ${help(spec)}`);
148
+ return 2;
149
+ }
150
+ if (rest.includes("--help") || rest.includes("-h")) {
151
+ console.log(commandHelp(spec, name, command));
152
+ return 0;
153
+ }
154
+ let parsed;
155
+ try {
156
+ parsed = parseArgs({
157
+ args: rest,
158
+ allowPositionals: true,
159
+ strict: true,
160
+ options: Object.fromEntries(Object.entries(command.options ?? {}).map(([key, option]) => [
161
+ key,
162
+ {
163
+ type: option.type,
164
+ ...option.default !== void 0 ? { default: option.default } : {}
165
+ }
166
+ ]))
167
+ });
168
+ } catch (error) {
169
+ console.error(`${spec.bin} ${name}: ${error instanceof Error ? error.message : String(error)}
170
+
171
+ ${commandHelp(spec, name, command)}`);
172
+ return 2;
173
+ }
174
+ return command.run(parsed.values, parsed.positionals);
175
+ };
176
+ var COMMON_SERVE_OPTIONS = {
177
+ port: { type: "string", value: "<port>", description: "Port to listen on" },
178
+ host: { type: "string", value: "<host>", description: "Interface to bind", default: "127.0.0.1" },
179
+ "admin-key": {
180
+ type: "string",
181
+ value: "<key>",
182
+ description: "Require x-mockingbird-admin-key on /__admin/* (env MOCKINGBIRD_ADMIN_KEY)"
183
+ },
184
+ seed: { type: "string", value: "<seed>", description: "Seed for every random choice" },
185
+ log: {
186
+ type: "string",
187
+ value: "<pretty|json|off>",
188
+ description: "Request log format",
189
+ default: "pretty"
190
+ },
191
+ "log-requests": {
192
+ type: "boolean",
193
+ description: "One JSON line per request: namespace, operationId, status, ids touched (never bodies). Same as --log json"
194
+ },
195
+ config: {
196
+ type: "string",
197
+ value: "<file>",
198
+ description: "Serve every service in a mockingbird.json config instead"
199
+ }
200
+ };
201
+ var formatLog = (format) => {
202
+ if (format === "off")
203
+ return void 0;
204
+ if (format === "json")
205
+ return (entry) => console.log(JSON.stringify(entry));
206
+ return (entry) => {
207
+ const op = entry.operationId ?? (entry.unmatched ? "UNMATCHED" : "-");
208
+ const ns = entry.namespace === "default" ? "" : ` [${entry.namespace}]`;
209
+ const fault = entry.faultId ? ` fault=${entry.faultId}` : "";
210
+ const adopted = entry.adopted ? " adopted" : "";
211
+ console.log(`${entry.service} ${entry.method} ${entry.path} ${entry.status} ${op} ${entry.durationMs}ms${ns}${fault}${adopted}`);
212
+ };
213
+ };
214
+ var asString = (value) => typeof value === "string" ? value : void 0;
215
+ var loadTarget = async (name, own) => {
216
+ if (name === own.name)
217
+ return own;
218
+ const specifier = `@crvouga/mockingbird-service-${name}/server`;
219
+ try {
220
+ const mod = await import(specifier);
221
+ if (!mod.serveTarget)
222
+ throw new Error(`${specifier} exports no serveTarget`);
223
+ return mod.serveTarget;
224
+ } catch (error) {
225
+ const reason = error instanceof Error ? error.message : String(error);
226
+ throw new Error(`cannot load service "${name}": ${reason}. Install @crvouga/mockingbird-service-${name}.`);
227
+ }
228
+ };
229
+ var start = async (target, values, config) => {
230
+ const runtime = await target.create(values, {
231
+ adminKey: config.adminKey,
232
+ seed: config.seed,
233
+ onLog: formatLog(config.log)
234
+ });
235
+ const listening = await listen(runtime, { port: config.port, host: config.host });
236
+ console.log(`${target.name} mock listening on ${listening.url}`);
237
+ console.log(`${target.name} health: GET ${listening.url}/health`);
238
+ console.log(`${target.name} admin: ${listening.url}/__admin (${config.adminKey ? "x-mockingbird-admin-key required" : "open \u2014 pass --admin-key to lock"})`);
239
+ for (const line of target.banner?.(runtime) ?? [])
240
+ console.log(`${target.name} ${line}`);
241
+ return listening;
242
+ };
243
+ var untilSignal = async (servers) => new Promise((resolve) => {
244
+ const stop = async () => {
245
+ await Promise.allSettled(servers.map((s) => s.close()));
246
+ resolve(0);
247
+ };
248
+ process.once("SIGINT", stop);
249
+ process.once("SIGTERM", stop);
250
+ });
251
+ var serveCommand = (target) => ({
252
+ summary: `Serve the ${target.name} mock over HTTP`,
253
+ options: { ...COMMON_SERVE_OPTIONS, ...target.options },
254
+ async run(values) {
255
+ const log = values["log-requests"] === true ? "json" : asString(values.log) ?? "pretty";
256
+ if (!["pretty", "json", "off"].includes(log)) {
257
+ console.error(`--log must be pretty, json or off (got ${log})`);
258
+ return 2;
259
+ }
260
+ const configPath = asString(values.config);
261
+ if (configPath !== void 0) {
262
+ const config = JSON.parse(await readFile(configPath, "utf8"));
263
+ const servers = [];
264
+ try {
265
+ for (const [name, entry] of Object.entries(config.services ?? {})) {
266
+ const each = await loadTarget(name, target);
267
+ servers.push(await start(each, entry.options ?? {}, {
268
+ port: entry.port ?? each.defaultPort,
269
+ host: entry.host ?? "127.0.0.1",
270
+ ...entry.adminKey !== void 0 ? { adminKey: entry.adminKey } : {},
271
+ ...entry.seed !== void 0 ? { seed: entry.seed } : {},
272
+ log: config.log ?? log
273
+ }));
274
+ }
275
+ } catch (error) {
276
+ await Promise.allSettled(servers.map((s) => s.close()));
277
+ console.error(error instanceof Error ? error.message : String(error));
278
+ return 1;
279
+ }
280
+ return untilSignal(servers);
281
+ }
282
+ const port = asString(values.port);
283
+ const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
284
+ const seed = asString(values.seed);
285
+ let listening;
286
+ try {
287
+ listening = await start(target, values, {
288
+ port: port === void 0 ? target.defaultPort : Number.parseInt(port, 10),
289
+ host: asString(values.host) ?? "127.0.0.1",
290
+ ...adminKey !== void 0 ? { adminKey } : {},
291
+ ...seed !== void 0 ? { seed } : {},
292
+ log
293
+ });
294
+ } catch (error) {
295
+ console.error(error instanceof Error ? error.message : String(error));
296
+ return 1;
297
+ }
298
+ return untilSignal([listening]);
299
+ }
300
+ });
301
+
302
+ // src/server.ts
303
+ var DEFAULT_PORT = 8792;
304
+ var createServer2 = async (options = {}) => {
305
+ const { port, host, ...rest } = options;
306
+ const runtime = createRuntime({ tickMs: 100, ...rest });
307
+ const listening = await listen(runtime, {
308
+ port: port ?? 0,
309
+ ...host !== void 0 ? { host } : {}
310
+ });
311
+ return {
312
+ ...listening,
313
+ runtime,
314
+ close: async () => {
315
+ runtime.stop();
316
+ await listening.close();
317
+ }
318
+ };
319
+ };
320
+ var text = (value) => typeof value === "string" ? value : void 0;
321
+ var serveTarget = {
322
+ name: "flex",
323
+ defaultPort: DEFAULT_PORT,
324
+ options: {
325
+ "webhook-url": {
326
+ type: "string",
327
+ value: "<url>",
328
+ description: "Deliver webhooks here (e.g. http://127.0.0.1:3000/billing/webhooks/flex)"
329
+ },
330
+ "webhook-secret": {
331
+ type: "string",
332
+ value: "<fwhsec_\u2026|whsec_\u2026>",
333
+ description: "Svix signing secret (the app's FLEX_WEBHOOK_SECRET)"
334
+ },
335
+ "public-url": {
336
+ type: "string",
337
+ value: "<url>",
338
+ description: "Base URL of the hosted page in session URLs (default: the request origin)"
339
+ },
340
+ "event-naming": {
341
+ type: "string",
342
+ value: "<dotted|underscored>",
343
+ description: "Send checkout.session.* (dotted, default) or the checkout_session.* aliases"
344
+ }
345
+ },
346
+ create: (values, common) => {
347
+ const url = text(values["webhook-url"]);
348
+ const secret = text(values["webhook-secret"]);
349
+ const publicUrl = text(values["public-url"]);
350
+ const naming = text(values["event-naming"]);
351
+ if (naming && naming !== "dotted" && naming !== "underscored") {
352
+ throw new Error("--event-naming must be dotted or underscored");
353
+ }
354
+ return createRuntime({
355
+ tickMs: 100,
356
+ ...url ? { webhooks: { url, ...secret ? { secret } : {} } } : {},
357
+ settings: {
358
+ ...publicUrl ? { publicUrl } : {},
359
+ ...naming ? { eventNaming: naming } : {}
360
+ },
361
+ ...common.adminKey !== void 0 ? { adminKey: common.adminKey } : {},
362
+ ...common.seed !== void 0 ? { seed: common.seed } : {},
363
+ ...common.onLog ? { onLog: common.onLog } : {}
364
+ });
365
+ },
366
+ banner: () => [
367
+ "auth: Authorization: Bearer fsk_test_\u2026 (test mode) or fsk_\u2026 (live mode)",
368
+ "hosted page: GET /pay/<checkout_session_id> (cards 4000051230000072 HSA, 4242424242424242, 4000000000000002 declines)",
369
+ "namespaces: x-mockingbird-namespace, /ns/<name>/\u2026, or PUT /__admin/credentials {<FLEX_API_KEY>: <ns>}"
370
+ ]
371
+ };
372
+
373
+ export {
374
+ runCli,
375
+ serveCommand,
376
+ DEFAULT_PORT,
377
+ createServer2 as createServer,
378
+ serveTarget
379
+ };
380
+ //# sourceMappingURL=chunk-Q6AKODYZ.js.map