@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 +5 -0
- package/README.md +213 -0
- package/dist/chunk-Q6AKODYZ.js +380 -0
- package/dist/chunk-Q6AKODYZ.js.map +7 -0
- package/dist/chunk-RB3RIE5G.js +4546 -0
- package/dist/chunk-RB3RIE5G.js.map +7 -0
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +1112 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +1414 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +7 -0
- package/package.json +97 -0
package/CHANGELOG.md
ADDED
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
|