@huynle/hub 0.8.7 → 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 (181) hide show
  1. package/CHANGELOG.md +129 -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/cli.ts +23 -10
  8. package/packages/cli/src/http.ts +1 -0
  9. package/packages/cli/src/launches.ts +5 -1
  10. package/packages/cli/src/mcp.ts +28 -8
  11. package/packages/cli/src/messaging.ts +14 -0
  12. package/packages/cli/src/team-intake-mcp.ts +26 -2
  13. package/packages/codex/package.json +1 -1
  14. package/packages/node/package.json +1 -1
  15. package/packages/preset/package.json +1 -1
  16. package/packages/protocol/package.json +1 -1
  17. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  18. package/packages/protocol/src/artifacts.ts +43 -0
  19. package/packages/protocol/src/credentials.ts +38 -0
  20. package/packages/protocol/src/index.ts +56 -5
  21. package/packages/protocol/src/launch.ts +7 -2
  22. package/packages/protocol/src/markdown.ts +753 -0
  23. package/packages/protocol/src/media-allowlist.ts +240 -0
  24. package/packages/protocol/src/media.ts +120 -0
  25. package/packages/protocol/src/preview-bundle.ts +221 -0
  26. package/packages/protocol/src/voice-recording.ts +8 -0
  27. package/packages/runtime-openshell/package.json +1 -1
  28. package/packages/runtime-sdk/package.json +1 -1
  29. package/packages/sdk/package.json +1 -1
  30. package/packages/sdk/src/client.ts +17 -3
  31. package/packages/sdk/src/errors.ts +1 -0
  32. package/packages/sdk/src/team-intake.ts +8 -0
  33. package/packages/sdk/src/types.ts +16 -2
  34. package/packages/spoke-sdk/package.json +1 -1
  35. package/src/central/agent/delivery.ts +35 -6
  36. package/src/central/agent/errors.ts +2 -0
  37. package/src/central/agent/events.ts +11 -2
  38. package/src/central/agent/launch-engine.ts +65 -4
  39. package/src/central/agent/launch-routes.ts +8 -0
  40. package/src/central/agent/launch-spec.ts +18 -1
  41. package/src/central/agent/launch-store.ts +2 -2
  42. package/src/central/agent/message-routes.ts +49 -11
  43. package/src/central/agent/messages.ts +17 -4
  44. package/src/central/agent/not-after.ts +28 -0
  45. package/src/central/agent/placement.ts +36 -4
  46. package/src/central/agent/routes.ts +3 -1
  47. package/src/central/agent/webhook-routes.ts +21 -9
  48. package/src/central/agent/webhooks.ts +27 -9
  49. package/src/central/artifacts/host.ts +3 -1
  50. package/src/central/artifacts/maintenance.ts +2 -1
  51. package/src/central/artifacts/preview-bundle.ts +81 -0
  52. package/src/central/artifacts/previews.ts +94 -0
  53. package/src/central/artifacts/routes.ts +12 -1
  54. package/src/central/artifacts/store.ts +198 -8
  55. package/src/central/config.ts +34 -0
  56. package/src/central/fetch-budget.ts +59 -0
  57. package/src/central/fetcher.ts +372 -0
  58. package/src/central/http.ts +277 -22
  59. package/src/central/media-remote.ts +248 -0
  60. package/src/central/media-timeline.ts +317 -0
  61. package/src/central/media-unfurl.ts +119 -0
  62. package/src/central/profiles/content.ts +3 -35
  63. package/src/central/recording/routes.ts +2 -2
  64. package/src/central/recording/schema.ts +3 -1
  65. package/src/central/registry.ts +449 -21
  66. package/src/central/spoke-ws.ts +17 -3
  67. package/src/central/store.ts +21 -0
  68. package/src/central/teams/intake-routes.ts +2 -1
  69. package/src/central/teams/intake.ts +200 -15
  70. package/src/central/unfurl-parse.ts +313 -0
  71. package/src/index.ts +68 -8
  72. package/src/speakable.ts +33 -1
  73. package/src/spoke/conversation.ts +7 -4
  74. package/src/spoke/deliver.ts +19 -0
  75. package/src/spoke/media-bytes.ts +171 -0
  76. package/src/spoke/media-policy.ts +394 -0
  77. package/src/spoke/media.ts +189 -0
  78. package/src/spoke/own-server.ts +30 -0
  79. package/src/spoke/preview-bundle.ts +538 -0
  80. package/src/spoke/spoke.ts +387 -17
  81. package/src/spoke/timeline.ts +59 -5
  82. package/src/spoke/voice-front.ts +335 -0
  83. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  84. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  85. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  86. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  87. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  88. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  89. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  90. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  91. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  92. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  93. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  94. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  95. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  96. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  97. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  98. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  99. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  100. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  101. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  102. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  103. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  104. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  105. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  106. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  107. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  108. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  109. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  110. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  111. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  112. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  113. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  114. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  115. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  116. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  117. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  118. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  119. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  120. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  121. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  122. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  123. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  124. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  125. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  126. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  127. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  128. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  129. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  130. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  131. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  132. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  133. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  134. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  135. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  136. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  137. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  138. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  139. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  140. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  141. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  142. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  143. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  144. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  145. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  146. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  147. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  148. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  149. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  150. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  151. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  152. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  153. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  154. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  155. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  156. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  157. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  158. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  159. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  160. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  161. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  162. package/web/dist/assets/source-BWlvaODa.js +1 -0
  163. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  164. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  165. package/web/dist/index.html +8 -6
  166. package/web/src/voice/turn/LICENSE +28 -0
  167. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  168. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  169. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  170. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  171. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  172. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  173. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  174. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  175. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  176. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  177. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  178. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  179. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  180. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  181. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,248 @@
