@crvouga/mockingbird-service-aha

中文文档
3.11.0发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 1.6万

Stateful AHA phlebotomy emulator with signed HTTP orders, status webhooks, and a real deterministic SSH/SFTP result server.

原始使用文档
文档目录9 节
  1. Install
  2. Usage
  3. Routes
  4. Webhooks
  5. Admin (beyond the standard contract)
  6. Namespaces
  7. SFTP result delivery
  8. Deliberately not modelled
  9. API

@crvouga/mockingbird-service-aha

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator of the AHA (Advanced Health Academy) at-home phlebotomy partner API for test suites: HMAC-signed create-order and cancel, and — its main job — the order-status webhooks AHA posts back. The vendor has no pull API, so every downstream effect (EMR appointment booking, storefront status, "blood drawn") starts with a webhook; the emulator emits one on demand, with every field our handler reads, so the ZIP-routed bloodwork path can finally be tested.

  • Operation coverage: SUPPORT.md
  • The vendor publishes no spec: the contract (openapi.yaml) is hand-authored from our consumers' zod schemas and wire shapes.

Install

npm install -D @crvouga/mockingbird-service-aha

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-aha serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

Point the app at the emulator:

EnvValue
AHA_API_URLhttp://127.0.0.1:8799 (the lab-provider path already allows loopback; AhaService needs an http exception, see G-A1 / S10.2)
AHA_API_KEY / AHA_API_SECRETanything, or the pair passed as --api-key / --api-secret to verify signatures exactly
AHA_USE_LEGACY_AUTHtrue switches to X-<Partner>-Auth-Key (e.g. X-Acme-Auth-Key); both modes are accepted
AHA_WEBHOOK_SECRETthe same value as --webhook-secret
npx mockingbird-aha serve --port 8799 \
  --webhook-url http://127.0.0.1:3000/bloodwork/aha-webhook \
  --webhook-secret "$AHA_WEBHOOK_SECRET" \
  --api-key "$AHA_API_KEY" --api-secret "$AHA_API_SECRET" \
  --envelope raw --auto-schedule 2000
import { createHmac } from "node:crypto"
import { createServer } from "@crvouga/mockingbird-service-aha/server"

// Test setup: the emulator, posting order-status webhooks to the app's handler.
const server = await createServer({
  webhooks: { url: "http://127.0.0.1:3000/bloodwork/aha-webhook", secret: "aha-webhook-secret" },
})

// Application code: its usual signed request, with AHA_API_URL set to server.url.
const [apiUrl, apiKey, apiSecret] = [server.url, "aha-key", "aha-secret"]
const path = "/v1/acme/create-order"
const timestamp = Date.now().toString()
const signature = createHmac("sha256", apiSecret)
  .update(`${apiKey}:${path}:${timestamp}`)
  .digest("base64")
const created = await fetch(`${apiUrl}${path}`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "X-API-KEY": apiKey,
    "X-TIMESTAMP": timestamp,
    "X-SIGNATURE": signature,
  },
  body: JSON.stringify({
    partner_order_id: "AC-101", patient_id: "pat-1", biological_sex: "Female",
    patient_first_name: "Ada", patient_last_name: "Lovelace", patient_dob: "1990-12-10",
    patient_phone_number: "5555550100", patient_address_line1: "501 N 5th St",
    patient_city: "Phoenix", patient_state: "AZ", patient_zipcode: "85004",
    service_type: "Full Service", npi: "1234567893", ordering_physician: "Dr. Grace Hopper",
    test_codes: [{ test_code: "CBC", test_description: "Complete blood count" }],
  }),
})
// → {content: {partner_order_id: "AC-101", order_number: "AHA-…"}, message, status: "SUCCESS"}

// Test control: move the order as AHA would. Each transition posts a webhook to the app.
const transition = (body: unknown) =>
  fetch(`${server.url}/__admin/orders/AC-101/transition`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  })
// AHA books the draw: our handler books the EMR appointment for 10:30 Denver time.
await transition({
  status: "Scheduled",
  scheduledAt: "2026-10-01T16:30:00Z",
  timeZone: "America/Denver",
})
// The phlebotomist checks out with a sample: our handler sets vitalBloodDrawn.
await transition({ status: "Check Out", drawStatus: "Sample Collected" })
await server.close()

Routes

The {partner} path segment is your account's slug (e.g. acme); the emulator accepts any.

RouteBehaviour
POST /v1/{partner}/create-orderValidates the body (partner_order_id, patient fields, biological_sex, service_type, npi, ordering_physician, test_codes, optional preferred_schedule_date/time, patient_timezone). Create or update: a repeated partner_order_id keeps its order_number. Answers {content: {partner_order_id, order_number}, message, status: "SUCCESS"}.
POST /v1/{partner}/cancel{partner_order_id, notes: [{note_type: "CANCELLATION", notes}]} → {message, status}. Emits a Cancelled webhook (turn off with cancelWebhook: false). Unknown id → 404; an order whose sample was collected → 200 with status: "ERROR". The AHA order_number is accepted in partner_order_id too, because our lab-provider client sends it there (G-A1).

