@huynle/hub 0.8.6 → 0.9.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,59 @@
1
+ // The per-user budget of the hub's web fetches (design §4): 30 fetches a minute and 4 at once per user, ONE budget
2
+ // for GET /api/media/remote and GET /api/media/unfurl together (http.ts hands both handlers the same one), so the two
3
+ // routes cannot add up to more than either allows. Cache hits and requests refused before fetching never take from
4
+ // it. In memory, per pod in cluster mode (like the routes' caches).
5
+
6
+ const WINDOW_MS = 60_000
7
+ export const FETCH_BUDGET_DEFAULTS = { perMinute: 30, concurrent: 4 } as const
8
+
9
+ export type BudgetTake =
10
+ | { readonly ok: true; readonly release: () => void }
11
+ /** `retryAfter`: seconds until a fetch may start again. */
12
+ | { readonly ok: false; readonly reason: "minute" | "concurrent"; readonly limit: number; readonly retryAfter: number }
13
+
14
+ export interface FetchBudget {
15
+ /** One fetch for `userID` now, or why not; release() (idempotent) when the fetch ends, failures included. */
16
+ take(userID: string): BudgetTake
17
+ }
18
+
19
+ export interface FetchBudgetOptions {
20
+ readonly now?: () => number
21
+ readonly perMinute?: number
22
+ readonly concurrent?: number
23
+ }
24
+
25
+ export function createFetchBudget(options: FetchBudgetOptions = {}): FetchBudget {
26
+ const now = options.now ?? Date.now
27
+ const perMinute = options.perMinute ?? FETCH_BUDGET_DEFAULTS.perMinute
28
+ const concurrent = options.concurrent ?? FETCH_BUDGET_DEFAULTS.concurrent
29
+ const users = new Map<string, { starts: number[]; active: number }>()
30
+
31
+ function take(userID: string): BudgetTake {
32
+ const t = now()
33
+ const user = users.get(userID) ?? { starts: [], active: 0 }
34
+ users.set(userID, user)
35
+ user.starts = user.starts.filter((start) => start > t - WINDOW_MS)
36
+ const idle = () => {
37
+ if (user.active === 0 && user.starts.length === 0) users.delete(userID)
38
+ }
39
+ if (user.starts.length >= perMinute) {
40
+ idle()
41
+ return { ok: false, reason: "minute", limit: perMinute, retryAfter: Math.max(1, Math.ceil((user.starts[0]! + WINDOW_MS - t) / 1000)) }
42
+ }
43
+ if (user.active >= concurrent) return { ok: false, reason: "concurrent", limit: concurrent, retryAfter: 1 }
44
+ user.starts.push(t)
45
+ user.active++
46
+ let released = false
47
+ return {
48
+ ok: true,
49
+ release: () => {
50
+ if (released) return
51
+ released = true
52
+ user.active--
53
+ idle()
54
+ },
55
+ }
56
+ }
57
+
58
+ return { take }
59
+ }
@@ -0,0 +1,372 @@
1
+ // The safe outbound fetcher (design §4): the hub fetches user-chosen web URLs (remote images, link previews) for
2
+ // signed-in users only. Without care that is SSRF: the hub would reach into its own network (loopback, the pod and
3
+ // service networks, the cloud metadata IP). Rules:
4
+ // - http/https on their default ports only, no userinfo; numeric host tricks (2130706433, 0177.0.0.1, 0x7f.1, …)
5
+ // are normalised by the WHATWG URL parser and then checked as addresses.
6
+ // - The host is resolved once per hop with the injected resolver, and refused when ANY answer is in a denied range
7
+ // (DENIED below, plus HUB_FETCH_DENY_CIDRS). IPv4-mapped, IPv4-compatible and NAT64 IPv6 are refused outright.
8
+ // - The connection goes to the exact address that was checked (DNS rebinding): Bun fetch on an IP-literal URL with
9
+ // Host = the hostname. Bun takes SNI and certificate verification from Host (quirk w8towhww), so Host never
10
+ // carries a port (only default ports are allowed anyway), and keepalive is off: a pooled socket for that IP would
11
+ // otherwise skip certificate verification for a different hostname.
12
+ // - Redirects are followed manually, at most MAX_REDIRECTS, each hop fully re-checked. No cookies or credentials;
13
+ // User-Agent hub-preview/1. Timeouts: connect (until the response headers) and total (DNS, hops and body).
14
+ // - The body is streamed and capped: over the cap the request is aborted (or truncated, when asked). It is never
15
+ // decompressed (decompress: false, Accept-Encoding: identity, any other Content-Encoding refused): Bun inflates
16
+ // before the cap would see the bytes, so a 1 MiB gzip bomb could grow to gigabytes.
17
+ // Errors carry the host, never the path or query (a URL may hold leaked data).
18
+ import { isIPv4, isIPv6 } from "node:net"
19
+
20
+ export type ResolveHost = (host: string) => Promise<readonly string[]>
21
+
22
+ export type FetchErrorKind = "bad_url" | "refused" | "too_large" | "upstream" | "timeout"
23
+
24
+ const STATUS: Record<FetchErrorKind, number> = { bad_url: 400, refused: 403, too_large: 413, upstream: 502, timeout: 504 }
25
+
26
+ /** Why a fetch did not happen or failed; `status` is the HTTP status a route answers with. */
27
+ export class FetchError extends Error {
28
+ readonly status: number
29
+ constructor(
30
+ readonly kind: FetchErrorKind,
31
+ message: string,
32
+ readonly host?: string,
33
+ readonly upstreamStatus?: number,
34
+ ) {
35
+ super(message)
36
+ this.name = "FetchError"
37
+ this.status = STATUS[kind]
38
+ }
39
+ }
40
+
41
+ /** An address block: family, network (masked) and prefix length. */
42
+ export interface Cidr {
43
+ readonly v6: boolean
44
+ readonly net: bigint
45
+ readonly bits: number
46
+ }
47
+
48
+ export const USER_AGENT = "hub-preview/1"
49
+ export const MAX_REDIRECTS = 3
50
+ export const MAX_URL_CHARS = 2048
51
+ const DEFAULT_TIMEOUTS = { connectMs: 3000, totalMs: 8000 } as const
52
+
53
+ // ---------- addresses ----------
54
+
55
+ const V4_BITS = 32
56
+ const V6_BITS = 128
57
+
58
+ function v4Value(ip: string): bigint {
59
+ return ip.split(".").reduce((n, part) => (n << 8n) | BigInt(Number(part)), 0n)
60
+ }
61
+
62
+ /** The 128-bit value of a valid IPv6 address (no zone), embedded dotted IPv4 tails included. */
63
+ function v6Value(ip: string): bigint {
64
+ let text = ip.toLowerCase()
65
+ const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)
66
+ if (tail) {
67
+ const n = v4Value(tail[1]!)
68
+ text = `${text.slice(0, -tail[1]!.length)}${(n >> 16n).toString(16)}:${(n & 0xffffn).toString(16)}`
69
+ }
70
+ const [head, rest] = text.split("::") as [string, string | undefined]
71
+ const left = head ? head.split(":") : []
72
+ const right = rest === undefined ? [] : rest ? rest.split(":") : []
73
+ const groups = rest === undefined ? left : [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right]
74
+ return groups.reduce((n, g) => (n << 16n) | BigInt(parseInt(g, 16)), 0n)
75
+ }
76
+
77
+ /** An IP address as {v6, value}, or undefined when the text is not one (zones are not addresses here). */
78
+ function parseIP(text: string): { v6: boolean; value: bigint } | undefined {
79
+ if (text.includes("%")) return undefined
80
+ if (isIPv4(text)) return { v6: false, value: v4Value(text) }
81
+ if (isIPv6(text)) return { v6: true, value: v6Value(text) }
82
+ return undefined
83
+ }
84
+
85
+ function cidr(net: string, bits: number): Cidr {
86
+ const ip = parseIP(net)!
87
+ const size = ip.v6 ? V6_BITS : V4_BITS
88
+ return { v6: ip.v6, net: mask(ip.value, bits, size), bits }
89
+ }
90
+
91
+ const mask = (value: bigint, bits: number, size: number): bigint => (bits === 0 ? 0n : (value >> BigInt(size - bits)) << BigInt(size - bits))
92
+
93
+ const contains = (block: Cidr, ip: { v6: boolean; value: bigint }) =>
94
+ block.v6 === ip.v6 && mask(ip.value, block.bits, block.v6 ? V6_BITS : V4_BITS) === block.net
95
+
96
+ /** Never a public service. */
97
+ const DENIED: readonly Cidr[] = [
98
+ cidr("0.0.0.0", 8), // "this network"
99
+ cidr("10.0.0.0", 8), // private
100
+ cidr("100.64.0.0", 10), // CGNAT
101
+ cidr("127.0.0.0", 8), // loopback
102
+ cidr("169.254.0.0", 16), // link-local, cloud metadata
103
+ cidr("172.16.0.0", 12), // private
104
+ cidr("192.0.0.0", 24), // IETF protocol assignments
105
+ cidr("192.0.2.0", 24), // TEST-NET-1
106
+ cidr("192.88.99.0", 24), // 6to4 relay anycast (deprecated)
107
+ cidr("192.168.0.0", 16), // private
108
+ cidr("198.18.0.0", 15), // benchmarking
109
+ cidr("198.51.100.0", 24), // TEST-NET-2
110
+ cidr("203.0.113.0", 24), // TEST-NET-3
111
+ cidr("224.0.0.0", 4), // multicast
112
+ cidr("240.0.0.0", 4), // reserved, broadcast (255.255.255.255 included)
113
+ cidr("::", 96), // unspecified, loopback, IPv4-compatible (deprecated)
114
+ cidr("::ffff:0:0", 96), // IPv4-mapped: refused whatever the embedded address
115
+ cidr("::ffff:0:0:0", 96), // IPv4-translated (SIIT)
116
+ cidr("64:ff9b::", 96), // NAT64 well-known prefix
117
+ cidr("64:ff9b:1::", 48), // NAT64 local-use prefix
118
+ cidr("100::", 64), // discard-only
119
+ cidr("2001::", 32), // Teredo: tunnels to an obfuscated IPv4 address
120
+ cidr("2001:db8::", 32), // documentation
121
+ cidr("fc00::", 7), // unique local
122
+ cidr("fe80::", 10), // link-local
123
+ cidr("fec0::", 10), // site-local (deprecated)
124
+ cidr("ff00::", 8), // multicast
125
+ ]
126
+
127
+ /** 6to4 (2002::/16) tunnels to the IPv4 address in bits 16-48: refused when that address is. */
128
+ const SIX_TO_FOUR = cidr("2002::", 16)
129
+
130
+ /** True when the hub must not connect to `address` (anything that is not a plain IP address counts as denied). */
131
+ export function isDeniedAddress(address: string, extra: readonly Cidr[] = []): boolean {
132
+ const ip = parseIP(address.trim().replace(/^\[(.*)\]$/, "$1"))
133
+ if (!ip) return true
134
+ if (DENIED.some((block) => contains(block, ip)) || extra.some((block) => contains(block, ip))) return true
135
+ if (contains(SIX_TO_FOUR, ip)) {
136
+ const v4 = { v6: false, value: (ip.value >> 80n) & 0xffffffffn }
137
+ return DENIED.some((block) => contains(block, v4)) || extra.some((block) => contains(block, v4))
138
+ }
139
+ return false
140
+ }
141
+
142
+ /** HUB_FETCH_DENY_CIDRS: a comma list of CIDRs or bare addresses (v4 or v6). Throws on any invalid entry. */
143
+ export function parseDenyCidrs(text: string | undefined): Cidr[] {
144
+ const out: Cidr[] = []
145
+ for (const raw of (text ?? "").split(",")) {
146
+ const entry = raw.trim()
147
+ if (!entry) continue
148
+ const invalid = () => new Error(`HUB_FETCH_DENY_CIDRS: ${JSON.stringify(entry)} is not an IPv4 or IPv6 CIDR (e.g. 10.42.0.0/16, fd00::/8)`)
149
+ const match = /^([^/\s]+)(?:\/(\d{1,3}))?$/.exec(entry)
150
+ if (!match) throw invalid()
151
+ const ip = parseIP(match[1]!)
152
+ if (!ip) throw invalid()
153
+ const size = ip.v6 ? V6_BITS : V4_BITS
154
+ const bits = match[2] === undefined ? size : Number(match[2])
155
+ if (bits > size) throw invalid()
156
+ out.push({ v6: ip.v6, net: mask(ip.value, bits, size), bits })
157
+ }
158
+ return out
159
+ }
160
+
161
+ // ---------- URLs ----------
162
+
163
+ /** A URL the fetcher may request: http(s) on the default port, no userinfo, no fragment. Throws FetchError bad_url. */
164
+ export function checkURL(raw: string | URL): URL {
165
+ const text = String(raw)
166
+ if (text.length > MAX_URL_CHARS) throw new FetchError("bad_url", `The URL is longer than ${MAX_URL_CHARS} characters`)
167
+ let url: URL
168
+ try {
169
+ url = new URL(text)
170
+ } catch {
171
+ throw new FetchError("bad_url", "Not a valid absolute URL")
172
+ }
173
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new FetchError("bad_url", "Only http and https URLs can be fetched")
174
+ if (url.username || url.password) throw new FetchError("bad_url", "URLs with credentials are refused", url.hostname)
175
+ // WHATWG drops the scheme's default port, so any port left is a non-default one (https on 80 included).
176
+ if (url.port !== "") throw new FetchError("bad_url", "Only the default ports (80 for http, 443 for https) are allowed", url.hostname)
177
+ if (!url.hostname) throw new FetchError("bad_url", "The URL has no host")
178
+ url.hash = ""
179
+ return url
180
+ }
181
+
182
+ // ---------- the fetcher ----------
183
+
184
+ export interface FetcherOptions {
185
+ /** DNS: every address of a host (production: node:dns lookup with all:true; tests: a fake). */
186
+ readonly resolve: ResolveHost
187
+ /** Denied on top of the built-in ranges (HUB_FETCH_DENY_CIDRS, see parseDenyCidrs). Never removes any. */
188
+ readonly denyCidrs?: readonly Cidr[]
189
+ /** The HTTP client (default Bun's fetch). It receives the pinned IP-literal URL. */
190
+ readonly fetchImpl?: typeof fetch
191
+ readonly timeouts?: { readonly connectMs?: number; readonly totalMs?: number }
192
+ }
193
+
194
+ export interface FetchOptions {
195
+ /** Bytes of the (decoded) body read at most. */
196
+ readonly maxBytes: number
197
+ /** Over maxBytes: keep the first maxBytes (truncated: true) instead of failing too_large. */
198
+ readonly truncate?: boolean
199
+ /** The Accept header (default *\/*). */
200
+ readonly accept?: string
201
+ }
202
+
203
+ export interface Fetched {
204
+ /** The final URL (after redirects). */
205
+ readonly url: URL
206
+ /** The address that was checked and connected to for the final hop. */
207
+ readonly address: string
208
+ readonly status: number
209
+ readonly headers: Headers
210
+ readonly body: Uint8Array<ArrayBuffer>
211
+ readonly truncated: boolean
212
+ }
213
+
214
+ export interface Fetcher {
215
+ readonly timeouts: { readonly connectMs: number; readonly totalMs: number }
216
+ /** GET `raw` safely; throws FetchError. */
217
+ fetch(raw: string | URL, options: FetchOptions): Promise<Fetched>
218
+ }
219
+
220
+ const isRedirect = (status: number) => status === 301 || status === 302 || status === 303 || status === 307 || status === 308
221
+
222
+ export function createFetcher(options: FetcherOptions): Fetcher {
223
+ const extra = options.denyCidrs ?? []
224
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
225
+ const timeouts = { connectMs: options.timeouts?.connectMs ?? DEFAULT_TIMEOUTS.connectMs, totalMs: options.timeouts?.totalMs ?? DEFAULT_TIMEOUTS.totalMs }
226
+
227
+ /** The checked address to connect to for `url`'s host. */
228
+ async function pin(url: URL, signal: AbortSignal): Promise<string> {
229
+ const host = url.hostname
230
+ const literal = host.startsWith("[") ? host.slice(1, -1) : isIPv4(host) ? host : undefined
231
+ if (literal !== undefined) {
232
+ if (isDeniedAddress(literal, extra)) throw new FetchError("refused", `${host} is a refused address`, host)
233
+ return literal
234
+ }
235
+ const name = host.toLowerCase().replace(/\.$/, "")
236
+ if (name === "localhost" || name.endsWith(".localhost")) throw new FetchError("refused", `${host} is a local name`, host)
237
+ let addresses: readonly string[]
238
+ try {
239
+ addresses = await abortable(options.resolve(name), signal)
240
+ } catch (cause) {
241
+ if (signal.aborted) throw cause
242
+ throw new FetchError("upstream", `${host} does not resolve`, host)
243
+ }
244
+ if (addresses.length === 0) throw new FetchError("upstream", `${host} does not resolve`, host)
245
+ if (addresses.some((a) => isDeniedAddress(a, extra))) throw new FetchError("refused", `${host} resolves to a refused address`, host)
246
+ return addresses[0]!.replace(/^\[(.*)\]$/, "$1")
247
+ }
248
+
249
+ async function run(raw: string | URL, request: FetchOptions): Promise<Fetched> {
250
+ const controller = new AbortController()
251
+ let timedOut = false
252
+ const expire = () => {
253
+ timedOut = true
254
+ controller.abort()
255
+ }
256
+ const total = setTimeout(expire, timeouts.totalMs)
257
+ let connect: ReturnType<typeof setTimeout> | undefined
258
+ let host: string | undefined
259
+ try {
260
+ let url = checkURL(raw)
261
+ for (let hop = 0; ; hop++) {
262
+ host = url.hostname
263
+ const address = await pin(url, controller.signal)
264
+ const target = new URL(url)
265
+ target.hostname = isIPv6(address) ? `[${address}]` : address
266
+ connect = setTimeout(expire, timeouts.connectMs)
267
+ // Raced with the signal too, so the timeouts hold even for a client that ignores it.
268
+ // TLS: Bun verifies the certificate against Host (the SNI name). Known Bun bug, not fixable here: it accepts
269
+ // a wildcard certificate across several labels (*.w.test matches y.x.w.test), for every fetch in the hub.
270
+ const init: RequestInit & { decompress: boolean } = {
271
+ method: "GET",
272
+ // Only default ports reach here, so Host has no port, and a single trailing dot is dropped (the checked
273
+ // name and address stay as they are): Host is also the SNI name (never "name:443" or "name.").
274
+ headers: { host: url.host.replace(/\.$/, ""), "user-agent": USER_AGENT, accept: request.accept ?? "*/*", "accept-encoding": "identity" },
275
+ redirect: "manual",
276
+ keepalive: false,
277
+ credentials: "omit",
278
+ decompress: false,
279
+ signal: controller.signal,
280
+ }
281
+ const response = await abortable(fetchImpl(target.href, init), controller.signal)
282
+ clearTimeout(connect)
283
+ if (isRedirect(response.status)) {
284
+ await response.body?.cancel().catch(() => {})
285
+ const location = response.headers.get("location")
286
+ if (!location) throw new FetchError("upstream", `${host} answered a redirect without a location`, host, response.status)
287
+ if (hop >= MAX_REDIRECTS) throw new FetchError("upstream", `More than ${MAX_REDIRECTS} redirects`, host, response.status)
288
+ let next: URL
289
+ try {
290
+ next = new URL(location, url)
291
+ } catch {
292
+ throw new FetchError("upstream", `${host} answered an invalid redirect`, host, response.status)
293
+ }
294
+ url = checkURL(next)
295
+ continue
296
+ }
297
+ if (response.status < 200 || response.status > 299) {
298
+ await response.body?.cancel().catch(() => {})
299
+ throw new FetchError("upstream", `${host} answered HTTP ${response.status}`, host, response.status)
300
+ }
301
+ const encoding = response.headers.get("content-encoding")?.trim().toLowerCase()
302
+ if (encoding && encoding !== "identity") {
303
+ await response.body?.cancel().catch(() => {})
304
+ throw new FetchError("upstream", `${host} sent a compressed response (content-encoding ${encoding}), which is refused`, host)
305
+ }
306
+ const declared = Number(response.headers.get("content-length") ?? NaN)
307
+ if (!request.truncate && Number.isFinite(declared) && declared > request.maxBytes) {
308
+ await response.body?.cancel().catch(() => {})
309
+ throw new FetchError("too_large", `The response from ${host} is larger than ${request.maxBytes} bytes`, host)
310
+ }
311
+ const { body, truncated } = await readCapped(response, request, host, controller)
312
+ return { url, address, status: response.status, headers: response.headers, body, truncated }
313
+ }
314
+ } catch (cause) {
315
+ if (timedOut) throw new FetchError("timeout", `${host ?? "The site"} did not answer in time`, host)
316
+ if (cause instanceof FetchError) throw cause
317
+ throw new FetchError("upstream", `Could not fetch from ${host ?? "the site"}`, host)
318
+ } finally {
319
+ clearTimeout(total)
320
+ clearTimeout(connect)
321
+ }
322
+ }
323
+
324
+ return { timeouts, fetch: run }
325
+ }
326
+
327
+ async function readCapped(response: Response, request: FetchOptions, host: string, controller: AbortController): Promise<{ body: Uint8Array<ArrayBuffer>; truncated: boolean }> {
328
+ if (!response.body) return { body: new Uint8Array(0), truncated: false }
329
+ const reader = response.body.getReader()
330
+ const chunks: Uint8Array[] = []
331
+ let size = 0
332
+ while (true) {
333
+ const { done, value } = await abortable(reader.read(), controller.signal).catch(async (cause: unknown) => {
334
+ await reader.cancel().catch(() => {})
335
+ throw cause
336
+ })
337
+ if (done) break
338
+ if (size + value.byteLength > request.maxBytes) {
339
+ await reader.cancel().catch(() => {})
340
+ controller.abort()
341
+ if (!request.truncate) throw new FetchError("too_large", `The response from ${host} is larger than ${request.maxBytes} bytes`, host)
342
+ chunks.push(value.subarray(0, request.maxBytes - size))
343
+ return { body: concat(chunks, request.maxBytes), truncated: true }
344
+ }
345
+ chunks.push(value)
346
+ size += value.byteLength
347
+ }
348
+ return { body: concat(chunks, size), truncated: false }
349
+ }
350
+
351
+ function concat(chunks: readonly Uint8Array[], size: number): Uint8Array<ArrayBuffer> {
352
+ const out = new Uint8Array(size)
353
+ let at = 0
354
+ for (const chunk of chunks) {
355
+ out.set(chunk, at)
356
+ at += chunk.byteLength
357
+ }
358
+ return out
359
+ }
360
+
361
+ /** `promise`, rejected early when `signal` aborts (a slow resolver counts against the total timeout). */
362
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
363
+ if (signal.aborted) return Promise.reject(new Error("aborted"))
364
+ return new Promise<T>((resolve, reject) => {
365
+ const onAbort = () => reject(new Error("aborted"))
366
+ signal.addEventListener("abort", onAbort, { once: true })
367
+ promise.then(
368
+ (value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
369
+ (cause) => (signal.removeEventListener("abort", onAbort), reject(cause)),
370
+ )
371
+ })
372
+ }