@sproutboat/wire 0.9.3 → 0.11.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,5 +1,29 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
8
+
9
+ - Both backends, the broker and the standalone client, check the address they resolved, not the hostname, so DNS rebinding can't get past the check. The broker connects to the vetted address and verifies TLS against the name.
10
+ - Blocked: loopback, RFC 1918, link-local and cloud metadata, CGNAT, benchmarking, documentation, multicast and reserved ranges, IPv6 ULA and link-local, and the IPv4 address inside IPv4-mapped, NAT64 and 6to4 addresses.
11
+ - An operator can let addresses through with `SB_EGRESS_ALLOW` (`*` or exact addresses) in the broker's or the binary's environment. Handler code can't.
12
+ - `@sproutboat/runtime`: `URL.hostname` and `URL.port` handle IPv6 literals (`[::1]:9` used to give hostname `[`). Every sprout gets `fetch()`, with no binding needed. `validateHttpSyncSource` no longer takes an `outboundAllowed` argument, and `Bindings` loses `outbound`.
13
+ - `@sproutboat/wire`: `Bindings` loses `outbound`.
14
+ - `@sproutboat/config`: `outbound` is accepted but ignored, with a warning in the new `warnings` field of a successful parse (`OUTBOUND_IGNORED`). It will be refused in a later minor release.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [4dd16b7]
19
+ - @sproutboat/runtime@0.14.0
20
+
21
+ ## 0.10.0
22
+
23
+ ### Minor Changes
24
+
25
+ - 276fd90: New `doDb` option and `--do-db` flag: Durable Object storage and alarms can live in their own SQLite file instead of the per-deployment database (baronunread/sproutboat#207). The platform points it at a per-project file so an object's state survives redeploys, as on Workers. Without it, behaviour is unchanged.
26
+
3
27
  ## 0.9.3
4
28
 
5
29
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,8 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@sproutboat/assets": "^0.3.0"
14
+ "@sproutboat/assets": "^0.3.0",
15
+ "@sproutboat/runtime": "^0.14.0"
15
16
  },
16
17
  "exports": {
17
18
  ".": "./src/index.ts",
@@ -29,7 +29,8 @@ afterEach(() => {
29
29
  });
30
30
 
31
31
  function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
32
- const b = createBroker(opts);
32
+ // A public address, so fetch tests are about fetch and not about DNS (#174).
33
+ const b = createBroker({ resolveImpl: async () => [{ address: "93.184.216.34" }], ...opts });
33
34
  brokers.push(b);
34
35
  return b;
35
36
  }
@@ -151,20 +152,70 @@ test("secrets resolve only when both bound and present", async () => {
151
152
  await expect(b.dispatch({ op: "secret.get", name: "UNBOUND" })).rejects.toThrow("not bound");
152
153
  });
153
154
 