1
+ // GET /api/media/remote?url=… (design §4): web images for the console, fetched by the hub through the safe fetcher
2
+ // (./fetcher.ts) for signed-in users only. Only PNG, JPEG, WebP and GIF are served (sniffed from the bytes, never
3
+ // SVG), within REMOTE_MEDIA_LIMITS, with the §1 media headers. A tenant-scoped in-memory cache (per pod in cluster
4
+ // mode) keyed by the normalised URL keeps images for the site's lifetime clamped to 1-24 h and failures for 5 min.
5
+ // Per user: 30 fetches a minute and 4 at once (429), one budget shared with /api/media/unfurl (./fetch-budget.ts);
6
+ // cache hits are free. Each fetch is audited with the user, the
7
+ // host and the outcome; the full URL never is (it may carry leaked data). HUB_REMOTE_FETCH=off turns it off.
8
+ import { createHash } from "node:crypto"
9
+ import { MEDIA_LIMITS } from "../protocol"
10
+ import { createFetchBudget, type FetchBudget } from "./fetch-budget"
11
+ import { checkURL, FetchError, type Fetcher } from "./fetcher"
12
+
13
+ /** Remote image caps: those of timeline images (MEDIA_LIMITS: 8 MiB, 40 MP, 16,384 px per side). */
14
+ export const REMOTE_MEDIA_LIMITS = { maxBytes: MEDIA_LIMITS.maxBytes, maxPixels: MEDIA_LIMITS.maxPixels, maxSide: MEDIA_LIMITS.maxSide } as const
15
+
16
+ const MIN_TTL_MS = 3_600_000
17
+ const MAX_TTL_MS = 24 * 3_600_000
18
+ const FAILURE_TTL_MS = 5 * 60_000
19
+ const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024
20
+ const MAX_CACHE_ENTRIES = 4096
21
+ /** What a cached failure counts as against the byte bound. */
22
+ const FAILURE_COST = 256
23
+ const ACCEPT = "image/png,image/jpeg,image/webp,image/gif;q=0.9,*/*;q=0.1"
24
+
25
+ /** The §1 media headers, shared by remote and timeline images (./media-timeline.ts). */
26
+ export const MEDIA_HEADERS = {
27
+ "x-content-type-options": "nosniff",
28
+ "content-security-policy": "default-src 'none'; sandbox",
29
+ "cache-control": "private, no-cache",
30
+ "cross-origin-resource-policy": "same-origin",
31
+ "referrer-policy": "no-referrer",
32
+ } as const
33
+
34
+ export interface SniffedImage {
35
+ readonly mime: "image/png" | "image/jpeg" | "image/webp" | "image/gif"
36
+ readonly width: number
37
+ readonly height: number
38
+ }
39
+
40
+ const u16be = (b: Uint8Array, at: number) => (b[at]! << 8) | b[at + 1]!
41
+ const u16le = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8)
42
+ const u24le = (b: Uint8Array, at: number) => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16)
43
+ const u32be = (b: Uint8Array, at: number) => ((b[at]! << 24) >>> 0) + ((b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!)
44
+ const u32le = (b: Uint8Array, at: number) => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0
45
+ const text = (b: Uint8Array, at: number, s: string) => b.length >= at + s.length && [...s].every((c, i) => b[at + i] === c.charCodeAt(0))
46
+
47
+ /** The type (from magic bytes) and dimensions (from the header) of a PNG, JPEG, WebP or GIF; undefined otherwise. */
48
+ export function sniffImage(b: Uint8Array): SniffedImage | undefined {
49
+ const found = sniff(b)
50
+ return found && found.width > 0 && found.height > 0 ? found : undefined
51
+ }
52
+
53
+ function sniff(b: Uint8Array): SniffedImage | undefined {
54
+ if (b.length >= 24 && b[0] === 0x89 && text(b, 1, "PNG\r\n\x1a\n") && text(b, 12, "IHDR")) {
55
+ return { mime: "image/png", width: u32be(b, 16), height: u32be(b, 20) }
56
+ }
57
+ if (b.length >= 10 && (text(b, 0, "GIF87a") || text(b, 0, "GIF89a"))) return { mime: "image/gif", width: u16le(b, 6), height: u16le(b, 8) }
58
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) return jpegSize(b)
59
+ if (b.length >= 30 && text(b, 0, "RIFF") && text(b, 8, "WEBP")) {
60
+ if (text(b, 12, "VP8X")) return { mime: "image/webp", width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }
61
+ if (text(b, 12, "VP8L") && b[20] === 0x2f) {
62
+ const bits = u32le(b, 21)
63
+ return { mime: "image/webp", width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
64
+ }
65
+ if (text(b, 12, "VP8 ") && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
66
+ return { mime: "image/webp", width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }
67
+ }
68
+ }
69
+ return undefined
70
+ }
71
+
72
+ /** Walks the JPEG markers to the first frame header (SOFn). */
73
+ function jpegSize(b: Uint8Array): SniffedImage | undefined {
74
+ let i = 2
75
+ while (i + 3 < b.length) {
76
+ if (b[i] !== 0xff) return undefined
77
+ const marker = b[i + 1]!
78
+ if (marker === 0xff) {
79
+ i++ // fill byte
80
+ continue
81
+ }
82
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
83
+ i += 2 // standalone markers
84
+ continue
85
+ }
86
+ if (marker === 0xd9 || marker === 0xda) return undefined // end of image, or scan data before any frame header
87
+ const length = u16be(b, i + 2)
88
+ if (length < 2) return undefined
89
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
90
+ if (i + 9 > b.length) return undefined
91
+ return { mime: "image/jpeg", width: u16be(b, i + 7), height: u16be(b, i + 5) }
92
+ }
93
+ i += 2 + length
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ export interface RemoteMediaAudit {
99
+ readonly actor: string
100
+ readonly action: string
101
+ readonly target?: string
102
+ readonly detail?: string
103
+ }
104
+
105
+ export interface RemoteMediaOptions {
106
+ readonly fetcher: Fetcher
107
+ /** False: HUB_REMOTE_FETCH=off; every request is refused and nothing is fetched. */
108
+ readonly enabled: boolean
109
+ readonly now?: () => number
110
+ /** The hub's audit log (store.audit). */
111
+ readonly audit: (entry: RemoteMediaAudit) => unknown
112
+ /** The cache's byte bound (default 64 MiB). */
113
+ readonly cacheBytes?: number
114
+ /** The per-user fetch budget (http.ts shares one with /api/media/unfurl); default: its own, 30 a minute and 4 at once. */
115
+ readonly budget?: FetchBudget
116
+ }
117
+
118
+ export interface RemoteMedia {
119
+ /** Answers GET /api/media/remote for `userID` (already authenticated as a signed-in user). */
120
+ handle(userID: string, request: Request, url: URL): Promise<Response>
121
+ }
122
+
123
+ type Entry =
124
+ | { readonly expires: number; readonly cost: number; readonly ok: true; readonly bytes: Uint8Array<ArrayBuffer>; readonly mime: string; readonly etag: string }
125
+ | { readonly expires: number; readonly cost: number; readonly ok: false; readonly status: number; readonly code: string; readonly message: string }
126
+
127
+ /** The 429 text of a refused budget take (shared with ./media-unfurl.ts). */
128
+ export const budgetMessage = (refused: { reason: "minute" | "concurrent"; limit: number }) =>
129
+ refused.reason === "minute" ? `At most ${refused.limit} web fetches (images and link previews) a minute` : `At most ${refused.limit} web fetches (images and link previews) at once`
130
+
131
+ export const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
132
+ Response.json({ error: message, code }, { status, headers: { "x-content-type-options": "nosniff", "cache-control": "no-store", ...headers } })
133
+
134
+ /** How long the site says a response may be cached, clamped to 1-24 h (shared with ./media-unfurl.ts). */
135
+ export function lifetime(headers: Headers, now: number): number {
136
+ const control = headers.get("cache-control") ?? ""
137
+ const maxAge = /(?:^|[,\s])max-age\s*=\s*"?(\d+)/i.exec(control)
138
+ let ms = 0
139
+ if (maxAge) ms = Number(maxAge[1]) * 1000
140
+ else {
141
+ const expires = Date.parse(headers.get("expires") ?? "")
142
+ const date = Date.parse(headers.get("date") ?? "")
143
+ if (Number.isFinite(expires)) ms = expires - (Number.isFinite(date) ? date : now)
144
+ }
145
+ return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, Number.isFinite(ms) ? ms : 0))
146
+ }
147
+
148
+ const etagMatches = (header: string | null, etag: string) =>
149
+ !!header && header.split(",").some((tag) => {
150
+ const t = tag.trim()
151
+ return t === "*" || t === etag || t === `W/${etag}`
152
+ })
153
+
154
+ export function createRemoteMedia(options: RemoteMediaOptions): RemoteMedia {
155
+ const now = options.now ?? Date.now
156
+ const cacheBytes = options.cacheBytes ?? DEFAULT_CACHE_BYTES
157
+ const budget = options.budget ?? createFetchBudget({ now })
158
+ const cache = new Map<string, Entry>()
159
+ let cached = 0
160
+
161
+ function remember(key: string, entry: Entry): void {
162
+ forget(key)
163
+ if (entry.cost > cacheBytes) return
164
+ cache.set(key, entry)
165
+ cached += entry.cost
166
+ for (const [oldest, old] of cache) {
167
+ if (cached <= cacheBytes && cache.size <= MAX_CACHE_ENTRIES) break
168
+ cache.delete(oldest)
169
+ cached -= old.cost
170
+ }
171
+ }
172
+ function forget(key: string): void {
173
+ const old = cache.get(key)
174
+ if (!old) return
175
+ cache.delete(key)
176
+ cached -= old.cost
177
+ }
178
+ function lookup(key: string): Entry | undefined {
179
+ const entry = cache.get(key)
180
+ if (!entry) return undefined
181
+ if (entry.expires <= now()) {
182
+ forget(key)
183
+ return undefined
184
+ }
185
+ cache.delete(key) // most recently used last
186
+ cache.set(key, entry)
187
+ return entry
188
+ }
189
+
190
+ function answer(entry: Entry, request: Request): Response {
191
+ if (!entry.ok) return failure(entry.status, entry.code, entry.message)
192
+ if (etagMatches(request.headers.get("if-none-match"), entry.etag)) return new Response(null, { status: 304, headers: { ...MEDIA_HEADERS, etag: entry.etag } })
193
+ return new Response(entry.bytes, { headers: { ...MEDIA_HEADERS, "content-type": entry.mime, "content-length": String(entry.bytes.length), etag: entry.etag } })
194
+ }
195
+
196
+ /** The fetch outcome to cache: the image, or why not. */
197
+ async function load(target: URL): Promise<{ entry: Entry; host: string; outcome: string }> {
198
+ const at = now()
199
+ const fail = (status: number, code: string, message: string) => ({ expires: at + FAILURE_TTL_MS, cost: FAILURE_COST, ok: false as const, status, code, message })
200
+ try {
201
+ const got = await options.fetcher.fetch(target, { maxBytes: REMOTE_MEDIA_LIMITS.maxBytes, accept: ACCEPT })
202
+ const host = got.url.hostname
203
+ const image = sniffImage(got.body)
204
+ if (!image) return { entry: fail(415, "not_image", "Not a PNG, JPEG, WebP or GIF image"), host, outcome: "not_image" }
205
+ const { maxSide, maxPixels } = REMOTE_MEDIA_LIMITS
206
+ if (image.width > maxSide || image.height > maxSide || image.width * image.height > maxPixels) {
207
+ return { entry: fail(413, "too_large", `The image is larger than ${maxSide} px per side or ${maxPixels / 1_000_000} MP`), host, outcome: "too_large" }
208
+ }
209
+ const etag = `"sha256-${createHash("sha256").update(got.body).digest("base64url")}"`
210
+ const entry: Entry = { expires: at + lifetime(got.headers, at), cost: got.body.length, ok: true, bytes: got.body, mime: image.mime, etag }
211
+ return { entry, host, outcome: "ok" }
212
+ } catch (cause) {
213
+ if (!(cause instanceof FetchError)) throw cause
214
+ const code = cause.kind === "too_large" ? "too_large" : cause.kind
215
+ const message = cause.kind === "too_large" ? `The image is larger than ${REMOTE_MEDIA_LIMITS.maxBytes / 1024 / 1024} MiB` : cause.message
216
+ return { entry: fail(cause.status, code, message), host: cause.host ?? target.hostname, outcome: cause.kind }
217
+ }
218
+ }
219
+
220
+ async function handle(userID: string, request: Request, url: URL): Promise<Response> {
221
+ if (!options.enabled) return failure(403, "disabled", "Remote media is turned off on this hub (HUB_REMOTE_FETCH=off)")
222
+ const raw = url.searchParams.get("url")
223
+ if (!raw) return failure(400, "bad_url", "Expected a url query parameter")
224
+ let target: URL
225
+ try {
226
+ target = checkURL(raw)
227
+ } catch (cause) {
228
+ if (cause instanceof FetchError) return failure(cause.status, cause.kind, cause.message)
229
+ throw cause
230
+ }
231
+ const key = `${userID}\n${target.href}`
232
+ const hit = lookup(key)
233
+ if (hit) return answer(hit, request)
234
+
235
+ const slot = budget.take(userID)
236
+ if (!slot.ok) return failure(429, "rate_limited", budgetMessage(slot), { "retry-after": String(slot.retryAfter) })
237
+ try {
238
+ const { entry, host, outcome } = await load(target)
239
+ await Promise.resolve(options.audit({ actor: userID, action: "media.remote", target: host, detail: outcome })).catch(() => undefined)
240
+ remember(key, entry)
241
+ return answer(entry, request)
242
+ } finally {
243
+ slot.release()
244
+ }
245
+ }
246
+
247
+ return { handle }
248
+ }
@@ -0,0 +1,317 @@
1
+ // (S4 M3, session media design §1) GET /api/sessions/:session/timeline/messages/:message/media/:ref: an image of a
2
+ // session's timeline, pulled from the owning spoke on demand (op timelineMedia, feature "timelineMedia") chunk by
3
+ // chunk and answered to the console. The hub trusts nothing the spoke says about the bytes:
4
+ //
5
+ // - the first chunk's magic bytes must be the type the spoke claims, and one of MEDIA_MIMES (else 415);
6
+ // - the claimed dimensions must fit MEDIA_LIMITS (else 413), the length MEDIA_LIMITS.maxBytes;
7
+ // - every chunk must follow the previous one, and the length and SHA-256 of the whole must be the claimed ones.
8
+ //
9
+ // The whole image (≤ MEDIA_LIMITS.maxBytes) is collected and verified before anything is answered: a mismatch is 502
10
+ // integrity, never a partial 200 (Bun ends an errored chunked body cleanly, so a stream cannot signal it) and never a
11
+ // 304 for an unverified hash; it is not cached. One image gets 60 s in all (504 timeout), past each chunk's timeout.
12
+ //
13
+ // Answers carry the media headers (nosniff, sandbox CSP, CORP same-origin, no referrer, private no-cache) and an ETag
14
+ // of the SHA-256. SVG is always an attachment (never a document on the hub's origin); `?download=1` makes any type one.
15
+ //
16
+ // Cache: per pod, in memory, keyed by (user, session, message, ref), LRU within 64 MiB; never shared between users.
17
+ // Attachments (`f`) and tool items (`t`) of a message do not change, so a cached one is served (or answered 304 for a
18
+ // matching If-None-Match) without contacting the spoke, once the route's access check passed. A path (`p:`) names a
19
+ // file on the agent's machine that may change, so it is revalidated with one first-chunk call (which also re-checks
20
+ // the spoke's path policy); the later chunks come from the cache when the SHA-256 is unchanged. The caller bypasses the
21
+ // cache while a revert is staged (the spoke decides whether the message is hidden) and forgets a session when it is
22
+ // deleted or a revert is committed in it. Every hit also checks the session's current committed revert as the owning
23
+ // connection reports it (seen from any pod, so another pod's cache never outlives a revert) and a 10 min TTL. Limits: 4 spoke fetches in flight per user, 2 per spoke connection (429).
24
+ import { createHash } from "node:crypto"
25
+ import { MEDIA_LIMITS, MEDIA_MIMES, type MediaMime, type MediaRef, type TimelineMediaResult } from "../protocol"
26
+ import { MEDIA_HEADERS } from "./media-remote"
27
+
28
+ const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024
29
+ const DEFAULT_PER_USER = 4
30
+ const DEFAULT_PER_CONNECTION = 2
31
+ const DEFAULT_DEADLINE_MS = 60_000
32
+ /** Backstop: a cached image is fetched again after this long, whatever else happened. */
33
+ const CACHE_TTL_MS = 10 * 60_000
34
+ const SHA256 = /^[0-9a-f]{64}$/
35
+ const EXTENSIONS: Record<MediaMime, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif", "image/svg+xml": "svg" }
36
+
37
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
38
+ /** An SVG document: optional BOM, XML declaration, comments and doctype, then the root `<svg` (as the spoke sniffs). */
39
+ const SVG_START = /^\uFEFF?\s*(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE\s+svg[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>/]/i
40
+ const SVG_SNIFF_BYTES = 4096
41
+ const ascii = (b: Uint8Array, at: number, text: string) => b.length >= at + text.length && [...text].every((c, i) => b[at + i] === c.charCodeAt(0))
42
+
43
+ /** The image type of `bytes` by their magic bytes (one of MEDIA_MIMES), else undefined. */
44
+ export function sniffMediaMime(bytes: Uint8Array): MediaMime | undefined {
45
+ if (bytes.length >= 8 && PNG_SIGNATURE.every((v, i) => bytes[i] === v)) return "image/png"
46
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"
47
+ if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif"
48
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp"
49
+ if (SVG_START.test(new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, SVG_SNIFF_BYTES)))) return "image/svg+xml"
50
+ return undefined
51
+ }
52
+
53
+ export type TimelineMediaRequest = { readonly op: "timelineMedia"; readonly sessionID: string; readonly messageID: string; readonly ref: string; readonly offset: number }
54
+
55
+ export interface TimelineMediaOptions {
56
+ /** One timelineMedia op on the owning connection (spokes.request: routed to the owning pod in cluster mode). */
57
+ readonly request: (connID: string, req: TimelineMediaRequest) => Promise<TimelineMediaResult>
58
+ readonly cacheBytes?: number
59
+ readonly perUser?: number
60
+ readonly perConnection?: number
61
+ /** The longest one image may take, all chunks together (default 60 s). */
62
+ readonly deadlineMs?: number
63
+ /** The clock of the cache TTL. */
64
+ readonly now?: () => number
65
+ }
66
+
67
+ export interface TimelineMediaInput {
68
+ /** The signed-in user the route's access check admitted. */
69
+ readonly userID: string
70
+ /** The session's owning connection (feature "timelineMedia" checked by the caller). */
71
+ readonly connID: string
72
+ readonly sessionID: string
73
+ readonly messageID: string
74
+ readonly ref: string
75
+ readonly parsed: MediaRef
76
+ readonly ifNoneMatch: string | null
77
+ readonly download: boolean
78
+ /** Neither read nor fill the cache (a revert is staged in the session). */
79
+ readonly bypassCache: boolean
80
+ /**
81
+ * The session's latest committed revert (`revertCommitted.at`) as the owning connection reports it, seen from any
82
+ * pod: a cached entry filled under another one is stale (its message may be gone).
83
+ */
84
+ readonly revertAt: number | undefined
85
+ }
86
+
87
+ export interface TimelineMedia {
88
+ serve(input: TimelineMediaInput): Promise<Response>
89
+ /** Forgets a session's cached images (it was deleted, or a revert was committed in it). */
90
+ forgetSession(sessionID: string): void
91
+ /** Bytes cached now (tests, metrics). */
92
+ cachedBytes(): number
93
+ }
94
+
95
+ interface Entry {
96
+ readonly sessionID: string
97
+ readonly sha256: string
98
+ readonly mime: MediaMime
99
+ readonly data: Uint8Array<ArrayBuffer>
100
+ /** When it was cached (options.now). */
101
+ readonly filled: number
102
+ /** The session's committed revert (`at`) when it was cached. */
103
+ readonly revertAt: number | undefined
104
+ }
105
+
106
+ const failure = (status: number, code: string, message: string, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}) =>
107
+ Response.json({ error: message, code, ...extra }, { status, headers: { "x-content-type-options": "nosniff", "cache-control": "no-store", ...headers } })
108
+
109
+ /** An answer the spoke should never give (a bug or a hostile spoke): 502, nothing served. */
110
+ class BadAnswer extends Error {
111
+ constructor(message: string, readonly code = "bad_answer") {
112
+ super(message)
113
+ }
114
+ }
115
+
116
+ class DeadlineError extends Error {
117
+ constructor() {
118
+ super("The image took too long to load from the session's machine")
119
+ }
120
+ }
121
+
122
+ /** `promise`, or a DeadlineError once `deadline` (performance.now()) passes. */
123
+ function withDeadline<T>(promise: Promise<T>, deadline: number): Promise<T> {
124
+ const left = deadline - performance.now()
125
+ if (left <= 0) return Promise.reject(new DeadlineError())
126
+ let timer: ReturnType<typeof setTimeout> | undefined
127
+ const expired = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new DeadlineError()), left)))
128
+ return Promise.race([promise, expired]).finally(() => clearTimeout(timer))
129
+ }
130
+
131
+ const etagOf = (sha256: string) => `"sha256-${sha256}"`
132
+ const etagMatches = (header: string | null, etag: string) =>
133
+ !!header && header.split(",").some((tag) => {
134
+ const t = tag.trim()
135
+ return t === "*" || t === etag || t === `W/${etag}`
136
+ })
137
+
138
+ /** `inline`/`attachment` with an RFC 5987 file name: the path's base name or `<message>-<ref>`, with the type's extension. */
139
+ function disposition(input: TimelineMediaInput, mime: MediaMime): string {
140
+ const kind = input.download || mime === "image/svg+xml" ? "attachment" : "inline"
141
+ const ext = EXTENSIONS[mime]
142
+ const raw = input.parsed.kind === "path" ? input.parsed.path.split(/[\\/]/).pop()! : `${input.messageID}-${input.ref}`
143
+ let name = raw.normalize("NFC").replace(/[^\p{L}\p{N}._-]+/gu, "_").replace(/^[._]+/, "").slice(0, 120) || "image"
144
+ if (!name.toLowerCase().endsWith(`.${ext}`) && !(ext === "jpg" && /\.jpeg$/i.test(name))) name += `.${ext}`
145
+ const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
146
+ return `${kind}; filename*=UTF-8''${encoded}`
147
+ }
148
+
149
+ function decodeChunk(data: unknown): Uint8Array<ArrayBuffer> {
150
+ if (typeof data !== "string" || data.length > Math.ceil(MEDIA_LIMITS.chunkBytes / 3) * 4 + 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new BadAnswer("The spoke sent a malformed chunk")
151
+ const bytes = new Uint8Array(Buffer.from(data, "base64"))
152
+ if (bytes.length === 0 || bytes.length > MEDIA_LIMITS.chunkBytes) throw new BadAnswer("The spoke sent a malformed chunk")
153
+ return bytes
154
+ }
155
+
156
+ /** The checked metadata of a served answer. */
157
+ function checkInfo(media: TimelineMediaResult["media"]): { mime: MediaMime; bytes: number; sha256: string } | Response {
158
+ if (media && typeof media.mime === "string" && !MEDIA_MIMES.includes(media.mime)) return failure(415, "type", "This file is not an image the hub shows")
159
+ if (!media || !Number.isInteger(media.bytes) || media.bytes < 1 || typeof media.sha256 !== "string" || !SHA256.test(media.sha256)) {
160
+ throw new BadAnswer("The spoke described the image incompletely")
161
+ }
162
+ if (media.bytes > MEDIA_LIMITS.maxBytes) return failure(413, "too_large", "This image is too large to show here", { bytes: media.bytes, max: MEDIA_LIMITS.maxBytes })
163
+ const { width, height } = media
164
+ if (width !== undefined || height !== undefined) {
165
+ const { maxSide, maxPixels } = MEDIA_LIMITS
166
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width! < 0 || height! < 0) throw new BadAnswer("The spoke sent invalid dimensions")
167
+ if (width! > maxSide || height! > maxSide || width! * height! > maxPixels) return failure(413, "dimensions", `This image is larger than ${maxSide} px per side or ${maxPixels / 1_000_000} MP`)
168
+ }
169
+ return { mime: media.mime, bytes: media.bytes, sha256: media.sha256 }
170
+ }
171
+
172
+ /** The answer for a spoke result that serves no image (or undefined when it serves one). */
173
+ function refusal(result: TimelineMediaResult): Response | undefined {
174
+ if (result.tooLarge) return failure(413, "too_large", "This image is too large to show here", { bytes: result.tooLarge.bytes, max: result.tooLarge.max })
175
+ switch (result.refused) {
176
+ case "type":
177
+ return failure(415, "type", "This file is not an image the hub shows")
178
+ case "dimensions":
179
+ return failure(413, "dimensions", "This image has too many pixels to show here")
180
+ case "path":
181
+ return failure(404, "path", "This file is not in this session's files")
182
+ case "hidden":
183
+ return failure(404, "hidden", "This message is hidden by a revert")
184
+ case undefined:
185
+ break
186
+ default:
187
+ throw new BadAnswer("The spoke refused the image for an unknown reason")
188
+ }
189
+ if (!result.media && !result.chunk) return failure(404, "not_found", "No such image")
190
+ return undefined
191
+ }
192
+
193
+ export function createTimelineMedia(options: TimelineMediaOptions): TimelineMedia {
194
+ const cacheBytes = options.cacheBytes ?? DEFAULT_CACHE_BYTES
195
+ const perUser = options.perUser ?? DEFAULT_PER_USER
196
+ const perConnection = options.perConnection ?? DEFAULT_PER_CONNECTION
197
+ const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS
198
+ const now = options.now ?? Date.now
199
+ const cache = new Map<string, Entry>()
200
+ let cached = 0
201
+ const users = new Map<string, number>()
202
+ const conns = new Map<string, number>()
203
+
204
+ function remember(key: string, entry: Entry): void {
205
+ forget(key)
206
+ if (entry.data.length > Math.min(cacheBytes, MEDIA_LIMITS.maxBytes)) return
207
+ cache.set(key, entry)
208
+ cached += entry.data.length
209
+ for (const [oldest, old] of cache) {
210
+ if (cached <= cacheBytes) break
211
+ cache.delete(oldest)
212
+ cached -= old.data.length
213
+ }
214
+ }
215
+ function forget(key: string): void {
216
+ const old = cache.get(key)
217
+ if (!old) return
218
+ cache.delete(key)
219
+ cached -= old.data.length
220
+ }
221
+ /** A fresh entry: within the TTL, and filled under the session's current committed revert (any pod's spoke). */
222
+ function lookup(key: string, input: TimelineMediaInput): Entry | undefined {
223
+ const entry = cache.get(key)
224
+ if (!entry) return undefined
225
+ if (now() - entry.filled >= CACHE_TTL_MS || entry.revertAt !== input.revertAt) {
226
+ forget(key)
227
+ return undefined
228
+ }
229
+ cache.delete(key) // most recently used last
230
+ cache.set(key, entry)
231
+ return entry
232
+ }
233
+
234
+ const bump = (counts: Map<string, number>, id: string, by: number) => {
235
+ const n = (counts.get(id) ?? 0) + by
236
+ if (n > 0) counts.set(id, n)
237
+ else counts.delete(id)
238
+ }
239
+
240
+ function headers(input: TimelineMediaInput, mime: MediaMime, sha256: string, bytes: number): Record<string, string> {
241
+ return { ...MEDIA_HEADERS, "content-type": mime, "content-length": String(bytes), etag: etagOf(sha256), "content-disposition": disposition(input, mime) }
242
+ }
243
+ const notModified = (sha256: string) => new Response(null, { status: 304, headers: { ...MEDIA_HEADERS, etag: etagOf(sha256) } })
244
+ const fromCache = (input: TimelineMediaInput, entry: Entry) =>
245
+ etagMatches(input.ifNoneMatch, etagOf(entry.sha256)) ? notModified(entry.sha256) : new Response(entry.data, { headers: headers(input, entry.mime, entry.sha256, entry.data.length) })
246
+
247
+ async function serve(input: TimelineMediaInput): Promise<Response> {
248
+ const key = [input.userID, input.sessionID, input.messageID, input.ref].join("\n")
249
+ const cacheable = !input.bypassCache
250
+ if (!cacheable) forget(key)
251
+ const hit = cacheable ? lookup(key, input) : undefined
252
+ if (hit && input.parsed.kind !== "path") return fromCache(input, hit)
253
+ const pathHit = hit
254
+ if ((users.get(input.userID) ?? 0) >= perUser) return failure(429, "rate_limited", `At most ${perUser} images load at once`, {}, { "retry-after": "1" })
255
+ if ((conns.get(input.connID) ?? 0) >= perConnection) return failure(429, "rate_limited", `At most ${perConnection} images load at once from one machine`, {}, { "retry-after": "1" })
256
+ bump(users, input.userID, 1)
257
+ bump(conns, input.connID, 1)
258
+ let held = true
259
+ const release = () => {
260
+ if (!held) return
261
+ held = false
262
+ bump(users, input.userID, -1)
263
+ bump(conns, input.connID, -1)
264
+ }
265
+ const deadline = performance.now() + deadlineMs
266
+ const fetchChunk = (offset: number) =>
267
+ withDeadline(options.request(input.connID, { op: "timelineMedia", sessionID: input.sessionID, messageID: input.messageID, ref: input.ref, offset }), deadline)
268
+
269
+ try {
270
+ const first = await fetchChunk(0)
271
+ const refused = refusal(first)
272
+ if (refused) return refused
273
+ const info = checkInfo(first.media)
274
+ if (info instanceof Response) return info
275
+ if (!first.chunk || first.chunk.offset !== 0 || typeof first.chunk.last !== "boolean") throw new BadAnswer("The spoke sent no first chunk")
276
+ const head = decodeChunk(first.chunk.data)
277
+ if (sniffMediaMime(head) !== info.mime) return failure(415, "type", "This file is not the image type it claims to be")
278
+ if (head.length > info.bytes || (first.chunk.last && head.length !== info.bytes)) throw new BadAnswer("The image's length does not match", "integrity")
279
+ // A path's cached bytes were verified; the spoke's SHA-256 is over the file's whole current bytes, so the same
280
+ // one means the same image (answered from the cache, 304 included, without the later chunks).
281
+ if (pathHit && pathHit.sha256 === info.sha256) return fromCache(input, pathHit)
282
+
283
+ // The whole image is collected and verified before any byte (or a 304) is sent: ≤ MEDIA_LIMITS.maxBytes.
284
+ const whole = new Uint8Array(info.bytes)
285
+ whole.set(head)
286
+ let received = head.length
287
+ let last = first.chunk.last
288
+ while (!last) {
289
+ const next = await fetchChunk(received)
290
+ if (refusal(next) || !next.chunk || next.chunk.offset !== received || typeof next.chunk.last !== "boolean") throw new BadAnswer("The spoke stopped serving the image")
291
+ if (next.media && (next.media.sha256 !== info.sha256 || next.media.bytes !== info.bytes || next.media.mime !== info.mime)) throw new BadAnswer("The image changed while it was sent", "integrity")
292
+ const bytes = decodeChunk(next.chunk.data)
293
+ const end = received + bytes.length
294
+ if (end > info.bytes || (next.chunk.last ? end !== info.bytes : end >= info.bytes)) throw new BadAnswer("The image's length does not match", "integrity")
295
+ whole.set(bytes, received)
296
+ received = end
297
+ last = next.chunk.last
298
+ }
299
+ if (createHash("sha256").update(whole).digest("hex") !== info.sha256) throw new BadAnswer("The image's SHA-256 does not match", "integrity")
300
+ const entry: Entry = { sessionID: input.sessionID, sha256: info.sha256, mime: info.mime, data: whole, filled: now(), revertAt: input.revertAt }
301
+ if (cacheable) remember(key, entry)
302
+ return fromCache(input, entry)
303
+ } catch (cause) {
304
+ if (cause instanceof BadAnswer) return failure(502, cause.code, cause.message)
305
+ if (cause instanceof DeadlineError) return failure(504, "timeout", cause.message)
306
+ throw cause
307
+ } finally {
308
+ release()
309
+ }
310
+ }
311
+
312
+ function forgetSession(sessionID: string): void {
313
+ for (const [key, entry] of cache) if (entry.sessionID === sessionID) forget(key)
314
+ }
315
+
316
+ return { serve, forgetSession, cachedBytes: () => cached }
317
+ }