@sproutboat/runtime 0.2.0 → 0.4.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,64 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `crypto.subtle` subset (baronunread/sproutboat#133): `digest` (SHA-256 / SHA-384
8
+ / SHA-512) and HMAC `importKey` / `sign` / `verify` over a raw key. Enough for
9
+ JWTs and hand-rolled sessions; no ECDSA, AES or key wrapping.
10
+
11
+ Backed by ~300 lines of reference SHA-2 as inline C rather than BearSSL —
12
+ BearSSL links only in `--standalone` builds and the prelude C is shared with the
13
+ broker transport, so a link dependency would break non-standalone builds. Pure C
14
+ is transport-independent and lets a handler drop a vendored pure-JS SHA-256.
15
+ Verified against NIST vectors on both transports; surface is exactly standard so
16
+ it deletes cleanly when Porffor ships Web Crypto (CanadaHonk/porffor#347).
17
+ - Rate Limiting binding (baronunread/sproutboat#69): `env.<NAME>.limit({ key }) ->
18
+ { success }`.
19
+
20
+ - Config: `ratelimiters: [{ binding, limit, period }]` in `sproutboat.jsonc` —
21
+ at most `limit` calls per `period` seconds, per key.
22
+ - A fixed-window counter (`ratelimit` table) on **both** transports: the
23
+ embedded one for standalone binaries, and the broker (`ratelimit.check` op,
24
+ in the replay-dedup set) for deployed sprouts. `limit` / `period` travel in
25
+ the message so the op stays stateless.
26
+ - `ponytail`: fixed window, so a burst across the boundary can briefly reach
27
+ ~2x; swap for a two-window weighted count if that matters.
28
+ - `crypto.scryptVerify(password, salt, expected, { N, r, p })` (baronunread/sproutboat#153):
29
+ a scrypt (RFC 7914) implementation as inline C — PBKDF2-HMAC-SHA256 (reusing
30
+ #133's HMAC) + Salsa20/8 + BlockMix + ROMix.
31
+
32
+ Deliberately verify-only (re-derive and constant-time compare, no exposed raw
33
+ `scrypt()`): a migration path for password hashes made by Node/Bun `scrypt`
34
+ (e.g. `N=32768 r=8 p=3`), not a KDF blessed for new credentials — those should
35
+ use HMAC/PBKDF2 via `crypto.subtle`. Verified against the RFC 7914 §12 vector.
36
+ `N*r` is capped so a bad config cannot ask for gigabytes of scratch.
37
+
38
+ ### Patch Changes
39
+
40
+ - Embedded transport: set `PRAGMA busy_timeout=5000` on every connection. Within
41
+ one binary all binding ops are serial so there is no self-contention, but when
42
+ several copies of a standalone binary share a data dir behind `SO_REUSEPORT`
43
+ (the #154 scaling recipe) a writer now waits for the WAL lock instead of
44
+ failing `SQLITE_BUSY`.
45
+
46
+ ## 0.3.0
47
+
48
+ ### Minor Changes
49
+
50
+ - Standalone runtime: client IP, D1 online backup, and a prepared-statement cache.
51
+
52
+ - `request.cf.clientIp` is populated from the connection's remote address
53
+ (`x-sb-remote-addr`, appended by the server and stripped from client input),
54
+ with `SB_TRUSTED_PROXIES` resolving `X-Forwarded-For` behind a reverse proxy.
55
+ IPv4-mapped IPv6 peers are folded to dotted form. (baronunread/sproutboat#163)
56
+ - `env.<D1>.backup(name?)` — a Sproutboat extension: an online, integrity-checked
57
+ single-file snapshot via `VACUUM INTO`, on both the embedded and broker
58
+ transports. New `d1.backup` op. (baronunread/sproutboat#164)
59
+ - The embedded transport caches prepared statements per database (FIFO, 32/db)
60
+ instead of recompiling the SQL on every binding op. (baronunread/sproutboat#155)
61
+
3
62
  ## 0.2.0
4
63
 
5
64
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,33 @@
1
1
  # `@sproutboat/runtime`
2
2
 
3
- Single source of truth for the sprout runtime: the binding/trigger wrapper,
4
- handler source validation, both transports (broker and embedded), and the
3
+ The sprout runtime as a unit: the binding and trigger wrapper, handler
4
+ source validation, both transports (broker and embedded), and the
5
5
  native-fetch prelude. These move as one because `wrap.ts` locates the
6
6
  prelude and transports by file URL beside itself.
7
+
8
+ ```sh
9
+ bun add @sproutboat/runtime
10
+ ```
11
+
12
+ ```ts
13
+ import { preludePath, transportPath, wrapNativeFetchHandler } from "@sproutboat/runtime";
14
+ import { validateHttpSyncSource } from "@sproutboat/runtime";
15
+ ```
16
+
17
+ ## API
18
+
19
+ - `wrapNativeFetchHandler(...)` turns a user's `export default { fetch }`
20
+ into a native-fetch module with bindings, triggers, and env installed.
21
+ `Bindings`, `EMPTY_BINDINGS`, `readVarsFromEnv`, `readBindingsFromEnv`,
22
+ and `neutraliseExports` travel with it.
23
+ - `preludePath` / `transportPath(transport)` locate the prelude and the
24
+ `broker` / `embedded` transports on disk. The prelude is read as text
25
+ and prepended before compilation, never imported.
26
+ - `TRANSPORT_MARKER`, `BASELINE_COMPATIBILITY_DATE`, `Transport` type.
27
+ - `validateHttpSyncSource(source)` checks a bundled handler module
28
+ (`SourceValidation`), used by project checks on both sides.
29
+
30
+ The prelude shims the Web API surface handlers expect (URL, Response,
31
+ crypto random values, binding shims, trigger dispatch) and is identical
32
+ for both transports, which is what lets one conformance suite hold them
33
+ honest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,66 @@
1
+ // #163 — the client-IP resolution helpers live inline in native-fetch-prelude.js
2
+ // (the prelude is prepended as text and cannot import), so this test lifts that
3
+ // one pure-JS block out and exercises it directly. If the block moves or its
4
+ // boundary markers change, this fails loudly rather than silently testing nothing.
5
+ import { expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
10
+ const start = src.indexOf("function __sbParseHex(");
11
+ const end = src.indexOf("globalThis.__sbEntry");
12
+ if (start === -1 || end === -1 || end < start) throw new Error("client-ip helper block not found in prelude");
13
+
14
+ let env = {};
15
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
16
+ const load = new Function(
17
+ "__envMap",
18
+ `${src.slice(start, end)}
19
+ const __sbEnv = (k) => __envMap[k];
20
+ return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList };`,
21
+ );
22
+ const H = load(new Proxy({}, { get: (_, k) => env[k] }));
23
+
24
+ const req = (headers) => ({ headers: { get: (k) => headers[k.toLowerCase()] ?? null } });
25
+
26
+ test("__sbNormalizeIp folds IPv4-mapped IPv6 to dotted quad", () => {
27
+ expect(H.__sbNormalizeIp("0000:0000:0000:0000:0000:ffff:7f00:0001")).toBe("127.0.0.1");
28
+ expect(H.__sbNormalizeIp("::ffff:198.51.100.9")).toBe("198.51.100.9");
29
+ expect(H.__sbNormalizeIp("203.0.113.7")).toBe("203.0.113.7");
30
+ expect(H.__sbNormalizeIp("2001:db8::1")).toBe("2001:db8::1"); // real IPv6 untouched
31
+ expect(H.__sbNormalizeIp("")).toBe("");
32
+ });
33
+
34
+ test("__sbIpInCidr does IPv4 range math without sign errors above 2^31", () => {
35
+ expect(H.__sbIpInCidr("10.1.2.3", "10.0.0.0/8")).toBe(true);
36
+ expect(H.__sbIpInCidr("11.0.0.1", "10.0.0.0/8")).toBe(false);
37
+ expect(H.__sbIpInCidr("203.0.113.7", "203.0.113.7")).toBe(true); // bare IP
38
+ expect(H.__sbIpInCidr("192.168.1.1", "0.0.0.0/0")).toBe(true);
39
+ // 224.x is > 2^31 as a uint32 — the divide-not-mask path.
40
+ expect(H.__sbIpInCidr("224.0.0.5", "224.0.0.0/24")).toBe(true);
41
+ expect(H.__sbIpInCidr("224.0.1.5", "224.0.0.0/24")).toBe(false);
42
+ });
43
+
44
+ test("__sbClientIp: no trusted proxies means the peer wins, XFF ignored", () => {
45
+ env = {};
46
+ const r = req({ "x-sb-remote-addr": "::ffff:203.0.113.7", "x-forwarded-for": "1.2.3.4" });
47
+ expect(H.__sbClientIp(r)).toBe("203.0.113.7");
48
+ });
49
+
50
+ test("__sbClientIp: a trusted peer resolves the rightmost untrusted XFF hop", () => {
51
+ env = { SB_TRUSTED_PROXIES: "127.0.0.0/8, 10.0.0.0/8" };
52
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "127.0.0.1", "x-forwarded-for": "203.0.113.7, 10.1.2.3" }))).toBe(
53
+ "203.0.113.7",
54
+ );
55
+ // every hop trusted → fall back to the peer
56
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "127.0.0.1", "x-forwarded-for": "10.9.9.9" }))).toBe("127.0.0.1");
57
+ // mapped-v6 entry in the chain is normalised
58
+ expect(
59
+ H.__sbClientIp(req({ "x-sb-remote-addr": "10.0.0.1", "x-forwarded-for": "::ffff:198.51.100.9, 10.1.2.3" })),
60
+ ).toBe("198.51.100.9");
61
+ });
62
+
63
+ test("__sbClientIp: an untrusted peer cannot use XFF even if it sends one", () => {
64
+ env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
65
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe("198.51.100.9");
66
+ });
@@ -4,8 +4,10 @@
4
4
  // json(). This adds, additively, the rest of the WHATWG surface Worker code
5
5
  // expects: URLSearchParams (read + write), URL.prototype.searchParams and the
6
6
  // protocol/host/hostname/port/hash accessors, static Response.json,
7
- // crypto.randomUUID / crypto.getRandomValues, and structuredClone. Each is
8
- // feature-detected; delete a block once Porffor ships that global.
7
+ // crypto.randomUUID / crypto.getRandomValues, crypto.subtle (digest + HMAC
8
+ // sign/verify, #133), and structuredClone. Each is feature-detected; delete a
9
+ // block once Porffor ships that global. crypto.scryptVerify (#153) is a
10
+ // Sproutboat extension, not WHATWG, and stays.
9
11
  // Tracked upstream in patches/UPSTREAM.md.
10
12
  //
11
13
  // Declared before it is referenced: a getter body that names a later top-level
@@ -276,6 +278,141 @@ if (globalThis.crypto.randomUUID == null) {
276
278
  };
277
279
  }
278
280
 
281
+ // #133 — a WebCrypto subset: crypto.subtle.digest (SHA-256/384/512) and HMAC
282
+ // sign/verify over a raw key. Enough for JWTs and hand-rolled sessions; not a
283
+ // complete SubtleCrypto (no ECDSA, no AES, no key wrapping). The surface is
284
+ // exactly standard so it can be deleted when Porffor ships Web Crypto
285
+ // (CanadaHonk/porffor#347). Backed by the inline-C SHA-2 above, not a link
286
+ // dependency, so it works on the broker transport too.
287
+ function __sbHashBits(algo) {
288
+ const n = String((algo && algo.name) || algo || "").toUpperCase();
289
+ if (n === "SHA-256" || n === "SHA256") return "256";
290
+ if (n === "SHA-384" || n === "SHA384") return "384";
291
+ if (n === "SHA-512" || n === "SHA512") return "512";
292
+ return "";
293
+ }
294
+ // -> a latin1 string, one char per byte. Strings are UTF-8 encoded (matching
295
+ // TextEncoder); ArrayBuffer / typed-array input is copied byte for byte.
296
+ function __sbToBytes(input) {
297
+ if (input == null) return "";
298
+ if (__sbIsStr(input)) {
299
+ let s = "";
300
+ for (let i = 0; i < input.length; i++) {
301
+ const c = input.charCodeAt(i);
302
+ if (c < 0x80) s += String.fromCharCode(c);
303
+ else if (c < 0x800) s += String.fromCharCode(0xc0 | (c >> 6)) + String.fromCharCode(0x80 | (c & 0x3f));
304
+ else if (c >= 0xd800 && c < 0xdc00 && i + 1 < input.length) {
305
+ const cp = 0x10000 + ((c - 0xd800) << 10) + (input.charCodeAt(++i) - 0xdc00);
306
+ s +=
307
+ String.fromCharCode(0xf0 | (cp >> 18)) +
308
+ String.fromCharCode(0x80 | ((cp >> 12) & 0x3f)) +
309
+ String.fromCharCode(0x80 | ((cp >> 6) & 0x3f)) +
310
+ String.fromCharCode(0x80 | (cp & 0x3f));
311
+ } else
312
+ s +=
313
+ String.fromCharCode(0xe0 | (c >> 12)) +
314
+ String.fromCharCode(0x80 | ((c >> 6) & 0x3f)) +
315
+ String.fromCharCode(0x80 | (c & 0x3f));
316
+ }
317
+ return s;
318
+ }
319
+ const view = input.length !== undefined && input.buffer !== undefined ? input : new Uint8Array(input);
320
+ let s = "";
321
+ for (let i = 0; i < view.length; i++) s += String.fromCharCode(view[i] & 0xff);
322
+ return s;
323
+ }
324
+ function __sbBufFrom(latin1) {
325
+ const b = new Uint8Array(latin1.length);
326
+ for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
327
+ return b.buffer;
328
+ }
329
+ // Constant-time compare of two latin1 byte strings.
330
+ function __sbEqCt(a, b) {
331
+ if (a.length !== b.length) return false;
332
+ let diff = 0;
333
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
334
+ return diff === 0;
335
+ }
336
+ // An even-length all-hex string -> its bytes; anything else -> its raw bytes.
337
+ function __sbHexOrBytes(value) {
338
+ if (__sbIsStr(value) && value.length > 0 && value.length % 2 === 0) {
339
+ let out = "";
340
+ for (let i = 0; i < value.length; i++) {
341
+ const c = value.charCodeAt(i);
342
+ const d = c >= 48 && c <= 57 ? c - 48 : c >= 97 && c <= 102 ? c - 87 : c >= 65 && c <= 70 ? c - 55 : -1;
343
+ if (d < 0) return __sbToBytes(value);
344
+ if (i % 2 === 0) out += String.fromCharCode(d << 4);
345
+ else out = out.slice(0, -1) + String.fromCharCode(out.charCodeAt(out.length - 1) | d);
346
+ }
347
+ return out;
348
+ }
349
+ return __sbToBytes(value);
350
+ }
351
+
352
+ if (globalThis.crypto.subtle == null) {
353
+ globalThis.crypto.subtle = {
354
+ async digest(algo, data) {
355
+ const bits = __sbHashBits(algo);
356
+ if (!bits) throw new Error("crypto.subtle.digest: unsupported algorithm");
357
+ const out = __sbDigestRaw(bits, __sbToBytes(data));
358
+ if (!out) throw new Error("crypto.subtle.digest failed");
359
+ return __sbBufFrom(out);
360
+ },
361
+ async importKey(format, keyData, algo, extractable, usages) {
362
+ if (format !== "raw") throw new Error("crypto.subtle.importKey: only format 'raw' is supported");
363
+ const name = String((algo && algo.name) || algo || "").toUpperCase();
364
+ if (name !== "HMAC") throw new Error("crypto.subtle.importKey: only HMAC keys are supported");
365
+ const bits = __sbHashBits((algo && algo.hash) || "SHA-256");
366
+ if (!bits) throw new Error("crypto.subtle.importKey: unsupported hash");
367
+ return {
368
+ type: "secret",
369
+ extractable: !!extractable,
370
+ algorithm: { name: "HMAC", hash: { name: "SHA-" + bits } },
371
+ usages: usages || [],
372
+ __sbKey: __sbToBytes(keyData),
373
+ __sbBits: bits,
374
+ };
375
+ },
376
+ async sign(algo, key, data) {
377
+ if (!key || key.__sbKey == null) throw new Error("crypto.subtle.sign: only HMAC keys from importKey are supported");
378
+ const out = __sbHmacRaw(key.__sbBits, key.__sbKey, __sbToBytes(data));
379
+ if (!out) throw new Error("crypto.subtle.sign failed");
380
+ return __sbBufFrom(out);
381
+ },
382
+ async verify(algo, key, signature, data) {
383
+ if (!key || key.__sbKey == null) throw new Error("crypto.subtle.verify: only HMAC keys are supported");
384
+ const mac = __sbHmacRaw(key.__sbBits, key.__sbKey, __sbToBytes(data));
385
+ if (!mac) return false;
386
+ return __sbEqCt(mac, __sbToBytes(signature));
387
+ },
388
+ };
389
+ }
390
+
391
+ // #153 — scrypt verification, for migrating password hashes made elsewhere
392
+ // (Node/Bun `scrypt`, N/r/p). Not on WebCrypto; deliberately verify-only, so it
393
+ // is a migration tool rather than a KDF blessed for new credentials — new
394
+ // credentials should use HMAC/PBKDF2 via crypto.subtle. `expected` is the stored
395
+ // hash: a hex string, or bytes (ArrayBuffer / Uint8Array).
396
+ if (globalThis.crypto.scryptVerify == null) {
397
+ globalThis.crypto.scryptVerify = function (password, salt, expected, params) {
398
+ const p = params || {};
399
+ const N = p.N || p.cost || 16384;
400
+ const r = p.r || p.blockSize || 8;
401
+ const par = p.p || p.parallelization || 1;
402
+ const want = __sbHexOrBytes(expected);
403
+ if (want.length === 0) return false;
404
+ const dk = __sbScryptRaw(
405
+ __sbToBytes(password),
406
+ __sbToBytes(salt),
407
+ String(N),
408
+ String(r),
409
+ String(par),
410
+ String(want.length),
411
+ );
412
+ return dk.length === want.length && __sbEqCt(dk, want);
413
+ };
414
+ }
415
+
279
416
  // ---------------------------------------------------------------------------
280
417
  // Bindings: env.<KV>, env.<SECRET>, env.<D1>, env.<R2>, and globalThis.fetch,
281
418
  // backed by a Bun broker on a loopback TCP port. The transport is inline C —
@@ -300,10 +437,291 @@ Porffor.c`
300
437
  #include <fcntl.h>
301
438
  #include <errno.h>
302
439
  #include <time.h>
440
+ #include <stdint.h>
303
441
 
304
442
  u32 porf_native_fetch_alloc_bytestring(const char* input, size_t len);
305
443
  int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_len, char** out_owned);
