@instafy/cli 0.2.3 → 0.2.4

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 (52) 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 +180 -0
  10. package/dist/bind/src/controller.mjs +244 -0
  11. package/dist/bind/src/executors/agent.mjs +336 -0
  12. package/dist/bind/src/executors/script.mjs +163 -0
  13. package/dist/bind/src/handler-kit.mjs +150 -0
  14. package/dist/bind/src/init.mjs +238 -0
  15. package/dist/bind/src/jobs.mjs +316 -0
  16. package/dist/bind/src/net.mjs +281 -0
  17. package/dist/bind/src/page-fetch.mjs +43 -0
  18. package/dist/bind/src/preview-host.mjs +114 -0
  19. package/dist/bind/src/protocol.mjs +103 -0
  20. package/dist/bind/src/records-schema.mjs +580 -0
  21. package/dist/bind/src/records.mjs +161 -0
  22. package/dist/bind/src/save-owner.mjs +492 -0
  23. package/dist/bind/src/schema-lite.mjs +212 -0
  24. package/dist/bind/src/server.mjs +908 -0
  25. package/dist/bind/src/sse.mjs +119 -0
  26. package/dist/bind/src/starters/blank/app.js +17 -0
  27. package/dist/bind/src/starters/blank/index.html +22 -0
  28. package/dist/bind/src/starters/checklist/app.js +171 -0
  29. package/dist/bind/src/starters/checklist/bound.json +29 -0
  30. package/dist/bind/src/starters/checklist/data.json +26 -0
  31. package/dist/bind/src/starters/checklist/index.html +23 -0
  32. package/dist/bind/src/starters/compare/app.js +316 -0
  33. package/dist/bind/src/starters/compare/bound.json +26 -0
  34. package/dist/bind/src/starters/compare/data.json +170 -0
  35. package/dist/bind/src/starters/compare/index.html +23 -0
  36. package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
  37. package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
  38. package/dist/bind/src/starters/ranked/app.js +166 -0
  39. package/dist/bind/src/starters/ranked/bound.json +26 -0
  40. package/dist/bind/src/starters/ranked/data.json +142 -0
  41. package/dist/bind/src/starters/ranked/index.html +23 -0
  42. package/dist/bind/src/starters/site.js +888 -0
  43. package/dist/bind/src/starters/theme.css +492 -0
  44. package/dist/bind/src/starters/timeline/app.js +143 -0
  45. package/dist/bind/src/starters/timeline/data.json +41 -0
  46. package/dist/bind/src/starters/timeline/index.html +22 -0
  47. package/dist/bind/src/static.mjs +141 -0
  48. package/dist/bind/src/tokens.mjs +102 -0
  49. package/dist/bind/src/try.mjs +139 -0
  50. package/dist/bind/src/web.mjs +191 -0
  51. package/dist/cli.js +2117 -365
  52. package/package.json +3 -2