154
- test("fetch is gated by the exact-host allowlist", async () => {
155
+ test("fetch reaches any public host, with no allowlist (#174)", async () => {
155
156
  const calls: string[] = [];
156
157
  const fetchImpl: FetchLike = async (url) => {
157
158
  calls.push(String(url));
158
159
  return new Response("body", { status: 200, headers: { "x-mark": "1" } });
159
160
  };
160
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
161
-
162
- await expect(b.dispatch({ op: "fetch", url: "https://evil.example.com/x" })).rejects.toThrow("allowlist");
163
- expect(calls).toEqual([]);
161
+ const b = make({ fetchImpl });
164
162
 
163
+ await expect(b.dispatch({ op: "fetch", url: "ftp://api.example.com/x" })).rejects.toThrow("unsupported protocol");
165
164
  const res = await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
166
165
  expect(res).toMatchObject({ ok: true, status: 200, body: "body" });
167
- expect(calls).toEqual(["https://api.example.com/x"]);
166
+ expect(calls).toEqual(["https://93.184.216.34/x"]);
167
+ });
168
+
169
+ test("#174: fetch never connects to a private address, whatever the name says", async () => {
170
+ const calls: string[] = [];
171
+ const fetchImpl: FetchLike = async (url) => {
172
+ calls.push(String(url));
173
+ return new Response("ok");
174
+ };
175
+ const resolving = (addresses: string[]) => async () => addresses.map((address) => ({ address }));
176
+
177
+ // A public-looking name that resolves somewhere private is refused before connect.
178
+ for (const address of ["127.0.0.1", "169.254.169.254", "10.0.0.7", "::1", "64:ff9b::7f00:1"]) {
179
+ const b = make({ fetchImpl, resolveImpl: resolving([address]), egressAllow: [] });
180
+ await expect(b.dispatch({ op: "fetch", url: "https://api.example.com/x" })).rejects.toThrow(
181
+ `api.example.com resolves to ${address}, a private or reserved address`,
182
+ );
183
+ }
184
+ expect(calls).toEqual([]);
185
+
186
+ // A mixed answer connects to the public address, not the first one.
187
+ const mixed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7", "2606:4700::1111"]), egressAllow: [] });
188
+ await mixed.dispatch({ op: "fetch", url: "https://api.example.com/x" });
189
+ expect(calls).toEqual(["https://[2606:4700::1111]/x"]);
190
+
191
+ // The operator, not the handler, can let one address through.
192
+ const allowed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7"]), egressAllow: ["10.0.0.7"] });
193
+ expect(await allowed.dispatch({ op: "fetch", url: "https://api.example.com/x" })).toMatchObject({ status: 200 });
194
+ });
195
+
196
+ test("#174: the pinned connection still sends the name as Host and verifies TLS against it", async () => {
197
+ const inits: RequestInit[] = [];
198
+ const fetchImpl: FetchLike = async (_url, init) => {
199
+ inits.push(init ?? {});
200
+ return new Response("ok");
201
+ };
202
+ const b = make({ fetchImpl });
203
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
204
+ expect(new Headers(inits[0].headers).get("host")).toBe("api.example.com");
205
+ expect(inits[0]).toMatchObject({ tls: { serverName: "api.example.com" } });
206
+ });
207
+
208
+ test("#174: a real request reaches an allowed local upstream through the pinned address", async () => {
209
+ const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => new Response(req.headers.get("host")) });
210
+ try {
211
+ const host = `localhost:${upstream.port}`;
212
+ const b = make({ resolveImpl: undefined, egressAllow: ["127.0.0.1"] });
213
+ expect(await b.dispatch({ op: "fetch", url: `http://${host}/` })).toMatchObject({ status: 200, body: host });
214
+ const blocked = make({ resolveImpl: undefined, egressAllow: [] });
215
+ await expect(blocked.dispatch({ op: "fetch", url: `http://${host}/` })).rejects.toThrow("SB_EGRESS_ALLOW=");
216
+ } finally {
217
+ upstream.stop(true);
218
+ }
168
219
  });
169
220
 
170
221
  test("#232: a binary fetch request body is sent as its bytes, a text body as UTF-8", async () => {
@@ -173,7 +224,7 @@ test("#232: a binary fetch request body is sent as its bytes, a text body as UTF
173
224
  seen.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
174
225
  return new Response("ok");
175
226
  };
176
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
227
+ const b = make({ fetchImpl });
177
228
  // The runtime sends a binary body one char per byte, flagged bodyBinary.
178
229
  const raw = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
179
230
  await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: raw, bodyBinary: true });
@@ -186,7 +237,7 @@ test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded
186
237
  const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
187
238
  const fetchImpl: FetchLike = async (url) =>
188
239
  new Response(String(url).endsWith("/bin") ? binary : "café", { status: 200 });
189
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
240
+ const b = make({ fetchImpl });
190
241
 
191
242
  const raw = decodeV1Frame(
192
243
  await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "fetch", url: "https://api.example.com/bin" })),
@@ -706,6 +757,40 @@ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
706
757
  // #74 — a binding wired to an account-level resource stores in its own file
707
758
  // under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
