@hoardodile/workbench 0.0.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.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * The workbench's HTTP surface, shared by the vite dev server
3
+ * (`vite.config.ts`) and the published standalone server (`serve.mjs`)
4
+ * so the two can never drift.
5
+ *
6
+ * Everything the page needs about a resource arrives through provider
7
+ * callbacks. That is what keeps this package dependency-free while
8
+ * still reaching real data: `hoardodile plugin dev` owns the sandbox,
9
+ * the storage reader and the render pipeline, and passes them in. Run
10
+ * standalone against a plain directory, the built-in providers below
11
+ * cover the offline case.
12
+ *
13
+ * Routes:
14
+ * GET /plugin/* built plugin bundle
15
+ * GET /data/<path>[?res=] raw entry bytes
16
+ * GET /data/?list=1[&res=] entry names
17
+ * GET /data/?stat=<path>[&res=] entry size
18
+ * GET /api/workbench/resources resource picker list
19
+ * GET /api/workbench/context?res=<id> hooks + seeded state
20
+ * GET /api/resources/:id/files/:token/* plugin file URLs
21
+ * …?size=preview preview variant
22
+ * GET /api/resources/:id/frame/:token/:name/:ms video seek frame
23
+ */
24
+
25
+ import { createHash } from "node:crypto"
26
+ import {
27
+ existsSync,
28
+ mkdirSync,
29
+ readdirSync,
30
+ readFileSync,
31
+ renameSync,
32
+ rmSync,
33
+ statSync,
34
+ writeFileSync,
35
+ } from "node:fs"
36
+ import { join, resolve, sep } from "node:path"
37
+
38
+ export function contentTypeOf(path) {
39
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase()
40
+ switch (ext) {
41
+ case ".html":
42
+ return "text/html; charset=utf-8"
43
+ case ".js":
44
+ case ".mjs":
45
+ return "text/javascript; charset=utf-8"
46
+ case ".css":
47
+ return "text/css; charset=utf-8"
48
+ case ".json":
49
+ return "application/json; charset=utf-8"
50
+ case ".svg":
51
+ return "image/svg+xml"
52
+ case ".png":
53
+ return "image/png"
54
+ case ".jpg":
55
+ case ".jpeg":
56
+ return "image/jpeg"
57
+ case ".gif":
58
+ return "image/gif"
59
+ case ".webp":
60
+ return "image/webp"
61
+ case ".avif":
62
+ return "image/avif"
63
+ case ".mp4":
64
+ return "video/mp4"
65
+ case ".webm":
66
+ return "video/webm"
67
+ case ".mp3":
68
+ return "audio/mpeg"
69
+ case ".flac":
70
+ return "audio/flac"
71
+ case ".wav":
72
+ return "audio/wav"
73
+ case ".woff2":
74
+ return "font/woff2"
75
+ case ".woff":
76
+ return "font/woff"
77
+ default:
78
+ return "application/octet-stream"
79
+ }
80
+ }
81
+
82
+ function walkFiles(root) {
83
+ const out = []
84
+ function walk(current, prefix) {
85
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
86
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
87
+ if (entry.isDirectory()) walk(join(current, entry.name), rel)
88
+ else if (entry.isFile()) out.push(rel)
89
+ }
90
+ }
91
+ walk(root, "")
92
+ return out.sort()
93
+ }
94
+
95
+ /** Resolve `rel` under `root`, or `undefined` when it escapes. */
96
+ function safeJoin(root, rel) {
97
+ const abs = resolve(root, rel)
98
+ if (abs !== root && !abs.startsWith(root + sep)) return undefined
99
+ return abs
100
+ }
101
+
102
+ function sendJson(res, value) {
103
+ res.setHeader("content-type", "application/json; charset=utf-8")
104
+ res.setHeader("cache-control", "no-store")
105
+ res.end(JSON.stringify(value ?? null))
106
+ }
107
+
108
+ function sendBytes(res, contentType, bytes) {
109
+ res.setHeader("content-type", contentType)
110
+ res.end(bytes)
111
+ }
112
+
113
+ function notFound(res) {
114
+ res.statusCode = 404
115
+ res.end("not found")
116
+ }
117
+
118
+ /**
119
+ * Providers over one plain directory — the offline default, and what a
120
+ * standalone `serve.mjs --data <dir>` uses. The directory stands in for
121
+ * a single resource.
122
+ */
123
+ export function createDirectoryProviders(dataDir, resId = "workbench") {
124
+ const root = resolve(dataDir)
125
+ return {
126
+ resources: () => [{ id: resId, name: "Workbench" }],
127
+ files: {
128
+ list: () => walkFiles(root),
129
+ stat: (_resId, path) => {
130
+ const abs = safeJoin(root, path)
131
+ if (abs === undefined || !existsSync(abs)) return undefined
132
+ const info = statSync(abs)
133
+ return info.isFile() ? { sizeBytes: info.size } : undefined
134
+ },
135
+ read: (_resId, path) => {
136
+ const abs = safeJoin(root, path)
137
+ if (abs === undefined || !existsSync(abs)) return undefined
138
+ if (statSync(abs).isDirectory()) return undefined
139
+ return readFileSync(abs)
140
+ },
141
+ },
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Mobile viewport initial-scale factor injected into every served plugin
147
+ * page — the host server runs `wrapHtml` with the same value
148
+ * (apps/server/src/infra/http/plugin-render.ts). NOTE: keep in sync with
149
+ * `MOBILE_INITIAL_SCALE` in `@hoardodile/ui/viewport` (single source of
150
+ * truth, Design.md — Layout); `mounts.test.ts` guards the alignment.
151
+ * This module stays dependency-free, so the constant is mirrored, not
152
+ * imported.
153
+ */
154
+ const PLUGIN_SHELL_VIEWPORT_SCALE = 0.8
155
+
156
+ /**
157
+ * Wraps a plugin page in the same shell the host server produces
158
+ * (plugin-render.ts `wrapHtml`, kept byte-identical): the viewport meta
159
+ * the app injects, the overflow reset, and the postMessage bridge that
160
+ * exposes `__pluginContext` / `__pluginVisibility` as CustomEvents for
161
+ * SDK builds that predate the pure-postMessage protocol.
162
+ */
163
+ export function wrapPluginHtml(body) {
164
+ return [
165
+ "<!DOCTYPE html>",
166
+ "<html>",
167
+ "<head>",
168
+ '<meta charset="utf-8">',
169
+ `<meta name="viewport" content="width=device-width, initial-scale=${PLUGIN_SHELL_VIEWPORT_SCALE}, maximum-scale=1.0, user-scalable=0">`,
170
+ '<style type="text/css">html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden}</style>',
171
+ "</head>",
172
+ "<body>",
173
+ `<script>(function(){window.__pluginContext=undefined;window.__pluginVisibility=undefined;window.addEventListener("message",function(e){if(e.source!==window.parent)return;if(e.data?.type==="push"){if(e.data?.key==="context"){window.__pluginContext=e.data.data;window.dispatchEvent(new CustomEvent("context-ready",{detail:e.data.data}))}else if(e.data?.key==="visibility"){window.__pluginVisibility=e.data.data;window.dispatchEvent(new CustomEvent("visibility-changed",{detail:e.data.data}))}}})})();</script>`,
174
+ body,
175
+ "</body>",
176
+ "</html>",
177
+ ].join("")
178
+ }
179
+
180
+ /** Read-only mount of a directory under `basePath` (the plugin bundle). */
181
+ function staticMount(basePath, dir) {
182
+ const root = resolve(dir)
183
+ return (req, res) => {
184
+ const url = new URL(req.url ?? "/", "http://workbench.local")
185
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
186
+ return false
187
+ }
188
+ // Sandboxed plugin iframes have the opaque origin "null"; their
189
+ // asset fetches need permissive CORS, same as the real server's
190
+ // plugin render route.
191
+ res.setHeader("access-control-allow-origin", "*")
192
+ const rel = decodeURIComponent(url.pathname.slice(basePath.length)).replace(
193
+ /^\/+/,
194
+ "",
195
+ )
196
+ const abs = safeJoin(root, rel)
197
+ if (abs === undefined) {
198
+ res.statusCode = 403
199
+ res.end("forbidden")
200
+ return true
201
+ }
202
+ if (!existsSync(abs) || statSync(abs).isDirectory()) {
203
+ notFound(res)
204
+ return true
205
+ }
206
+ const ext = abs.slice(abs.lastIndexOf(".")).toLowerCase()
207
+ if (ext === ".html") {
208
+ // Mirror the host server: the page runs in a sandboxed iframe
209
+ // (no allow-same-origin). The same sandbox via CSP keeps it in
210
+ // an opaque origin even top-level; frame-ancestors restricts
211
+ // embedding to the workbench origin (the page and /plugin/*
212
+ // share it, so the embedded workbench keeps working).
213
+ res.setHeader(
214
+ "content-security-policy",
215
+ "sandbox allow-scripts allow-forms allow-downloads; frame-ancestors 'self'",
216
+ )
217
+ res.setHeader("x-content-type-options", "nosniff")
218
+ sendBytes(
219
+ res,
220
+ contentTypeOf(abs),
221
+ wrapPluginHtml(readFileSync(abs, "utf-8")),
222
+ )
223
+ return true
224
+ }
225
+ sendBytes(res, contentTypeOf(abs), readFileSync(abs))
226
+ return true
227
+ }
228
+ }
229
+
230
+ /** `/data` mount: entry listing, stat and bytes for the selected resource. */
231
+ function dataMount(files) {
232
+ return async (req, res) => {
233
+ const url = new URL(req.url ?? "/", "http://workbench.local")
234
+ if (url.pathname !== "/data" && !url.pathname.startsWith("/data/")) {
235
+ return false
236
+ }
237
+ res.setHeader("access-control-allow-origin", "*")
238
+ const resId = url.searchParams.get("res") ?? ""
239
+ if (url.searchParams.has("list")) {
240
+ sendJson(res, await files.list(resId))
241
+ return true
242
+ }
243
+ const statPath = url.searchParams.get("stat")
244
+ if (statPath !== null) {
245
+ const stat = await files.stat(resId, decodeURIComponent(statPath))
246
+ sendJson(res, stat === undefined ? null : stat.sizeBytes)
247
+ return true
248
+ }
249
+ const rel = decodeURIComponent(url.pathname.slice("/data".length)).replace(
250
+ /^\/+/,
251
+ "",
252
+ )
253
+ const bytes = await files.read(resId, rel)
254
+ if (bytes === undefined) {
255
+ notFound(res)
256
+ return true
257
+ }
258
+ sendBytes(res, contentTypeOf(rel), bytes)
259
+ return true
260
+ }
261
+ }
262
+
263
+ const FILE_URL_RE = /^\/api\/resources\/([^/]+)\/files\/(?:[^/]*)\/(.+)$/
264
+ const COVER_URL_RE = /^\/api\/resources\/([^/]+)\/cover$/
265
+ const FRAME_URL_RE =
266
+ /^\/api\/resources\/([^/]+)\/frame\/(?:[^/]*)\/([^/]+)\/([^/]+)$/
267
+ const EXTRACTED_URL_RE =
268
+ /^\/api\/resources\/([^/]+)\/extracted\/(?:[^/]*)\/(.+)$/
269
+ // NOTE: keep the token-path route family in sync with the server's auth
270
+ // preHandler and the web service worker (see
271
+ // apps/server/src/infra/http/plugin.ts). The cover route mirrors the
272
+ // server's token-free `GET /api/resources/:id/cover`.
273
+
274
+ /**
275
+ * The resource cover, rendered the way the app does it (`coverLocal`
276
+ * pick -> thumb pipeline). Mirrors the server's
277
+ * `GET /api/resources/:id/cover?size=thumb`; a missing cover or render
278
+ * surfaces as the same placeholder-shaped 404 the app sends.
279
+ */
280
+ function coverMount(cover) {
281
+ return async (req, res) => {
282
+ const url = new URL(req.url ?? "/", "http://workbench.local")
283
+ const match = url.pathname.match(COVER_URL_RE)
284
+ if (match === null) return false
285
+ res.setHeader("access-control-allow-origin", "*")
286
+ const rendered = await cover(decodeURIComponent(match[1] ?? ""))
287
+ if (rendered === undefined) {
288
+ res.statusCode = 404
289
+ res.setHeader("content-type", "application/json")
290
+ res.end('{"error":"no cover","reason":"placeholder"}')
291
+ return true
292
+ }
293
+ sendBytes(res, rendered.contentType, readRendered(rendered))
294
+ return true
295
+ }
296
+ }
297
+
298
+ /**
299
+ * The plugin file URL shape the real server exposes. `?size=preview`
300
+ * (and the generic variant parameters `fmt`/`fit`/`area`/`q`) goes
301
+ * through the render provider when one is wired, so the workbench
302
+ * serves the same derived variant production does; without a provider
303
+ * it falls back to the original bytes.
304
+ */
305
+ function resourceFilesMount(files, preview) {
306
+ return async (req, res) => {
307
+ const url = new URL(req.url ?? "/", "http://workbench.local")
308
+ const match = url.pathname.match(FILE_URL_RE)
309
+ if (match === null) return false
310
+ res.setHeader("access-control-allow-origin", "*")
311
+ const resId = decodeURIComponent(match[1] ?? "")
312
+ const rel = decodeURIComponent(match[2] ?? "")
313
+ const variantQuery = collectVariantQuery(url)
314
+ if (variantQuery !== undefined && preview !== undefined) {
315
+ const rendered = await preview(resId, rel, variantQuery)
316
+ if (rendered !== undefined) {
317
+ sendBytes(res, rendered.contentType, readRendered(rendered))
318
+ return true
319
+ }
320
+ }
321
+ const bytes = await files.read(resId, rel)
322
+ if (bytes === undefined) {
323
+ notFound(res)
324
+ return true
325
+ }
326
+ sendBytes(res, contentTypeOf(rel), bytes)
327
+ return true
328
+ }
329
+ }
330
+
331
+ /**
332
+ * The variant parameters of a file URL, or `undefined` when none are
333
+ * present. Raw strings pass through untouched — the render provider
334
+ * parses and validates them.
335
+ */
336
+ function collectVariantQuery(url) {
337
+ const get = (name) => url.searchParams.get(name)
338
+ const size = get("size")
339
+ const fmt = get("fmt")
340
+ const fit = get("fit")
341
+ const area = get("area")
342
+ const q = get("q")
343
+ const requested =
344
+ size === "preview" ||
345
+ fmt !== null ||
346
+ fit !== null ||
347
+ area !== null ||
348
+ q !== null
349
+ if (!requested) return undefined
350
+ const query = {}
351
+ if (size !== null) query.size = size
352
+ if (fmt !== null) query.fmt = fmt
353
+ if (fit !== null) query.fit = fit
354
+ if (area !== null) query.area = area
355
+ if (q !== null) query.q = q
356
+ return query
357
+ }
358
+
359
+ /** Video seek-preview frames, rendered on demand by the provider. */
360
+ function frameMount(frame) {
361
+ return async (req, res) => {
362
+ const url = new URL(req.url ?? "/", "http://workbench.local")
363
+ const match = url.pathname.match(FRAME_URL_RE)
364
+ if (match === null) return false
365
+ res.setHeader("access-control-allow-origin", "*")
366
+ const timeMs = Number(match[3])
367
+ if (!Number.isFinite(timeMs) || timeMs < 0) {
368
+ res.statusCode = 400
369
+ res.end("invalid time")
370
+ return true
371
+ }
372
+ const rendered = await frame(
373
+ decodeURIComponent(match[1] ?? ""),
374
+ decodeURIComponent(match[2] ?? ""),
375
+ timeMs,
376
+ )
377
+ if (rendered === undefined) {
378
+ notFound(res)
379
+ return true
380
+ }
381
+ sendBytes(res, rendered.contentType, readRendered(rendered))
382
+ return true
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Files materialized by the plugin's `extractArchive` hook. The
388
+ * production server reads them from the extraction cache; the workbench
389
+ * delegates to the same provider the CLI wires.
390
+ */
391
+ function extractedMount(files) {
392
+ return async (req, res) => {
393
+ const url = new URL(req.url ?? "/", "http://workbench.local")
394
+ const match = url.pathname.match(EXTRACTED_URL_RE)
395
+ if (match === null) return false
396
+ if (files.extracted === undefined) return false
397
+ res.setHeader("access-control-allow-origin", "*")
398
+ const resId = decodeURIComponent(match[1] ?? "")
399
+ const rel = decodeURIComponent(match[2] ?? "")
400
+ const bytes = await files.extracted(resId, rel)
401
+ if (bytes === undefined) {
402
+ notFound(res)
403
+ return true
404
+ }
405
+ sendBytes(res, contentTypeOf(rel), bytes)
406
+ return true
407
+ }
408
+ }
409
+
410
+ /** A render provider may answer with bytes in hand or a cached path. */
411
+ function readRendered(rendered) {
412
+ return rendered.bytes ?? readFileSync(rendered.path)
413
+ }
414
+
415
+ /**
416
+ * The page's own API: which resources can be opened, and everything
417
+ * known about the selected one (sandboxed hook results plus the
418
+ * plugin-visible state used to seed the mock host).
419
+ */
420
+ function workbenchApiMount(providers) {
421
+ return async (req, res) => {
422
+ const url = new URL(req.url ?? "/", "http://workbench.local")
423
+ if (!url.pathname.startsWith("/api/workbench/")) return false
424
+ res.setHeader("access-control-allow-origin", "*")
425
+ if (url.pathname === "/api/workbench/resources") {
426
+ sendJson(res, await providers.resources())
427
+ return true
428
+ }
429
+ if (url.pathname === "/api/workbench/context") {
430
+ const resId = url.searchParams.get("res") ?? ""
431
+ const [snapshot, state] = await Promise.all([
432
+ providers.snapshot?.(resId),
433
+ providers.state?.(resId),
434
+ ])
435
+ sendJson(res, {
436
+ resId,
437
+ snapshot: snapshot ?? null,
438
+ state: state ?? null,
439
+ // Rendering capabilities the page surfaces in its status
440
+ // line, so a missing one reads as "not wired" rather than
441
+ // as a broken plugin.
442
+ capabilities: {
443
+ preview: providers.preview !== undefined,
444
+ frame: providers.frame !== undefined,
445
+ },
446
+ })
447
+ return true
448
+ }
449
+ notFound(res)
450
+ return true
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Build the workbench's request handlers in match order. Each returns
456
+ * `true` when it handled the request.
457
+ */
458
+ export function createWorkbenchMounts(opts) {
459
+ const { pluginDir, providers } = opts
460
+ const mounts = []
461
+ if (pluginDir !== undefined) mounts.push(staticMount("/plugin", pluginDir))
462
+ if (providers.files !== undefined) {
463
+ mounts.push(dataMount(providers.files))
464
+ mounts.push(resourceFilesMount(providers.files, providers.preview))
465
+ mounts.push(extractedMount(providers.files))
466
+ }
467
+ if (providers.cover !== undefined) mounts.push(coverMount(providers.cover))
468
+ if (providers.frame !== undefined) mounts.push(frameMount(providers.frame))
469
+ if (opts.vault !== undefined) {
470
+ mounts.push(pluginAssetsMount(opts.vault))
471
+ mounts.push(workbenchVaultMount(opts.vault))
472
+ }
473
+ mounts.push(workbenchApiMount(providers))
474
+ return mounts
475
+ }
476
+
477
+ // ── Plugin asset vault (workbench dev) ────────────────────────────────────
478
+
479
+ const ASSET_URL_RE = /^\/api\/plugin-assets\/([^/]+)\/(?:[^/]*)\/(.+)$/
480
+ const VAULT_DOWNLOAD_PATH = "/api/workbench/vault/download"
481
+ const VAULT_DELETE_PATH = "/api/workbench/vault/delete"
482
+
483
+ /**
484
+ * Dev-only mirror of the server's plugin asset pipeline, in the plain
485
+ * dependency-free module the standalone workbench ships:
486
+ *
487
+ * - `GET /api/plugin-assets/:id/:token/*` serves the local vault
488
+ * (token accepted verbatim — dev; the app issues HMAC-scoped ones).
489
+ * `nosniff`, `access-control-allow-origin: *` (opaque-origin iframe),
490
+ * HTML demoted to an attachment, `no-store` cache.
491
+ * - policy mirrors the server where it matters: http(s) only, no URL
492
+ * userinfo, ≤5 redirects, size cap (`WORKBENCH_VAULT_MAX_BYTES`,
493
+ * default 200 MiB), optional sha256 pin, atomic temp→rename write,
494
+ * dest confined to the plugin's vault directory.
495
+ */
496
+ function pluginAssetsMount(vaultRoot) {
497
+ return async (req, res) => {
498
+ const url = new URL(req.url ?? "/", "http://workbench.local")
499
+ const match = url.pathname.match(ASSET_URL_RE)
500
+ if (match === null) return false
501
+ const abs = vaultFile(
502
+ vaultRoot,
503
+ decodeURIComponent(match[1] ?? ""),
504
+ decodeURIComponent(match[2] ?? ""),
505
+ )
506
+ if (abs === undefined) {
507
+ res.statusCode = 403
508
+ res.end("forbidden")
509
+ return true
510
+ }
511
+ let info
512
+ try {
513
+ info = statSync(abs)
514
+ } catch {
515
+ info = undefined
516
+ }
517
+ if (info === undefined || !info.isFile()) {
518
+ notFound(res)
519
+ return true
520
+ }
521
+ res.setHeader("x-content-type-options", "nosniff")
522
+ res.setHeader("access-control-allow-origin", "*")
523
+ res.setHeader("cache-control", "no-store")
524
+ const ext = `.${abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()}`
525
+ const isHtml = ext === ".html" || ext === ".htm"
526
+ res.setHeader(
527
+ "content-type",
528
+ isHtml ? "application/octet-stream" : contentTypeOf(abs),
529
+ )
530
+ if (isHtml) {
531
+ res.setHeader("content-disposition", "attachment")
532
+ }
533
+ res.end(readFileSync(abs))
534
+ return true
535
+ }
536
+ }
537
+
538
+ function workbenchVaultMount(vaultRoot) {
539
+ return async (req, res) => {
540
+ const url = new URL(req.url ?? "/", "http://workbench.local")
541
+ if (
542
+ url.pathname === VAULT_DOWNLOAD_PATH &&
543
+ (req.method === "POST" || req.method === "PUT")
544
+ ) {
545
+ await handleVaultDownload(
546
+ req,
547
+ res,
548
+ vaultRoot,
549
+ url.searchParams.get("force") === "1",
550
+ )
551
+ return true
552
+ }
553
+ if (url.pathname === VAULT_DELETE_PATH && req.method === "POST") {
554
+ const body = await readJsonBody(req)
555
+ const { pluginId, path } = body ?? {}
556
+ if (typeof pluginId !== "string" || typeof path !== "string") {
557
+ sendJson(res, { error: "pluginId and path are required" })
558
+ return true
559
+ }
560
+ const abs = vaultFile(vaultRoot, pluginId, path)
561
+ if (abs === undefined || !validVaultDest(path)) {
562
+ res.statusCode = 403
563
+ res.end("forbidden")
564
+ return true
565
+ }
566
+ let existed = false
567
+ try {
568
+ existed = statSync(abs).isFile()
569
+ } catch {
570
+ existed = false
571
+ }
572
+ if (existed) rmSync(abs)
573
+ sendJson(res, { existed })
574
+ return true
575
+ }
576
+ return false
577
+ }
578
+ }
579
+
580
+ async function handleVaultDownload(req, res, vaultRoot, force) {
581
+ const body = await readJsonBody(req)
582
+ const { pluginId, url, dest, sha256 } = body ?? {}
583
+ if (
584
+ typeof pluginId !== "string" ||
585
+ typeof url !== "string" ||
586
+ typeof dest !== "string"
587
+ ) {
588
+ sendJson(res, { error: "pluginId, url and dest are required" })
589
+ return
590
+ }
591
+ const abs = vaultFile(vaultRoot, pluginId, dest)
592
+ if (abs === undefined || !validVaultDest(dest)) {
593
+ res.statusCode = 403
594
+ res.end("forbidden")
595
+ return
596
+ }
597
+
598
+ // Cache-first: an existing destination resolves without consent;
599
+ // without `force` a miss answers `missing` (the page then asks the
600
+ // user and re-issues with `force`).
601
+ let existing
602
+ try {
603
+ const info = statSync(abs)
604
+ existing = info.isFile() ? { path: dest, sizeBytes: info.size } : undefined
605
+ } catch {
606
+ existing = undefined
607
+ }
608
+ if (existing !== undefined) {
609
+ sendJson(res, {
610
+ status: "cached",
611
+ path: existing.path,
612
+ sizeBytes: existing.sizeBytes,
613
+ sha256: sha256File(abs),
614
+ })
615
+ return
616
+ }
617
+ if (!force) {
618
+ sendJson(res, { status: "missing" })
619
+ return
620
+ }
621
+
622
+ const maxBytes =
623
+ Number(process.env.WORKBENCH_VAULT_MAX_BYTES) || 200 * 1024 * 1024
624
+ try {
625
+ const fetched = await fetchWithPolicy(url, maxBytes)
626
+ if (sha256 !== undefined && fetched.sha256 !== sha256) {
627
+ throw new Error(
628
+ `sha256 mismatch: expected ${sha256}, got ${fetched.sha256}`,
629
+ )
630
+ }
631
+ mkdirSync(resolve(abs, ".."), { recursive: true })
632
+ const tmp = `${abs}.tmp-${Date.now()}`
633
+ writeFileSync(tmp, fetched.bytes)
634
+ renameSync(tmp, abs)
635
+ sendJson(res, {
636
+ status: "downloaded",
637
+ path: dest,
638
+ sizeBytes: fetched.bytes.length,
639
+ sha256: fetched.sha256,
640
+ })
641
+ } catch (err) {
642
+ sendJson(res, {
643
+ status: "error",
644
+ error: err instanceof Error ? err.message : String(err),
645
+ })
646
+ }
647
+ }
648
+
649
+ /** A vault file path under `<root>/<pluginId>/<dest>`, or undefined. */
650
+ function vaultFile(vaultRoot, pluginId, dest) {
651
+ if (typeof pluginId !== "string" || pluginId.length === 0) return undefined
652
+ return safeJoin(resolve(vaultRoot), `${pluginId}/${dest}`)
653
+ }
654
+
655
+ /** Dev mirror of the server's dest rules: no empty/`.`, no `..`, no separators inside a segment. */
656
+ function validVaultDest(dest) {
657
+ return (
658
+ typeof dest === "string" &&
659
+ dest.length > 0 &&
660
+ dest
661
+ .split("/")
662
+ .every(
663
+ (s) =>
664
+ s.length > 0 &&
665
+ s !== "." &&
666
+ s !== ".." &&
667
+ !s.includes("\\") &&
668
+ !s.includes(":"),
669
+ )
670
+ )
671
+ }
672
+
673
+ /** Fetch with the dev-policy subset: http(s), no userinfo, ≤5 redirects, size cap. */
674
+ async function fetchWithPolicy(rawUrl, maxBytes) {
675
+ let url = new URL(rawUrl)
676
+ function vet(next) {
677
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
678
+ throw new Error(`unsupported scheme: ${next.protocol}`)
679
+ }
680
+ if (next.username.length > 0 || next.password.length > 0) {
681
+ throw new Error("URLs must not embed credentials")
682
+ }
683
+ return next
684
+ }
685
+ vet(url)
686
+ for (let hop = 0; hop <= 5; hop++) {
687
+ const response = await fetch(url, { redirect: "manual" })
688
+ if (response.status >= 300 && response.status < 400) {
689
+ const location = response.headers.get("location")
690
+ if (location === null)
691
+ throw new Error("redirect without a Location header")
692
+ url = vet(new URL(location, url))
693
+ continue
694
+ }
695
+ if (response.status < 200 || response.status >= 300) {
696
+ throw new Error(`HTTP ${response.status} from ${url.href}`)
697
+ }
698
+ if (response.body === null) throw new Error("empty response body")
699
+ const hash = createHash("sha256")
700
+ const chunks = []
701
+ let seen = 0
702
+ for await (const chunk of response.body) {
703
+ seen += chunk.length
704
+ if (seen > maxBytes) {
705
+ throw new Error(`response exceeds the ${maxBytes}-byte cap`)
706
+ }
707
+ hash.update(chunk)
708
+ chunks.push(chunk)
709
+ }
710
+ const bytes = Buffer.concat(chunks)
711
+ return { bytes, sha256: hash.digest("hex") }
712
+ }
713
+ throw new Error("more than 5 redirects")
714
+ }
715
+
716
+ function sha256File(path) {
717
+ return createHash("sha256").update(readFileSync(path)).digest("hex")
718
+ }
719
+
720
+ async function readJsonBody(req) {
721
+ const chunks = []
722
+ for await (const chunk of req) chunks.push(chunk)
723
+ const raw = Buffer.concat(chunks).toString("utf-8")
724
+ if (raw.trim().length === 0) return undefined
725
+ try {
726
+ return JSON.parse(raw)
727
+ } catch {
728
+ return undefined
729
+ }
730
+ }