@@ -0,0 +1,41 @@
1
+ {
2
+ "title": "A three-day plan",
3
+ "lede": "Set up on day one, run the sessions on day two, wrap up on day three.",
4
+ "days": [
5
+ {
6
+ "n": 1,
7
+ "date": "2026-10-24",
8
+ "place": "Main hall",
9
+ "stops": [
10
+ { "ref": "stop:setup", "name": "Set up the room", "note": "Chairs for 40, one screen" },
11
+ { "ref": "stop:sound-check", "name": "Sound check", "time": { "v": "14:00", "src": "Venue booking", "url": "https://example.com/booking", "date": "2026-10-17" } },
12
+ { "ref": "stop:walkthrough", "name": "Walk-through with the team" }
13
+ ],
14
+ "legs": []
15
+ },
16
+ {
17
+ "n": 2,
18
+ "date": "2026-10-25",
19
+ "place": "Main hall and Room B",
20
+ "stops": [
21
+ { "ref": "stop:doors", "name": "Doors open", "time": { "v": "09:00", "src": "Venue booking", "url": "https://example.com/booking", "date": "2026-10-17" } },
22
+ { "ref": "stop:sessions", "name": "Morning sessions", "note": "Three talks, 30 minutes each" },
23
+ { "ref": "stop:workshop", "name": "Workshop in Room B", "facts": [{ "note": "step-free access to Room B" }] }
24
+ ],
25
+ "legs": [
26
+ { "from": "stop:sessions", "to": "stop:workshop", "mode": "walk", "duration": { "v": "5 min", "est": true } }
27
+ ]
28
+ },
29
+ {
30
+ "n": 3,
31
+ "date": "2026-10-26",
32
+ "place": "Online",
33
+ "stops": [
34
+ { "ref": "stop:review", "name": "Review call", "time": { "v": "10:00", "src": "Team calendar", "url": "https://example.com/calendar", "date": "2026-10-17" } },
35
+ { "ref": "stop:send-notes", "name": "Send the notes to everyone" }
36
+ ],
37
+ "legs": []
38
+ }
39
+ ],
40
+ "unchecked": ["Ask the venue whether Room B is step-free", "Confirm day 2 lunch with the caterer"]
41
+ }
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
7
+ <meta name="theme-color" content="#141414" media="(prefers-color-scheme: dark)">
8
+ <title>Day plan</title>
9
+ <link rel="stylesheet" href="theme.css">
10
+ <!-- Restyle only when asked: override --if-* tokens in a <style> here, after theme.css. -->
11
+ </head>
12
+ <body>
13
+ <main class="page">
14
+ <header class="head"><h1 class="title" id="title">Day plan</h1><p class="lede" id="lede"></p></header>
15
+ <section id="list"></section>
16
+ <footer class="notes" id="notes" hidden></footer>
17
+ </main>
18
+ <script src="__bind/client.js"></script>
19
+ <script src="site.js"></script>
20
+ <script src="app.js"></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,141 @@
1
+ // Static file serving: GET/HEAD, Range, MIME types, index.html, no dotfiles, no symlink escapes.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const MIME = {
6
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
7
+ ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8",
8
+ ".geojson": "application/geo+json; charset=utf-8", ".map": "application/json; charset=utf-8",
9
+ ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".csv": "text/csv; charset=utf-8",
10
+ ".xml": "application/xml; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".ico": "image/x-icon",
12
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".mp4": "video/mp4",
13
+ ".webm": "video/webm", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".pdf": "application/pdf",
14
+ ".wasm": "application/wasm", ".pmtiles": "application/octet-stream", ".pbf": "application/x-protobuf",
15
+ ".webmanifest": "application/manifest+json",
16
+ };
17
+
18
+ export function mimeOf(file) {
19
+ return MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
20
+ }
21
+
22
+ /** Parse a single "bytes=a-b" range. Returns {start, end}, "unsatisfiable", or null (serve whole file). */
23
+ export function parseRange(header, size) {
24
+ if (!header) return null;
25
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim());
26
+ if (!match) return null;
27
+ const [, a, b] = match;
28
+ if (a === "" && b === "") return null;
29
+ let start;
30
+ let end;
31
+ if (a === "") {
32
+ const suffix = Number(b);
33
+ if (suffix === 0) return "unsatisfiable";
34
+ start = Math.max(0, size - suffix);
35
+ end = size - 1;
36
+ } else {
37
+ start = Number(a);
38
+ end = b === "" ? size - 1 : Math.min(Number(b), size - 1);
39
+ }
40
+ if (start >= size || start > end) return "unsatisfiable";
41
+ return { start, end };
42
+ }
43
+
44
+ function send(res, status, text, headers = {}) {
45
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "x-content-type-options": "nosniff", ...headers });
46
+ res.end(text);
47
+ }
48
+
49
+ /**
50
+ * Resolve a URL path inside root. Returns {file, stat} | {redirect} | {status}.
51
+ * Dot segments (".bind", ".git", ".env") are never served.
52
+ */
53
+ export function resolveStatic(root, urlPath) {
54
+ let decoded;
55
+ try {
56
+ decoded = decodeURIComponent(urlPath);
57
+ } catch {
58
+ return { status: 400 };
59
+ }
60
+ if (decoded.includes("\0") || decoded.includes("\\")) return { status: 400 };
61
+ const segments = decoded.split("/").filter(Boolean);
62
+ if (segments.some((segment) => segment.startsWith(".") )) return { status: 404 };
63
+ let file = path.join(root, ...segments);
64
+ let stat;
65
+ try {
66
+ const real = fs.realpathSync(file);
67
+ const realRoot = fs.realpathSync(root);
68
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return { status: 404 };
69
+ stat = fs.statSync(real);
70
+ file = real;
71
+ } catch {
72
+ return { status: 404 };
73
+ }
74
+ if (stat.isDirectory()) {
75
+ if (!urlPath.endsWith("/")) return { redirect: `${urlPath}/` };
76
+ const index = path.join(file, "index.html");
77
+ try {
78
+ stat = fs.statSync(index);
79
+ file = index;
80
+ } catch {
81
+ return { status: 404, directory: file };
82
+ }
83
+ }
84
+ if (!stat.isFile()) return { status: 404 };
85
+ return { file, stat };
86
+ }
87
+
88
+ /**
89
+ * Serve a static file for GET/HEAD. `notFound(res)` may render something custom. `redirectBase` prefixes directory
90
+ * redirects (urlPath is relative to root); `htmlHeaders` are added to HTML responses only.
91
+ */
92
+ export function serveStatic(req, res, root, urlPath, { notFound = null, query = "", headers: extra = {}, htmlHeaders = {}, redirectBase = "", htmlTransform = null } = {}) {
93
+ if (req.method !== "GET" && req.method !== "HEAD") {
94
+ send(res, 405, "Method not allowed", { allow: "GET, HEAD" });
95
+ return;
96
+ }
97
+ const resolved = resolveStatic(root, urlPath);
98
+ if (resolved.redirect) {
99
+ res.writeHead(301, { location: redirectBase + resolved.redirect + (query ? `?${query}` : "") });
100
+ res.end();
101
+ return;
102
+ }
103
+ if (resolved.status) {
104
+ if (resolved.status === 404 && notFound && notFound(res, resolved)) return;
105
+ send(res, resolved.status, resolved.status === 404 ? "Not found" : "Bad request");
106
+ return;
107
+ }
108
+ const { file, stat } = resolved;
109
+ const headers = {
110
+ "content-type": mimeOf(file),
111
+ "accept-ranges": "bytes",
112
+ "cache-control": "no-cache",
113
+ "last-modified": stat.mtime.toUTCString(),
114
+ "x-content-type-options": "nosniff",
115
+ "referrer-policy": "no-referrer",
116
+ ...extra,
117
+ ...(mimeOf(file).startsWith("text/html") ? htmlHeaders : {}),
118
+ };
119
+ if (htmlTransform && mimeOf(file).startsWith("text/html")) {
120
+ // a page the binder adds to (the bind client for a plain site): the whole page, never a byte range of the file
121
+ let body;
122
+ try { body = Buffer.from(htmlTransform(fs.readFileSync(file, "utf8")), "utf8"); } catch { send(res, 500, "Could not read the page"); return; }
123
+ res.writeHead(200, { ...headers, "accept-ranges": "none", "content-length": body.length });
124
+ res.end(req.method === "HEAD" ? undefined : body);
125
+ return;
126
+ }
127
+ const range = parseRange(req.headers.range, stat.size);
128
+ if (range === "unsatisfiable") {
129
+ send(res, 416, "Range not satisfiable", { "content-range": `bytes */${stat.size}` });
130
+ return;
131
+ }
132
+ if (range) {
133
+ res.writeHead(206, { ...headers, "content-range": `bytes ${range.start}-${range.end}/${stat.size}`, "content-length": range.end - range.start + 1 });
134
+ if (req.method === "HEAD") return res.end();
135
+ fs.createReadStream(file, { start: range.start, end: range.end }).pipe(res);
136
+ return;
137
+ }
138
+ res.writeHead(200, { ...headers, "content-length": stat.size });
139
+ if (req.method === "HEAD") return res.end();
140
+ fs.createReadStream(file).on("error", () => res.destroy()).pipe(res);
141
+ }
@@ -0,0 +1,102 @@
1
+ // Viewer and admin tokens. Both live under .bind/ (mode 0700, files 0600); the static server never serves dotfiles.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+
6
+ export const BIND_DIR = ".bind";
7
+ const TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/;
8
+
9
+ export function randomToken(bytes = 24) {
10
+ return randomBytes(bytes).toString("base64url");
11
+ }
12
+
13
+ export function bindDir(dir) {
14
+ const full = path.join(dir, BIND_DIR);
15
+ fs.mkdirSync(full, { recursive: true, mode: 0o700 });
16
+ try {
17
+ fs.chmodSync(full, 0o700);
18
+ } catch {
19
+ // best effort on unusual file systems
20
+ }
21
+ // Tokens and the binder's log stay on this machine: a space's files are committed and synced, and the admin
22
+ // token must not end up in its history.
23
+ const ignore = path.join(full, ".gitignore");
24
+ try {
25
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "# instafy-bind: tokens and logs stay on this machine\n*\n", { mode: 0o600 });
26
+ } catch {
27
+ // best effort
28
+ }
29
+ return full;
30
+ }
31
+
32
+ /** Read a token file, or create it with a fresh token. `rotate` always writes a new one. */
33
+ export function ensureTokenFile(file, { rotate = false } = {}) {
34
+ if (!rotate) {
35
+ try {
36
+ const value = fs.readFileSync(file, "utf8").trim();
37
+ if (TOKEN_RE.test(value)) return value;
38
+ } catch {
39
+ // create below
40
+ }
41
+ }
42
+ const token = randomToken();
43
+ const tmp = `${file}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
45
+ fs.renameSync(tmp, file);
46
+ fs.chmodSync(file, 0o600);
47
+ return token;
48
+ }
49
+
50
+ /** Per-site viewer token, shared in the URL as #k=<token>. */
51
+ export function viewerToken(siteDir, options) {
52
+ return ensureTokenFile(path.join(bindDir(siteDir), "viewer-token"), options);
53
+ }
54
+
55
+ /** Admin token for commands and confirmations, in <root>/.bind/admin-token. The site never sees it. */
56
+ export function adminToken(rootDir, options) {
57
+ return ensureTokenFile(path.join(bindDir(rootDir), "admin-token"), options);
58
+ }
59
+
60
+ export function readAdminToken(rootDir) {
61
+ const value = fs.readFileSync(path.join(rootDir, BIND_DIR, "admin-token"), "utf8").trim();
62
+ if (!TOKEN_RE.test(value)) throw new Error("malformed .bind/admin-token");
63
+ return value;
64
+ }
65
+
66
+ /** Constant-time string comparison. */
67
+ export function tokensEqual(a, b) {
68
+ if (typeof a !== "string" || typeof b !== "string") return false;
69
+ const left = Buffer.from(a);
70
+ const right = Buffer.from(b);
71
+ return left.length === right.length && timingSafeEqual(left, right);
72
+ }
73
+
74
+ /** Walk up from dir to the folder holding .bind/server.json (written by `serve`). */
75
+ export function findServerState(dir) {
76
+ let current = path.resolve(dir);
77
+ for (;;) {
78
+ const file = path.join(current, BIND_DIR, "server.json");
79
+ if (fs.existsSync(file)) {
80
+ return { root: current, state: JSON.parse(fs.readFileSync(file, "utf8")) };
81
+ }
82
+ const parent = path.dirname(current);
83
+ if (parent === current) return null;
84
+ current = parent;
85
+ }
86
+ }
87
+
88
+ export function writeServerState(rootDir, state) {
89
+ const file = path.join(bindDir(rootDir), "server.json");
90
+ fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
91
+ return file;
92
+ }
93
+
94
+ export function removeServerState(rootDir, pid = process.pid) {
95
+ const file = path.join(rootDir, BIND_DIR, "server.json");
96
+ try {
97
+ const state = JSON.parse(fs.readFileSync(file, "utf8"));
98
+ if (state.pid === pid) fs.rmSync(file, { force: true });
99
+ } catch {
100
+ // already gone
101
+ }
102
+ }
@@ -0,0 +1,139 @@
1
+ // `instafy-bind try <dir> <intent> [params] [--save]`: run one script intent exactly as a viewer's click would (same
2
+ // input, same handler environment, same record checks), without serving the site; --save stores what it accepted. A model building a
3
+ // site uses it before replying, so a button that crashes or whose records are rejected never ships silently.
4
+ // SPDX-License-Identifier: AGPL-3.0-only
5
+ import { createHash } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { executorOf, loadBound, resolveIntent, targetOf } from "./bound.mjs";
9
+ import { applyDefaults, formatErrors, validate } from "./schema-lite.mjs";
10
+ import { checkRecords, writeRecords } from "./apply.mjs";
11
+ import { runScript } from "./executors/script.mjs";
12
+ import { bindDir } from "./tokens.mjs";
13
+
14
+ const TRIED_FILE = "tried.json";
15
+
16
+ function readJsonMaybe(file) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(file, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /** sha256 of a handler file (the doctor compares it with the last successful try). */
25
+ export function handlerHash(file) {
26
+ try {
27
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex").slice(0, 16);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** The last tries of a site: { [intent]: { hash, ok, at } }. */
34
+ export function readTried(siteDir) {
35
+ return readJsonMaybe(path.join(siteDir, ".bind", TRIED_FILE)) || {};
36
+ }
37
+
38
+ function writeTried(siteDir, name, entry) {
39
+ try {
40
+ const dir = bindDir(siteDir);
41
+ const tried = readTried(siteDir);
42
+ tried[name] = entry;
43
+ fs.writeFileSync(path.join(dir, TRIED_FILE), `${JSON.stringify(tried, null, 2)}\n`, { mode: 0o600 });
44
+ } catch {
45
+ // the result is still printed
46
+ }
47
+ }
48
+
49
+ function describe(record) {
50
+ const what = record.type === "claim" ? `${record.attr}: ${record.status === "uncertain" ? `uncertain (${record.note || "no note"})` : JSON.stringify(record.value)}`
51
+ : record.type === "offer" ? `offer ${record.price?.amount ?? "?"} ${record.price?.currency ?? ""} at ${record.seller?.name ?? record.prov?.source ?? "?"}`
52
+ : `${record.type}`;
53
+ return `${record.ref ?? ""} ${what} · ${record.prov?.source ?? "?"}`.trim();
54
+ }
55
+
56
+ /**
57
+ * Run one script intent. Resolves { exit, lines, accepted, rejected, error }.
58
+ * exit 0: the handler ended with at least one accepted record (a value or an uncertain claim).
59
+ * exit 1: it crashed, reported an error, sent no records, or every record was rejected.
60
+ * exit 64: the request itself was wrong (unknown intent, bad params, not a script intent).
61
+ */
62
+ export async function tryIntent(siteDir, name, rawParams = "{}", { allowLocal = false, allowEnv = [], timeoutMs = null, save = false } = {}) {
63
+ const dir = path.resolve(siteDir);
64
+ const lines = [];
65
+ let bound;
66
+ try {
67
+ bound = loadBound(dir);
68
+ } catch (error) {
69
+ return { exit: 64, lines: [`error ${error.message}`] };
70
+ }
71
+ const def = bound.intents?.[name];
72
+ if (!def) return { exit: 64, lines: [`error unknown intent "${name}" (intents: ${Object.keys(bound.intents || {}).join(", ")})`] };
73
+ if (executorOf(def) !== "script") return { exit: 64, lines: [`error ${name} is an agent intent; check its prompt with serve --dry-run instead`] };
74
+ const resolved = resolveIntent(def, { siteDir: dir });
75
+ if (!resolved.ok) return { exit: 64, lines: [`error ${name}: ${resolved.reason}`] };
76
+ let params;
77
+ try {
78
+ params = applyDefaults(def.params, JSON.parse(rawParams || "{}"));
79
+ } catch (error) {
80
+ return { exit: 64, lines: [`error params are not JSON: ${error.message}`] };
81
+ }
82
+ const problems = validate(def.params, params);
83
+ if (problems.length) return { exit: 64, lines: [`error params: ${formatErrors(problems)}`] };
84
+
85
+ const intent = { id: `try_${Date.now().toString(36)}`, name, params, ref: typeof params.ref === "string" ? params.ref : null, at: new Date().toISOString() };
86
+ const records = [];
87
+ const controller = new AbortController();
88
+ const limitS = timeoutMs ? timeoutMs / 1000 : def.timeout_s || 90;
89
+ const timer = setTimeout(() => controller.abort({ code: "timeout", message: `no answer within ${limitS} s` }), limitS * 1000);
90
+ timer.unref?.();
91
+ let summary = null;
92
+ let failure = null;
93
+ try {
94
+ const done = await runScript({
95
+ handler: resolved.handler,
96
+ siteDir: dir,
97
+ input: { intent, bound: def, site: { dir, title: bound.title || path.basename(dir) } },
98
+ signal: controller.signal,
99
+ onProgress: (p) => lines.push(`… ${p.step ?? ""}/${p.of ?? ""} ${p.message ?? ""}`.trimEnd()),
100
+ onRecords: (batch) => records.push(...batch),
101
+ log: (level, text) => {
102
+ if (level !== "debug") lines.push(`${level.padEnd(5)} ${text}`);
103
+ },
104
+ allowLocal,
105
+ envNames: def.env || [],
106
+ allowEnv,
107
+ });
108
+ summary = done.summary;
109
+ } catch (error) {
110
+ failure = error;
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ const { accepted, rejected } = checkRecords(structuredClone(records), { name, def, intent, job: intent.id });
115
+ for (const record of accepted) lines.push(`ok ${describe(record)}`);
116
+ for (const r of rejected) lines.push(`error record #${r.index} rejected: ${r.reason}`);
117
+ let exit = 0;
118
+ if (failure) {
119
+ lines.push(`error ${failure.code || "upstream"}: ${failure.message}`);
120
+ exit = 1;
121
+ } else if (!records.length) {
122
+ lines.push("error the handler sent no records: a click would leave the cell empty (send a value or kit.uncertain with a note)");
123
+ exit = 1;
124
+ } else if (!accepted.length) {
125
+ lines.push("error every record was rejected: a click would show an error");
126
+ exit = 1;
127
+ }
128
+ if (summary && !failure) lines.push(`done ${summary}`);
129
+ // --save: the accepted records go into the site the way a click's do (same merge, version bump), so a new page
130
+ // opens with its values; a failed try saves nothing
131
+ let saved = null;
132
+ if (save && exit === 0 && accepted.length) {
133
+ const file = writeRecords(dir, targetOf(name, def), accepted, { attribution: bound.attribution || [] });
134
+ saved = { file: file.rel, records: file.applied.length, version: file.version };
135
+ lines.push(`saved ${file.applied.length} record(s) to ${file.rel} (version ${file.version}); a page that is already open shows them after a reload command`);
136
+ } else if (save) lines.push("saved nothing: the try failed");
137
+ writeTried(dir, name, { hash: handlerHash(resolved.handler), ok: exit === 0, at: new Date().toISOString() });
138
+ return { exit, lines, accepted, rejected, saved, error: failure ? { code: failure.code, message: failure.message } : null };
139
+ }
@@ -0,0 +1,191 @@
1
+ // Polite page reads for script handlers (kit.fetch): honour robots.txt for "*" and the major AI user agents (checked
2
+ // again on every redirect hop) and stop at bot checks, CAPTCHAs and login walls. None is ever worked around. What a
3
+ // page returns is data to quote with its source, never instructions.
4
+ // SPDX-License-Identifier: AGPL-3.0-only
5
+ import { isUnavailable, pageFetch } from "./page-fetch.mjs";
6
+
7
+ export const USER_AGENT = "Instafy-Agent/1.0 (+https://instafy.dev; reads one public page on a person's request)";
8
+ export const AI_AGENTS = ["gptbot", "chatgpt-user", "oai-searchbot", "claudebot", "claude-user", "anthropic-ai", "perplexitybot", "ccbot", "google-extended"];
9
+
10
+ /** robots.txt → [{ agents: [lowercase], rules: [{ allow, path }] }] */
11
+ export function parseRobots(txt) {
12
+ const groups = [];
13
+ let current = null;
14
+ let lastWasAgent = false;
15
+ for (const raw of String(txt || "").split(/\r?\n/)) {
16
+ const line = raw.replace(/#.*/, "").trim();
17
+ const m = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
18
+ if (!m) continue;
19
+ const key = m[1].toLowerCase();
20
+ const value = m[2].trim();
21
+ if (key === "user-agent") {
22
+ if (!current || !lastWasAgent) {
23
+ current = { agents: [], rules: [] };
24
+ groups.push(current);
25
+ }
26
+ current.agents.push(value.toLowerCase());
27
+ lastWasAgent = true;
28
+ } else if ((key === "allow" || key === "disallow") && current) {
29
+ lastWasAgent = false;
30
+ if (value) current.rules.push({ allow: key === "allow", path: value });
31
+ } else lastWasAgent = false;
32
+ }
33
+ return groups;
34
+ }
35
+
36
+ const UNRESERVED = /[A-Za-z0-9\-._~]/;
37
+ /**
38
+ * The form robots.txt paths are compared in (RFC 9309 §2.2.2): %XX escapes of unreserved characters decoded, other
39
+ * escapes upper-cased, and (request paths) runs of "/" folded, so `/%70rivate` or `//private` cannot slip past
40
+ * `Disallow: /private` while the server still serves /private.
41
+ */
42
+ export function normalizeRobotsPath(value, { foldSlashes = true } = {}) {
43
+ let text = String(value).replace(/%([0-9A-Fa-f]{2})/g, (m, hex) => {
44
+ const ch = String.fromCharCode(parseInt(hex, 16));
45
+ return UNRESERVED.test(ch) ? ch : `%${hex.toUpperCase()}`;
46
+ });
47
+ if (foldSlashes) {
48
+ const q = text.indexOf("?");
49
+ const pathPart = q < 0 ? text : text.slice(0, q);
50
+ text = pathPart.replace(/\/{2,}/g, "/") + (q < 0 ? "" : text.slice(q));
51
+ }
52
+ return text;
53
+ }
54
+
55
+ function ruleMatches(rule, p) {
56
+ const normalized = normalizeRobotsPath(rule, { foldSlashes: false });
57
+ const re = new RegExp("^" + normalized.replace(/[.+?^{}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\\\$$|\$$/, "$"));
58
+ return re.test(normalizeRobotsPath(p));
59
+ }
60
+
61
+ /** Is `pathWithQuery` allowed for one agent? Longest matching rule wins; Allow wins a tie. */
62
+ export function robotsAllows(groups, agent, pathWithQuery) {
63
+ const a = agent.toLowerCase();
64
+ const named = (g) => g.agents.some((x) => x !== "*" && (a === x || a.startsWith(x)));
65
+ let rules = groups.filter(named).flatMap((g) => g.rules);
66
+ if (!groups.some(named)) rules = groups.filter((g) => g.agents.includes("*")).flatMap((g) => g.rules);
67
+ let best = null;
68
+ for (const r of rules) {
69
+ if (!ruleMatches(r.path, pathWithQuery)) continue;
70
+ if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow)) best = r;
71
+ }
72
+ return !best || best.allow;
73
+ }
74
+
75
+ /** The first agent (of "*" and the AI agents) that robots.txt keeps out of this path, or null. */
76
+ export function robotsBlocker(groups, pathWithQuery) {
77
+ for (const agent of ["*", ...AI_AGENTS]) if (!robotsAllows(groups, agent, pathWithQuery)) return agent;
78
+ return null;
79
+ }
80
+
81
+ /** A bot check, CAPTCHA or login wall instead of the page? Returns the reason or null. */
82
+ export function challengeReason(status, html) {
83
+ if (status === 401) return "login required (HTTP 401)";
84
+ if (status === 403 || status === 429) return `blocked (HTTP ${status})`;
85
+ const head = String(html).slice(0, 20000);
86
+ if (/g-recaptcha|h-captcha|hcaptcha\.com|cf-challenge|challenge-platform|cf_chl_|captcha-delivery|px-captcha|perimeterx|datadome|Just a moment\.\.\.|Attention Required!|verify you are (a )?human|are you a robot/i.test(head)) return "bot check / CAPTCHA page";
87
+ if (status === 202 && head.length < 4000) return "interstitial (HTTP 202)";
88
+ return null;
89
+ }
90
+
91
+ /** The bodies of <script type="application/ld+json"> elements, found with a linear scan (no backtracking regex). */
92
+ export function jsonLdBlocks(html, max = 50) {
93
+ const text = String(html);
94
+ const lower = text.toLowerCase();
95
+ const out = [];
96
+ let at = 0;
97
+ while (out.length < max) {
98
+ const open = lower.indexOf("<script", at);
99
+ if (open < 0) break;
100
+ const tagEnd = lower.indexOf(">", open + 7);
101
+ if (tagEnd < 0) break;
102
+ const close = lower.indexOf("</script", tagEnd + 1);
103
+ if (close < 0) break;
104
+ if (/\btype\s*=\s*["']?application\/ld\+json/.test(lower.slice(open + 7, tagEnd))) out.push(text.slice(tagEnd + 1, close));
105
+ at = close + 8;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * The schema.org offers a page publishes (JSON-LD Product / ProductGroup / Offer / AggregateOffer): what the page
112
+ * itself states as its price, instead of a number read off the layout. At most 5 distinct [amount, currency] pairs; nothing from the page is executed.
113
+ */
114
+ export function pageOffers(html, max = 5) {
115
+ const out = [];
116
+ const seen = new Set();
117
+ const typesOf = (n) => (Array.isArray(n?.["@type"]) ? n["@type"] : [n?.["@type"]]).filter(Boolean).map(String);
118
+ const num = (v) => {
119
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.replace(/[^\d.]/g, "")) : NaN;
120
+ return Number.isFinite(n) && n > 0 && n < 1e7 ? n : null;
121
+ };
122
+ const push = (offer, name) => {
123
+ if (out.length >= max) return;
124
+ const currency = typeof offer.priceCurrency === "string" && /^[A-Z]{3}$/.test(offer.priceCurrency.trim()) ? offer.priceCurrency.trim() : null;
125
+ const spec = Array.isArray(offer.priceSpecification) ? offer.priceSpecification[0] : offer.priceSpecification;
126
+ const amount = num(offer.price) ?? num(offer.lowPrice) ?? num(spec?.price);
127
+ if (amount == null) return;
128
+ const high = num(offer.highPrice);
129
+ const key = `${amount}|${high ?? ""}|${currency ?? spec?.priceCurrency ?? ""}`;
130
+ if (seen.has(key)) return;
131
+ seen.add(key);
132
+ const availability = typeof offer.availability === "string" ? offer.availability.replace(/^.*[/#]/, "").slice(0, 30) : null;
133
+ out.push({
134
+ amount, ...(high && high !== amount ? { high } : {}),
135
+ currency: currency || (typeof spec?.priceCurrency === "string" ? spec.priceCurrency.slice(0, 3) : null),
136
+ ...(availability ? { availability } : {}),
137
+ ...(typeof name === "string" && name.trim() ? { name: name.replace(/\s+/g, " ").trim().slice(0, 80) } : {}),
138
+ });
139
+ };
140
+ const visit = (node, name, depth = 0) => {
141
+ if (depth > 6 || !node || typeof node !== "object" || out.length >= max) return;
142
+ if (Array.isArray(node)) return node.forEach((x) => visit(x, name, depth + 1));
143
+ const t = typesOf(node);
144
+ const ownName = typeof node.name === "string" ? node.name : name;
145
+ if (t.some((x) => /^(Offer|AggregateOffer)$/.test(x))) push(node, name);
146
+ for (const key of ["@graph", "offers", "hasVariant", "itemListElement", "item"]) if (node[key]) visit(node[key], ownName, depth + 1);
147
+ };
148
+ for (const text of jsonLdBlocks(html)) {
149
+ let block;
150
+ try {
151
+ block = JSON.parse(text.trim());
152
+ } catch {
153
+ continue;
154
+ }
155
+ visit(block, null);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ const robotsCache = new Map();
161
+
162
+ async function robotsGroups(origin, { fetchImpl, allowLocal, timeoutMs }) {
163
+ if (robotsCache.has(origin)) return robotsCache.get(origin);
164
+ let result;
165
+ try {
166
+ const r = await fetchImpl(`${origin}/robots.txt`, {
167
+ allowLocal, timeoutMs: Math.min(timeoutMs, 10_000), maxBytes: 500_000, followRedirects: true, answerAnyStatus: true,
168
+ headers: { "user-agent": USER_AGENT, accept: "text/plain" },
169
+ });
170
+ // 4xx = no rules; 5xx = treat as "do not crawl" (the robots.txt standard).
171
+ if (r.status >= 500) result = { refused: `robots.txt answered HTTP ${r.status} (treated as disallow)` };
172
+ else result = { groups: r.status < 400 ? parseRobots(await r.text()) : [] };
173
+ } catch (error) {
174
+ // a site that does not answer at all is unavailable, not "no rules": the page read stops here (not cached)
175
+ if (error?.code === "policy" || isUnavailable(error)) throw error;
176
+ result = { groups: [] };
177
+ }
178
+ robotsCache.set(origin, result);
179
+ return result;
180
+ }
181
+
182
+ /** The policy check for one URL (robots.txt): { refused: reason | null }. */
183
+ export async function checkPolicy(href, { fetchImpl = pageFetch, allowLocal = false, timeoutMs = 20_000 } = {}) {
184
+ const u = new URL(href);
185
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { refused: `not a web URL (${u.protocol})` };
186
+ const robots = await robotsGroups(u.origin, { fetchImpl, allowLocal, timeoutMs });
187
+ if (robots.refused) return { refused: robots.refused };
188
+ const blocker = robotsBlocker(robots.groups, u.pathname + u.search);
189
+ if (blocker) return { refused: `robots.txt disallows ${u.pathname} for ${blocker === "*" ? "all bots" : blocker}` };
190
+ return { refused: null };
191
+ }