@instafy/cli 0.2.3 → 0.3.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 (53) hide show
  1. package/README.md +4 -0
  2. package/dist/bind/LICENSE +661 -0
  3. package/dist/bind/bin/instafy-bind.mjs +542 -0
  4. package/dist/bind/client/bind-client.js +1219 -0
  5. package/dist/bind/src/apply.mjs +293 -0
  6. package/dist/bind/src/bound.mjs +202 -0
  7. package/dist/bind/src/budget.mjs +58 -0
  8. package/dist/bind/src/check.mjs +176 -0
  9. package/dist/bind/src/config.mjs +169 -0
  10. package/dist/bind/src/controller.mjs +244 -0
  11. package/dist/bind/src/detached-env.mjs +27 -0
  12. package/dist/bind/src/executors/agent.mjs +336 -0
  13. package/dist/bind/src/executors/script.mjs +163 -0
  14. package/dist/bind/src/handler-kit.mjs +150 -0
  15. package/dist/bind/src/init.mjs +238 -0
  16. package/dist/bind/src/jobs.mjs +316 -0
  17. package/dist/bind/src/net.mjs +281 -0
  18. package/dist/bind/src/page-fetch.mjs +43 -0
  19. package/dist/bind/src/preview-host.mjs +114 -0
  20. package/dist/bind/src/protocol.mjs +103 -0
  21. package/dist/bind/src/records-schema.mjs +580 -0
  22. package/dist/bind/src/records.mjs +161 -0
  23. package/dist/bind/src/save-owner.mjs +492 -0
  24. package/dist/bind/src/schema-lite.mjs +212 -0
  25. package/dist/bind/src/server.mjs +908 -0
  26. package/dist/bind/src/sse.mjs +119 -0
  27. package/dist/bind/src/starters/blank/app.js +17 -0
  28. package/dist/bind/src/starters/blank/index.html +22 -0
  29. package/dist/bind/src/starters/checklist/app.js +171 -0
  30. package/dist/bind/src/starters/checklist/bound.json +29 -0
  31. package/dist/bind/src/starters/checklist/data.json +26 -0
  32. package/dist/bind/src/starters/checklist/index.html +23 -0
  33. package/dist/bind/src/starters/compare/app.js +316 -0
  34. package/dist/bind/src/starters/compare/bound.json +26 -0
  35. package/dist/bind/src/starters/compare/data.json +170 -0
  36. package/dist/bind/src/starters/compare/index.html +23 -0
  37. package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
  38. package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
  39. package/dist/bind/src/starters/ranked/app.js +166 -0
  40. package/dist/bind/src/starters/ranked/bound.json +26 -0
  41. package/dist/bind/src/starters/ranked/data.json +142 -0
  42. package/dist/bind/src/starters/ranked/index.html +23 -0
  43. package/dist/bind/src/starters/site.js +888 -0
  44. package/dist/bind/src/starters/theme.css +492 -0
  45. package/dist/bind/src/starters/timeline/app.js +143 -0
  46. package/dist/bind/src/starters/timeline/data.json +41 -0
  47. package/dist/bind/src/starters/timeline/index.html +22 -0
  48. package/dist/bind/src/static.mjs +141 -0
  49. package/dist/bind/src/tokens.mjs +102 -0
  50. package/dist/bind/src/try.mjs +139 -0
  51. package/dist/bind/src/web.mjs +191 -0
  52. package/dist/cli.js +2261 -387
  53. package/package.json +3 -2