306
444
 
445
+ // --- #133: SHA-2 + HMAC ---------------------------------------------------
446
+ // Standalone links BearSSL, but a deployed sprout does not, and this inline C
447
+ // is shared by both transports — so a plain reference SHA-2 rather than a link
448
+ // dependency. It also lets a handler drop a vendored pure-JS SHA-256. Exposed
449
+ // as crypto.subtle.digest / sign / verify (HMAC), which deletes cleanly when
450
+ // Porffor ships Web Crypto (CanadaHonk/porffor#347).
451
+ #define SB_ROR32(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
452
+ #define SB_ROR64(x, n) (((x) >> (n)) | ((x) << (64 - (n))))
453
+
454
+ typedef struct { uint32_t h[8]; uint64_t len; unsigned char buf[64]; size_t n; } sb_sha256_ctx;
455
+ static const uint32_t sb_k256[64] = {
456
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
457
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
458
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
459
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
460
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
461
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
462
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
463
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 };
464
+ static void sb_sha256_block(sb_sha256_ctx* c, const unsigned char* p) {
465
+ uint32_t w[64], a,b,cc,d,e,f,g,h,t1,t2;
466
+ for (int i = 0; i < 16; i++)
467
+ w[i] = ((uint32_t)p[i*4]<<24)|((uint32_t)p[i*4+1]<<16)|((uint32_t)p[i*4+2]<<8)|((uint32_t)p[i*4+3]);
468
+ for (int i = 16; i < 64; i++) {
469
+ uint32_t s0 = SB_ROR32(w[i-15],7) ^ SB_ROR32(w[i-15],18) ^ (w[i-15]>>3);
470
+ uint32_t s1 = SB_ROR32(w[i-2],17) ^ SB_ROR32(w[i-2],19) ^ (w[i-2]>>10);
471
+ w[i] = w[i-16] + s0 + w[i-7] + s1;
472
+ }
473
+ a=c->h[0];b=c->h[1];cc=c->h[2];d=c->h[3];e=c->h[4];f=c->h[5];g=c->h[6];h=c->h[7];
474
+ for (int i = 0; i < 64; i++) {
475
+ uint32_t S1 = SB_ROR32(e,6) ^ SB_ROR32(e,11) ^ SB_ROR32(e,25);
476
+ uint32_t ch = (e & f) ^ (~e & g);
477
+ t1 = h + S1 + ch + sb_k256[i] + w[i];
478
+ uint32_t S0 = SB_ROR32(a,2) ^ SB_ROR32(a,13) ^ SB_ROR32(a,22);
479
+ uint32_t maj = (a & b) ^ (a & cc) ^ (b & cc);
480
+ t2 = S0 + maj;
481
+ h=g;g=f;f=e;e=d+t1;d=cc;cc=b;b=a;a=t1+t2;
482
+ }
483
+ c->h[0]+=a;c->h[1]+=b;c->h[2]+=cc;c->h[3]+=d;c->h[4]+=e;c->h[5]+=f;c->h[6]+=g;c->h[7]+=h;
484
+ }
485
+ static void sb_sha256_init(sb_sha256_ctx* c) {
486
+ c->h[0]=0x6a09e667;c->h[1]=0xbb67ae85;c->h[2]=0x3c6ef372;c->h[3]=0xa54ff53a;
487
+ c->h[4]=0x510e527f;c->h[5]=0x9b05688c;c->h[6]=0x1f83d9ab;c->h[7]=0x5be0cd19;c->len=0;c->n=0;
488
+ }
489
+ static void sb_sha256_update(sb_sha256_ctx* c, const unsigned char* p, size_t n) {
490
+ c->len += n;
491
+ while (n) {
492
+ size_t k = 64 - c->n; if (k > n) k = n;
493
+ memcpy(c->buf + c->n, p, k); c->n += k; p += k; n -= k;
494
+ if (c->n == 64) { sb_sha256_block(c, c->buf); c->n = 0; }
495
+ }
496
+ }
497
+ static void sb_sha256_final(sb_sha256_ctx* c, unsigned char out[32]) {
498
+ uint64_t bits = c->len * 8;
499
+ unsigned char pad = 0x80;
500
+ sb_sha256_update(c, &pad, 1);
501
+ unsigned char z = 0;
502
+ while (c->n != 56) sb_sha256_update(c, &z, 1);
503
+ unsigned char lb[8];
504
+ for (int i = 0; i < 8; i++) lb[i] = (unsigned char)(bits >> (56 - i*8));
505
+ sb_sha256_update(c, lb, 8);
506
+ for (int i = 0; i < 8; i++) {
507
+ out[i*4]=(unsigned char)(c->h[i]>>24);out[i*4+1]=(unsigned char)(c->h[i]>>16);
508
+ out[i*4+2]=(unsigned char)(c->h[i]>>8);out[i*4+3]=(unsigned char)c->h[i];
509
+ }
510
+ }
511
+
512
+ typedef struct { uint64_t h[8]; uint64_t lenhi, lenlo; unsigned char buf[128]; size_t n; } sb_sha512_ctx;
513
+ static const uint64_t sb_k512[80] = {
514
+ 0x428a2f98d728ae22ULL,0x7137449123ef65cdULL,0xb5c0fbcfec4d3b2fULL,0xe9b5dba58189dbbcULL,
515
+ 0x3956c25bf348b538ULL,0x59f111f1b605d019ULL,0x923f82a4af194f9bULL,0xab1c5ed5da6d8118ULL,
516
+ 0xd807aa98a3030242ULL,0x12835b0145706fbeULL,0x243185be4ee4b28cULL,0x550c7dc3d5ffb4e2ULL,
517
+ 0x72be5d74f27b896fULL,0x80deb1fe3b1696b1ULL,0x9bdc06a725c71235ULL,0xc19bf174cf692694ULL,
518
+ 0xe49b69c19ef14ad2ULL,0xefbe4786384f25e3ULL,0x0fc19dc68b8cd5b5ULL,0x240ca1cc77ac9c65ULL,
519
+ 0x2de92c6f592b0275ULL,0x4a7484aa6ea6e483ULL,0x5cb0a9dcbd41fbd4ULL,0x76f988da831153b5ULL,
520
+ 0x983e5152ee66dfabULL,0xa831c66d2db43210ULL,0xb00327c898fb213fULL,0xbf597fc7beef0ee4ULL,
521
+ 0xc6e00bf33da88fc2ULL,0xd5a79147930aa725ULL,0x06ca6351e003826fULL,0x142929670a0e6e70ULL,
522
+ 0x27b70a8546d22ffcULL,0x2e1b21385c26c926ULL,0x4d2c6dfc5ac42aedULL,0x53380d139d95b3dfULL,
523
+ 0x650a73548baf63deULL,0x766a0abb3c77b2a8ULL,0x81c2c92e47edaee6ULL,0x92722c851482353bULL,
524
+ 0xa2bfe8a14cf10364ULL,0xa81a664bbc423001ULL,0xc24b8b70d0f89791ULL,0xc76c51a30654be30ULL,
525
+ 0xd192e819d6ef5218ULL,0xd69906245565a910ULL,0xf40e35855771202aULL,0x106aa07032bbd1b8ULL,
526
+ 0x19a4c116b8d2d0c8ULL,0x1e376c085141ab53ULL,0x2748774cdf8eeb99ULL,0x34b0bcb5e19b48a8ULL,
527
+ 0x391c0cb3c5c95a63ULL,0x4ed8aa4ae3418acbULL,0x5b9cca4f7763e373ULL,0x682e6ff3d6b2b8a3ULL,
528
+ 0x748f82ee5defb2fcULL,0x78a5636f43172f60ULL,0x84c87814a1f0ab72ULL,0x8cc702081a6439ecULL,
529
+ 0x90befffa23631e28ULL,0xa4506cebde82bde9ULL,0xbef9a3f7b2c67915ULL,0xc67178f2e372532bULL,
530
+ 0xca273eceea26619cULL,0xd186b8c721c0c207ULL,0xeada7dd6cde0eb1eULL,0xf57d4f7fee6ed178ULL,
531
+ 0x06f067aa72176fbaULL,0x0a637dc5a2c898a6ULL,0x113f9804bef90daeULL,0x1b710b35131c471bULL,
532
+ 0x28db77f523047d84ULL,0x32caab7b40c72493ULL,0x3c9ebe0a15c9bebcULL,0x431d67c49c100d4cULL,
533
+ 0x4cc5d4becb3e42b6ULL,0x597f299cfc657e2aULL,0x5fcb6fab3ad6faecULL,0x6c44198c4a475817ULL };
534
+ static void sb_sha512_block(sb_sha512_ctx* c, const unsigned char* p) {
535
+ uint64_t w[80], a,b,cc,d,e,f,g,h,t1,t2;
536
+ for (int i = 0; i < 16; i++) {
537
+ w[i] = 0; for (int j = 0; j < 8; j++) w[i] = (w[i]<<8) | p[i*8+j];
538
+ }
539
+ for (int i = 16; i < 80; i++) {
540
+ uint64_t s0 = SB_ROR64(w[i-15],1) ^ SB_ROR64(w[i-15],8) ^ (w[i-15]>>7);
541
+ uint64_t s1 = SB_ROR64(w[i-2],19) ^ SB_ROR64(w[i-2],61) ^ (w[i-2]>>6);
542
+ w[i] = w[i-16] + s0 + w[i-7] + s1;
543
+ }
544
+ a=c->h[0];b=c->h[1];cc=c->h[2];d=c->h[3];e=c->h[4];f=c->h[5];g=c->h[6];h=c->h[7];
545
+ for (int i = 0; i < 80; i++) {
546
+ uint64_t S1 = SB_ROR64(e,14) ^ SB_ROR64(e,18) ^ SB_ROR64(e,41);
547
+ uint64_t ch = (e & f) ^ (~e & g);
548
+ t1 = h + S1 + ch + sb_k512[i] + w[i];
549
+ uint64_t S0 = SB_ROR64(a,28) ^ SB_ROR64(a,34) ^ SB_ROR64(a,39);
550
+ uint64_t maj = (a & b) ^ (a & cc) ^ (b & cc);
551
+ t2 = S0 + maj;
552
+ h=g;g=f;f=e;e=d+t1;d=cc;cc=b;b=a;a=t1+t2;
553
+ }
554
+ c->h[0]+=a;c->h[1]+=b;c->h[2]+=cc;c->h[3]+=d;c->h[4]+=e;c->h[5]+=f;c->h[6]+=g;c->h[7]+=h;
555
+ }
556
+ static void sb_sha512_init(sb_sha512_ctx* c, int is384) {
557
+ if (is384) {
558
+ c->h[0]=0xcbbb9d5dc1059ed8ULL;c->h[1]=0x629a292a367cd507ULL;c->h[2]=0x9159015a3070dd17ULL;c->h[3]=0x152fecd8f70e5939ULL;
559
+ c->h[4]=0x67332667ffc00b31ULL;c->h[5]=0x8eb44a8768581511ULL;c->h[6]=0xdb0c2e0d64f98fa7ULL;c->h[7]=0x47b5481dbefa4fa4ULL;
560
+ } else {
561
+ c->h[0]=0x6a09e667f3bcc908ULL;c->h[1]=0xbb67ae8584caa73bULL;c->h[2]=0x3c6ef372fe94f82bULL;c->h[3]=0xa54ff53a5f1d36f1ULL;
562
+ c->h[4]=0x510e527fade682d1ULL;c->h[5]=0x9b05688c2b3e6c1fULL;c->h[6]=0x1f83d9abfb41bd6bULL;c->h[7]=0x5be0cd19137e2179ULL;
563
+ }
564
+ c->lenhi=0;c->lenlo=0;c->n=0;
565
+ }
566
+ static void sb_sha512_update(sb_sha512_ctx* c, const unsigned char* p, size_t n) {
567
+ uint64_t add = n; if ((c->lenlo += add) < add) c->lenhi++;
568
+ while (n) {
569
+ size_t k = 128 - c->n; if (k > n) k = n;
570
+ memcpy(c->buf + c->n, p, k); c->n += k; p += k; n -= k;
571
+ if (c->n == 128) { sb_sha512_block(c, c->buf); c->n = 0; }
572
+ }
573
+ }
574
+ static void sb_sha512_final(sb_sha512_ctx* c, unsigned char* out, int is384) {
575
+ uint64_t bhi = (c->lenhi << 3) | (c->lenlo >> 61), blo = c->lenlo << 3;
576
+ unsigned char pad = 0x80;
577
+ sb_sha512_update(c, &pad, 1);
578
+ unsigned char z = 0;
579
+ while (c->n != 112) sb_sha512_update(c, &z, 1);
580
+ unsigned char lb[16];
581
+ for (int i = 0; i < 8; i++) lb[i] = (unsigned char)(bhi >> (56 - i*8));
582
+ for (int i = 0; i < 8; i++) lb[8+i] = (unsigned char)(blo >> (56 - i*8));
583
+ sb_sha512_update(c, lb, 16);
584
+ int words = is384 ? 6 : 8;
585
+ for (int i = 0; i < words; i++)
586
+ for (int j = 0; j < 8; j++) out[i*8+j] = (unsigned char)(c->h[i] >> (56 - j*8));
587
+ }
588
+
589
+ // One-shot digest. algo: 256 | 384 | 512. out must hold 32/48/64 bytes.
590
+ // Returns the digest length, or 0 for an unknown algo.
591
+ static size_t sb_digest(int algo, const unsigned char* msg, size_t mlen, unsigned char* out) {
592
+ if (algo == 256) { sb_sha256_ctx c; sb_sha256_init(&c); sb_sha256_update(&c, msg, mlen); sb_sha256_final(&c, out); return 32; }
593
+ if (algo == 384) { sb_sha512_ctx c; sb_sha512_init(&c, 1); sb_sha512_update(&c, msg, mlen); sb_sha512_final(&c, out, 1); return 48; }
594
+ if (algo == 512) { sb_sha512_ctx c; sb_sha512_init(&c, 0); sb_sha512_update(&c, msg, mlen); sb_sha512_final(&c, out, 0); return 64; }
595
+ return 0;
596
+ }
597
+
598
+ // HMAC(algo) per RFC 2104. Block size is 64 for SHA-256, 128 for SHA-384/512.
599
+ static size_t sb_hmac(int algo, const unsigned char* key, size_t klen,
600
+ const unsigned char* msg, size_t mlen, unsigned char* out) {
601
+ size_t bs = algo == 256 ? 64 : 128;
602
+ unsigned char k[128], ipad[128], opad[128], inner[64];
603
+ memset(k, 0, sizeof(k));
604
+ if (klen > bs) { sb_digest(algo, key, klen, k); }
605
+ else memcpy(k, key, klen);
606
+ for (size_t i = 0; i < bs; i++) { ipad[i] = k[i] ^ 0x36; opad[i] = k[i] ^ 0x5c; }
607
+ size_t dl;
608
+ if (algo == 256) {
609
+ sb_sha256_ctx c; sb_sha256_init(&c);
610
+ sb_sha256_update(&c, ipad, bs); sb_sha256_update(&c, msg, mlen); sb_sha256_final(&c, inner);
611
+ sb_sha256_ctx o; sb_sha256_init(&o);
612
+ sb_sha256_update(&o, opad, bs); sb_sha256_update(&o, inner, 32); sb_sha256_final(&o, out);
613
+ dl = 32;
614
+ } else {
615
+ int is384 = algo == 384; dl = is384 ? 48 : 64;
616
+ sb_sha512_ctx c; sb_sha512_init(&c, is384);
617
+ sb_sha512_update(&c, ipad, bs); sb_sha512_update(&c, msg, mlen); sb_sha512_final(&c, inner, is384);
618
+ sb_sha512_ctx o; sb_sha512_init(&o, is384);
619
+ sb_sha512_update(&o, opad, bs); sb_sha512_update(&o, inner, dl); sb_sha512_final(&o, out, is384);
620
+ }
621
+ return dl;
622
+ }
623
+
624
+ // --- #153: scrypt (RFC 7914), for verifying pre-existing password hashes ----
625
+ // PBKDF2-HMAC-SHA256 (reuses sb_hmac) + Salsa20/8 + BlockMix + ROMix. Exposed
626
+ // only as crypto.scryptVerify: a migration path for hashes made elsewhere
627
+ // (Node/Bun scrypt), not a blessed KDF for new credentials.
628
+ static void sb_pbkdf2_hmac256(const unsigned char* pw, size_t pwlen,
629
+ const unsigned char* salt, size_t saltlen,
630
+ uint32_t iters, unsigned char* out, size_t dklen) {
631
+ unsigned char block[64], u[32], t[32];
632
+ uint32_t i = 1;
633
+ size_t done = 0;
634
+ while (done < dklen) {
635
+ unsigned char* s2 = (unsigned char*)malloc(saltlen + 4);
636
+ memcpy(s2, salt, saltlen);
637
+ s2[saltlen] = (unsigned char)(i >> 24); s2[saltlen+1] = (unsigned char)(i >> 16);
638
+ s2[saltlen+2] = (unsigned char)(i >> 8); s2[saltlen+3] = (unsigned char)i;
639
+ sb_hmac(256, pw, pwlen, s2, saltlen + 4, u);
640
+ free(s2);
641
+ memcpy(t, u, 32);
642
+ // iters is 1 for scrypt's use of PBKDF2, but support the general case.
643
+ for (uint32_t j = 1; j < iters; j++) {
644
+ sb_hmac(256, pw, pwlen, u, 32, u);
645
+ for (int k = 0; k < 32; k++) t[k] ^= u[k];
646
+ }
647
+ memcpy(block, t, 32);
648
+ size_t take = dklen - done; if (take > 32) take = 32;
649
+ memcpy(out + done, block, take);
650
+ done += take; i++;
651
+ }
652
+ }
653
+ static void sb_salsa20_8(uint32_t out[16], const uint32_t in[16]) {
654
+ uint32_t x[16];
655
+ for (int i = 0; i < 16; i++) x[i] = in[i];
656
+ for (int i = 0; i < 4; i++) {
657
+ x[ 4] ^= SB_ROR32(x[ 0] + x[12], 32 - 7); x[ 8] ^= SB_ROR32(x[ 4] + x[ 0], 32 - 9);
658
+ x[12] ^= SB_ROR32(x[ 8] + x[ 4], 32 - 13); x[ 0] ^= SB_ROR32(x[12] + x[ 8], 32 - 18);
659
+ x[ 9] ^= SB_ROR32(x[ 5] + x[ 1], 32 - 7); x[13] ^= SB_ROR32(x[ 9] + x[ 5], 32 - 9);
660
+ x[ 1] ^= SB_ROR32(x[13] + x[ 9], 32 - 13); x[ 5] ^= SB_ROR32(x[ 1] + x[13], 32 - 18);
661
+ x[14] ^= SB_ROR32(x[10] + x[ 6], 32 - 7); x[ 2] ^= SB_ROR32(x[14] + x[10], 32 - 9);
662
+ x[ 6] ^= SB_ROR32(x[ 2] + x[14], 32 - 13); x[10] ^= SB_ROR32(x[ 6] + x[ 2], 32 - 18);
663
+ x[ 3] ^= SB_ROR32(x[15] + x[11], 32 - 7); x[ 7] ^= SB_ROR32(x[ 3] + x[15], 32 - 9);
664
+ x[11] ^= SB_ROR32(x[ 7] + x[ 3], 32 - 13); x[15] ^= SB_ROR32(x[11] + x[ 7], 32 - 18);
665
+ x[ 1] ^= SB_ROR32(x[ 0] + x[ 3], 32 - 7); x[ 2] ^= SB_ROR32(x[ 1] + x[ 0], 32 - 9);
666
+ x[ 3] ^= SB_ROR32(x[ 2] + x[ 1], 32 - 13); x[ 0] ^= SB_ROR32(x[ 3] + x[ 2], 32 - 18);
667
+ x[ 6] ^= SB_ROR32(x[ 5] + x[ 4], 32 - 7); x[ 7] ^= SB_ROR32(x[ 6] + x[ 5], 32 - 9);
668
+ x[ 4] ^= SB_ROR32(x[ 7] + x[ 6], 32 - 13); x[ 5] ^= SB_ROR32(x[ 4] + x[ 7], 32 - 18);
669
+ x[11] ^= SB_ROR32(x[10] + x[ 9], 32 - 7); x[ 8] ^= SB_ROR32(x[11] + x[10], 32 - 9);
670
+ x[ 9] ^= SB_ROR32(x[ 8] + x[11], 32 - 13); x[10] ^= SB_ROR32(x[ 9] + x[ 8], 32 - 18);
671
+ x[12] ^= SB_ROR32(x[15] + x[14], 32 - 7); x[13] ^= SB_ROR32(x[12] + x[15], 32 - 9);
672
+ x[14] ^= SB_ROR32(x[13] + x[12], 32 - 13); x[15] ^= SB_ROR32(x[14] + x[13], 32 - 18);
673
+ }
674
+ for (int i = 0; i < 16; i++) out[i] = x[i] + in[i];
675
+ }
676
+ // BlockMix: B is 2r 64-byte blocks (as uint32 words). Result into out.
677
+ static void sb_blockmix(const uint32_t* b, uint32_t* out, size_t r) {
678
+ uint32_t x[16], y[16];
679
+ memcpy(x, b + (2*r - 1) * 16, 64);
680
+ for (size_t i = 0; i < 2*r; i++) {
681
+ for (int k = 0; k < 16; k++) x[k] ^= b[i*16 + k];
682
+ sb_salsa20_8(y, x);
683
+ memcpy(x, y, 64);
684
+ size_t dst = (i % 2 == 0) ? (i/2) : (r + (i-1)/2);
685
+ memcpy(out + dst*16, x, 64);
686
+ }
687
+ }
688
+ static uint64_t sb_integerify(const uint32_t* b, size_t r) {
689
+ const uint32_t* p = b + (2*r - 1) * 16;
690
+ return ((uint64_t)p[0]) | (((uint64_t)p[1]) << 32);
691
+ }
692
+ // scrypt(pw, salt, N, r, p) -> dk[dklen]. Returns 0 on success, -1 on bad
693
+ // params or allocation failure. Caps N*r so a bad config cannot ask for GB.
694
+ static int sb_scrypt(const unsigned char* pw, size_t pwlen, const unsigned char* salt, size_t saltlen,
695
+ uint64_t N, uint32_t r, uint32_t p, unsigned char* dk, size_t dklen) {
696
+ if (r == 0 || p == 0 || N < 2 || (N & (N - 1)) != 0) return -1;
697
+ if ((uint64_t)r * N > (1ULL << 20)) return -1; // <= 128 MiB of V
698
+ size_t blk = 128 * (size_t)r; // one B block, bytes
699
+ unsigned char* b = (unsigned char*)malloc(blk * p);
700
+ uint32_t* v = (uint32_t*)malloc(blk * (size_t)N);
701
+ uint32_t* xy = (uint32_t*)malloc(blk * 2);
702
+ if (!b || !v || !xy) { free(b); free(v); free(xy); return -1; }
703
+ sb_pbkdf2_hmac256(pw, pwlen, salt, saltlen, 1, b, blk * p);
704
+ for (uint32_t i = 0; i < p; i++) {
705
+ uint32_t* x = (uint32_t*)(b + (size_t)i * blk);
706
+ uint32_t* y = xy;
707
+ for (uint64_t j = 0; j < N; j++) {
708
+ memcpy(v + j * (blk / 4), x, blk);
709
+ sb_blockmix(x, y, r);
710
+ memcpy(x, y, blk);
711
+ }
712
+ for (uint64_t j = 0; j < N; j++) {
713
+ uint64_t k = sb_integerify(x, r) & (N - 1);
714
+ const uint32_t* vk = v + k * (blk / 4);
715
+ for (size_t w = 0; w < blk / 4; w++) x[w] ^= vk[w];
716
+ sb_blockmix(x, y, r);
717
+ memcpy(x, y, blk);
718
+ }
719
+ }
720
+ sb_pbkdf2_hmac256(pw, pwlen, b, blk * p, 1, dk, dklen);
721
+ free(b); free(v); free(xy);
722
+ return 0;
723
+ }
724
+
307
725
  // Fill buf with n bytes from the OS CSPRNG. /dev/urandom is present on Linux and
