@crvouga/mockingbird-service-twilio 3.10.5 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog — @crvouga/mockingbird-service-twilio
2
2
 
3
- ## 3.10.5 (2026-11-03)
3
+ ## 3.12.0 (2026-11-12)
4
+
5
+ ### Features
6
+
7
+ - one dashboard for every admin UI and notification view ([d9fdf4d](https://github.com/crvouga/mockingbird/commit/d9fdf4dc1faeaa5c385f34bd5516323e49284b4d))
8
+
9
+ ## 3.11.0 (2026-11-10)
10
+
11
+ ### Features
12
+
13
+ - mockingbird.yaml as the one way to configure cloud and local fleets ([f7df8a1](https://github.com/crvouga/mockingbird/commit/f7df8a1bb6d17ca1c211832ff895d3014c015fab))
14
+
15
+ ## 3.10.5 (2026-11-02)
4
16
 
5
17
  ### Fixes and improvements
6
18
 
@@ -11,16 +23,13 @@
11
23
  - parse form fields into a null-prototype object ([53e172f](https://github.com/crvouga/mockingbird/commit/53e172f9d639b0d2e50fcefc63c1fef9c808d398))
12
24
  - stop the page-hide save inheriting the last write's result as `force` ([551c2dc](https://github.com/crvouga/mockingbird/commit/551c2dcd3615f82e70e0cd0b8a11c82a663d18b6))
13
25
  - keep the record id in the State table's id column ([2fc9bee](https://github.com/crvouga/mockingbird/commit/2fc9beeba6dd8bfe129993a4ff53fbd2925bf569))
26
+ - list migrations applied in the same second in run order ([f8c0591](https://github.com/crvouga/mockingbird/commit/f8c059146a65bfaf7fe99b4d3b6e812e82602bb9))
14
27
  - prune idempotency keys after 24 hours on the mock clock ([094c8b1](https://github.com/crvouga/mockingbird/commit/094c8b124211ae02901c26eb96cca410434b855a))
15
28
  - drop the previous emulator's manifest when switching emulators ([7ed4666](https://github.com/crvouga/mockingbird/commit/7ed46661288662a3789dbe013f1a195905b217e4))
16
29
  - reject a migration list that repeats an id, naming it ([47721d9](https://github.com/crvouga/mockingbird/commit/47721d96a29f4b5184322d19c1b10750086e8da4))
17
30
  - save and replay a 500 the endpoint produced under an Idempotency-Key ([789bbcc](https://github.com/crvouga/mockingbird/commit/789bbcccb792cddaf27f4f6db6d2fcf2da1d07fe))
18
31
  - reserve the manual admin view id ([3d4da92](https://github.com/crvouga/mockingbird/commit/3d4da926182ce609fba44e0fd58abcbb253eee2c))
19
32
 
20
- ### Dependencies
21
-
22
- - `@crvouga/mockingbird-service-sqlite`
23
-
24
33
  ## 3.10.4 (2026-11-02)
25
34
 
26
35
  ### Fixes and improvements
package/DISCOVERY.md CHANGED
@@ -4,6 +4,35 @@ This is the installed-package index for coding agents and tooling. All relative
4
4
  inside `node_modules/@crvouga/mockingbird-service-twilio/`; no repository checkout is needed to discover the emulator's
5
5
  supported surface or documented behavior.
6
6
 
7
+ ## Run it in a fleet
8
+
9
+ The recommended way to run this emulator is in a fleet, next to the other emulators the
10
+ application calls: one `mockingbird.json`, one process, each emulator on its own port.
11
+
12
+ ```sh
13
+ npm install -D @crvouga/mockingbird-fleet @crvouga/mockingbird-service-twilio
14
+ ```
15
+
16
+ `mockingbird.json` (add one entry per installed emulator; `"port": 0` picks a free port):
17
+
18
+ ```json
19
+ {
20
+ "services": {
21
+ "twilio": { "port": 0 }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ```sh
27
+ npx mockingbird-fleet serve --config mockingbird.json --dashboard --ready-file ready.json
28
+ ```
29
+
30
+ Read this emulator's URL from `services.<name>.url` in `ready.json` and point the client at it.
31
+ `adminBase` in the same file is the fleet controller, which resets, snapshots and restores every
32
+ emulator in one call, and `dashboard` is one page with every HTTP emulator's admin UI and the
33
+ notifications the application sent. The guide: <https://mockingbird.chrisvouga.dev/docs/fleets>. The README covers running this
34
+ emulator alone or in-process.
35
+
7
36
  ## Capability and behavior sources
8
37
 
9
38
  | Question | Authoritative file | What it contains |
package/README.md CHANGED
@@ -117,7 +117,7 @@ await server.close()
117
117
  | `POST /verify/v2/Services/{VA}/Verifications` | `To` (valid E.164, or an email for `Channel=email`), `Channel`, optional `CustomCode`. Creates a `VE…` verification with a 6-digit code (or `fixedCode`). Sending again to the same pending verification keeps its sid and code and adds a `send_code_attempts` entry. The 6th send to one number within 10 minutes is 429 `60203`. A bad `To` is 400 `60200`. An unknown service sid (not `VA` + 32 hex) is 404 `20404`. |
118
118
  | `POST /verify/v2/Services/{VA}/VerificationCheck` | `Code` plus `VerificationSid` or `To`. The right code returns 200 `approved`, `valid: true`. A wrong code returns 200 `pending` and counts an attempt. The 6th check is 429 `60202`. A missing, approved, canceled or expired (10 minutes on the emulator clock) verification is 404 `20404`. |
119
119
  | `GET` / `POST /verify/v2/Services/{VA}/Verifications/{VE}` | Fetches a verification, or sets `Status=canceled\|approved` on one. |
120
- | `POST /api/2010-04-01/Accounts/{AC}/Messages.json` | `To`, `Body` or `MediaUrl`, and `From` or `MessagingServiceSid`. Returns 201 with an `SM…` sid (`MM…` with media) and RFC 2822 dates. `MessagingServiceSid` alone is status `accepted`, `from: null`, `num_segments: "0"` (a sender is not chosen yet). `From` alone is status `queued`. Both are kept and the status is `queued` (Twilio queues on a specific sender). There is no idempotency key: the same body creates another sid. Errors: 21604 (no To), 21602 (no Body), 21603 (no From), 21211 (invalid To), 21617 (over 1600 characters). The message is recorded in the outbox. |
120
+ | `POST /api/2010-04-01/Accounts/{AC}/Messages.json` | `To`, `Body` or `MediaUrl`, and `From` or `MessagingServiceSid`. Returns 201 with an `SM…` sid (`MM…` with media) and RFC 2822 dates. `MessagingServiceSid` alone is status `accepted`, `from: null`, `num_segments: "0"` (a sender is not chosen yet). `From` alone is status `queued`. Both are kept and the status is `queued` (Twilio queues on a specific sender). There is no idempotency key: the same body creates another sid. Errors: 21604 (no To), 21602 (no Body), 21603 (no From), 21211 (invalid To), 21617 (over 1600 characters). The message is recorded in the outbox. **WhatsApp:** a `To` of `whatsapp:+15551234567` sends a WhatsApp message; the number behind the prefix is validated the same way (21211 when it is not a phone number), the response's `to` keeps the `whatsapp:` prefix, and the outbox entry has `channel: "whatsapp"` instead of `"sms"`. Pair it with a `whatsapp:` `From`, form-encoded so the `+` survives (`--data-urlencode` in curl). |
121
121
  | `GET /api/2010-04-01/Accounts/{AC}/Messages/{SM}.json` | Reads a message back. |
122
122
  | `GET /api/2010-04-01/Accounts/{AC}/Messages.json` | Newest-first Message resources for that account. `PageSize` defaults to 50 (1–1000), `PageToken` follows `next_page_uri`/`previous_page_uri`; `Page` is client state. Links keep the vendor path without `/api`, so apply the normal product-prefix rewrite. `To` and `From` filter stored resources. |
123
123
  | `GET /api/2010-04-01/Accounts/{AC}/Recordings/{RE}.wav` | The recording as a RIFF WAV. With `RequestedChannels=2` you get both channels. Otherwise a dual-channel recording is mixed down to mono, as Twilio does. |
@@ -174,6 +174,8 @@ can also come from the `x-mockingbird-namespace` header or a `/__admin/ns/<name>
174
174
  - Delivery: messages stay `queued`/`accepted`, no status callbacks are sent (our consumer sets no
175
175
  `statusCallback`), and no voice calls, conferences or media streams happen.
176
176
  - Message collection `DateSent` filters (messages are never sent by the emulator).
177
+ - WhatsApp beyond the Messages API address form: no sender registration, message
178
+ template or 24-hour session window is enforced, and a `whatsapp:` `From` is stored as sent.
177
179
  - Paid Lookup data packages (`Fields=line_type_intelligence`, `caller_name`, …) are always
178
180
  `null`.
179
181
  - Verify channels other than SMS are accepted and recorded, but nothing is delivered. Verify
@@ -9,7 +9,7 @@ import {
9
9
  parseFaultHeader,
10
10
  resolveAdminPrefix,
11
11
  restoreClock
12
- } from "./chunk-G7SKB56Q.js";
12
+ } from "./chunk-RDYQAAJW.js";
13
13
 
14
14
  // ../../adapters/node/dist/cli.js
15
15
  import { readFile as readFile5 } from "node:fs/promises";
@@ -1662,9 +1662,7 @@ data: ${JSON.stringify(data)}
1662
1662
  async handle(request, path, namespace, body, admin) {
1663
1663
  const route = path.slice("/fleet".length), method = request.method;
1664
1664
  if (path === "/ui")
1665
- return new Response(fleetConsole(this.ctx.config.adminPrefix ?? "/__admin"), {
1666
- headers: { "content-type": "text/html; charset=utf-8" }
1667
- });
1665
+ return new Response(fleetConsole(this.ctx.config.adminPrefix ?? "/__admin", this.ctx.dashboard?.()), { headers: { "content-type": "text/html; charset=utf-8" } });
1668
1666
  if (route === "" && method === "GET")
1669
1667
  return Response.json({
1670
1668
  ...this.capability(),
@@ -2634,7 +2632,8 @@ data: ${JSON.stringify(data)}
2634
2632
  clearInterval(this.expiryTimer);
2635
2633
  }
2636
2634
  };
2637
- var fleetConsole = (prefix) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Mockingbird Fleet</title><style>body{font:16px system-ui;max-width:1000px;margin:3rem auto;padding:1rem;background:#10151b;color:#e0e9f2}input,select,button{padding:.7rem;margin:.3rem;background:#233142;color:inherit;border:1px solid #60768b;border-radius:5px}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#9fdbff}</style><h1>Mockingbird Fleet</h1><label>Admin key <input id="key" type="password" autocomplete="off"></label><label>Namespace <input id="ns" value="default"></label><button id="refresh">Refresh</button><section id="children"></section><label>Action <select id="action"><option value="snapshots">Capture checkpoint</option><option value="reset">Reset namespace</option></select></label><button id="run">Run</button><h2>Lifecycle and operations</h2><pre id="result"></pre><script type="module">const base=${JSON.stringify(`${prefix}/fleet`)};const key=document.querySelector('#key'),ns=document.querySelector('#ns'),result=document.querySelector('#result');async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'content-type':'application/json','x-mockingbird-admin-key':key.value},...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();result.textContent=JSON.stringify(data,null,2);return data}document.querySelector('#refresh').onclick=async()=>{const data=await api('');const host=document.querySelector('#children');host.replaceChildren();for(const [name,child]of Object.entries(data.manifest?.services??{})){const link=document.createElement('a');link.href=child.adminUrl;link.textContent=name+' ('+child.protocol+') ';host.append(link)}await api('/operations?namespace='+encodeURIComponent(ns.value))};document.querySelector('#run').onclick=()=>api('/namespaces/'+encodeURIComponent(ns.value)+'/'+document.querySelector('#action').value,'POST',{});</script></html>`;
2635
+ var escapeAttribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
2636
+ var fleetConsole = (prefix, dashboard) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Mockingbird Fleet</title><style>body{font:16px system-ui;max-width:1000px;margin:3rem auto;padding:1rem;background:#10151b;color:#e0e9f2}input,select,button{padding:.7rem;margin:.3rem;background:#233142;color:inherit;border:1px solid #60768b;border-radius:5px}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#9fdbff}</style><h1>Mockingbird Fleet</h1>${dashboard ? `<p id="dashboard">Every emulator's admin UI and the notifications UI are on the <a href="${escapeAttribute(dashboard)}">dashboard</a>.</p>` : ""}<label>Admin key <input id="key" type="password" autocomplete="off"></label><label>Namespace <input id="ns" value="default"></label><button id="refresh">Refresh</button><section id="children"></section><label>Action <select id="action"><option value="snapshots">Capture checkpoint</option><option value="reset">Reset namespace</option></select></label><button id="run">Run</button><h2>Lifecycle and operations</h2><pre id="result"></pre><script type="module">const base=${JSON.stringify(`${prefix}/fleet`)};const key=document.querySelector('#key'),ns=document.querySelector('#ns'),result=document.querySelector('#result');async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'content-type':'application/json','x-mockingbird-admin-key':key.value},...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();result.textContent=JSON.stringify(data,null,2);return data}document.querySelector('#refresh').onclick=async()=>{const data=await api('');const host=document.querySelector('#children');host.replaceChildren();for(const [name,child]of Object.entries(data.manifest?.services??{})){const link=document.createElement('a');link.href=child.adminUrl;link.textContent=name+' ('+child.protocol+') ';host.append(link)}await api('/operations?namespace='+encodeURIComponent(ns.value))};document.querySelector('#run').onclick=()=>api('/namespaces/'+encodeURIComponent(ns.value)+'/'+document.querySelector('#action').value,'POST',{});</script></html>`;
2638
2637
 
2639
2638
  // ../../adapters/node/dist/fleet-preflight.js
2640
2639
  import { readFile, realpath } from "node:fs/promises";
@@ -2698,6 +2697,28 @@ var fleet_config_schema_default = {
2698
2697
  }
2699
2698
  }
2700
2699
  },
2700
+ dashboard: {
2701
+ description: "Serve the dashboard (every emulator's admin UI and the notifications UI) next to the fleet. Honored by mockingbird-fleet serve.",
2702
+ anyOf: [
2703
+ {
2704
+ type: "boolean"
2705
+ },
2706
+ {
2707
+ type: "object",
2708
+ additionalProperties: false,
2709
+ properties: {
2710
+ port: {
2711
+ type: "integer",
2712
+ minimum: 0,
2713
+ maximum: 65535
2714
+ },
2715
+ host: {
2716
+ type: "string"
2717
+ }
2718
+ }
2719
+ }
2720
+ ]
2721
+ },
2701
2722
  cors: {
2702
2723
  type: "object",
2703
2724
  required: ["origins"],
@@ -2982,6 +3003,7 @@ async function preflightFleet(config, options) {
2982
3003
  throw new Error("protected archive policy requires a protected repository; configure its protector before startup");
2983
3004
  resolveAdminPrefix(config.adminPrefix);
2984
3005
  normalizeBootControls(config.adminControls);
3006
+ validateDashboard(config.dashboard);
2985
3007
  if (config.idBase !== void 0)
2986
3008
  normalizeIdBase(config.idBase, "idBase");
2987
3009
  const targets = /* @__PURE__ */ new Map();
@@ -3057,7 +3079,7 @@ async function preflightFleet(config, options) {
3057
3079
  break;
3058
3080
  }
3059
3081
  }
3060
- targets.set(name, { ...target, packageVersion: packageVersion ?? "3.10.5" });
3082
+ targets.set(name, { ...target, packageVersion: packageVersion ?? "3.12.0" });
3061
3083
  } catch (error) {
3062
3084
  throw new Error(`service ${name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3063
3085
  }
@@ -3142,6 +3164,22 @@ function validateFleetPolicies(options) {
3142
3164
  if (duration !== void 0 && (!Number.isSafeInteger(duration) || duration < 1))
3143
3165
  throw new Error("hook deadlines must be positive integer milliseconds");
3144
3166
  }
3167
+ function validateDashboard(value) {
3168
+ if (value === void 0 || typeof value === "boolean")
3169
+ return;
3170
+ if (!value || typeof value !== "object" || Array.isArray(value))
3171
+ throw new Error("dashboard: expected true, false or { port, host }");
3172
+ for (const [key, entry] of Object.entries(value)) {
3173
+ if (key === "port") {
3174
+ if (!Number.isInteger(entry) || entry < 0 || entry > 65535)
3175
+ throw new Error("dashboard.port: expected an integer from 0 to 65535");
3176
+ } else if (key === "host") {
3177
+ if (typeof entry !== "string" || !entry)
3178
+ throw new Error("dashboard.host: expected a host");
3179
+ } else
3180
+ throw new Error(`dashboard.${key}: unknown key; expected port or host`);
3181
+ }
3182
+ }
3145
3183
 
3146
3184
  // ../../adapters/node/dist/serve.js
3147
3185
  import { createServer } from "node:http";
@@ -3597,6 +3635,7 @@ async function startFleet(config, options) {
3597
3635
  const checkpoints = /* @__PURE__ */ new Map();
3598
3636
  let supervisor;
3599
3637
  let management;
3638
+ let dashboard;
3600
3639
  let manifest;
3601
3640
  const privateConnections = () => Object.fromEntries([...children].map(([name, child]) => [
3602
3641
  name,
@@ -3664,6 +3703,7 @@ async function startFleet(config, options) {
3664
3703
  }
3665
3704
  }
3666
3705
  const cleanup = await Promise.allSettled([
3706
+ dashboard?.close(),
3667
3707
  supervisor?.close(),
3668
3708
  removeOwned(options.readyFile, id),
3669
3709
  removeOwned(options.connectionsFile, id)
@@ -4030,6 +4070,7 @@ async function startFleet(config, options) {
4030
4070
  clock,
4031
4071
  transaction,
4032
4072
  manifest: () => manifest,
4073
+ dashboard: () => dashboard?.url,
4033
4074
  publish,
4034
4075
  recovery,
4035
4076
  controlling
@@ -4093,6 +4134,10 @@ async function startFleet(config, options) {
4093
4134
  }
4094
4135
  }
4095
4136
  await management.startPolicies();
4137
+ if (options.dashboard) {
4138
+ dashboard = await options.dashboard({ manifest, config });
4139
+ manifest.dashboard = dashboard.url;
4140
+ }
4096
4141
  await publish();
4097
4142
  if (manifest.state === "ready")
4098
4143
  options.onReady?.(manifest);
@@ -4924,6 +4969,8 @@ var serveCommand = (target) => ({
4924
4969
  const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
4925
4970
  if (adminKey2 !== void 0)
4926
4971
  config.adminKey = adminKey2;
4972
+ if (config.dashboard)
4973
+ console.error(`${configPath}: "dashboard" is served by mockingbird-fleet (npx @crvouga/mockingbird-fleet serve --config ${configPath}); a service's own serve command starts the fleet without it`);
4927
4974
  const adminControls = await resolveServeControls(values, process.env, config.adminControls);
4928
4975
  if (adminControls !== void 0)
4929
4976
  config.adminControls = adminControls;
@@ -5122,4 +5169,4 @@ export {
5122
5169
  createServer2 as createServer,
5123
5170
  serveTarget
5124
5171
  };
5125
- //# sourceMappingURL=chunk-6GAK6VIP.js.map
5172
+ //# sourceMappingURL=chunk-45C4QYRB.js.map