@@ -0,0 +1,908 @@
1
+ // The binder: static server + intent endpoint + SSE + apply step + jobs + tokens, for one site or a gallery.
2
+ // Every endpoint is served under __bind/.
3
+ import fs from "node:fs";
4
+ import http from "node:http";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createHash } from "node:crypto";
8
+ import { BOUND_FILE, executorOf, intentCatalog, loadBound, resolveIntent } from "./bound.mjs";
9
+ import { applyDefaults, formatErrors, validate } from "./schema-lite.mjs";
10
+ import { checkRecords, collectRecords } from "./apply.mjs";
11
+ import { ownSiteWrites, releaseSiteWrites } from "./save-owner.mjs";
12
+ import { AgentBudget, DEFAULT_AGENT_BUDGET_PER_HOUR } from "./budget.mjs";
13
+ import { EventHub } from "./sse.mjs";
14
+ import { JobManager } from "./jobs.mjs";
15
+ import {
16
+ COMMAND_NAMES, MAX_COMMAND_BYTES, MAX_INTENT_BYTES, MAX_PARAMS_BYTES, PROTOCOL_VERSION, checkIntentEnvelope, hostMessage, safeText,
17
+ } from "./protocol.mjs";
18
+ import { adminToken, bindDir, tokensEqual, viewerToken } from "./tokens.mjs";
19
+ import { agentExecutorRefusal, agentUnavailableReason, agentUsesOwnerConversation, resolveControllerConfig } from "./config.mjs";
20
+ import { ControllerClient } from "./controller.mjs";
21
+ import { checkUriParams } from "./net.mjs";
22
+ import { runScript } from "./executors/script.mjs";
23
+ import { runAgent } from "./executors/agent.mjs";
24
+ import { serveStatic } from "./static.mjs";
25
+ import { defaultPreviewStateFile, previewHostParts, previewTunnelReader } from "./preview-host.mjs";
26
+
27
+ const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
28
+
29
+ /** The controller settings when the agent executor is off: no URL, no conversation and, above all, no login. */
30
+ function offControllerConfig() {
31
+ const config = { controllerUrl: null, tokenSource: "off", hasToken: false, conversationId: null, agentConversationId: null, spaceId: null, profile: null, configFile: null, note: null };
32
+ Object.defineProperty(config, "token", { value: null, enumerable: false });
33
+ return config;
34
+ }
35
+ // CSP host-source has no IPv6 literal form, so [::1] cannot be listed.
36
+ const LOOPBACK_ANCESTORS = ["http://localhost:*", "http://127.0.0.1:*"];
37
+
38
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
39
+ export const CLIENT_FILE = path.join(HERE, "..", "client", "bind-client.js");
40
+ const ENDPOINT_RE = /^(.*?)\/__bind\/([A-Za-z.-]+)$/;
41
+ /** Site folders never served: handler code, tests and packages. */
42
+ const PRIVATE_DIRS = new Set(["handlers", "test", "tests", "node_modules"]);
43
+ /** Site files that need the viewer token: the manifest and everything the binder writes. */
44
+ const VIEWER_ONLY_RE = /^(bound\.json|data\/version\.json|data\/records\/.*)$/;
45
+
46
+ /**
47
+ * A site path the way a case-insensitive filesystem (macOS, Windows) reads it: lower case, with the Windows aliases
48
+ * of a name cut (trailing dots and spaces, an ":stream" suffix). The guards below compare this form, so
49
+ * `/Bound.json`, `/DATA/records/x.json` or `/handlers./x.mjs` are the files they name, not other files.
50
+ */
51
+ export function guardKey(rel) {
52
+ return String(rel).split(/[\\/]/).map((seg) => {
53
+ const name = seg.toLowerCase();
54
+ const colon = name.indexOf(":");
55
+ return trimEnd(colon >= 0 ? name.slice(0, colon) : name, ". ");
56
+ }).filter(Boolean).join("/");
57
+ }
58
+
59
+ /** `text` without any of `chars` at its end (a plain loop: no regex backtracking on long runs). */
60
+ export function trimEnd(text, chars) {
61
+ let end = text.length;
62
+ while (end > 0 && chars.includes(text[end - 1])) end -= 1;
63
+ return text.slice(0, end);
64
+ }
65
+
66
+ /**
67
+ * Is a request path (relative to the site, decoded) one of the never-served folders, or a file that needs the viewer
68
+ * token? Checks the path as asked and, when it names a file, the real path of that file inside the site (an 8.3 short
69
+ * name or any other spelling the filesystem resolves), so no spelling reaches what the canonical name guards.
70
+ */
71
+ export function guardedPath(siteRealDir, rel) {
72
+ const keys = [guardKey(rel)];
73
+ try {
74
+ const real = fs.realpathSync.native(path.join(siteRealDir, ...String(rel).split("/").filter(Boolean)));
75
+ const inside = path.relative(siteRealDir, real);
76
+ if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) keys.push(guardKey(inside.split(path.sep).join("/")));
77
+ } catch {
78
+ // no such file: the asked spelling is all there is
79
+ }
80
+ return {
81
+ hidden: keys.some((key) => PRIVATE_DIRS.has(key.split("/")[0])),
82
+ viewerOnly: keys.some((key) => VIEWER_ONLY_RE.test(key)),
83
+ };
84
+ }
85
+
86
+ function json(res, status, body, headers = {}) {
87
+ const text = JSON.stringify(body);
88
+ res.writeHead(status, {
89
+ "content-type": "application/json; charset=utf-8",
90
+ "cache-control": "no-store",
91
+ "x-content-type-options": "nosniff",
92
+ "content-length": Buffer.byteLength(text),
93
+ ...headers,
94
+ });
95
+ res.end(text);
96
+ }
97
+
98
+ function readBody(req, limit) {
99
+ return new Promise((resolve, reject) => {
100
+ const chunks = [];
101
+ let size = 0;
102
+ let done = false;
103
+ req.on("data", (chunk) => {
104
+ if (done) return;
105
+ size += chunk.length;
106
+ if (size > limit) {
107
+ done = true;
108
+ req.pause();
109
+ reject(Object.assign(new Error("body too large"), { status: 413 }));
110
+ } else chunks.push(chunk);
111
+ });
112
+ req.on("end", () => {
113
+ if (!done) resolve(Buffer.concat(chunks).toString("utf8"));
114
+ });
115
+ req.on("error", reject);
116
+ });
117
+ }
118
+
119
+ /** Answer a body that broke its limit and drop the connection once the answer is out. */
120
+ function refuseBody(req, res, error, body) {
121
+ if (error.status === 413) res.once("finish", () => req.destroy());
122
+ return json(res, error.status || 400, body, error.status === 413 ? { connection: "close" } : {});
123
+ }
124
+
125
+ /** Cookie that carries the viewer token for pages that don't run the bind client. */
126
+ export function tokenCookieName(siteDir) {
127
+ return `bind_k_${createHash("sha256").update(siteDir).digest("hex").slice(0, 12)}`;
128
+ }
129
+
130
+ /**
131
+ * Every value the request carries for these cookie names. A browser sends one entry per (name, domain, path), so a
132
+ * page on a sibling subdomain can add a `Domain=<parent>` cookie of the same name next to ours, and it may come first;
133
+ * callers check each value instead of trusting the first.
134
+ */
135
+ function readCookies(req, names) {
136
+ const values = [];
137
+ for (const part of String(req.headers.cookie || "").split(";")) {
138
+ const eq = part.indexOf("=");
139
+ if (eq > 0 && names.includes(part.slice(0, eq).trim())) values.push(part.slice(eq + 1).trim());
140
+ }
141
+ return values;
142
+ }
143
+
144
+ /**
145
+ * The `__Host-` form of the site cookie: browsers accept it only from a secure page, host-only (no Domain) and with
146
+ * Path=/, so no other host can set or shadow it.
147
+ */
148
+ const hostCookieName = (name) => `__Host-${name}`;
149
+
150
+ /** "hash" when the site's index.html loads the bind client (it reads #k=), else "query" (?k= sets a cookie). */
151
+ function linkStyleOf(siteDir) {
152
+ try {
153
+ const html = fs.readFileSync(path.join(siteDir, "index.html"), "utf8");
154
+ return CLIENT_TAG_RE.test(html) ? "hash" : "query";
155
+ } catch {
156
+ return "hash";
157
+ }
158
+ }
159
+
160
+ function nativeRealpath(dir) {
161
+ try {
162
+ return fs.realpathSync.native(dir);
163
+ } catch {
164
+ return dir;
165
+ }
166
+ }
167
+
168
+ function readJsonMaybe(file) {
169
+ try {
170
+ return JSON.parse(fs.readFileSync(file, "utf8"));
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function escapeHtml(text) {
177
+ return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
178
+ }
179
+
180
+ /**
181
+ * Find the site folders: the root itself when it has bound.json, else each subfolder that has one (a gallery). With
182
+ * `plain`, a folder with only an index.html (a plain static site: no intents) is a site too.
183
+ */
184
+ export function discoverSites(root, { plain = false } = {}) {
185
+ if (fs.existsSync(path.join(root, BOUND_FILE))) return [{ name: path.basename(path.resolve(root)), prefix: "", dir: fs.realpathSync(root) }];
186
+ const sites = [];
187
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
188
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
189
+ if (fs.existsSync(path.join(root, entry.name, BOUND_FILE))) sites.push({ name: entry.name, prefix: `/${entry.name}`, dir: fs.realpathSync(path.join(root, entry.name)) });
190
+ }
191
+ if (!sites.length && plain && fs.existsSync(path.join(root, "index.html"))) {
192
+ return [{ name: path.basename(path.resolve(root)), prefix: "", dir: fs.realpathSync(root), plain: true }];
193
+ }
194
+ return sites;
195
+ }
196
+
197
+ const CLIENT_TAG_RE = /__bind\/client\.js/;
198
+ /**
199
+ * A plain site's pages get the bind client when they don't load it themselves, so every site Studio shows can be
200
+ * presented and followed (and ask for location) without the page knowing about it.
201
+ */
202
+ export function withBindClient(html, prefix = "") {
203
+ if (CLIENT_TAG_RE.test(html)) return html;
204
+ const tag = `<script src="${prefix}/__bind/client.js"></script>`;
205
+ const at = html.search(/<\/head\s*>/i);
206
+ if (at >= 0) return html.slice(0, at) + tag + html.slice(at);
207
+ const body = html.search(/<\/body\s*>/i);
208
+ return body >= 0 ? html.slice(0, body) + tag + html.slice(body) : html + tag;
209
+ }
210
+
211
+ /**
212
+ * Create a binder.
213
+ * @param {object} options
214
+ * @param {string} options.root a site folder (with bound.json) or a gallery folder
215
+ * @param {number} [options.port] 0 picks a free port
216
+ * @param {string} [options.host]
217
+ * @param {boolean} [options.noToken] skip the viewer token (localhost only)
218
+ * @param {boolean} [options.preview] preview mode: no viewer token; also answer the private preview capability host
219
+ * that `instafy preview open --port <port>` opened for this port (see preview-host.mjs for why that is safe)
220
+ * @param {string} [options.previewStateFile] the CLI's preview state (tests; default ~/.instafy/cli-preview-state.json)
221
+ * @param {boolean} [options.agentExecutor] the owner's opt-in (serve --agent-executor) to run agent intents as Octo turns
222
+ * on their own Instafy login, for clicks made on this machine only. Off by default: "Ask Octo" clicks are then asks
223
+ * that the page hands to Studio, which sends them as the clicker's own chat message, and the binder holds no
224
+ * Instafy credential at all
225
+ * @param {boolean} [options.dryRun] agent intents clicked on this machine print their prompt instead of dispatching
226
+ * @param {boolean} [options.confirm] every job waits for confirmation
227
+ * @param {boolean} [options.allowLocal] let URL params and handler fetches reach private addresses
228
+ * @param {boolean} [options.trustProxy] believe x-forwarded-host (only behind your own proxy)
229
+ * @param {number} [options.agentBudget] Octo dispatches per site per hour (repairs included)
230
+ * @param {string[]} [options.allowEnv] credential-like env names bound.json may pass to handlers
231
+ * @param {boolean} [options.save] in a space's checkout, commit the data files the binder writes (default on; see
232
+ * save-owner.mjs). Off (`serve --no-save`) leaves them as changed files, which a stop keeps as unsaved work
233
+ * @param {{delayMs?: number, maxDelayMs?: number}} [options.saveOptions] commit timing (tests)
234
+ * @param {(level: string, text: string) => void} [options.log]
235
+ * @param {(prompt: string, job: object) => void} [options.onDryRun]
236
+ * @param {(config: object) => object|null} [options.createClient] controller client factory (tests)
237
+ * @param {object} [options.hubOptions] EventHub limits (tests)
238
+ */
239
+ export function createBinder(options) {
240
+ const root = path.resolve(options.root);
241
+ const {
242
+ port = 4180, host = "127.0.0.1", noToken = false, dryRun = false, confirm = false, allowLocal = false,
243
+ log: externalLog = () => {}, onDryRun = () => {}, pollMs, killGraceMs, publicOrigins = [], lookup, env = process.env,
244
+ trustProxy = false, agentBudget = DEFAULT_AGENT_BUDGET_PER_HOUR, allowEnv = [], preview = false,
245
+ previewStateFile = defaultPreviewStateFile(), agentExecutor = false, save = true, saveOptions = {},
246
+ } = options;
247
+ // Preview mode is tokenless too: the broker's forwardAuth on the capability host is the auth.
248
+ const tokenless = noToken || preview;
249
+
250
+ // A public URL without a viewer token would let anyone with the link run the site's checks: refuse it. (With
251
+ // --preview a --public-url name needs the viewer token: only the loopback names and the preview card are tokenless.)
252
+ if (noToken && publicOrigins.length) throw new Error("--no-token cannot be combined with --public-url: a public link needs the viewer token (serve without --no-token and share the #k= link)");
253
+ if (tokenless && !["127.0.0.1", "localhost", "::1"].includes(host)) {
254
+ throw new Error(`${preview ? "--preview" : "--no-token"} is only allowed when serving on localhost (--host 127.0.0.1)`);
255
+ }
256
+ const previewTunnels = preview ? previewTunnelReader(previewStateFile) : null;
257
+ if (agentExecutor && agentExecutorRefusal(env)) throw new Error(agentExecutorRefusal(env));
258
+
259
+ const siteEntries = discoverSites(root, { plain: true });
260
+ if (siteEntries.length === 0) throw new Error(`${root} has no ${BOUND_FILE} (and no subfolder with one) and no index.html. Run \`instafy-bind init ${options.root}\`.`);
261
+
262
+ // The message log: .bind/binder.log (NDJSON, never served). Dry-run prompts land here too ("prompt" level,
263
+ // log file only). Every line is cleaned of control characters at this sink: refs, handler output and controller
264
+ // errors can come from viewers or web pages and must not drive the owner's terminal.
265
+ let logFile = null;
266
+ const log = (level, raw) => {
267
+ const text = safeText(raw, { multiline: level === "prompt" });
268
+ if (level !== "prompt") externalLog(level, text);
269
+ try {
270
+ logFile ??= path.join(bindDir(root), "binder.log");
271
+ fs.appendFileSync(logFile, `${JSON.stringify({ at: new Date().toISOString(), level, text })}\n`, { mode: 0o600 });
272
+ } catch {
273
+ // logging must not break serving
274
+ }
275
+ };
276
+
277
+ const configArgs = {
278
+ serverUrl: options.serverUrl, accessToken: options.accessToken, conversation: options.conversation,
279
+ agentConversation: options.agentConversation, space: options.space, env, cwd: root,
280
+ };
281
+ // "Ask Octo" is host-mediated (docs/Bound-Sites.md, "Ask Octo"): the page hands the click to Studio, which sends it as
282
+ // the clicker's own chat message, billed like typing it. So by default the binder never reads an Instafy login. Only
283
+ // the owner's explicit --agent-executor turns agent intents into Octo turns on the owner's login, and only for clicks
284
+ // made on this machine (ownerLocal): a click through the preview host, a tunnel or a proxy can be anyone's, and
285
+ // owner-paid asks from other people have no opt-in.
286
+ const controllerConfig = agentExecutor ? resolveControllerConfig(configArgs) : offControllerConfig();
287
+ const agentReason = dryRun || !agentExecutor ? null : agentUnavailableReason(controllerConfig);
288
+ // A saved login may be refreshed by the CLI while serve runs: after a 401, read it again once.
289
+ const refreshToken = () => {
290
+ if (controllerConfig.tokenSource !== "config") return null;
291
+ const again = resolveControllerConfig(configArgs);
292
+ return again.tokenSource === "config" ? again.token : null;
293
+ };
294
+ const client = dryRun || !agentExecutor || agentReason
295
+ ? null
296
+ : options.createClient
297
+ ? options.createClient(controllerConfig)
298
+ : new ControllerClient({ controllerUrl: controllerConfig.controllerUrl, token: controllerConfig.token, refreshToken });
299
+ if (client && agentUsesOwnerConversation(controllerConfig)) {
300
+ log("warn", "agent intents post into your own conversation (--agent-conversation equals --conversation): every viewer click becomes a turn there");
301
+ }
302
+ // A preview binder with a public URL answers whoever has the public link. Agent intents run Octo turns on the
303
+ // owner's login and credits, so they are not offered there (script intents are); owner-paid asks from strangers
304
+ // need an explicit opt-in that does not exist yet (SITE-BACKEND-CONTRACT public_octo).
305
+ const publicAgentReason = tokenless && publicOrigins.length && !dryRun
306
+ ? "not offered on a public link (anyone with the link could spend the owner's Octo turns)"
307
+ : null;
308
+ // dry_run and ready apply to clicks made on this machine; every other click of an agent intent is an ask via Studio.
309
+ const agentState = dryRun ? "dry_run" : !agentExecutor ? "host" : agentReason || publicAgentReason ? "unavailable" : "ready";
310
+ const ownerTurns = agentState === "dry_run" || agentState === "ready";
311
+ const admin = adminToken(root);
312
+
313
+ const sites = siteEntries.map((entry) => {
314
+ const bound = entry.plain ? { intents: {} } : loadBound(entry.dir);
315
+ // runnable: what the binder runs for anyone. asks: agent intents, which a click hands to Studio. ownerRunnable:
316
+ // agent intents the binder itself runs, for clicks made on this machine only (--agent-executor or --dry-run).
317
+ const runnable = new Set();
318
+ const asks = new Set();
319
+ const ownerRunnable = new Set();
320
+ const unavailable = {};
321
+ const resolved = {};
322
+ for (const [name, def] of Object.entries(bound.intents)) {
323
+ const r = resolveIntent(def, { siteDir: entry.dir });
324
+ if (!r.ok) unavailable[name] = r.reason;
325
+ else if (r.executor === "agent") {
326
+ asks.add(name);
327
+ resolved[name] = r;
328
+ if (ownerTurns) ownerRunnable.add(name);
329
+ else if (agentExecutor) unavailable[name] = `agent: ${agentReason || publicAgentReason} (clicks in Studio still ask Octo as the clicker)`;
330
+ } else {
331
+ runnable.add(name);
332
+ resolved[name] = r;
333
+ }
334
+ }
335
+ const site = {
336
+ ...entry,
337
+ realDir: nativeRealpath(entry.dir),
338
+ bound,
339
+ title: bound.title || entry.name,
340
+ runnable,
341
+ asks,
342
+ ownerRunnable,
343
+ unavailable,
344
+ resolved,
345
+ // --preview keeps a token for --public-url names; its loopback names and the preview card need none
346
+ token: noToken || (preview && !publicOrigins.length) ? null : viewerToken(entry.dir),
347
+ cookie: tokenCookieName(entry.dir),
348
+ linkStyle: linkStyleOf(entry.dir),
349
+ hub: new EventHub(options.hubOptions),
350
+ session: agentSession(entry.dir),
351
+ budget: new AgentBudget({ dir: entry.dir, perHour: agentBudget }),
352
+ };
353
+ // The binder owns what it writes for clicks: in a space's checkout it commits those files (save-owner.mjs).
354
+ site.saver = save
355
+ ? ownSiteWrites(entry.dir, { ...saveOptions, label: `the ${entry.name} site`, log: (level, text) => log(level, `[${entry.name}] ${text}`) })
356
+ : null;
357
+ site.jobs = new JobManager({
358
+ site,
359
+ hub: site.hub,
360
+ confirmAll: confirm,
361
+ log: (level, text) => log(level, `[${site.name}] ${text}`),
362
+ runExecutor: (job, ctx) => runJob(site, job, ctx),
363
+ admit: (def, executor) => {
364
+ if (executor !== "agent" || dryRun || site.budget.remaining() > 0) return null;
365
+ return { http: 429, body: { reason: "budget", retry_after_s: site.budget.retryAfterS() } };
366
+ },
367
+ });
368
+ for (const [name, reason] of Object.entries(unavailable)) log("warn", `[${site.name}] intent ${name} is not available: ${reason}`);
369
+ return site;
370
+ });
371
+
372
+ /**
373
+ * Where a site's agent turns go: --agent-conversation when given, else the site's own thread (created in the space
374
+ * on first use, as a child of the owner's conversation, and remembered in .bind/agent-thread.json).
375
+ */
376
+ function agentSession(siteDir) {
377
+ const session = {
378
+ conversationId: controllerConfig.agentConversationId,
379
+ spaceId: controllerConfig.spaceId,
380
+ parentConversationId: controllerConfig.conversationId,
381
+ creating: null,
382
+ onThread: null,
383
+ };
384
+ if (session.conversationId) return session;
385
+ const file = path.join(siteDir, ".bind", "agent-thread.json");
386
+ const saved = readJsonMaybe(file);
387
+ if (saved && saved.controller === controllerConfig.controllerUrl && saved.space === controllerConfig.spaceId && typeof saved.conversationId === "string") {
388
+ session.conversationId = saved.conversationId;
389
+ }
390
+ session.onThread = (id) => {
391
+ log("info", `created the site's Octo thread ${id}`);
392
+ try {
393
+ fs.writeFileSync(path.join(bindDir(siteDir), "agent-thread.json"), `${JSON.stringify({ controller: controllerConfig.controllerUrl, space: controllerConfig.spaceId, conversationId: id, created_at: new Date().toISOString() })}\n`, { mode: 0o600 });
394
+ } catch (error) {
395
+ log("warn", `could not save the thread id: ${error.message}`);
396
+ }
397
+ };
398
+ return session;
399
+ }
400
+
401
+ function runJob(site, job, ctx) {
402
+ const def = job.def;
403
+ if (job.executor === "script") {
404
+ return runScript({
405
+ handler: site.resolved[job.name].handler,
406
+ siteDir: site.dir,
407
+ input: { intent: job.intent, bound: def, site: { dir: site.dir, title: site.title } },
408
+ signal: ctx.signal,
409
+ onProgress: ctx.progress,
410
+ onRecords: ctx.records,
411
+ log: ctx.log,
412
+ allowLocal,
413
+ envNames: def.env || [],
414
+ allowEnv,
415
+ ...(killGraceMs !== undefined ? { killGraceMs } : {}),
416
+ });
417
+ }
418
+ return runAgent({
419
+ name: job.name,
420
+ def,
421
+ intent: job.intent,
422
+ siteTitle: site.title,
423
+ client,
424
+ session: site.session,
425
+ budget: site.budget,
426
+ signal: ctx.signal,
427
+ onProgress: ctx.progress,
428
+ onRecords: ctx.records,
429
+ log: ctx.log,
430
+ dryRun,
431
+ onDryRun: (prompt) => onDryRun(prompt, job),
432
+ check: (records) =>
433
+ checkRecords(structuredClone(records), { name: job.name, def, intent: job.intent, job: job.id }).rejected.map((r) => `records[${r.index}]: ${r.reason}`),
434
+ ...(pollMs !== undefined ? { pollMs } : {}),
435
+ });
436
+ }
437
+
438
+ const siteFor = (prefixPath) =>
439
+ sites.find((site) => site.prefix === "" || prefixPath === site.prefix || prefixPath.startsWith(`${site.prefix}/`)) || null;
440
+
441
+ /** The viewer token from the x-bind-token header or the site cookie (?k= in the query only where allowQuery). */
442
+ function viewerOk(site, req, url, { allowQuery = false } = {}) {
443
+ if (!tokenNeeded(site, req)) return true;
444
+ const offered = req.headers["x-bind-token"] || (allowQuery ? url.searchParams.get("k") : "");
445
+ if (offered) return tokensEqual(String(offered), site.token);
446
+ return readCookies(req, [hostCookieName(site.cookie), site.cookie]).some((value) => tokensEqual(value, site.token));
447
+ }
448
+
449
+ /** Is this request's Host one of the --public-url names? */
450
+ function publicHost(req) {
451
+ const offered = String(req.headers.host || "").toLowerCase();
452
+ return publicOrigins.some((origin) => {
453
+ try {
454
+ return new URL(origin).host.toLowerCase() === offered;
455
+ } catch {
456
+ return false;
457
+ }
458
+ });
459
+ }
460
+
461
+ /** Does this request need the viewer token? Always with one, except in preview mode off the --public-url names. */
462
+ function tokenNeeded(site, req) {
463
+ if (!site.token) return false;
464
+ return !preview || publicHost(req);
465
+ }
466
+
467
+ /** A request that carries the token in the x-bind-token header: a browser only sends that same-origin (CORS preflight). */
468
+ const headerTokenOk = (site, req) => Boolean(site.token) && tokensEqual(String(req.headers["x-bind-token"] || ""), site.token);
469
+
470
+ /**
471
+ * DNS rebinding guard for --no-token and --preview: without a token anything that reaches the port could act, so
472
+ * only the names this binder was started for are accepted. With a token a rebinding page has nothing to send.
473
+ * Preview mode also accepts the capability host of a preview opened for this port (preview-host.mjs).
474
+ */
475
+ function hostOk(req) {
476
+ if (!tokenless) return true;
477
+ const port = address?.port;
478
+ const names = ["127.0.0.1", "localhost", "[::1]", host.includes(":") ? `[${host}]` : host];
479
+ const allowed = new Set(names.map((name) => `${name}:${port}`.toLowerCase()));
480
+ // Loopback names without a port too: the tunnel broker's health probe sends `Host: localhost`. No web page
481
+ // outside this machine has a loopback origin, so these cannot be used for DNS rebinding.
482
+ for (const name of ["127.0.0.1", "localhost", "[::1]"]) allowed.add(name);
483
+ for (const origin of publicOrigins) {
484
+ try {
485
+ allowed.add(new URL(origin).host.toLowerCase());
486
+ } catch {
487
+ // ignored
488
+ }
489
+ }
490
+ const offered = String(req.headers.host || "").toLowerCase();
491
+ return allowed.has(offered) || previewHostOk(offered);
492
+ }
493
+
494
+ /**
495
+ * A capability host whose tunnel is a preview `instafy preview open` recorded for this binder's port, on exactly the
496
+ * preview domain it recorded for it (the tunnel id alone is not secret; see preview-host.mjs).
497
+ */
498
+ function previewHostOk(hostHeader) {
499
+ return previewStudioOrigins(hostHeader) !== null;
500
+ }
501
+
502
+ /**
503
+ * The Studio origins that frame the preview this capability host belongs to ([] when `instafy preview open` recorded
504
+ * none), or null when the host is not a preview of this port.
505
+ */
506
+ function previewStudioOrigins(hostHeader) {
507
+ if (!previewTunnels) return null;
508
+ const parts = previewHostParts(hostHeader);
509
+ const studios = parts ? previewTunnels(address?.port).get(parts.tunnel)?.get(parts.suffix) : null;
510
+ return studios ? [...studios] : null;
511
+ }
512
+
513
+ /**
514
+ * Who may host this page in a frame and talk to it (Ask Octo, location, follow mode): bound.json parentOrigins, plus
515
+ * on a preview capability host the Studio origin recorded for that preview. The bind client takes host messages
516
+ * from these origins only (and its own), so with none listed a framing page gets nothing.
517
+ */
518
+ function parentOriginsFor(site, req) {
519
+ const studios = req ? previewStudioOrigins(String(req.headers.host || "").toLowerCase()) || [] : [];
520
+ return [...new Set([...(site.bound.parentOrigins || []), ...studios])];
521
+ }
522
+
523
+ /**
524
+ * Admin calls (commands to every viewer, confirmations) come from `instafy-bind command|confirm` on this machine,
525
+ * which uses the binder's own loopback URL. Refuse them on any other name (the preview capability host, a
526
+ * --public-url tunnel, a proxy) even with the admin token, so a token that leaked cannot be used from outside.
527
+ */
528
+ function adminLocal(req) {
529
+ const socket = String(req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
530
+ if (!/^(127\.|::1$)/.test(socket)) return false;
531
+ if (req.headers["x-forwarded-for"] || req.headers["x-forwarded-host"]) return false;
532
+ const offered = String(req.headers.host || "").toLowerCase();
533
+ const own = `${host.includes(":") ? `[${host}]` : host}:${address?.port}`.toLowerCase();
534
+ return LOOPBACK_HOST.test(offered) || offered === own;
535
+ }
536
+
537
+ /** A click by the owner on this machine: the same test as admin calls (loopback socket and name, no proxy). */
538
+ const ownerLocal = (req) => adminLocal(req);
539
+
540
+ function adminOk(req) {
541
+ if (!adminLocal(req)) return false;
542
+ const header = req.headers.authorization || "";
543
+ const value = header.startsWith("Bearer ") ? header.slice(7).trim() : req.headers["x-bind-admin"] || "";
544
+ return tokensEqual(String(value), admin);
545
+ }
546
+
547
+ function originOk(req) {
548
+ const origin = req.headers.origin;
549
+ if (!origin || origin === "null") return !origin;
550
+ let originHost;
551
+ try {
552
+ originHost = new URL(origin).host;
553
+ } catch {
554
+ return false;
555
+ }
556
+ const own = [trustProxy ? req.headers["x-forwarded-host"] : null, req.headers.host].filter(Boolean).flatMap((h) => String(h).split(",").map((x) => x.trim()));
557
+ if (own.includes(originHost)) return true;
558
+ return publicOrigins.some((allowed) => {
559
+ try {
560
+ return new URL(allowed).host === originHost;
561
+ } catch {
562
+ return false;
563
+ }
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Who opened a request, for per-source limits: the socket's address, or, when the socket is loopback (the preview
569
+ * tunnel, a local proxy), the address the closest proxy appended to x-forwarded-for (Traefik replaces the header
570
+ * for clients it does not trust, so the last entry is the one it saw). A loopback request with no such header is
571
+ * this machine itself and is not limited per source.
572
+ */
573
+ function sourceOf(req) {
574
+ const socket = String(req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
575
+ if (socket && !/^(127\.|::1$)/.test(socket)) return socket;
576
+ const forwarded = String(req.headers["x-forwarded-for"] || "").split(",").map((x) => x.trim()).filter(Boolean);
577
+ return forwarded.at(-1) || null;
578
+ }
579
+
580
+ /** The intents the binder runs for this request: agent intents too when the owner clicked on this machine. */
581
+ function runnableFor(site, req) {
582
+ return site.ownerRunnable.size && ownerLocal(req) ? new Set([...site.runnable, ...site.ownerRunnable]) : site.runnable;
583
+ }
584
+
585
+ /** The agent intents a click on this request hands to Studio (an ask as the clicker), not to the binder. */
586
+ function asksFor(site, req) {
587
+ return site.ownerRunnable.size && ownerLocal(req) ? new Set() : site.asks;
588
+ }
589
+
590
+ function hello(site, req) {
591
+ return hostMessage("hello", {
592
+ capabilities: { intents: [...runnableFor(site, req)], asks: [...asksFor(site, req)] },
593
+ parentOrigins: parentOriginsFor(site, req),
594
+ });
595
+ }
596
+
597
+ async function handleIntent(site, req, res, url) {
598
+ if (!viewerOk(site, req, url)) return json(res, 401, { status: "rejected", reason: "token" });
599
+ // A header token proves a same-origin script (or a non-browser client); a cookie alone needs the Origin check.
600
+ if (!headerTokenOk(site, req) && !originOk(req)) return json(res, 403, { status: "rejected", reason: "origin" });
601
+ if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) {
602
+ return json(res, 415, { status: "rejected", reason: "content-type must be application/json" });
603
+ }
604
+ let body;
605
+ try {
606
+ body = await readBody(req, MAX_INTENT_BYTES);
607
+ } catch (error) {
608
+ return refuseBody(req, res, error, { status: "rejected", reason: error.status === 413 ? "too_large" : "bad body" });
609
+ }
610
+ let msg;
611
+ try {
612
+ msg = JSON.parse(body);
613
+ } catch {
614
+ return json(res, 400, { status: "rejected", reason: "body is not JSON" });
615
+ }
616
+ const envelope = checkIntentEnvelope(msg);
617
+ if (envelope) return json(res, 400, { status: "rejected", id: msg?.id, reason: envelope });
618
+ delete msg.by; // unverified hint; the binder stamps its own observer
619
+
620
+ if (msg.name === "cancel") {
621
+ const target = msg.params?.id;
622
+ const result = typeof target === "string" ? site.jobs.cancel(target, msg.params?.key) : null;
623
+ if (result?.denied) return json(res, 403, { status: "rejected", id: msg.id, reason: "only the viewer who sent an intent can cancel it" });
624
+ return result ? json(res, 200, { status: "cancelled", id: msg.id, job: result.job.id }) : json(res, 404, { status: "rejected", id: msg.id, reason: "no active job for that id" });
625
+ }
626
+ const def = site.bound.intents[msg.name];
627
+ if (!def || !Object.prototype.hasOwnProperty.call(site.bound.intents, msg.name)) {
628
+ return json(res, 404, { status: "rejected", id: msg.id, reason: "unknown intent" });
629
+ }
630
+ // An agent intent from anyone but the owner on this machine is an ask: the page hands it to Studio, which sends
631
+ // it as the clicker's own message. The binder never starts an Octo turn for it (and holds no login to do so).
632
+ // (The owner's own click on this machine with --agent-executor but no usable login hears why instead, below.)
633
+ const ownerHearsWhy = agentExecutor && ownerLocal(req) && site.unavailable[msg.name];
634
+ if (site.asks.has(msg.name) && !runnableFor(site, req).has(msg.name) && !ownerHearsWhy) {
635
+ return json(res, 409, { status: "rejected", id: msg.id, reason: "ask_via_host" });
636
+ }
637
+ if (!runnableFor(site, req).has(msg.name)) return json(res, 404, { status: "rejected", id: msg.id, reason: site.unavailable[msg.name] || "not available" });
638
+ const params = applyDefaults(def.params, msg.params);
639
+ if (Buffer.byteLength(JSON.stringify(params)) > MAX_PARAMS_BYTES) return json(res, 400, { status: "rejected", id: msg.id, reason: "params_too_large" });
640
+ const errors = validate(def.params, params);
641
+ if (errors.length) return json(res, 400, { status: "rejected", id: msg.id, reason: formatErrors(errors) });
642
+ // URL params: public addresses only, and inside agent.allowed_origins for agent intents (the page a viewer
643
+ // points Octo's browser at is part of the prompt's attack surface).
644
+ const allowedOrigins = executorOf(def) === "agent" ? def.agent?.allowed_origins || null : null;
645
+ const ssrf = await checkUriParams(def.params, params, { allowLocal, allowedOrigins, ...(lookup ? { lookup } : {}) });
646
+ if (ssrf) return json(res, 400, { status: "rejected", id: msg.id, reason: `policy: ${ssrf}` });
647
+ msg.params = params;
648
+ const result = site.jobs.submit(msg, def);
649
+ return json(res, result.http, result.body);
650
+ }
651
+
652
+ async function handleCommand(site, req, res) {
653
+ if (!adminLocal(req)) return json(res, 403, { ok: false, reason: "admin calls are only accepted on the binder's loopback address" });
654
+ if (!adminOk(req)) return json(res, 401, { ok: false, reason: "admin token required" });
655
+ if (req.headers.origin && !originOk(req)) return json(res, 403, { ok: false, reason: "origin" });
656
+ let body;
657
+ try {
658
+ body = JSON.parse(await readBody(req, MAX_COMMAND_BYTES));
659
+ } catch (error) {
660
+ return refuseBody(req, res, error, { ok: false, reason: error.status === 413 ? "too_large" : "body is not JSON" });
661
+ }
662
+ if (!COMMAND_NAMES.includes(body?.name)) return json(res, 400, { ok: false, reason: `name must be one of ${COMMAND_NAMES.join(", ")}` });
663
+ const args = body.args ?? {};
664
+ if (!args || typeof args !== "object" || Array.isArray(args)) return json(res, 400, { ok: false, reason: "args must be an object" });
665
+ const event = site.hub.broadcast(hostMessage("command", { command: { name: body.name, args } }));
666
+ log("info", `[${site.name}] command ${body.name} -> ${site.hub.size} viewer(s)`);
667
+ return json(res, 202, { ok: true, event, viewers: site.hub.size });
668
+ }
669
+
670
+ async function handleConfirm(site, req, res) {
671
+ if (!adminLocal(req)) return json(res, 403, { ok: false, reason: "admin calls are only accepted on the binder's loopback address" });
672
+ if (!adminOk(req)) return json(res, 401, { ok: false, reason: "admin token required" });
673
+ let body;
674
+ try {
675
+ body = JSON.parse(await readBody(req, 1024));
676
+ } catch (error) {
677
+ return refuseBody(req, res, error, { ok: false, reason: error.status === 413 ? "too_large" : "body is not JSON" });
678
+ }
679
+ const ids = body?.job === "all" ? site.jobs.pendingConfirmations().map((j) => j.id) : [body?.job];
680
+ const done = ids.map((id) => site.jobs.confirm(id, body?.ok !== false)).filter(Boolean);
681
+ return done.length ? json(res, 200, { ok: true, jobs: done.map((j) => j.id) }) : json(res, 404, { ok: false, reason: "no job waiting for confirmation" });
682
+ }
683
+
684
+ async function handleEndpoint(site, endpoint, req, res, url) {
685
+ const method = req.method;
686
+ const allow = (expected) => {
687
+ if (method === expected || (expected === "GET" && method === "HEAD")) return true;
688
+ json(res, 405, { ok: false, reason: "method not allowed" }, { allow: expected });
689
+ return false;
690
+ };
691
+ switch (endpoint) {
692
+ case "health":
693
+ if (!allow("GET")) return;
694
+ return json(res, 200, {
695
+ ok: true, host: "instafy-bind", v: PROTOCOL_VERSION, intents: [...runnableFor(site, req)],
696
+ catalog: intentCatalog(site.bound, runnableFor(site, req)),
697
+ asks: intentCatalog(site.bound, asksFor(site, req)).map((def) => ({ ...def, via: "host" })),
698
+ site: { title: site.title }, token: tokenNeeded(site, req),
699
+ viewer: viewerOk(site, req, url), parentOrigins: parentOriginsFor(site, req),
700
+ executors: { script: "ready", agent: agentState },
701
+ });
702
+ case "session": {
703
+ // The client trades #k= for an HttpOnly cookie once, so EventSource, polling and the page's own fetches
704
+ // carry the token without it sitting in URLs or in page-readable storage.
705
+ if (!allow("POST")) return;
706
+ if (!headerTokenOk(site, req)) return json(res, 401, { ok: false, reason: "token" });
707
+ return json(res, 200, { ok: true }, { "set-cookie": tokenCookie(site, req) });
708
+ }
709
+ case "client.js":
710
+ if (!allow("GET")) return;
711
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache", "x-content-type-options": "nosniff" });
712
+ return res.end(method === "HEAD" ? undefined : fs.readFileSync(CLIENT_FILE));
713
+ case "intent":
714
+ if (!allow("POST")) return;
715
+ return handleIntent(site, req, res, url);
716
+ case "events":
717
+ if (!allow("GET")) return;
718
+ if (!viewerOk(site, req, url, { allowQuery: true })) return json(res, 401, { ok: false, reason: "token" });
719
+ site.hub.attach(req, res, { hello: hello(site, req), lastEventId: req.headers["last-event-id"] ?? url.searchParams.get("lastEventId"), source: sourceOf(req) });
720
+ return;
721
+ case "jobs":
722
+ if (!allow("GET")) return;
723
+ if (!viewerOk(site, req, url) && !adminOk(req)) return json(res, 401, { ok: false, reason: "token" });
724
+ return json(res, 200, { jobs: site.jobs.list() });
725
+ case "records":
726
+ if (!allow("GET")) return;
727
+ if (!viewerOk(site, req, url)) return json(res, 401, { ok: false, reason: "token" });
728
+ return json(res, 200, collectRecords(site.dir, site.bound));
729
+ case "command":
730
+ if (!allow("POST")) return;
731
+ return handleCommand(site, req, res);
732
+ case "confirm":
733
+ if (!allow("POST")) return;
734
+ return handleConfirm(site, req, res);
735
+ default:
736
+ return json(res, 404, { ok: false, reason: "unknown endpoint" });
737
+ }
738
+ }
739
+
740
+ function galleryIndex(res) {
741
+ const items = sites.map((s) => `<li><a href="${escapeHtml(s.prefix)}/">${escapeHtml(s.title)}</a></li>`).join("");
742
+ const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bound sites</title><body style="font:16px system-ui;margin:24px"><h1>Bound sites</h1><ul>${items}</ul><p>Share links with their #k= token are printed by <code>instafy-bind serve</code>.</p></body>`;
743
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
744
+ res.end(html);
745
+ return true;
746
+ }
747
+
748
+ /**
749
+ * Is the viewer's page HTTPS? Behind a trusted proxy that says so, or on a --public-url name whose URL is https
750
+ * (the tunnel terminates TLS for it).
751
+ */
752
+ function viewerOnHttps(req) {
753
+ if (trustProxy && req.headers["x-forwarded-proto"] === "https") return true;
754
+ const offered = String(req.headers.host || "").toLowerCase();
755
+ return publicOrigins.some((origin) => {
756
+ try {
757
+ const url = new URL(origin);
758
+ return url.protocol === "https:" && url.host.toLowerCase() === offered;
759
+ } catch {
760
+ return false;
761
+ }
762
+ });
763
+ }
764
+
765
+ /**
766
+ * The site cookie. On HTTPS it is Secure, and a site served at the root takes the `__Host-` prefix, so a page on
767
+ * another host under the same parent domain (another tunnel, say) cannot plant or shadow it.
768
+ */
769
+ function tokenCookie(site, req) {
770
+ const secure = viewerOnHttps(req);
771
+ const path = site.prefix || "/";
772
+ const name = secure && path === "/" ? hostCookieName(site.cookie) : site.cookie;
773
+ return `${name}=${site.token}; Path=${path}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
774
+ }
775
+
776
+ /**
777
+ * frame-ancestors for HTML: the site itself plus bound.json parentOrigins. On a preview capability host, the Studio
778
+ * origin `instafy preview open` recorded for it as well. A preview with no recorded Studio origin (an older
779
+ * controller or CLI) gets no header, so Studio can still show it, but its page then takes no host messages
780
+ * (parentOriginsFor): no asks, no location from the host and no follow mode.
781
+ */
782
+ function frameHeaders(site, req) {
783
+ const hostHeader = String(req?.headers.host || "").toLowerCase();
784
+ const studios = req ? previewStudioOrigins(hostHeader) : null;
785
+ if (studios) {
786
+ const origins = parentOriginsFor(site, req);
787
+ return studios.length ? { "content-security-policy": `frame-ancestors 'self' ${origins.join(" ")}` } : {};
788
+ }
789
+ // Preview mode on a loopback name: `instafy preview check` frames the site from a throwaway page on another
790
+ // loopback port, so loopback origins may frame it too. Only pages served from this machine have such an origin,
791
+ // and preview mode has no viewer token for a framing page to steal anyway.
792
+ const loopback = preview && LOOPBACK_HOST.test(hostHeader) ? LOOPBACK_ANCESTORS : [];
793
+ const ancestors = ["'self'", ...loopback, ...(site?.bound.parentOrigins || [])].join(" ");
794
+ return { "content-security-policy": `frame-ancestors ${ancestors}` };
795
+ }
796
+
797
+ const server = http.createServer((req, res) => {
798
+ let url;
799
+ try {
800
+ url = new URL(req.url, "http://binder.local");
801
+ } catch {
802
+ return json(res, 400, { ok: false });
803
+ }
804
+ if (!hostOk(req)) {
805
+ const reason = preview
806
+ ? `unexpected Host header: this binder answers localhost and the private preview opened with \`instafy preview open --port ${address?.port}\``
807
+ : "unexpected Host header (start serve with --public-url for other names)";
808
+ return json(res, 421, { ok: false, reason });
809
+ }
810
+ const match = ENDPOINT_RE.exec(url.pathname);
811
+ if (match) {
812
+ const site = siteFor(match[1]);
813
+ if (!site) return json(res, 404, { ok: false, reason: "no bound site here" });
814
+ Promise.resolve(handleEndpoint(site, match[2], req, res, url)).catch((error) => {
815
+ log("error", `${req.method} ${url.pathname}: ${error.stack || error.message}`);
816
+ if (!res.headersSent) json(res, 500, { ok: false, reason: "internal error" });
817
+ else res.end();
818
+ });
819
+ return;
820
+ }
821
+ // Token handoff for pages without the bind client: GET <page>?k=<token> sets an HttpOnly cookie scoped to the
822
+ // site and redirects to the clean URL, so same-origin fetch and EventSource carry the token on their own.
823
+ const offered = req.method === "GET" ? url.searchParams.get("k") : null;
824
+ if (offered) {
825
+ const site = siteFor(url.pathname);
826
+ if (site?.token && tokensEqual(offered, site.token)) {
827
+ url.searchParams.delete("k");
828
+ res.writeHead(303, {
829
+ location: `${url.pathname}${url.search}`,
830
+ "set-cookie": tokenCookie(site, req),
831
+ "cache-control": "no-store",
832
+ "referrer-policy": "no-referrer",
833
+ });
834
+ return res.end();
835
+ }
836
+ }
837
+ const gallery = sites.length > 1 || sites[0].prefix !== "";
838
+ if (gallery && (url.pathname === "/" || url.pathname === "")) return galleryIndex(res);
839
+ // Only discovered site folders are served; a gallery root's other folders never are.
840
+ const site = siteFor(url.pathname);
841
+ if (!site) return json(res, 404, { ok: false, reason: "not found" });
842
+ if (url.pathname === site.prefix) {
843
+ res.writeHead(301, { location: `${site.prefix}/${url.search}` });
844
+ return res.end();
845
+ }
846
+ let rel;
847
+ try {
848
+ rel = decodeURIComponent(url.pathname.slice(site.prefix.length)).replace(/^\/+/, "");
849
+ } catch {
850
+ return json(res, 400, { ok: false });
851
+ }
852
+ const guard = guardedPath(site.realDir, rel);
853
+ if (guard.hidden) return json(res, 404, { ok: false, reason: "not found" });
854
+ if (guard.viewerOnly && !viewerOk(site, req, url)) return json(res, 401, { ok: false, reason: "token" });
855
+ serveStatic(req, res, site.dir, url.pathname.slice(site.prefix.length) || "/", {
856
+ query: url.search.slice(1),
857
+ redirectBase: site.prefix,
858
+ htmlHeaders: frameHeaders(site, req),
859
+ htmlTransform: site.plain ? (html) => withBindClient(html, site.prefix) : null,
860
+ });
861
+ });
862
+ server.keepAliveTimeout = 5000;
863
+ server.headersTimeout = 15_000;
864
+ server.requestTimeout = 30_000;
865
+
866
+ let address = null;
867
+ const binder = {
868
+ root,
869
+ sites,
870
+ server,
871
+ controller: controllerConfig,
872
+ preview,
873
+ agentState,
874
+ agentReason: agentReason || publicAgentReason,
875
+ get url() {
876
+ return address ? `http://${host.includes(":") ? `[${host}]` : host}:${address.port}` : null;
877
+ },
878
+ shareUrl(site, base = binder.url, style = site.linkStyle) {
879
+ const publicBase = publicOrigins.some((origin) => trimEnd(origin, "/") === trimEnd(String(base), "/"));
880
+ if (!site.token || (preview && !publicBase)) return `${base}${site.prefix}/`;
881
+ return style === "query" ? `${base}${site.prefix}/?k=${site.token}` : `${base}${site.prefix}/#k=${site.token}`;
882
+ },
883
+ adminToken: admin,
884
+ listen() {
885
+ return new Promise((resolve, reject) => {
886
+ server.once("error", reject);
887
+ server.listen(port, host, () => {
888
+ server.off("error", reject);
889
+ address = server.address();
890
+ resolve(binder);
891
+ });
892
+ });
893
+ },
894
+ async close() {
895
+ for (const site of sites) {
896
+ site.jobs.close();
897
+ site.hub.close();
898
+ // What is left is committed now, so a stopped binder leaves nothing it wrote unsaved.
899
+ if (site.saver) releaseSiteWrites(site.dir);
900
+ }
901
+ server.closeAllConnections?.();
902
+ await new Promise((resolve) => server.close(() => resolve()));
903
+ },
904
+ };
905
+ return binder;
906
+ }
907
+
908
+ export { executorOf };