308
726
  // macOS and inside the bubblewrap sandbox; blocking is not a concern after the
309
727
  // pool is seeded. Returns 0, or -1 if the source could not be read in full.
@@ -354,6 +772,101 @@ function __sbRandomBytes(nStr) {
354
772
  return out;
355
773
  }
356
774
 
775
+ // #133 — one-shot SHA-2 digest. `algoStr` is "256"|"384"|"512"; `data` is a
776
+ // latin1 string (one char per byte). Returns the raw digest as a bytestring.
777
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
778
+ function __sbDigestRaw(algoStr, data) {
779
+ let out = "";
780
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
781
+ Porffor.c`
782
+ const char* __as; size_t __asl; char* __aso = 0;
783
+ porf_native_fetch_read_value(algoStr, &__as, &__asl, &__aso);
784
+ char __ab[8]; size_t __ak = __asl < 7 ? __asl : 7;
785
+ memcpy(__ab, __as, __ak); __ab[__ak] = 0;
786
+ if (__aso) free(__aso);
787
+ int __algo = atoi(__ab);
788
+
789
+ const char* __d; size_t __dl; char* __do = 0;
790
+ porf_native_fetch_read_value(data, &__d, &__dl, &__do);
791
+ unsigned char __out[64];
792
+ size_t __n = sb_digest(__algo, (const unsigned char*)__d, __dl, __out);
793
+ if (__do) free(__do);
794
+ if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
795
+ `;
796
+ return out;
797
+ }
798
+
799
+ // #133 — HMAC-SHA-2. `algoStr` "256"|"384"|"512"; `key` and `data` latin1.
800
+ // Returns the raw MAC as a bytestring.
801
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
802
+ function __sbHmacRaw(algoStr, key, data) {
803
+ let out = "";
804
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
805
+ Porffor.c`
806
+ const char* __as; size_t __asl; char* __aso = 0;
807
+ porf_native_fetch_read_value(algoStr, &__as, &__asl, &__aso);
808
+ char __ab[8]; size_t __ak = __asl < 7 ? __asl : 7;
809
+ memcpy(__ab, __as, __ak); __ab[__ak] = 0;
810
+ if (__aso) free(__aso);
811
+ int __algo = atoi(__ab);
812
+
813
+ const char* __k; size_t __kl; char* __ko = 0;
814
+ porf_native_fetch_read_value(key, &__k, &__kl, &__ko);
815
+ unsigned char* __kc = (unsigned char*)malloc(__kl ? __kl : 1);
816
+ if (__kc) memcpy(__kc, __k, __kl);
817
+ if (__ko) free(__ko);
818
+
819
+ const char* __d; size_t __dl; char* __do = 0;
820
+ porf_native_fetch_read_value(data, &__d, &__dl, &__do);
821
+ unsigned char __out[64];
822
+ size_t __n = __kc ? sb_hmac(__algo, __kc, __kl, (const unsigned char*)__d, __dl, __out) : 0;
823
+ if (__do) free(__do);
824
+ if (__kc) free(__kc);
825
+ if (__n) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__out, __n), 195);
826
+ `;
827
+ return out;
828
+ }
829
+
830
+ // #153 — scrypt derivation. All params are decimal strings. Returns the derived
831
+ // key as a bytestring, or '' on bad params / allocation failure.
832
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
833
+ function __sbScryptRaw(pw, salt, nStr, rStr, pStr, dkLenStr) {
834
+ let out = "";
835
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
836
+ Porffor.c`
837
+ const char* __p; size_t __pl; char* __po = 0;
838
+ porf_native_fetch_read_value(pw, &__p, &__pl, &__po);
839
+ unsigned char* __pc = (unsigned char*)malloc(__pl ? __pl : 1);
840
+ if (__pc) memcpy(__pc, __p, __pl);
841
+ if (__po) free(__po);
842
+
843
+ const char* __s; size_t __sl; char* __so = 0;
844
+ porf_native_fetch_read_value(salt, &__s, &__sl, &__so);
845
+ unsigned char* __sc = (unsigned char*)malloc(__sl ? __sl : 1);
846
+ if (__sc) memcpy(__sc, __s, __sl);
847
+ if (__so) free(__so);
848
+
849
+ long __N = 0, __r = 0, __pp = 0, __dk = 0;
850
+ { const char* q; size_t ql; char* qo = 0; char nb[24];
851
+ porf_native_fetch_read_value(nStr, &q, &ql, &qo); { size_t k = ql < 23 ? ql : 23; memcpy(nb, q, k); nb[k] = 0; } if (qo) free(qo); __N = atol(nb);
852
+ porf_native_fetch_read_value(rStr, &q, &ql, &qo); { size_t k = ql < 23 ? ql : 23; memcpy(nb, q, k); nb[k] = 0; } if (qo) free(qo); __r = atol(nb);
853
+ porf_native_fetch_read_value(pStr, &q, &ql, &qo); { size_t k = ql < 23 ? ql : 23; memcpy(nb, q, k); nb[k] = 0; } if (qo) free(qo); __pp = atol(nb);
854
+ porf_native_fetch_read_value(dkLenStr, &q, &ql, &qo); { size_t k = ql < 23 ? ql : 23; memcpy(nb, q, k); nb[k] = 0; } if (qo) free(qo); __dk = atol(nb);
855
+ }
856
+ if (__pc && __sc && __dk > 0 && __dk <= 1024) {
857
+ unsigned char* __d = (unsigned char*)malloc((size_t)__dk);
858
+ if (__d) {
859
+ if (sb_scrypt(__pc, __pl, __sc, __sl, (uint64_t)__N, (uint32_t)__r, (uint32_t)__pp, __d, (size_t)__dk) == 0)
860
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__d, (size_t)__dk), 195);
861
+ free(__d);
862
+ }
863
+ }
864
+ if (__pc) free(__pc);
865
+ if (__sc) free(__sc);
866
+ `;
867
+ return out;
868
+ }
869
+
357
870
  // #63 — every request carries the protocol version it was built against and an