Auth. HMAC mode: X-API-KEY, X-TIMESTAMP (epoch ms, within ±5 min of wall-clock time), X-SIGNATURE = base64 HMAC-SHA256(secret, "<apiKey>:<path>:<timestamp>"), where path is the request path without host, body or /__admin/ns/<name> prefix. With a known key + secret (--api-key/--api-secret or credentials in settings) the signature is verified exactly; with none configured any key is accepted and the signature is checked for shape only. Legacy mode: X-<Partner>-Auth-Key (+ X-API-Version: 1.0), e.g. X-Acme-Auth-Key; any partner name is accepted. Failures are 401 {status: "ERROR", message}.

Envelope (G-A1). AhaService expects the raw {content, message, status}; AhaLabProvider expects {success: true, data: {…}}. Raw is the default; choose with --envelope raw|wrapped or per namespace with PUT /__admin/settings {"envelope": "wrapped"}.

Idempotency. X-Idempotency-Key (the lab-provider path): the same key and body replays the stored response (idempotent-replayed: true); the same key with a different body is 409.

Webhooks

POST <webhook-url> (our route: POST /bloodwork/aha-webhook) with Authorization: Token <AHA_WEBHOOK_SECRET>. Every body has status, partnerOrderId, ahaOrderId, plus, by status (all local times in the order's IANA zone):

statusExtra fields
Scheduled, RescheduledscheduleServiceTime (YYYY-MM-DDTHH:mm:ss, moment-parsable) and scheduleServiceTimeZone (IANA) — required by our handler though absent from the DTO — plus scheduledServiceDate/Time/TimeZone and scheduleConfirmationDate/Time/TimeZone
Check IncheckInDate, checkInTime, checkInTimeZone
Check OutdrawStatus (default Sample Collected), drawStatusDate/Time/TimeZone
Lab Testing In ProgressdropOffDate, dropOffTime, dropOffTimeZone
Cancelled, Non Scheduled Update—

drawStatus values: Sample Collected, Completed (drawn), Patient Refused, UTO, Patient Not Home, Patient Rescheduled, Order Cancelled, Others, Patient Asked to Reschedule (draw failed). Non-2xx answers are retried (immediately, 5 s, 5 min, 30 min, 2 h); GET /__admin/webhooks, …/events, …/replay, …/flush as usual.

Admin (beyond the standard contract)

RouteEffect
POST /__admin/orders/:partnerOrderId/transition{status, drawStatus?, scheduledAt?, timeZone?} emits the webhook. status is any value above (case and _ forgiven; unknown values are sent verbatim). scheduledAt (ISO or epoch ms) defaults to the order's preferred slot, else the next hour 24 h out; Rescheduled defaults to one day later. timeZone defaults to the order's patient_timezone, else America/New_York. :partnerOrderId may also be the order_number.
PUT /__admin/settings{envelope?, credentials?: [{apiKey, apiSecret?}], allowLegacy?, timestampToleranceMs?, defaultTimeZone?, cancelWebhook?, autoSchedule?: {afterMs, leadMs?} | ms | null} for the calling namespace. GET shows them with secrets masked.
POST /__admin/tickEmit every autoSchedule webhook that is due on the emulator clock (the served emulator ticks every 100 ms).
GET /__admin/ordersThe namespace's orders (ids, status, appointment, zone — no patient data).

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}): bad_signature (401), rate_limited (429 → rate_limit), server_error (500), order_error (200 with inner status: "ERROR"), invalid_response (200 with a body neither zod schema accepts), webhook_duplicate, webhook_reorder, webhook_drop.

Namespaces

x-mockingbird-namespace, a /__admin/ns/<name> prefix on AHA_API_URL (the signature still covers only the path after it), or by API key: PUT /__admin/credentials {"credentials": {"<AHA_API_KEY>": "<namespace>"}}.

SFTP result delivery

createAhaSftpServer from ./sftp starts a real SSH/SFTP server on an ephemeral port and shares order state with createRuntime. It supports password or public-key authentication, host-key verification, list/stat, binary upload/download, atomic temp-file rename, delete, nested directories, stable POSIX permissions and emulator-clock timestamps. The deterministic Ed25519 host key is stable between runs.

import { Client } from "ssh2"
import { createRuntime } from "@crvouga/mockingbird-service-aha"
import { createAhaSftpServer } from "@crvouga/mockingbird-service-aha/sftp"

