@crvouga/mockingbird-service-rxvortex 1.0.0 → 1.1.1

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,15 +1,24 @@
1
1
  # Changelog — @crvouga/mockingbird-service-rxvortex
2
2
 
3
+ ## 1.1.1 (2026-09-26)
4
+
5
+ ### Fixes and improvements
6
+
7
+ - take the rejected-request byte count from content-length, never by reading the body ([a0e5fb2](https://github.com/crvouga/mockingbird/commit/a0e5fb2fc80ab725c3552b612f3f091fd6c857fb))
8
+ - reject a present body by media type, and log what a rejected request sent ([12e351a](https://github.com/crvouga/mockingbird/commit/12e351aba53cfb7b41e31a7809419455fc2902eb))
9
+
10
+ ## 1.1.0 (2026-09-25)
11
+
12
+ ### Features
13
+
14
+ - load a preset catalog into a served mock and promote to ready ([471288e](https://github.com/crvouga/mockingbird/commit/471288e46b1f34a62f0c1b7e7a76dc30920c39e7))
15
+
3
16
  ## 1.0.0 (2026-09-24)
4
17
 
5
18
  ### ⚠️ Breaking changes
6
19
 
7
20
  - vendor branding for every service; purge internal mocks and consumer names ([f689534](https://github.com/crvouga/mockingbird/commit/f6895343543550dafcb7d76b426cae7635e03226))
8
21
 
9
- ### Dependencies
10
-
11
- - `@crvouga/mockingbird-service-sqlite`
12
-
13
22
  ## 0.2.0 (2026-09-23)
14
23
 
15
24
  ### Features
package/README.md CHANGED
@@ -29,9 +29,15 @@ value as `--webhook-secret`.
29
29
  npx mockingbird-rxvortex serve --port 8791 \
30
30
  --webhook-url http://127.0.0.1:3000/prescriptions/webhooks/rxvortex \
31
31
  --webhook-secret "$RXVORTEX_WEBHOOK_SECRET" \
32
- --auto-advance "2000:Fill,Shipping,Delivered"
32
+ --auto-advance "2000:Fill,Shipping,Delivered" \
33
+ --catalog ./preset-catalog.json
33
34
  ```
34
35
 
36
+ `--catalog` loads your own preset catalog into every namespace: a JSON array of rows, or the
37
+ `{data: [...]}` body `GET /api/v1/preset-catalog-items` answers with, so a recorded sandbox
38
+ response loads as-is. Only `catalog_id` and `medication_name` are required. `POST /__admin/reset`
39
+ returns to these rows. Pass `--unknown-presets accept` to take any well-formed preset id instead.
40
+
35
41
  ```ts
36
42
  import { createRuntime } from "@crvouga/mockingbird-service-rxvortex"
37
43
 
@@ -62,7 +68,7 @@ await post("/__admin/orders/pay_123/transition", { to: "Delivered" })
62
68
  | Route | Behaviour |
63
69
  | --- | --- |
64
70
  | `POST /api/v1/generate-access-token` | JSON `{client_id, client_secret}` → `{access_token, token_type: "Bearer", expires_in: 86400}`. Any pair works unless `clients` is set (`PUT /__admin/settings`). Tokens stay valid 24 h on the mock clock; our client caches for 24 h and never refreshes on 401. |
65
- | `POST /api/v1/orders` | Validates the submit payload against the contract; a violation is 422 `{message, errors: {"patient.phone": ["…"]}}`. An inactive or unknown `preset_catalog_id` is 422. A repeated `order.sender_order_id` is 409. Success: `{success: true, order_tracking_id: "RXV-…", sender_order_id, status: "Created"}` (the tracking id is always a string). |
71
+ | `POST /api/v1/orders` | Validates the submit payload against the contract; a violation is 422 `{message, errors: {"patient.phone": ["…"]}}`. An inactive or unknown `preset_catalog_id` is 422 (under `unknownPresets: "accept"` an unknown UUID becomes an active catalog row named after the request's `medication_name`). A repeated `order.sender_order_id` is 409. Success: `{success: true, order_tracking_id: "RXV-…", sender_order_id, status: "Created"}` (the tracking id is always a string). |
66
72
  | `GET /api/v1/orders/{id}` | `id` is the tracking id **or** the sender order id (our payment id, the recovery lookup). Returns `rxstatus`, `orderstatus`, `shipping_status`, `delivered_date`, `trackingnumber`, `shippingservice`, `shippingcarrier`, `shipmenttrackingurl`, `cancellable`, and the id under `order_tracking_id`, `tracking_id` and `orderReferenceID`. |
67
73
  | `DELETE /api/v1/orders/{id}` | Cancels while `cancellable` (until shipped), emitting the webhook; otherwise 409. |
68
74
  | `GET /api/v1/preset-catalog-items` | `{data: [...]}` rows with `catalog_id`, `medication_name`, `medication_strength`, `package_size`, `quantity`, `quantity_units`, `medication_form`, `route`, `states`, `status`. Includes the custom-cream anchor preset `e404ad76-0f82-4b04-8f25-841650e2e819` and one inactive row. |
@@ -82,7 +88,9 @@ now, and `PUT /__admin/webhook-endpoints` sets per-namespace receivers.
82
88
  | Route | Effect |
83
89
  | --- | --- |
84
90
  | `POST /__admin/orders/:id/transition` | `{to, trackingnumber?, shippingcarrier?, shippingservice?, delivered_date?}`. `to` is a vendor status: `Fill`, `PV1 Complete`, `Compound`, `Out of Stock`, `On Hold`, `Shipping`, `Delivered`, `Cancelled`, `Rejected`, `Error`, or any string (used verbatim). The three status fields move together; shipping generates tracking when none is given. |
85
- | `PUT /__admin/settings` | `{tokenTtlSeconds?, staticTokens?, clients?, autoAdvance?: {afterMs, path} \| null}` for the calling namespace. `staticTokens` admits `RXVORTEX_API_TOKEN` (the catalog client's static bearer). |
91
+ | `PUT /__admin/catalog` | `{items: [{catalog_id, medication_name, medication_strength?, package_size?, quantity?, quantity_units?, medication_form?, route?, states?, status?}], mode?: "replace" \| "merge"}` for the calling namespace (`replace` is the default; `merge` upserts by `catalog_id`). Answers `{count}`, the namespace's row count. `items` may also be sent as `data`. |
92
+ | `GET /__admin/catalog` | The namespace's rows, as `{data: [...]}`. |
93
+ | `PUT /__admin/settings` | `{tokenTtlSeconds?, staticTokens?, clients?, autoAdvance?: {afterMs, path} \| null, unknownPresets?: "reject" \| "accept"}` for the calling namespace. `unknownPresets` defaults to `reject`, as the sandbox does. `staticTokens` admits `RXVORTEX_API_TOKEN` (the catalog client's static bearer). |
86
94
  | `POST /__admin/tick` | Apply every auto-advance step that is due on the mock clock (the served mock also ticks every 100 ms). |
87
95
  | `GET /__admin/orders` | The namespace's orders. |
88
96
 
@@ -104,7 +112,8 @@ the client id they were issued to).
104
112
  - Real fulfilment timing: nothing moves on its own unless `autoAdvance` is set.
105
113
  - Patient and prescriber details are validated, never stored or echoed back.
106
114
  - The live catalog: the default rows are synthesised in the live client's field names (no
107
- sandbox recording exists); pass `catalog` to load recorded rows.
115
+ sandbox recording exists). Load recorded rows with `serve --catalog`, `PUT /__admin/catalog`,
116
+ or the `catalog` option.
108
117
  - Refills and multi-order shipments.
109
118
 
110
119
  ## API
@@ -118,7 +127,8 @@ the client id they were issued to).
118
127
  | `tokenCredential` | function | The client id a bearer token was issued to (how credentials map to namespaces). |
119
128
  | `CUSTOM_CREAM_ANCHOR_PRESET_ID` | string | The sandbox custom-cream anchor preset id. |
120
129
  | `DEFAULT_CATALOG` | array | The default preset catalog rows. |
130
+ | `parseCatalog`, `parseCatalogItem` | functions | Catalog rows from a JSON array or a recorded `{data: [...]}` body (missing optional fields default to `null`, `states` to `[]`, `status` to `"active"`); throw on a malformed row. |
121
131
  | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
122
- | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http` (auto-advance ticks every 100 ms); the `serve` CLI target; port 8791. |
132
+ | `createServer`, `serveTarget`, `DEFAULT_PORT`, `loadCatalogFile` (`./server`) | Node | Serve over `node:http` (auto-advance ticks every 100 ms); the `serve` CLI target; port 8791; read a `--catalog` file. |
123
133
 
124
134
  Part of [mockingbird](https://github.com/crvouga/mockingbird).
@@ -1366,6 +1366,100 @@ var operationMetadata = (operation) => {
1366
1366
 
1367
1367
  // ../core/dist/service.js
1368
1368
  import { Hono } from "hono";
1369
+
1370
+ // ../core/dist/validation.js
1371
+ var requestBodyOf = (context) => {
1372
+ const requestBody = context.operation.operation.requestBody;
1373
+ return requestBody ? deref(context.document, requestBody) : void 0;
1374
+ };
1375
+ var mediaTypeMatches = (accepted, actual) => {
1376
+ const pattern = mediaTypeOf(accepted) ?? accepted.toLowerCase();
1377
+ if (pattern === actual || pattern === "*/*")
1378
+ return true;
1379
+ const [type, subtype] = pattern.split("/");
1380
+ const [actualType, actualSubtype] = actual.split("/");
1381
+ if (type !== actualType && type !== "*")
1382
+ return false;
1383
+ if (subtype === void 0 || subtype === "*")
1384
+ return true;
1385
+ if (subtype.startsWith("*+"))
1386
+ return actualSubtype?.endsWith(subtype.slice(1)) === true;
1387
+ return false;
1388
+ };
1389
+ var acceptedKeyFor = (content, mediaType) => {
1390
+ if (!content || mediaType === void 0)
1391
+ return void 0;
1392
+ const keys = Object.keys(content);
1393
+ return keys.find((key) => mediaTypeOf(key) === mediaType) ?? keys.find((key) => mediaTypeMatches(key, mediaType));
1394
+ };
1395
+ var unsupportedMediaType = (context, options = {}) => {
1396
+ if (context.body.kind === "empty" && options.includeEmpty !== true)
1397
+ return void 0;
1398
+ const content = requestBodyOf(context)?.content;
1399
+ if (!content || Object.keys(content).length === 0)
1400
+ return void 0;
1401
+ const mediaType = mediaTypeOf(context.request.headers.get("content-type"));
1402
+ if (acceptedKeyFor(content, mediaType) !== void 0)
1403
+ return void 0;
1404
+ return { mediaType: mediaType ?? null, accepted: Object.keys(content) };
1405
+ };
1406
+ var recorded = /* @__PURE__ */ new WeakMap();
1407
+ var recordedIssues = (request) => recorded.get(request);
1408
+ var bodyIssues = (context, contentType = "application/json") => {
1409
+ const issues = findBodyIssues(context, contentType);
1410
+ if (issues.length > 0)
1411
+ recorded.set(context.request, issues);
1412
+ return issues;
1413
+ };
1414
+ var findBodyIssues = (context, contentType) => {
1415
+ const resolved = requestBodyOf(context);
1416
+ if (!resolved)
1417
+ return [];
1418
+ const unsupported = unsupportedMediaType(context);
1419
+ if (unsupported) {
1420
+ return [
1421
+ {
1422
+ path: "",
1423
+ kind: "media_type",
1424
+ message: unsupported.mediaType === null ? "request body has no content-type" : `request body media type ${unsupported.mediaType} is not supported`
1425
+ }
1426
+ ];
1427
+ }
1428
+ const key = acceptedKeyFor(resolved.content, mediaTypeOf(context.request.headers.get("content-type"))) ?? contentType;
1429
+ const schema = resolved.content?.[key]?.schema;
1430
+ if (!schema)
1431
+ return [];
1432
+ if (context.body.kind === "invalid") {
1433
+ return [
1434
+ {
1435
+ path: "",
1436
+ kind: "syntax",
1437
+ message: `request body is not valid ${context.body.mediaType}`
1438
+ }
1439
+ ];
1440
+ }
1441
+ const value = context.body.kind === "json" || context.body.kind === "form" || context.body.kind === "text" ? context.body.value : void 0;
1442
+ if (value === void 0) {
1443
+ return resolved.required && context.body.kind === "empty" ? [{ path: "", kind: "required", message: "request body is required" }] : [];
1444
+ }
1445
+ return validateValue(context.document, resolveSchema(context.document, schema), value).map((issue) => ({ path: issue.path.join("."), kind: "schema", message: issue.message }));
1446
+ };
1447
+ var issuesByField = (issues) => {
1448
+ const out = {};
1449
+ for (const issue of issues) {
1450
+ const missing = /^missing required property (.+)$/.exec(issue.message);
1451
+ const field = missing ? [issue.path, missing[1]].filter(Boolean).join(".") : issue.path || "body";
1452
+ const message = missing ? `The ${field} field is required.` : `The ${field} field ${issue.message}.`;
1453
+ out[field] = [...out[field] ?? [], message];
1454
+ }
1455
+ return out;
1456
+ };
1457
+
1458
+ // ../core/dist/service.js
1459
+ var withIssues = (request, response) => {
1460
+ const issues = response.status >= 400 ? recordedIssues(request) : void 0;
1461
+ return issues && issues.length > 0 ? annotateResponse(response, { issues }) : response;
1462
+ };
1369
1463
  var defineOperations = (handlers) => handlers;
1370
1464
  var OperationRegistryError = class extends Error {
1371
1465
  problems;
@@ -1451,9 +1545,7 @@ var createService = (options) => {
1451
1545
  now
1452
1546
  };
1453
1547
  const short = await options.before?.(context);
1454
- if (short)
1455
- return short;
1456
- return handler(context);
1548
+ return withIssues(request, short ?? await handler(context));
1457
1549
  };
1458
1550
  app.on(operation.method.toUpperCase(), honoPath(operation.path), route);
1459
1551
  }
@@ -1490,7 +1582,7 @@ var restoreNamespace = (sqlite, namespace, snapshot) => {
1490
1582
  };
1491
1583
 
1492
1584
  // ../core/dist/version.js
1493
- var PACKAGE_VERSION = true ? "1.0.0" : UNRELEASED_VERSION;
1585
+ var PACKAGE_VERSION = true ? "1.1.1" : UNRELEASED_VERSION;
1494
1586
 
1495
1587
  // ../core/dist/signing.js
1496
1588
  var encoder = new TextEncoder();
@@ -2250,8 +2342,15 @@ var createRuntime = (options) => {
2250
2342
  const started = monotonicNow();
2251
2343
  const url = new URL(request.url);
2252
2344
  const operationId = operationIdFor(request, url.pathname);
2345
+ const contentLength = request.headers.get("content-length");
2346
+ const received = {
2347
+ contentType: request.headers.get("content-type"),
2348
+ bodyBytes: contentLength !== null && /^\d+$/.test(contentLength) ? Number(contentLength) : null,
2349
+ transferEncoding: request.headers.get("transfer-encoding")
2350
+ };
2253
2351
  const log = (status, faultId, response2) => {
2254
2352
  const noted = response2 ? responseNotes(response2) : void 0;
2353
+ const rejected = status >= 400 && faultId === void 0;
2255
2354
  const entry = {
2256
2355
  service: options.name,
2257
2356
  namespace,
@@ -2263,7 +2362,9 @@ var createRuntime = (options) => {
2263
2362
  unmatched: options.document !== void 0 && operationId === void 0,
2264
2363
  ...faultId !== void 0 ? { faultId } : {},
2265
2364
  ...noted?.ids && Object.keys(noted.ids).length > 0 ? { ids: noted.ids } : {},
2266
- ...noted?.adopted ? { adopted: true } : {}
2365
+ ...noted?.adopted ? { adopted: true } : {},
2366
+ ...rejected ? { request: { ...received } } : {},
2367
+ ...rejected && noted?.issues && noted.issues.length > 0 ? { issues: noted.issues.map((issue) => ({ ...issue })) } : {}
2267
2368
  };
2268
2369
  metrics.record(entry);
2269
2370
  journal.record({ ...entry, at: new Date(clock.now()).toISOString() });
@@ -2470,35 +2571,6 @@ var presetRoutes = (presets, runtime) => ({
2470
2571
  }
2471
2572
  });
2472
2573
 
2473
- // ../core/dist/validation.js
2474
- var bodyIssues = (context, contentType = "application/json") => {
2475
- const requestBody = context.operation.operation.requestBody;
2476
- if (!requestBody)
2477
- return [];
2478
- const resolved = deref(context.document, requestBody);
2479
- const schema = resolved.content?.[contentType]?.schema;
2480
- if (!schema)
2481
- return [];
2482
- const value = context.body.kind === "json" || context.body.kind === "form" ? context.body.value : void 0;
2483
- if (context.body.kind === "invalid") {
2484
- return [{ path: "", message: `request body is not valid ${context.body.mediaType}` }];
2485
- }
2486
- if (value === void 0) {
2487
- return resolved.required ? [{ path: "", message: "request body is required" }] : [];
2488
- }
2489
- return validateValue(context.document, resolveSchema(context.document, schema), value).map((issue) => ({ path: issue.path.join("."), message: issue.message }));
2490
- };
2491
- var issuesByField = (issues) => {
2492
- const out = {};
2493
- for (const issue of issues) {
2494
- const missing = /^missing required property (.+)$/.exec(issue.message);
2495
- const field = missing ? [issue.path, missing[1]].filter(Boolean).join(".") : issue.path || "body";
2496
- const message = missing ? `The ${field} field is required.` : `The ${field} field ${issue.message}.`;
2497
- out[field] = [...out[field] ?? [], message];
2498
- }
2499
- return out;
2500
- };
2501
-
2502
2574
  // src/generated/openapi.ts
2503
2575
  var document = JSON.parse(`{"openapi":"3.1.0","info":{"title":"RxVortex (Strive) pharmacy API (Mockingbird subset)","description":"Stateful mock subset of the RxVortex / Strive compounding-pharmacy API: client-credentials\\ntoken, order submit, status, cancel, lookup by sender order id (recovery), and the preset\\ncatalog. Hand-authored from the consumer's adapters (the vendor publishes no spec).\\n","version":"1","x-mockingbird-upstream":{"note":"Hand-authored from the wire shapes our consumer reads and writes (rxvortex-auth.service.ts, rxvortex-fulfillment.adapter.ts, rxvortex-live.client.ts)."}},"servers":[{"url":"https://sandbox.rxvortex.com"}],"security":[{"bearerAuth":[]}],"paths":{"/api/v1/generate-access-token":{"post":{"operationId":"GenerateAccessToken","security":[],"x-mockingbird":{"supported":true,"parity":{"enabled":true,"safe":true}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["client_id","client_secret"],"properties":{"client_id":{"type":"string","minLength":1,"maxLength":64},"client_secret":{"type":"string","minLength":1,"maxLength":64}}}}}},"responses":{"200":{"description":"Access token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"401":{"description":"Bad credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}}}}},"/api/v1/orders":{"post":{"operationId":"CreateOrder","x-mockingbird":{"supported":true,"parity":{"enabled":true,"safe":false}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrderBody"}}}},"responses":{"200":{"description":"Order accepted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrderResponse"}}}},"401":{"description":"Unauthenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Duplicate sender_order_id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}}}}},"/api/v1/orders/{orderId}":{"parameters":[{"name":"orderId","in":"path","required":true,"description":"The vendor order tracking id, or the sender order id (our payment id).","schema":{"type":"string","x-mockingbird-resource-ref":{"type":"order","missing":"RXV-00000000-MISSING"}}}],"get":{"operationId":"GetOrder","x-mockingbird":{"supported":true,"parity":{"enabled":true,"safe":true}},"responses":{"200":{"description":"Order status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderStatus"}}}},"401":{"description":"Unauthenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"Unknown order","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}},"delete":{"operationId":"CancelOrder","x-mockingbird":{"supported":true,"parity":{"enabled":true,"safe":false}},"responses":{"200":{"description":"Cancelled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CancelResponse"}}}},"401":{"description":"Unauthenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"Unknown order","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Not cancellable in its current status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/api/v1/preset-catalog-items":{"get":{"operationId":"ListPresetCatalogItems","x-mockingbird":{"supported":true,"parity":{"enabled":true,"safe":true}},"responses":{"200":{"description":"Preset catalog","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CatalogItem"}}}}}}},"401":{"description":"Unauthenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer"}},"schemas":{"TokenResponse":{"type":"object","required":["access_token","token_type","expires_in"],"properties":{"access_token":{"type":"string","x-mockingbird-volatile":{"kind":"token"}},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"integer"}}},"Address":{"type":"object","required":["line1","city","state","postal_code","country"],"properties":{"line1":{"type":"string","minLength":1,"maxLength":80},"line2":{"type":"string","maxLength":80},"city":{"type":"string","minLength":1,"maxLength":40},"state":{"type":"string","pattern":"^[A-Z]{2}$"},"postal_code":{"type":"string","pattern":"^[0-9]{5}(-[0-9]{4})?$"},"country":{"type":"string","enum":["US"]}}},"Patient":{"type":"object","required":["sender_patient_id","first_name","last_name","dob","gender","email","phone","address"],"properties":{"sender_patient_id":{"type":"string","minLength":1,"maxLength":64},"first_name":{"type":"string","minLength":1,"maxLength":40},"last_name":{"type":"string","minLength":1,"maxLength":40},"dob":{"type":"string","pattern":"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"},"gender":{"type":"string","enum":["male","female"]},"email":{"type":"string","format":"email","maxLength":120},"phone":{"type":"string","pattern":"^[0-9]{3}-[0-9]{3}-[0-9]{4}$"},"address":{"$ref":"#/components/schemas/Address"}}},"Prescriber":{"type":"object","required":["first_name","last_name","npi"],"properties":{"first_name":{"type":"string","minLength":1,"maxLength":40},"last_name":{"type":"string","minLength":1,"maxLength":40},"npi":{"type":"string","pattern":"^[0-9]{10}$"},"dea_number":{"type":"string","maxLength":20},"license_number":{"type":"string","maxLength":20},"license_state":{"type":"string","maxLength":2},"phone":{"type":"string","maxLength":20},"address":{"$ref":"#/components/schemas/Address"}}},"MedicationRequest":{"type":"object","required":["type","preset_catalog_id","sender_med_request_id","medication_name","quantity","quantity_units","days_supply_duration","refills","instructions"],"properties":{"type":{"type":"string","enum":["new"]},"preset_catalog_id":{"type":"string","x-mockingbird-resource-ref":{"type":"catalog_item","missing":"00000000-0000-4000-8000-000000000000"}},"sender_med_request_id":{"type":"string","minLength":1,"maxLength":64},"medication_name":{"type":"string","minLength":1,"maxLength":120},"medication_strength":{"type":"string","maxLength":60},"medication_form":{"type":"string","maxLength":40},"quantity":{"type":"number","exclusiveMinimum":0,"maximum":1000},"quantity_units":{"type":"string","enum":["mL","grams","each"]},"days_supply_duration":{"type":"integer","minimum":1,"maximum":365},"refills":{"type":"integer","minimum":0,"maximum":12},"instructions":{"type":"string","minLength":1,"maxLength":500},"note":{"type":"string","maxLength":1000},"authored_on_datetime":{"type":"string","maxLength":40},"clinical_difference":{"type":"string","maxLength":1000},"schedule_code":{"type":"string","enum":["2","3","4","5"]}}},"ClinicalList":{"type":"object","properties":{"has_known_allergies":{"type":"boolean"},"has_known_diseases":{"type":"boolean"},"has_known_medications":{"type":"boolean"},"entries":{"type":"array","maxItems":5,"items":{"type":"object","required":["description"],"properties":{"description":{"type":"string","maxLength":200}}}}}},"CreateOrderBody":{"type":"object","required":["patient","prescriber","order","medication_requests"],"properties":{"patient":{"$ref":"#/components/schemas/Patient"},"prescriber":{"$ref":"#/components/schemas/Prescriber"},"order":{"type":"object","required":["bill_to","ship_to","sender_order_id"],"properties":{"bill_to":{"type":"string","enum":["practice","patient"]},"ship_to":{"type":"string","enum":["patient","practice"]},"sender_order_id":{"type":"string","minLength":1,"maxLength":64}}},"medication_requests":{"type":"array","minItems":1,"maxItems":3,"items":{"$ref":"#/components/schemas/MedicationRequest"}},"clinical":{"type":"object","properties":{"allergies":{"$ref":"#/components/schemas/ClinicalList"},"diseases":{"$ref":"#/components/schemas/ClinicalList"},"medications":{"$ref":"#/components/schemas/ClinicalList"}}},"shipment":{"type":"object","properties":{"recipient_first_name":{"type":"string","maxLength":40},"recipient_last_name":{"type":"string","maxLength":40},"recipient_email":{"type":"string","maxLength":80},"recipient_phone":{"type":"string","maxLength":20},"address":{"$ref":"#/components/schemas/Address"}}}}},"CreateOrderResponse":{"type":"object","required":["success","message","order_tracking_id","sender_order_id","status"],"properties":{"success":{"type":"boolean"},"message":{"type":"string"},"order_tracking_id":{"type":"string","x-mockingbird-resource":{"type":"order","identity":true},"x-mockingbird-volatile":{"kind":"id"}},"sender_order_id":{"type":"string"},"status":{"type":"string"}}},"OrderStatus":{"type":"object","required":["order_tracking_id","tracking_id","orderReferenceID","sender_order_id","rxstatus","orderstatus","shipping_status","cancellable","created_at","updated_at"],"properties":{"order_tracking_id":{"type":"string","x-mockingbird-volatile":{"kind":"id"}},"tracking_id":{"type":"string","x-mockingbird-volatile":{"kind":"id"}},"orderReferenceID":{"type":"string","x-mockingbird-volatile":{"kind":"id"}},"sender_order_id":{"type":"string"},"rxstatus":{"type":"string"},"orderstatus":{"type":"string"},"shipping_status":{"type":"string"},"delivered_date":{"type":["string","null"]},"trackingnumber":{"type":["string","null"]},"shippingservice":{"type":["string","null"]},"shippingcarrier":{"type":["string","null"]},"shipmenttrackingurl":{"type":["string","null"]},"cancellable":{"type":"boolean"},"created_at":{"type":"string","x-mockingbird-volatile":{"kind":"timestamp"}},"updated_at":{"type":"string","x-mockingbird-volatile":{"kind":"timestamp"}}}},"CancelResponse":{"type":"object","required":["success","message","order_tracking_id","rxstatus"],"properties":{"success":{"type":"boolean"},"message":{"type":"string"},"order_tracking_id":{"type":"string","x-mockingbird-volatile":{"kind":"id"}},"rxstatus":{"type":"string"}}},"CatalogItem":{"type":"object","required":["catalog_id","medication_name","status"],"properties":{"catalog_id":{"type":"string","x-mockingbird-resource":{"type":"catalog_item","identity":true}},"medication_name":{"type":"string"},"medication_strength":{"type":["string","null"]},"package_size":{"type":["string","null"]},"quantity":{"type":["number","null"]},"quantity_units":{"type":["string","null"]},"medication_form":{"type":["string","null"]},"route":{"type":["string","null"]},"states":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["active","inactive"]}}},"ErrorBody":{"type":"object","required":["message"],"properties":{"message":{"type":"string"},"success":{"type":"boolean"}}},"ValidationError":{"type":"object","required":["message","errors"],"properties":{"message":{"type":"string"},"errors":{"oneOf":[{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}},{"type":"array","items":{"type":"object"}}]}}}}}}`);
2504
2576
  var operationIds = ["GenerateAccessToken", "CreateOrder", "GetOrder", "CancelOrder", "ListPresetCatalogItems"];
@@ -2587,13 +2659,61 @@ var DEFAULT_CATALOG = [
2587
2659
  status: "inactive"
2588
2660
  }
2589
2661
  ];
2662
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2663
+ var optionalString = (row, key) => {
2664
+ const value = row[key];
2665
+ if (value === void 0 || value === null) return null;
2666
+ if (typeof value !== "string") throw new Error(`${key} must be a string or null`);
2667
+ return value;
2668
+ };
2669
+ var parseCatalogItem = (value) => {
2670
+ if (!isRecord4(value)) throw new Error("a catalog row must be an object");
2671
+ const { catalog_id, medication_name, quantity, states, status } = value;
2672
+ if (typeof catalog_id !== "string" || catalog_id.length === 0) {
2673
+ throw new Error("catalog_id must be a non-empty string");
2674
+ }
2675
+ if (typeof medication_name !== "string") {
2676
+ throw new Error(`${catalog_id}: medication_name must be a string`);
2677
+ }
2678
+ if (quantity !== void 0 && quantity !== null && typeof quantity !== "number") {
2679
+ throw new Error(`${catalog_id}: quantity must be a number or null`);
2680
+ }
2681
+ if (states !== void 0 && (!Array.isArray(states) || states.some((s) => typeof s !== "string"))) {
2682
+ throw new Error(`${catalog_id}: states must be a list of state codes`);
2683
+ }
2684
+ if (status !== void 0 && status !== "active" && status !== "inactive") {
2685
+ throw new Error(`${catalog_id}: status must be "active" or "inactive"`);
2686
+ }
2687
+ try {
2688
+ return {
2689
+ catalog_id,
2690
+ medication_name,
2691
+ medication_strength: optionalString(value, "medication_strength"),
2692
+ package_size: optionalString(value, "package_size"),
2693
+ quantity: typeof quantity === "number" ? quantity : null,
2694
+ quantity_units: optionalString(value, "quantity_units"),
2695
+ medication_form: optionalString(value, "medication_form"),
2696
+ route: optionalString(value, "route"),
2697
+ states: states ?? [],
2698
+ status: status ?? "active"
2699
+ };
2700
+ } catch (error) {
2701
+ throw new Error(`${catalog_id}: ${error.message}`);
2702
+ }
2703
+ };
2704
+ var parseCatalog = (value) => {
2705
+ const rows = Array.isArray(value) ? value : isRecord4(value) ? value.data : void 0;
2706
+ if (!Array.isArray(rows)) throw new Error("a catalog must be an array or {data: [...]}");
2707
+ return rows.map(parseCatalogItem);
2708
+ };
2590
2709
 
2591
2710
  // src/state.ts
2592
2711
  var DEFAULT_SETTINGS = {
2593
2712
  tokenTtlSeconds: 86400,
2594
2713
  staticTokens: [],
2595
2714
  clients: [],
2596
- autoAdvance: null
2715
+ autoAdvance: null,
2716
+ unknownPresets: "reject"
2597
2717
  };
2598
2718
  var RxVortexState = class {
2599
2719
  constructor(sqlite, namespace, seed) {
@@ -2611,15 +2731,25 @@ var RxVortexState = class {
2611
2731
  ids;
2612
2732
  /** Re-apply the catalog and settings after a reset. */
2613
2733
  ensureSeeded() {
2614
- if (this.catalog.count() === 0) {
2615
- for (const item of this.seed.catalog.length > 0 ? this.seed.catalog : DEFAULT_CATALOG) {
2616
- this.catalog.insert(item.catalog_id, item);
2617
- }
2618
- }
2734
+ if (this.catalog.count() === 0) this.putCatalog(this.seed.catalog ?? DEFAULT_CATALOG, "merge");
2619
2735
  if (!this.settings.has("settings")) {
2620
2736
  this.settings.insert("settings", { ...DEFAULT_SETTINGS, ...this.seed.settings });
2621
2737
  }
2622
2738
  }
2739
+ /** Load rows: `replace` drops every other row first, `merge` upserts by `catalog_id`. */
2740
+ putCatalog(items, mode) {
2741
+ if (mode === "replace") {
2742
+ for (const row of this.catalog.list()) this.catalog.delete(row.id);
2743
+ }
2744
+ for (const item of items) {
2745
+ if (this.catalog.has(item.catalog_id)) this.catalog.update(item.catalog_id, item);
2746
+ else this.catalog.insert(item.catalog_id, item);
2747
+ }
2748
+ return this.catalog.count();
2749
+ }
2750
+ catalogRows() {
2751
+ return this.catalog.list({ order: "oldest" }).map((row) => row.value);
2752
+ }
2623
2753
  current() {
2624
2754
  return this.settings.get("settings") ?? DEFAULT_SETTINGS;
2625
2755
  }
@@ -2730,10 +2860,10 @@ var RXVORTEX_PRESETS = {
2730
2860
  };
2731
2861
  var json3 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
2732
2862
  var adminError3 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2733
- var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2863
+ var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2734
2864
  var parseAutoAdvance = (value) => {
2735
2865
  if (value === null) return null;
2736
- if (!isRecord4(value)) return "autoAdvance must be {afterMs, path} or null";
2866
+ if (!isRecord5(value)) return "autoAdvance must be {afterMs, path} or null";
2737
2867
  if (typeof value.afterMs !== "number" || value.afterMs < 0)
2738
2868
  return "autoAdvance.afterMs must be ms";
2739
2869
  if (!Array.isArray(value.path) || value.path.some((s) => typeof s !== "string")) {
@@ -2744,7 +2874,7 @@ var parseAutoAdvance = (value) => {
2744
2874
  var adminRoutes = (runtime) => ({
2745
2875
  "GET /orders": ({ namespace }) => json3(200, { orders: runtime.instance(namespace).orders() }),
2746
2876
  "POST /orders/:id/transition": ({ params, body, namespace }) => {
2747
- if (!isRecord4(body) || typeof body.to !== "string") {
2877
+ if (!isRecord5(body) || typeof body.to !== "string") {
2748
2878
  return adminError3(
2749
2879
  400,
2750
2880
  'expected {"to": "<vendor status>", "trackingnumber"?, "shippingcarrier"?}'
@@ -2760,9 +2890,27 @@ var adminRoutes = (runtime) => ({
2760
2890
  });
2761
2891
  return order ? json3(200, order) : adminError3(404, `no order ${params.id}`);
2762
2892
  },
2893
+ "GET /catalog": ({ namespace }) => json3(200, { data: runtime.instance(namespace).state.catalogRows() }),
2894
+ "PUT /catalog": ({ body, namespace }) => {
2895
+ const rows = isRecord5(body) ? body.items ?? body.data : void 0;
2896
+ if (!isRecord5(body) || !Array.isArray(rows)) {
2897
+ return adminError3(400, 'expected {"items": [{catalog_id, medication_name, \u2026}], "mode"?}');
2898
+ }
2899
+ const mode = body.mode ?? "replace";
2900
+ if (mode !== "replace" && mode !== "merge") {
2901
+ return adminError3(400, 'mode must be "replace" or "merge"');
2902
+ }
2903
+ let items;
2904
+ try {
2905
+ items = rows.map(parseCatalogItem);
2906
+ } catch (error) {
2907
+ return adminError3(400, `items: ${error.message}`);
2908
+ }
2909
+ return json3(200, { count: runtime.instance(namespace).state.putCatalog(items, mode) });
2910
+ },
2763
2911
  "GET /settings": ({ namespace }) => json3(200, runtime.instance(namespace).state.current()),
2764
2912
  "PUT /settings": ({ body, namespace }) => {
2765
- if (!isRecord4(body)) return adminError3(400, "expected a JSON object");
2913
+ if (!isRecord5(body)) return adminError3(400, "expected a JSON object");
2766
2914
  const patch = {};
2767
2915
  if (body.tokenTtlSeconds !== void 0) {
2768
2916
  if (typeof body.tokenTtlSeconds !== "number")
@@ -2776,7 +2924,7 @@ var adminRoutes = (runtime) => ({
2776
2924
  if (body.clients !== void 0) {
2777
2925
  if (!Array.isArray(body.clients))
2778
2926
  return adminError3(400, "clients: [{client_id, client_secret}]");
2779
- patch.clients = body.clients.filter(isRecord4).map((c) => ({
2927
+ patch.clients = body.clients.filter(isRecord5).map((c) => ({
2780
2928
  client_id: String(c.client_id),
2781
2929
  client_secret: String(c.client_secret)
2782
2930
  }));
@@ -2786,6 +2934,12 @@ var adminRoutes = (runtime) => ({
2786
2934
  if (typeof parsed === "string") return adminError3(400, parsed);
2787
2935
  patch.autoAdvance = parsed;
2788
2936
  }
2937
+ if (body.unknownPresets !== void 0) {
2938
+ if (body.unknownPresets !== "reject" && body.unknownPresets !== "accept") {
2939
+ return adminError3(400, 'unknownPresets: "reject" | "accept"');
2940
+ }
2941
+ patch.unknownPresets = body.unknownPresets;
2942
+ }
2789
2943
  return json3(200, runtime.instance(namespace).state.update(patch));
2790
2944
  },
2791
2945
  "POST /tick": ({ namespace }) => json3(200, { applied: runtime.instance(namespace).tick() })
@@ -2843,6 +2997,7 @@ var createRuntime2 = (options = {}) => {
2843
2997
  // src/index.ts
2844
2998
  var RXVORTEX_NAMESPACE = "rxvortex";
2845
2999
  var TOKEN_PREFIX = "rxv_";
3000
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2846
3001
  var base64url = (value) => toBase64(new TextEncoder().encode(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2847
3002
  var fromBase64url = (value) => {
2848
3003
  try {
@@ -2920,7 +3075,7 @@ var RxVortexAPI = class {
2920
3075
  this.now = options.now ?? (() => Date.now());
2921
3076
  this.onWebhook = options.onWebhook;
2922
3077
  this.state = new RxVortexState(sqlite, namespace, {
2923
- catalog: options.catalog ?? [],
3078
+ catalog: options.catalog,
2924
3079
  settings: options.settings ?? {}
2925
3080
  });
2926
3081
  const handlers = defineOperations({
@@ -2928,9 +3083,7 @@ var RxVortexAPI = class {
2928
3083
  CreateOrder: (context) => this.createOrder(context),
2929
3084
  GetOrder: (context) => this.getOrder(context),
2930
3085
  CancelOrder: (context) => this.cancelOrder(context),
2931
- ListPresetCatalogItems: () => jsonRes(200, {
2932
- data: this.state.catalog.list({ order: "oldest" }).map((row) => row.value)
2933
- })
3086
+ ListPresetCatalogItems: () => jsonRes(200, { data: this.state.catalogRows() })
2934
3087
  });
2935
3088
  this.service = createService({
2936
3089
  document,
@@ -3009,9 +3162,10 @@ var RxVortexAPI = class {
3009
3162
  }
3010
3163
  const order = body.order;
3011
3164
  const meds = body.medication_requests;
3165
+ const adoptable = (med) => this.state.current().unknownPresets === "accept" && UUID.test(med.preset_catalog_id);
3012
3166
  const unknown = meds.find((med) => {
3013
3167
  const item = this.state.catalog.get(med.preset_catalog_id);
3014
- return !item || item.status !== "active";
3168
+ return item ? item.status !== "active" : !adoptable(med);
3015
3169
  });
3016
3170
  if (unknown) {
3017
3171
  return jsonRes(422, {
@@ -3033,6 +3187,9 @@ var RxVortexAPI = class {
3033
3187
  { ids: { orderId: existing.order_tracking_id } }
3034
3188
  );
3035
3189
  }
3190
+ for (const med of meds) {
3191
+ if (!this.state.catalog.has(med.preset_catalog_id)) this.adoptPreset(med);
3192
+ }
3036
3193
  const now = this.iso();
3037
3194
  const created = {
3038
3195
  order_tracking_id: this.state.nextTrackingId(),
@@ -3076,6 +3233,25 @@ var RxVortexAPI = class {
3076
3233
  { ids }
3077
3234
  );
3078
3235
  }
3236
+ /**
3237
+ * Under `unknownPresets: "accept"`, a well-formed preset id the catalog lacks becomes an
3238
+ * active row described by the request, so later submits and catalog reads see it.
3239
+ */
3240
+ adoptPreset(med) {
3241
+ const item = {
3242
+ catalog_id: med.preset_catalog_id,
3243
+ medication_name: med.medication_name,
3244
+ medication_strength: med.medication_strength ?? null,
3245
+ package_size: null,
3246
+ quantity: med.quantity,
3247
+ quantity_units: med.quantity_units,
3248
+ medication_form: med.medication_form ?? null,
3249
+ route: null,
3250
+ states: [],
3251
+ status: "active"
3252
+ };
3253
+ this.state.putCatalog([item], "merge");
3254
+ }
3079
3255
  getOrder(context) {
3080
3256
  const order = this.state.findOrder(context.params.orderId ?? "");
3081
3257
  if (!order) return jsonRes(404, { message: "Order not found." });
@@ -3182,10 +3358,12 @@ export {
3182
3358
  supportedOperationIds,
3183
3359
  CUSTOM_CREAM_ANCHOR_PRESET_ID,
3184
3360
  DEFAULT_CATALOG,
3361
+ parseCatalogItem,
3362
+ parseCatalog,
3185
3363
  RXVORTEX_PRESETS,
3186
3364
  createRuntime2 as createRuntime,
3187
3365
  RXVORTEX_NAMESPACE,
3188
3366
  tokenCredential,
3189
3367
  RxVortexAPI
3190
3368
  };
3191
- //# sourceMappingURL=chunk-DNELVPK4.js.map
3369
+ //# sourceMappingURL=chunk-5KK6W2PE.js.map