358
871
  // id unique to this process. The id is what makes a resend safe: the transport
359
872
  // retries the *same bytes*, so a broker that already applied the request can
@@ -421,6 +934,14 @@ function __sbMakeD1(dbName) {
421
934
  __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
422
935
  return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
423
936
  },
937
+ // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
938
+ // snapshot of this database. Standalone only for now — the broker transport
939
+ // returns an error until hosted backup (#139) covers it. `name` lands under
940
+ // `<data-dir>/backups/`; omit it for a timestamped default. See #164.
941
+ backup(name) {
942
+ const r = __sbRpc("d1.backup", { db: dbName, name: name == null ? "" : String(name) });
943
+ return { path: r.path, bytes: r.bytes };
944
+ },
424
945
  };
425
946
  }
426
947
 
@@ -585,6 +1106,21 @@ globalThis.__sbInstallBindings = function (target, bindings) {
585
1106
  target[b.binding] = __sbMakeDONamespace(b.binding, b.className);
586
1107
  }
587
1108
 
1109
+ // #69 — env.<NAME>.limit({ key }) -> { success }. Fixed window: at most
1110
+ // `limit` calls per `period` seconds for a given key. limit/period travel in
1111
+ // the message so the op stays stateless (the transport has no config).
1112
+ // Returns sync like the other shims (CF's is a Promise; the runtime is sync).
1113
+ for (let i = 0; i < (bindings.ratelimiters || []).length; i++) {
1114
+ const rl = bindings.ratelimiters[i];
1115
+ target[rl.binding] = {
1116
+ limit(options) {
1117
+ const key = options && options.key != null ? String(options.key) : "";
1118
+ const r = __sbRpc("ratelimit.check", { name: rl.binding, key, limit: rl.limit, period: rl.period });
1119
+ return { success: !!r.success };
1120
+ },
1121
+ };
1122
+ }
1123
+
588
1124
  // Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