// Test setup: the SFTP endpoint, sharing order state with the HTTP emulator, and one result.
const runtime = createRuntime()
const sftp = await createAhaSftpServer({
  runtime,
  accounts: [{ username: "aha", password: "local-test-password" }],
})
sftp.seed("/outbox/AHA-1001_result.pdf", new TextEncoder().encode("%PDF-1.4"))

// Application code: the ssh2 client it already uses. Only host, port and host key change.
const client = new Client()
await new Promise<void>((resolve, reject) =>
  client.once("ready", resolve).once("error", reject).connect({
    host: sftp.host,
    port: sftp.port,
    username: "aha",
    password: "local-test-password",
    hostVerifier: (key: Buffer) => key.equals(sftp.hostPublicKey),
  }),
)
const pdf = await new Promise<Buffer>((resolve, reject) =>
  client.sftp((error, session) => {
    if (error) return reject(error)
    session.readFile("/outbox/AHA-1001_result.pdf", (error, bytes) =>
      error ? reject(error) : resolve(bytes),
    )
  }),
)
client.end()
await sftp.close()

Use seed() to install arbitrary binary fixtures or publishResult(orderId, bytes) to place a stable AHA-…_result.pdf in /outbox. The first complete download moves the linked HTTP order to Lab Testing In Progress and emits its webhook; repeat polling/download does not repeat that transition. fault() forces the next operation to disconnect, deny permission, report disk-full, or accept only part of a write. journal() exposes paths, operation names, byte counts, and outcomes—never credentials or file contents. reset(namespace?) clears deterministic filesystem state. Duplicate destination names fail, so .tmp → final rename is atomic.

Deliberately not modelled

  • Downstream S3 ingestion and aha_results_queue processing after the SFTP handoff.
  • Real scheduling: AHA contacts the patient; nothing moves unless a test transitions the order or sets autoSchedule.
  • The serviceable-ZIP list (our app's own fixture decides eligibility before calling AHA).
  • Patient details are validated, never stored or echoed.
  • Which envelope the real vendor uses (G-A1): both are served, one per namespace.

API

ExportKindDescription
AhaAPIclassThe in-process emulator: fetch(request), reset(), transition(id, {status, drawStatus?, scheduledAt?, timeZone?}), tick(), orders(). Options: sqlite, now, namespace, settings, onWebhook, wallClock.
createRuntimefunctionThe emulator with the full service contract. Options: webhooks: {url, secret, retryDelaysMs?, fetch?}, settings, tickMs, wallClock, clock, seed, adminKey, onLog.
AHA_PRESETSobjectEvery named fault preset.
AHA_NAMESPACEstringThe service name, "aha".
WEBHOOK_PATHstring"/bloodwork/aha-webhook", our receiver's route.
ORDER_STATUSES, DRAW_STATUSESarraysThe vendor status spellings the webhooks use.
verifyAuthfunctionThe HMAC / legacy verification the emulator applies (an error message, or undefined).
apiKeyCredentialfunctionThe API key a request carries (how credentials map to namespaces).
isTimeZone, zonedParts, zonedToEpochfunctionsIANA-zone helpers used to fill the local date/time fields.
document, operationIds, supportedOperationIdsvaluesThe vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server)NodeServe over node:http (autoSchedule ticks every 100 ms); the serve CLI target; port 8799.
createAhaSftpServer (./sftp)NodeReal SSH/SFTP endpoint with deterministic host key/filesystem, shared order state, transfer controls, and an ephemeral port.

Part of mockingbird.

版本与发布记录

@crvouga/mockingbird-service-aha 共发布 42 个版本,首次发布于 2026/09/23;过去 12 个月发布了 42 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.11.0latest2026/11/11今天
3.10.92026/11/038 天前
3.10.82026/11/038 天前
3.10.72026/11/038 天前
3.10.62026/11/038 天前
3.10.52026/11/038 天前

关于 @crvouga/mockingbird-service-aha 的常见问题

@crvouga/mockingbird-service-aha 是什么?

Stateful AHA phlebotomy emulator with signed HTTP orders, status webhooks, and a real deterministic SSH/SFTP result server.

如何安装 @crvouga/mockingbird-service-aha?

在项目目录运行 npm install @crvouga/mockingbird-service-aha,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-aha,可以用 npx mockingbird-aha 直接运行。

@crvouga/mockingbird-service-aha 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-aha 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-aha 可以商用吗?

@crvouga/mockingbird-service-aha 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-aha 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-aha 在 2026/10/11 – 2026/11/09 这 30 天内被下载 16,247 次,日均约 542 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-aha 还在维护吗?

最新版本 3.11.0 发布于 2026/11/11(今天);至今共发布 42 个版本,其中过去 12 个月发布了 42 个;首次发布于 2026/09/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。