708
759
  // broker with a fresh per-deployment db but the same resourceDir).
760
+ test("#207: Durable Object storage and alarms in doDb outlive a redeploy", async () => {
761
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-do-"));
762
+ try {
763
+ mkdirSync(join(root, "dep-a"), { recursive: true });
764
+ mkdirSync(join(root, "dep-b"), { recursive: true });
765
+ const doDb = join(root, "do_0123456789abcdef01234567.sqlite");
766
+ const bindings = { do: [{ binding: "COUNTER", className: "Counter" }] };
767
+
768
+ const first = createBroker({ db: join(root, "dep-a", "state.sqlite"), doDb, bindings });
769
+ await first.dispatch({ op: "do.storage.put", cls: "Counter", id: "global", key: "n", value: "41" });
770
+ await first.dispatch({ op: "do.alarm.set", cls: "Counter", id: "global", at: 1234 });
771
+ first.close();
772
+
773
+ // A redeploy: new per-deployment state.sqlite, same per-project doDb.
774
+ const second = createBroker({ db: join(root, "dep-b", "state.sqlite"), doDb, bindings });
775
+ expect(await second.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toEqual({
776
+ ok: true,
777
+ found: true,
778
+ value: "41",
779
+ });
780
+ expect(await second.dispatch({ op: "do.alarm.get", cls: "Counter", id: "global" })).toMatchObject({ at: 1234 });
781
+ second.close();
782
+
783
+ // Without doDb the state stays per deployment, as before.
784
+ const third = createBroker({ db: join(root, "dep-a", "state.sqlite"), bindings });
785
+ expect(await third.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toMatchObject({
786
+ found: false,
787
+ });
788
+ third.close();
789
+ } finally {
790
+ rmSync(root, { recursive: true, force: true });
791
+ }
792
+ });
793
+
709
794
  test("resource-backed KV persists across brokers and is shared by id", async () => {
710
795
  const root = mkdtempSync(join(tmpdir(), "sb-broker-res-"));
711
796
  try {
@@ -1077,21 +1162,21 @@ test("service: declared but not deployed says so, instead of a bare 502", async
1077
1162
  );
1078
1163
  });
1079
1164
 
1080
- test("service: a call is not subject to the outbound allowlist", async () => {
1081
- // The target is reached internally through the edge, so a project with no
1082
- // `outbound` hosts can still call a service binding.
1165
+ test("service: a call reaches the edge on loopback, which plain fetch() may not", async () => {
1166
+ // The target is reached internally through the edge, so the private-address
1167
+ // block (#174) does not apply to it.
1083
1168
  const fetchImpl: FetchLike = async () => new Response("ok", { status: 200 });
1084
1169
  const b = make({
1085
- bindings: { ...svcBindings, outbound: [] },
1170
+ bindings: svcBindings,
1086
1171
  services: { AUTH: "auth-api.andrea.example.com" },
1087
1172
  edgeUrl: "http://127.0.0.1:8080/",
1088
1173
  fetchImpl,
1174
+ resolveImpl: async () => [{ address: "127.0.0.1" }],
1175
+ egressAllow: [],
1089
1176
  });
1090
1177
  expect((await b.dispatch({ op: "service.fetch", binding: "AUTH", url: "https://service/" })).status).toBe(200);
1091
- // ...while real egress to the same host is still refused.
1092
- await expect(b.dispatch({ op: "fetch", url: "https://auth-api.andrea.example.com/" })).rejects.toThrow(
1093
- /outbound allowlist/,
1094
- );
1178
+ // ...while real egress to the same place is refused.
1179
+ await expect(b.dispatch({ op: "fetch", url: "http://127.0.0.1:8080/" })).rejects.toThrow("private or reserved");
1095
1180
  });
1096
1181
 
1097
1182
  // --- v1 frames (#63) --------------------------------------------------------
package/src/broker.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * reply payload : "<json>"
14
14
  *
15
15
  * ponytail: one SQLite file for KV, secrets from a plain JSON file, fetch
16
- * allowlisted by exact host. Encryption at rest, per-key TTLs, redirect
17
- * re-validation and private-IP blocking are v2 — the exact-host allowlist is
18
- * the only SSRF control today.
16
+ * to any public address, never a private one (#174).
17
+ * Encryption at rest and per-key TTLs are v2; redirects are never followed,
18
+ * so there is nothing to re-validate.
19
19
  */
20
20
  import { Database, type Statement } from "bun:sqlite";
21
21
  import { createHash, randomBytes } from "node:crypto";
@@ -24,13 +24,15 @@ import { open } from "node:fs/promises";
24
24
  import { dirname, join, normalize, resolve } from "node:path";
25
25
  import { parseArgs } from "node:util";
26
26
  import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
27
+ import { egressAllowList, egressRefusal } from "@sproutboat/runtime";
28
+ import { lookup } from "node:dns/promises";
29
+ import { isIP } from "node:net";
27
30
  import { isSafeInteger } from "./json";
28
31
  import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
29
32
 
30
33
  export type Bindings = {
31
34
  kv: string[];
32
35
  secrets: string[];
33
- outbound: string[];
34
36
  d1: string[];
35
37
  r2: string[];
36
38
  queues: string[];
@@ -85,6 +87,12 @@ export type BrokerOptions = {
85
87
  * data outlives a redeploy.
86
88
  */
87
89
  resourceDir?: string;
90
+ /**
91
+ * #207 — SQLite file for Durable Object storage and alarms. The supervisor
92
+ * points this at a per-project path, so an object's state outlives a
93
+ * redeploy as it does on Workers. Defaults to the per-broker `db`.
94
+ */
95
+ doDb?: string;
88
96
  /** Account whose R2 resources this broker may mutate. */
89
97
  ownerId?: string;
90
98
  /** Account-wide R2 byte ceiling. Zero disables the ceiling for local development. */
@@ -111,6 +119,11 @@ export type BrokerOptions = {
111
119
  assetsDir?: string;
112
120
  /** Injected in tests; defaults to the global `fetch`. */
113
121
  fetchImpl?: FetchLike;
122
+ /** Injected in tests; defaults to `dns.lookup` with every address. */
123
+ resolveImpl?: (hostname: string) => Promise<Array<{ address: string }>>;
124
+ /** #174 — private addresses `fetch()` may reach anyway. Defaults to
125
+ * `SB_EGRESS_ALLOW` (`*` or comma-separated exact addresses). */
126
+ egressAllow?: string[];
114
127
  };
115
128
 
116
129
  type SqlParam = string | number | null;
@@ -208,7 +221,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
208
221
  const bindings: Bindings = {
209
222
  kv: [],
210
223
  secrets: [],
211
- outbound: [],
212
224
  d1: [],
213
225
  r2: [],
214
226
  queues: [],
@@ -249,6 +261,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
249
261
  const replayed = new Map<string, Frame>();
250
262
  const serviceHosts = opts.services ?? {};
251
263
  const doFetch = opts.fetchImpl ?? fetch;
264
+ const resolveHost = opts.resolveImpl ?? ((hostname: string) => lookup(hostname, { all: true }));
265
+ const egressAllow = opts.egressAllow ?? egressAllowList(process.env.SB_EGRESS_ALLOW);
252
266
 
253
267
  const dbPath = opts.db ?? ":memory:";
254
268
  const inMemory = dbPath === ":memory:" || dbPath === "";
@@ -312,9 +326,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
312
326
  };
313
327
 
314
328
  const db = openStore(dbPath);
315
- // Durable Object storage and Analytics Engine rows stay in the per-broker db —
316
- // neither is an account-level resource (#74).
317
- db.exec(
329
+ // #207 — Durable Object state lives in its own file when the supervisor gives
330
+ // one (per project, stable across deploys); otherwise in the per-broker db.
331
+ const doDb = opts.doDb ? new Database(opts.doDb, { create: true }) : db;
332
+ if (doDb !== db) {
333
+ doDb.exec("PRAGMA journal_mode = WAL");
334
+ doDb.exec("PRAGMA synchronous = NORMAL");
335
+ }
336
+ doDb.exec(
318
337
  "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
319
338
  "PRIMARY KEY (cls, id, key))",
320
339
  );
@@ -324,12 +343,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
324
343
  );
325
344
  // #125 — at most one pending alarm per object, which is the Workers rule: a
326
345
  // later setAlarm replaces the earlier one rather than queueing beside it.
327
- db.exec(
346
+ doDb.exec(
328
347
  "CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, " +
329
348
  "attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
330
349
  );
331
350
  // Alarm polling reads the earliest due objects across classes.
332
- db.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
351
+ doDb.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
333
352
  // #69 — fixed-window rate-limiter counters, per binding name + key.
334
353
  db.exec(
335
354
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
@@ -1118,7 +1137,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1118
1137
  *
1119
1138
  * The size is the remote host's choice, and the body is held whole: measured
1120
1139
  * on a standalone binary, a 100 MB response took resident memory from 43 MB
1121
- * to 321 MB. One allowlisted upstream having a bad day should not be able to
1140
+ * to 321 MB. One upstream having a bad day should not be able to
1122
1141
  * end the process. 32 MiB by default, and the same variable the embedded
1123
1142
  * transport reads so both backends agree.
1124
1143
  */
@@ -1168,7 +1187,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1168
1187
  throw new Error(`invalid url: ${str(msg.url)}`);
1169
1188
  }
1170
1189
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`unsupported protocol: ${url.protocol}`);
1171
- if (!bindings.outbound.includes(url.host)) throw new Error(`host not in outbound allowlist: ${url.host}`);
1172
1190
 
1173
1191
  const headers = new Headers();
1174
1192
  if (Array.isArray(msg.headers)) {
@@ -1176,13 +1194,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1176
1194
  if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
1177
1195
  }
1178
1196
  }
1197
+ // #174 — resolve, vet every address, then connect to the vetted one, never
1198
+ // the name: handing the name to fetch() would resolve it again, and DNS
1199
+ // rebinding lives in that gap. TLS still verifies against the name.
1200
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
1201
+ let addresses: Array<{ address: string }>;
1202
+ try {
1203
+ addresses = await resolveHost(hostname);
1204
+ } catch {
1205
+ throw new Error(`could not resolve ${url.hostname}`);
1206
+ }
1207
+ let refusal: string | null = null;
1208
+ const vetted = addresses.find(({ address }) => {
1209
+ const why = egressRefusal(url.hostname, address, egressAllow);
1210
+ refusal ??= why;
1211
+ return why === null;
1212
+ });
1213
+ if (!vetted) throw new Error(refusal ?? `could not resolve ${url.hostname}`);
1214
+ const pinned = new URL(url);
1215
+ pinned.hostname = isIP(vetted.address) === 6 ? `[${vetted.address}]` : vetted.address;
1216
+ headers.set("host", url.host);
1217
+
1179
1218
  const method = str(msg.method || "GET").toUpperCase();
1180
- const res = await doFetch(url, {
1219
+ const init: RequestInit & { tls?: { serverName: string } } = {
1181
1220
  method,
1182
1221
  headers,
1183
1222
  body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1184
1223
  redirect: "manual",
1185
- });
1224
+ };
1225
+ // Bun's TLS option: SNI and certificate checks against the name, not the address.
1226
+ if (url.protocol === "https:" && !isIP(hostname)) init.tls = { serverName: hostname };
1227
+ const res = await doFetch(pinned, init);
1186
1228
  const outHeaders: Array<[string, string]> = [];
1187
1229
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1188
1230
  return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
@@ -1564,7 +1606,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1564
1606
 
1565
1607
  case "do.storage.get": {
1566
1608
  const cls = requireDoClass(msg.cls);
1567
- const row = db
1609
+ const row = doDb
1568
1610
  .query<{ value: string }, [string, string, string]>(
1569
1611
  "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
1570
1612
  )
@@ -1572,24 +1614,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1572
1614
  return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
1573
1615
  }
1574
1616
  case "do.storage.put":
1575
- db.query(
1617
+ doDb.query(
1576
1618
  "INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) " +
1577
1619
  "ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
1578
1620
  ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
1579
1621
  return { ok: true };
1580
1622
  case "do.storage.delete": {
1581
- const r = db
1623
+ const r = doDb
1582
1624
  .query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?")
1583
1625
  .run(requireDoClass(msg.cls), str(msg.id), str(msg.key));
1584
1626
  return { ok: true, deleted: r.changes > 0 };
1585
1627
  }
1586
1628
  case "do.storage.delete_all":
1587
- db.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1629
+ doDb.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1588
1630
  return { ok: true };
1589
1631
  case "do.storage.list": {
1590
1632
  const cls = requireDoClass(msg.cls);
1591
1633
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
1592
- const rows = db
1634
+ const rows = doDb
1593
1635
  .query<{ key: string; value: string }, [string, string, string, number]>(
1594
1636
  "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
1595
1637
  )
@@ -1600,19 +1642,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1600
1642
  // #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
1601
1643
  // for that object, and attempts resets because this is a fresh schedule.
1602
1644
  case "do.alarm.set":
1603
- db.query(
1645
+ doDb.query(
1604
1646
  "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
1605
1647
  "ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
1606
1648
  ).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
1607
1649
  return { ok: true };
1608
1650
  case "do.alarm.get": {
1609
- const row = db
1651
+ const row = doDb
1610
1652
  .query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
1611
1653
  .get(requireDoClass(msg.cls), str(msg.id));
1612
1654
  return { ok: true, at: row ? row.at : null };
1613
1655
  }
1614
1656
  case "do.alarm.delete": {
1615
- const r = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1657
+ const r = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1616
1658
  return { ok: true, deleted: r.changes > 0 };
1617
1659
  }
1618
1660
 
@@ -1930,14 +1972,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1930
1972
  function fireAlarmsOnce(): void {
1931
1973
  if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0) return;
1932
1974
  const now = Date.now();
1933
- const due = db
1975
+ const due = doDb
1934
1976
  .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
1935
1977
  "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
1936
1978
  )
1937
1979
  .all(now);
1938
1980
  if (due.length === 0) return;
1939
- const claim = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
1940
- const rearm = db.query(
1981
+ const claim = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
1982
+ const rearm = doDb.query(
1941
1983
  "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
1942
1984
  );
1943
1985
  for (const row of due) {
@@ -1980,6 +2022,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1980
2022
  for (const t of timers) clearInterval(t);
1981
2023
  for (const conn of d1Conns.values()) conn.close();
1982
2024
  for (const conn of resourceDbs.values()) conn.close();
2025
+ if (doDb !== db) doDb.close();
1983
2026
  db.close();
1984
2027
  },
1985
2028
  };
@@ -2086,6 +2129,7 @@ if (import.meta.main) {
2086
2129
  db: { type: "string" },
2087
2130
  "data-dir": { type: "string" },
2088
2131
  "resource-dir": { type: "string" },
2132
+ "do-db": { type: "string" },
2089
2133
  "owner-id": { type: "string" },
2090
2134
  "r2-quota-bytes": { type: "string" },
2091
2135
  "r2-resource-ids": { type: "string" },
@@ -2138,6 +2182,7 @@ if (import.meta.main) {
2138
2182
  db: values.db,
2139
2183
  dataDir: values["data-dir"],
2140
2184
  resourceDir: values["resource-dir"],
2185
+ doDb: values["do-db"],
2141
2186
  ownerId: values["owner-id"],
2142
2187
  r2QuotaBytes: Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
2143
2188
  r2ResourceIds: values["r2-resource-ids"] ? String(values["r2-resource-ids"]).split(",").filter(Boolean) : undefined,