589
1125
  // already serves matching files directly; the sprout only calls this for paths
590
1126
  // it wants to own (SPA fallback, auth-gated files). The transport keeps the
@@ -844,9 +1380,119 @@ function __sbTriggerAuthed(request) {
844
1380
  return request.headers.get("x-sb-token") === want;
845
1381
  }
846
1382
 
1383
+ // #163 — the connection's remote address, for `request.cf.clientIp`.
1384
+ //
1385
+ // `x-sb-remote-addr` is the TCP peer, appended by the server (see
1386
+ // patches/UPSTREAM.md #163) and stripped from anything a client sends, so it
1387
+ // cannot be forged. With no proxy in front, that is the client. Behind one, set
1388
+ // `SB_TRUSTED_PROXIES` to a comma-separated list of trusted CIDRs (or bare IPs):
1389
+ // when the peer is trusted, the client is the rightmost `x-forwarded-for` entry
1390
+ // that is not itself a trusted hop.
1391
+ //
1392
+ // ponytail: IPv4 CIDR ranges + exact-string match (which covers a bare IPv6).
1393
+ // An IPv6 *prefix* in SB_TRUSTED_PROXIES matches nothing — widen __sbIpInCidr if
1394
+ // a deployment ever fronts its sprout with an IPv6 proxy range.
1395
+ function __sbParseHex(s) {
1396
+ let n = 0;
1397
+ for (let i = 0; i < s.length; i++) {
1398
+ const c = s.charCodeAt(i);
1399
+ let d = -1;
1400
+ if (c >= 48 && c <= 57) d = c - 48;
1401
+ else if (c >= 97 && c <= 102) d = c - 87;
1402
+ else if (c >= 65 && c <= 70) d = c - 55;
1403
+ if (d < 0) return -1;
1404
+ n = n * 16 + d;
1405
+ }
1406
+ return n;
1407
+ }
1408
+ // uWS hands back IPv4-mapped IPv6 for IPv4 clients on a dual-stack socket
1409
+ // (`::ffff:1.2.3.4`, or fully expanded `0:0:0:0:0:ffff:0102:0304`). Fold those
1410
+ // to the dotted IPv4 so CIDR matching and `clientIp` see a plain address.
1411
+ function __sbNormalizeIp(ip) {
1412
+ const s = String(ip || "");
1413
+ if (s.indexOf(":") === -1) return s;
1414
+ const low = s.toLowerCase();
1415
+ const dotted = low.indexOf("::ffff:");
1416
+ if (dotted === 0 && low.indexOf(".") !== -1) return low.slice(7);
1417
+ const g = low.split(":");
1418
+ if (g.length === 8) {
1419
+ let mapped = __sbParseHex(g[5]) === 0xffff;
1420
+ for (let i = 0; i < 5; i++) if (__sbParseHex(g[i] || "0") !== 0) mapped = false;
1421
+ if (mapped) {
1422
+ const hi = __sbParseHex(g[6] || "0");
1423
+ const lo = __sbParseHex(g[7] || "0");
1424
+ if (hi >= 0 && lo >= 0) {
1425
+ return ((hi / 256) | 0) + "." + (hi & 0xff) + "." + ((lo / 256) | 0) + "." + (lo & 0xff);
1426
+ }
1427
+ }
1428
+ }
1429
+ return s;
1430
+ }
1431
+ function __sbTrim(s) {
1432
+ let a = 0;
1433
+ let b = s.length;
1434
+ while (a < b && (s.charCodeAt(a) === 32 || s.charCodeAt(a) === 9)) a++;
1435
+ while (b > a && (s.charCodeAt(b - 1) === 32 || s.charCodeAt(b - 1) === 9)) b--;
1436
+ return s.slice(a, b);
1437
+ }
1438
+ function __sbSplitList(raw) {
1439
+ const out = [];
1440
+ const parts = String(raw || "").split(",");
1441
+ for (let i = 0; i < parts.length; i++) {
1442
+ const v = __sbTrim(parts[i]);
1443
+ if (v) out.push(v);
1444
+ }
1445
+ return out;
1446
+ }
1447
+ function __sbIpToLong(ip) {
1448
+ const p = String(ip).split(".");
1449
+ if (p.length !== 4) return -1;
1450
+ let n = 0;
1451
+ for (let i = 0; i < 4; i++) {
1452
+ if (p[i] === "" || p[i].length > 3) return -1;
1453
+ const o = Number(p[i]);
1454
+ if (!(o >= 0 && o <= 255) || o !== (o | 0)) return -1;
1455
+ n = n * 256 + o;
1456
+ }
1457
+ return n;
1458
+ }
1459
+ function __sbIpInCidr(ip, cidr) {
1460
+ const slash = cidr.indexOf("/");
1461
+ if (slash === -1) return ip === cidr; // bare IP: exact match, covers IPv6
1462
+ const addr = __sbIpToLong(ip);
1463
+ const base = __sbIpToLong(cidr.slice(0, slash));
1464
+ const bits = Number(cidr.slice(slash + 1));
1465
+ if (addr < 0 || base < 0 || !(bits >= 0 && bits <= 32)) return false;
1466
+ if (bits === 0) return true;
1467
+ if (bits === 32) return addr === base;
1468
+ // Divide rather than mask: a 32-bit `&` in JS is signed and would miscompare
1469
+ // addresses above 2^31.
1470
+ const size = Math.pow(2, 32 - bits);
1471
+ return Math.floor(addr / size) === Math.floor(base / size);
1472
+ }
1473
+ function __sbIpTrusted(ip, list) {
1474
+ for (let i = 0; i < list.length; i++) if (__sbIpInCidr(ip, list[i])) return true;
1475
+ return false;
1476
+ }
1477
+ function __sbClientIp(request) {
1478
+ const peer = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
1479
+ const trusted = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
1480
+ if (!trusted.length || !__sbIpTrusted(peer, trusted)) return peer;
1481
+ const xff = __sbSplitList(request.headers.get("x-forwarded-for"));
1482
+ for (let i = xff.length - 1; i >= 0; i--) {
1483
+ const hop = __sbNormalizeIp(xff[i]);
1484
+ if (!__sbIpTrusted(hop, trusted)) return hop;
1485
+ }
1486
+ return peer;
1487
+ }
1488
+
847
1489
  globalThis.__sbEntry = function (handlers, request) {
848
1490
  const trigger = request.headers.get("x-sb-trigger");
849
1491
  if (!trigger) {
1492
+ // #163 — expose the resolved client IP the Workers way, before the handler runs.
1493
+ const __cf = request.cf || {};
1494
+ __cf.clientIp = __sbClientIp(request);
1495
+ request.cf = __cf;
850
1496
  // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
851
1497
  // process CPU delta across the handler is this invocation's CPU.
852
1498
  // ponytail: serial-turn assumption; revisit if the profile ever allows
@@ -35,6 +35,7 @@ extern int sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const
35
35
  extern int sqlite3_step(sqlite3_stmt*);
36
36
  extern int sqlite3_finalize(sqlite3_stmt*);
37
37
  extern int sqlite3_reset(sqlite3_stmt*);
38
+ extern int sqlite3_clear_bindings(sqlite3_stmt*);
38
39
  extern int sqlite3_column_count(sqlite3_stmt*);
39
40
  extern int sqlite3_column_type(sqlite3_stmt*, int);
40
41
  extern const unsigned char* sqlite3_column_text(sqlite3_stmt*, int);
@@ -50,6 +51,7 @@ extern const void* sqlite3_column_blob(sqlite3_stmt*, int);
50
51
  extern int sqlite3_changes(sqlite3*);
51
52
  extern int64_t sqlite3_last_insert_rowid(sqlite3*);
52
53
  extern const char* sqlite3_errmsg(sqlite3*);
54
+ extern int sqlite3_close(sqlite3*);
53
55
 
54
56
  #define SB_SQLITE_ROW 100
55
57
  #define SB_SQLITE_DONE 101
@@ -94,8 +96,13 @@ static int sb_db_for(const char* path) {
94
96
  // copy it saves once the file holds a few large objects. The page cache is
95
97
  // capped instead, and journal_size_limit stops the WAL staying huge after one
96
98
  // big write.
99
+ // busy_timeout: within one binary every binding op is serial, so there is no
100
+ // self-contention. It matters when the same data dir is served by several
101
+ // copies of the binary behind SO_REUSEPORT (#154 scaling recipe): WAL takes
102
+ // concurrent readers plus one writer across processes, and this makes a
103
+ // writer wait for the lock instead of failing SQLITE_BUSY.
97
104
  sqlite3_exec(db,
98
- "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
105
+ "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"
99
106
  "PRAGMA cache_size=-2000; PRAGMA journal_size_limit=16777216;",
100
107
  0, 0, 0);
101
108
  sb_dbs[sb_db_count] = db;
@@ -103,6 +110,42 @@ static int sb_db_for(const char* path) {
103
110
  return sb_db_count++;
104
111
  }
105
112
 
113
+ // #155 — a prepared-statement cache per database. Every binding op (KV, D1, DO
114
+ // storage, queue, AE) funnels through sb_sql_run, which used to prepare and
115
+ // finalize on every call — measured at ~0.6 ms/op, that compile step was most
116
+ // of an op's cost. Keyed by exact SQL text; reset and re-bound on reuse.
117
+ //
118
+ // ponytail: FIFO eviction over 32 slots, linear scan, no lock (the runtime
119
+ // serves one turn at a time). Move to LRU + a hash if a real app keeps >32 hot
120
+ // statements per db, or if execution ever overlaps. Entries are never finalized
121
+ // on shutdown — sb_db_for never closes a handle, the process owns it for life.
122
+ #define SB_STMT_CACHE 32
123
+ typedef struct { char* sql; sqlite3_stmt* st; } sb_cached_stmt;
124
+ static sb_cached_stmt sb_stmt_cache[SB_MAX_DB][SB_STMT_CACHE];
125
+ static int sb_stmt_fifo[SB_MAX_DB];
126
+
127
+ static sqlite3_stmt* sb_stmt_get(int idx, sqlite3* db, const char* sql) {
128
+ sb_cached_stmt* slots = sb_stmt_cache[idx];
129
+ for (int i = 0; i < SB_STMT_CACHE; i++) {
130
+ if (slots[i].sql && strcmp(slots[i].sql, sql) == 0) {
131
+ sqlite3_reset(slots[i].st);
132
+ sqlite3_clear_bindings(slots[i].st);
133
+ return slots[i].st;
134
+ }
135
+ }
136
+ sqlite3_stmt* st = 0;
137
+ if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) return 0;
138
+ int slot = sb_stmt_fifo[idx];
139
+ sb_stmt_fifo[idx] = (slot + 1) % SB_STMT_CACHE;
140
+ if (slots[slot].st) sqlite3_finalize(slots[slot].st);
141
+ free(slots[slot].sql);
142
+ size_t n = strlen(sql);
143
+ slots[slot].sql = (char*)malloc(n + 1);
144
+ if (slots[slot].sql) memcpy(slots[slot].sql, sql, n + 1);
145
+ slots[slot].st = st;
146
+ return st;
147
+ }
148
+
106
149
  // --- a growable output buffer, for building the reply JSON ------------------
107
150
  typedef struct { char* p; size_t len; size_t cap; } sb_buf;
108
151
  static void sb_buf_need(sb_buf* b, size_t extra) {
@@ -319,8 +362,8 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
319
362
  int idx = sb_db_for(path);
320
363
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
321
364
  sqlite3* db = sb_dbs[idx];
322
- sqlite3_stmt* st = 0;
323
- if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) {
365
+ sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
366
+ if (!st) {
324
367
  sb_puts(&b, "{\"ok\":false,\"error\":");
325
368
  const char* m = sqlite3_errmsg(db);
326
369
  sb_putjson(&b, m, strlen(m));
@@ -361,7 +404,9 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
361
404
  char tail[96];
362
405
  int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
363
406
  sb_put(&b, tail, (size_t)k);
364
- sqlite3_finalize(st);
407
+ // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
408
+ // now (not just on next reuse) so a SELECT stops holding its read transaction.
409
+ sqlite3_reset(st);
365
410
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
366
411
  free(b.p);
367
412
  sb_buf e = { 0, 0, 0 };
@@ -374,6 +419,67 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
374
419
  return b.p;
375
420
  }
376
421
 
422
+ // #164 — an online, consistent single-file snapshot of a database, via
423
+ // VACUUM INTO. Runs against the live db with no downtime (SQLite holds a read
424
+ // transaction for the copy) and writes a fresh, defragmented file with no WAL
425
+ // sidecars — exactly what you copy off the box. The copy is then reopened and
426
+ // PRAGMA integrity_check'd, so a bad backup fails here, not on restore.
427
+ // Returns {"ok":true,"bytes":n} or {"ok":false,"error":"..."}.
428
+ static char* sb_sql_backup(const char* src_path, const char* dest_path) {
429
+ sb_buf b = { 0, 0, 0 };
430
+ int idx = sb_db_for(src_path);
431
+ if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
432
+ sqlite3* db = sb_dbs[idx];
433
+
434
+ sb_mkdirs(dest_path);
435
+ unlink(dest_path); // VACUUM INTO refuses to overwrite an existing file
436
+
437
+ sqlite3_stmt* st = 0;
438
+ if (sqlite3_prepare_v2(db, "VACUUM INTO ?1", -1, &st, 0) != 0 || !st) {
439
+ sb_puts(&b, "{\"ok\":false,\"error\":");
440
+ const char* m = sqlite3_errmsg(db);
441
+ sb_putjson(&b, m, strlen(m));
442
+ sb_puts(&b, "}");
443
+ return b.p;
444
+ }
445
+ sqlite3_bind_text(st, 1, dest_path, -1, SB_SQLITE_TRANSIENT);
446
+ int rc = sqlite3_step(st);
447
+ sqlite3_finalize(st);
448
+ if (rc != SB_SQLITE_DONE) {
449
+ sb_puts(&b, "{\"ok\":false,\"error\":");
450
+ const char* m = sqlite3_errmsg(db);
451
+ sb_putjson(&b, m, strlen(m));
452
+ sb_puts(&b, "}");
453
+ return b.p;
454
+ }
455
+
456
+ sqlite3* chk = 0;
457
+ int ok = 0;
458
+ if (sqlite3_open(dest_path, &chk) == 0 && chk) {
459
+ sqlite3_stmt* cs = 0;
460
+ if (sqlite3_prepare_v2(chk, "PRAGMA integrity_check", -1, &cs, 0) == 0 && cs) {
461
+ if (sqlite3_step(cs) == SB_SQLITE_ROW) {
462
+ const unsigned char* r = sqlite3_column_text(cs, 0);
463
+ ok = r && strcmp((const char*)r, "ok") == 0;
464
+ }
465
+ sqlite3_finalize(cs);
466
+ }
467
+ }
468
+ if (chk) sqlite3_close(chk);
469
+ if (!ok) {
470
+ unlink(dest_path);
471
+ sb_puts(&b, "{\"ok\":false,\"error\":\"integrity check failed on the backup\"}");
472
+ return b.p;
473
+ }
474
+
475
+ struct stat sbuf;
476
+ long long bytes = stat(dest_path, &sbuf) == 0 ? (long long)sbuf.st_size : -1;
477
+ char head[64];
478
+ int k = snprintf(head, sizeof(head), "{\"ok\":true,\"bytes\":%lld}", bytes);
479
+ sb_put(&b, head, (size_t)k);
480
+ return b.p;
481
+ }
482
+
377
483
  // --- outbound HTTP + HTTPS ---------------------------------------------------
378
484
  // Plain sockets for http, BearSSL for https, with the Mozilla root set compiled
379
485
  // in (see src/bearssl.ts). Both directions share the request builder and the
@@ -894,6 +1000,33 @@ function __sbSqlScriptRaw(path, sql) {
894
1000
  return res;
895
1001
  }
896
1002
 
1003
+ // #164 — VACUUM INTO a fresh file. Two strings in, JSON out. Same pattern as
1004
+ // __sbSqlScriptRaw.
1005
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1006
+ function __sbD1BackupRaw(src, dest) {
1007
+ let res = "";
1008
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1009
+ Porffor.c`
1010
+ const char* __s; size_t __sl; char* __so = 0;
1011
+ porf_native_fetch_read_value(src, &__s, &__sl, &__so);
1012
+ char* __src = (char*)malloc(__sl + 1); memcpy(__src, __s, __sl); __src[__sl] = 0;
1013
+ if (__so) free(__so);
1014
+
1015
+ const char* __d; size_t __dl; char* __do = 0;
1016
+ porf_native_fetch_read_value(dest, &__d, &__dl, &__do);
1017
+ char* __dest = (char*)malloc(__dl + 1); memcpy(__dest, __d, __dl); __dest[__dl] = 0;
1018
+ if (__do) free(__do);
1019
+
1020
+ char* __out = sb_sql_backup(__src, __dest);
1021
+ free(__src); free(__dest);
1022
+ if (__out) {
1023
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
1024
+ free(__out);
1025
+ }
1026
+ `;
1027
+ return res;
1028
+ }
1029
+
897
1030
  // Outbound HTTP. String params so C can read each directly; the JS side has
898
1031
  // already split the URL and enforced the allowlist. `tlsFlag` is "1" or "0" —
899
1032
  // a string like the rest, so the marshalling stays uniform.
@@ -1051,6 +1184,10 @@ function __sbEnsureSchema() {
1051
1184
  s,
1052
1185
  "CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
1053
1186
  );
1187
+ __sbSql(
1188
+ s,
1189
+ "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (name, key))",
1190
+ );
1054
1191
  }
1055
1192
 
1056
1193
  function __sbHex(n) {
@@ -1199,6 +1336,20 @@ function __sbEmbeddedDispatch(msg) {
1199
1336
  return { ok: true, results: one.results, meta: one.meta, success: true };
1200
1337
  }
1201
1338
 
1339
+ if (op === "d1.backup") {
1340
+ const src = __sbD1Path(String(msg.db));
1341
+ // A caller-supplied name must be a plain basename; anything else falls back
1342
+ // to the timestamped default, so nothing can climb out of backups/.
1343
+ const given = String(msg.name || "");
1344
+ const name = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(given)
1345
+ ? given
1346
+ : String(msg.db) + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".sqlite";
1347
+ const dest = __sbDir() + "/backups/" + name;
1348
+ const reply = JSON.parse(__sbD1BackupRaw(src, dest));
1349
+ if (reply.ok === false) throw new Error("d1 backup: " + reply.error);
1350
+ return { ok: true, path: dest, bytes: reply.bytes };
1351
+ }
1352
+
1202
1353
  if (op === "r2.put") {
1203
1354
  const body = String(msg.body == null ? "" : msg.body);
1204
1355
  const etag = __sbHex(16);
@@ -1371,6 +1522,26 @@ function __sbEmbeddedDispatch(msg) {
1371
1522
  return { ok: true, count: total.rows.length ? Number(total.rows[0][0]) : 0, rows };
1372
1523
  }
1373
1524
 
1525
+ if (op === "ratelimit.check") {
1526
+ // #69 — fixed window. limit/period come in the message (the transport holds
1527
+ // no config). One upsert: same window -> count+1, new window -> reset to 1.
1528
+ // ponytail: fixed window, so a burst straddling the boundary can briefly
1529
+ // reach ~2x limit. Swap for a two-window weighted count if that matters.
1530
+ const limit = Math.max(Number(msg.limit) || 1, 1);
1531
+ const period = Math.max(Number(msg.period) || 60, 1);
1532
+ const now = Math.floor(Date.now() / 1000);
1533
+ const windowStart = now - (now % period);
1534
+ const r = __sbSql(
1535
+ store,
1536
+ "INSERT INTO ratelimit (name, key, window_start, count) VALUES (?1, ?2, ?3, 1) " +
1537
+ "ON CONFLICT (name, key) DO UPDATE SET count = CASE WHEN window_start = ?3 THEN count + 1 ELSE 1 END, " +
1538
+ "window_start = ?3 RETURNING count",
1539
+ [String(msg.name), String(msg.key == null ? "" : msg.key), windowStart],
1540
+ );
1541
+ const count = r.rows.length ? Number(r.rows[0][0]) : 1;
1542
+ return { ok: true, success: count <= limit };
1543
+ }
1544
+
1374
1545
  throw new Error("unknown op: " + op);
1375
1546
  }
1376
1547
 
package/src/wrap.ts CHANGED
@@ -65,6 +65,9 @@ export type Bindings = {
65
65
  * it resolves to is a runtime input, not part of the artifact. */
66
66
  services: Array<{ binding: string; service: string }>;
67
67
  crons: string[];
68
+ /** #69 — rate limiters: `env.<binding>.limit({ key }) -> { success }`, a
69
+ * fixed window of `limit` calls per `period` seconds, per key. */
70
+ ratelimiters: Array<{ binding: string; limit: number; period: number }>;
68
71
  /** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
69
72
  assets: string;
70
73
  };
@@ -80,6 +83,7 @@ export const EMPTY_BINDINGS: Bindings = {
80
83
  do: [],
81
84
  services: [],
82
85
  crons: [],
86
+ ratelimiters: [],
83
87
  assets: "",
84
88
  };
85
89
 
@@ -94,6 +98,7 @@ function hasBindings(b: Bindings): boolean {
94
98
  b.analytics.length > 0 ||
95
99
  b.do.length > 0 ||
96
100
  b.services.length > 0 ||
101
+ b.ratelimiters.length > 0 ||
97
102
  b.assets !== ""
98
103
  );
99
104
  }
@@ -251,6 +256,15 @@ export function readBindingsFromEnv(): Bindings {
251
256
  }
252
257
  }
253
258
  }
259
+ const isCount = (v: VarsJson): v is number => Number(v) === v && Number.isInteger(v) && v >= 1;
260
+ const ratelimiters: Array<{ binding: string; limit: number; period: number }> = [];
261
+ if (Array.isArray(parsed.ratelimiters)) {
262
+ for (const entry of parsed.ratelimiters) {
263
+ if (isVarsObject(entry) && isVarsString(entry.binding) && isCount(entry.limit) && isCount(entry.period)) {
264
+ ratelimiters.push({ binding: entry.binding, limit: entry.limit, period: entry.period });
265
+ }
266
+ }
267
+ }
254
268
  return {
255
269
  kv: strings(parsed.kv),
256
270
  secrets: strings(parsed.secrets),
@@ -262,6 +276,7 @@ export function readBindingsFromEnv(): Bindings {
262
276
  do: dos,
263
277
  services,
264
278
  crons: strings(parsed.crons),
279
+ ratelimiters,
265
280
  assets: isVarsString(parsed.assets) ? parsed.assets : "",
266
281
  };
267
282
  }