viewable-one 0.0.0-stage → 0.12.1

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 (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/logs.mjs ADDED
@@ -0,0 +1,120 @@
1
+ // THE DAEMON'S LOG, because a background process with no terminal has nowhere
2
+ // else to say anything.
3
+ //
4
+ // Until brief 19 the runner was a thing a person typed, so its output went to
5
+ // that person's terminal and there was nothing to keep. A LaunchAgent has no
6
+ // terminal, so the same lines go to ~/.glytch/logs/runner.log - and a file
7
+ // something appends to for ever is a disk leak, so it rotates by SIZE.
8
+ //
9
+ // Rotation is by size and not by date on purpose: a machine that spawns
10
+ // nothing writes a few lines a day and should keep months of them in one
11
+ // file, while a machine running sessions all night fills a file in hours. The
12
+ // budget that matters is the disk, so the budget is the one that is measured.
13
+
14
+ import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { glytchHome } from "./registry.mjs";
17
+
18
+ /** 5 MB per file, three rotations kept: at most ~20 MB of logs on a machine,
19
+ * which is small enough never to be noticed and large enough to hold the
20
+ * night a session went wrong. */
21
+ export const LOG_MAX_BYTES = 5 * 1024 * 1024;
22
+ export const LOG_KEEP = 3;
23
+
24
+ export const logDir = (env = process.env) => join(glytchHome(env), "logs");
25
+ export const logPath = (env = process.env) => join(logDir(env), "runner.log");
26
+
27
+ const sizeOf = async (path) => {
28
+ try {
29
+ return (await stat(path)).size;
30
+ } catch {
31
+ return 0;
32
+ }
33
+ };
34
+
35
+ /** Roll runner.log to runner.log.1, .1 to .2, and drop what falls past `keep`.
36
+ *
37
+ * Oldest first, so nothing overwrites a file it is about to move. Returns true
38
+ * when a rotation actually happened. Never throws: losing a log line is not a
39
+ * reason to take the daemon down. */
40
+ export async function rotateIfNeeded(
41
+ { max = LOG_MAX_BYTES, keep = LOG_KEEP, env = process.env } = {},
42
+ ) {
43
+ const path = logPath(env);
44
+ if ((await sizeOf(path)) < max) return false;
45
+ try {
46
+ await rm(`${path}.${keep}`, { force: true });
47
+ for (let n = keep - 1; n >= 1; n -= 1) {
48
+ try {
49
+ await rename(`${path}.${n}`, `${path}.${n + 1}`);
50
+ } catch {
51
+ /* that rotation does not exist yet */
52
+ }
53
+ }
54
+ await rename(path, `${path}.1`);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** Every rotated file beyond `keep`, so the sweep can remove what an older
62
+ * keep count (or a crash mid-rotation) left behind. Names only. */
63
+ export async function staleRotations({ keep = LOG_KEEP, env = process.env } = {}) {
64
+ const path = logPath(env);
65
+ const stale = [];
66
+ for (let n = keep + 1; n <= keep + 20; n += 1) {
67
+ if ((await sizeOf(`${path}.${n}`)) > 0) stale.push(`${path}.${n}`);
68
+ else {
69
+ try {
70
+ await stat(`${path}.${n}`);
71
+ stale.push(`${path}.${n}`);
72
+ } catch {
73
+ /* not there */
74
+ }
75
+ }
76
+ }
77
+ return stale;
78
+ }
79
+
80
+ /** Should a log line ALSO be written to stdout, on top of the file the caller
81
+ * always gets? (2026-09-21) The launchd/systemd plist that runs `glytch
82
+ * runner watch` as the daemon redirects that process's OWN stdout into the
83
+ * same `runner.log` file (`lib/logindaemon.mjs`'s `>> "$out" 2>&1`), so an
84
+ * echo there is not "also print it" - it is a second `appendFile` of the
85
+ * same line, doubling every line the daemon ever writes. A person running
86
+ * `viewable runner watch` by hand in a terminal has no such redirect and still
87
+ * wants to see what is happening, and `process.stdout.isTTY` is exactly the
88
+ * fact that tells the two apart: a terminal sets it, a file/pipe redirect
89
+ * does not. `GLYTCH_LOG_ECHO=1` overrides it either way, for a person who
90
+ * redirects `runner watch` somewhere else by hand and still wants the echo.
91
+ * `isTTY` is a parameter (not read from `process.stdout` inline) so this is
92
+ * provable without a real terminal. */
93
+ export function shouldEchoToStdout({ isTTY = process.stdout.isTTY, env = process.env } = {}) {
94
+ if ((env.VIEWABLE_LOG_ECHO ?? env.GLYTCH_LOG_ECHO) === "1") return true;
95
+ return Boolean(isTTY);
96
+ }
97
+
98
+ /** A `log` function of the shape every function in this package already takes
99
+ * (`(line) => void`), writing timestamped lines into the rotating file. It
100
+ * also echoes to stdout by default when stdout is a real terminal
101
+ * (`shouldEchoToStdout`) - `viewable runner watch` run by hand must still say
102
+ * what it is doing, while the same process under the daemon's plist (stdout
103
+ * already redirected into this same file) must not write every line twice.
104
+ * `echo` still overrides outright when a caller (a test, `GLYTCH_LOG_ECHO`)
105
+ * has already decided. */
106
+ export function makeFileLogger({ env = process.env, echo, max = LOG_MAX_BYTES, keep = LOG_KEEP } = {}) {
107
+ const doEcho = echo ?? shouldEchoToStdout({ env });
108
+ let queue = Promise.resolve();
109
+ return (line = "") => {
110
+ if (doEcho) process.stdout.write(`${line}\n`);
111
+ queue = queue
112
+ .then(async () => {
113
+ await mkdir(logDir(env), { recursive: true });
114
+ await rotateIfNeeded({ max, keep, env });
115
+ await appendFile(logPath(env), `${new Date().toISOString()} ${line}\n`, "utf8");
116
+ })
117
+ .catch(() => {});
118
+ return queue;
119
+ };
120
+ }
@@ -0,0 +1,242 @@
1
+ // Fresh configs write viewable (#2328). Existing glytch entries stay readable and keep their name (#2196).
2
+ // .mcp.json, as `glytch init` writes it.
3
+ //
4
+ // ONE WRITER SINCE 2026-10-15 (#494). The app had a second copy of these
5
+ // rules (src/folder/mcpConfig.ts) behind a Help button; that button went
6
+ // with the old Help, and its copy with it. The rules are pinned by
7
+ // bridge/test/fixtures/mcp-json-cases.json, read by bridge/test/mcpjson.test.mjs.
8
+ //
9
+ // THE FIXTURE BRIEFLY DIVERGED HERE, 2026-09-12 (brief 31, Move 3): this side
10
+ // took the `glytch` rename first, writing the key below, and the fixture was
11
+ // forked into mcp-json-cases.bridge.json so `src/folder/mcpConfig.ts` (which
12
+ // still wrote `board`) would not break. REUNITED the same day (Move 3b): that
13
+ // file now writes `glytch` too, with the same rename-in-place and cleanup
14
+ // rules, so this fixture is shared again and the fork is gone.
15
+ //
16
+ // The rules, all three of them, are the app's and are quoted from it:
17
+ //
18
+ // PRESENCE IS THE KEY, NOT THE VALUE. A dev checkout named a file, a
19
+ // stranger's named the installed binary. Both are correct, so a `glytch`
20
+ // key in any shape means connected and nothing is written (one exception,
21
+ // 2026-10-03: a url at the retired address is moved, see below).
22
+ //
23
+ // A FILE WE CANNOT PARSE IS NEVER REPLACED. It is someone's work.
24
+ //
25
+ // A FILE WITH NO mcpServers KEY IS STILL SOMEONE'S CONFIG, so the key is
26
+ // added to it rather than the file being replaced.
27
+ //
28
+ // RENAMED 2026-09-12 (brief 31, Move 3, ruling 3): the key WRITTEN is
29
+ // `glytch`, not `board` - "board" is the product the user thinks on, "glytch"
30
+ // is what the server is called from here on. An existing `board` entry
31
+ // whose `url` is ours (`BOARD_MCP_URL`) is recognised as this package's own
32
+ // and renamed to `glytch` IN PLACE, the same upgrade path a legacy
33
+ // `command`/`args` entry already took - `mergeBoardServer` below treats
34
+ // "ours by url" and "ours by legacy command" as the same `kind:"legacy"`
35
+ // outcome, because both mean "this is our entry, just under the old key."
36
+ // A `board` entry that is neither (a stranger's own remote server, still
37
+ // legitimately named "board") is left exactly alone, `kind:"present"`,
38
+ // same as it always was. And a folder that already has BOTH - a `glytch`
39
+ // entry from a previous `init` plus a leftover `board` entry this rename
40
+ // left behind - gets the stale `board` table dropped on the next run.
41
+ //
42
+ // MOVED 2026-10-24: the board uses `https://app.viewable.one/mcp`; both older
43
+ // app hosts are retired (a Cloudflare redirect sends it to app, but a
44
+ // cross-origin redirect drops the Authorization header, so an agent config
45
+ // still naming the old address stops working rather than following). An entry
46
+ // at either RETIRED_BOARD_MCP_URLS address is ours exactly like one at the current URL, and
47
+ // wherever a merge keeps an entry of ours its `url` is replaced with the
48
+ // current one, keeping its other fields (`moveRetiredUrl` below). An entry at
49
+ // any OTHER url (a developer's GLYTCH_BOARD_URL, a stranger's server) is not
50
+ // touched.
51
+ //
52
+ // THE SHAPE NOW is one hosted endpoint, not a spawned binary: the board lives
53
+ // behind `https://app.viewable.one/mcp` (a streamable-HTTP MCP server, OAuth
54
+ // with dynamic client registration - routes.json rewrites `/mcp` and the
55
+ // `/.well-known/` and `/oauth/*` discovery routes to the `board-mcp` and
56
+ // `board-oauth` Supabase functions), so there is no more per-platform branch:
57
+ // an HTTP url needs no shell to spawn it, so ruling 2A's `cmd /c` wrapper
58
+ // does not exist on this shape at all. `BOARD_MCP_URL` is read at call time,
59
+ // not fixed at import time, so a test (or a developer's shell) can point it
60
+ // at another deployment with `GLYTCH_BOARD_URL` without a second copy of the
61
+ // default living anywhere else. `env` rather than `platform` is the only
62
+ // axis every writer below now takes, for the same reason ruling 2A took
63
+ // `platform` before it: the axis a shape can vary on has to be an argument,
64
+ // not an assumption about the machine running this code.
65
+ import { runnerApiUrlFrom } from "./runnerApi.mjs";
66
+ import { DEFAULT_APP_URL } from "./auth.mjs";
67
+ import { RETIRED_APP_URL, RETIRED_APP_URLS } from "./retired-address.mjs";
68
+
69
+ export const DEFAULT_BOARD_MCP_URL = "https://app.viewable.one/mcp";
70
+ /** The address this package wrote until 2026-10-03. ONE definition: every
71
+ * other bridge file imports it from here. */
72
+ export const RETIRED_BOARD_MCP_URL = `${RETIRED_APP_URL}/mcp`;
73
+ export const RETIRED_BOARD_MCP_URLS = RETIRED_APP_URLS.map(origin => `${origin}/mcp`);
74
+ export const BOARD_MCP_URL = (env = process.env) => {
75
+ const value = (env?.VIEWABLE_BOARD_URL ?? env?.GLYTCH_BOARD_URL);
76
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : DEFAULT_BOARD_MCP_URL;
77
+ };
78
+ /** Run children already have a bearer, so use the runner's direct function
79
+ * host for the production MCP endpoint. Keep discovered/custom endpoints.
80
+ * The hosted project address stays owned by runnerApiUrlFrom (#813). */
81
+ export function runnerMcpUrlFrom({ appUrl, mcpUrl }, env = process.env) {
82
+ if (appUrl !== DEFAULT_APP_URL || mcpUrl !== DEFAULT_BOARD_MCP_URL) return mcpUrl;
83
+ const runnerUrl = new URL(runnerApiUrlFrom(appUrl, env));
84
+ // A trailing slash still names the runner endpoint, not a directory.
85
+ runnerUrl.pathname = runnerUrl.pathname.replace(/\/+$/, "");
86
+ return new URL("board-mcp", runnerUrl).href;
87
+ }
88
+
89
+ /** Seconds Codex lets one board tool call run. check_answer can hold a call
90
+ * up to 50s waiting for the person's answer; Codex's own default is 60s. */
91
+ export const CODEX_TOOL_TIMEOUT_SEC = 120;
92
+
93
+ /** THE SHAPE EVERY WRITER PRODUCES NOW, on every platform: a remote MCP
94
+ * server, described the way Claude Code's own `claude mcp add --transport
95
+ * http` produces one. `env` stays a parameter (rather than reading
96
+ * `process.env` inline) for the same reason `boardServerFor` used to take
97
+ * `platform`: a test, or `GLYTCH_BOARD_URL` in a developer's shell, needs to
98
+ * say which URL without a second implementation. */
99
+ export const boardServerFor = (env = process.env) => ({ type: "http", url: BOARD_MCP_URL(env) });
100
+
101
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
102
+
103
+ // Two-space JSON with a trailing newline - what every editor and every
104
+ // formatter in this repo writes, so adding one entry does not show up as a
105
+ // whole-file diff.
106
+ const render = (value) => `${JSON.stringify(value, null, 2)}\n`;
107
+
108
+ /** Is this `command`/`args` pair one of the shapes THIS PACKAGE ITSELF wrote
109
+ * before the local server was retired? Recognised regardless of which writer
110
+ * produced it or which platform it ran on:
111
+ *
112
+ * - `pm-agent` (a bin alias every version up to 0.6.0 installed, spawned
113
+ * bare; 0.7.0 stopped shipping it, which is exactly why configs naming
114
+ * it have to be found and rewritten)
115
+ * - `glytch` with `mcp` among its args (ruling 2 of brief 19, POSIX)
116
+ * - `cmd` with `glytch` and `mcp` among its args (ruling 2A, native Windows)
117
+ * - `node` with an arg ending `pm-agent.mjs` (a dev checkout's own spelling
118
+ * of the same server)
119
+ *
120
+ * Anything else - a custom command, a disabled entry, someone's own remote
121
+ * server named "board" - is NOT recognised, and stays exactly as the
122
+ * presence rule above has always left it. This is the one place that
123
+ * definition lives; lib/project-mcp.mjs imports it rather than repeating it,
124
+ * so Codex and opencode upgrade the same shapes .mcp.json does. */
125
+ export function isLegacyBoardCommand(command, args) {
126
+ const list = Array.isArray(args) ? args.filter((a) => typeof a === "string") : [];
127
+ if (command === "pm-agent") return true;
128
+ if (command === "glytch" && list.includes("mcp")) return true;
129
+ if (command === "cmd" && list.includes("glytch") && list.includes("mcp")) return true;
130
+ if (command === "node" && list.some((a) => a.replace(/\\/g, "/").endsWith("pm-agent.mjs"))) return true;
131
+ return false;
132
+ }
133
+
134
+ /** Is this entry sitting at the retired address, and is that address not the
135
+ * one this run is pointed at (a developer who set GLYTCH_BOARD_URL to the old
136
+ * host on purpose has nothing to move)? Shared by every writer: Codex's TOML
137
+ * and opencode's JSON carry the same `url` key. */
138
+ export function entryIsAtRetiredUrl(entry, env) {
139
+ return isObject(entry) && RETIRED_BOARD_MCP_URLS.includes(entry.url) && BOARD_MCP_URL(env) !== entry.url;
140
+ }
141
+
142
+ /** The entry with its retired `url` replaced by the current one and every
143
+ * other field kept; any other entry comes back as the same object. */
144
+ export function moveRetiredUrl(entry, env) {
145
+ return entryIsAtRetiredUrl(entry, env) ? { ...entry, url: BOARD_MCP_URL(env) } : entry;
146
+ }
147
+
148
+ /** Is this `board` entry ours - either the old spawned-command shape
149
+ * (`isLegacyBoardCommand`) or already the hosted-URL shape this package
150
+ * itself wrote before the rename (`url` matches `BOARD_MCP_URL`, or the
151
+ * retired address this package wrote before 2026-10-03)? Either
152
+ * way it is safe to rename to `glytch` rather than a stranger's own server
153
+ * that merely happens to be named "board".
154
+ *
155
+ * EXPORTED (brief 31 review, 2026-09-12) so lib/uninstall-project.mjs can
156
+ * use this exact check for `.mcp.json` rather than keeping its own
157
+ * byte-identical copy - the removal side of this file's merge needs the
158
+ * same "is this ours" answer the merge itself does. */
159
+ export function boardEntryIsOurs(entry, env) {
160
+ if (!isObject(entry)) return false;
161
+ if (isLegacyBoardCommand(entry.command, entry.args)) return true;
162
+ return typeof entry.url === "string" && (entry.url === BOARD_MCP_URL(env) || RETIRED_BOARD_MCP_URLS.includes(entry.url));
163
+ }
164
+
165
+ /** Decide what to write, given whatever is on disk (null = no file).
166
+ *
167
+ * Returns one of:
168
+ * {kind:"create", text} - no file at all; `text` is a whole new one
169
+ * {kind:"add", text} - a file with other servers; `text` is it plus glytch
170
+ * {kind:"legacy", text} - an existing `board` entry is OUR OWN, either the
171
+ * retired spawned-command shape
172
+ * (isLegacyBoardCommand) or the hosted-URL shape
173
+ * written under the old key; `text` is it
174
+ * renamed to `glytch` in place (a legacy command
175
+ * entry is replaced with the canonical server
176
+ * value; a url-match entry keeps its own other
177
+ * fields). Also covers the cleanup case where
178
+ * both `glytch` and a stale `board` (ours) are
179
+ * present - `text` drops the leftover `board`.
180
+ * `moved:true` is added when the retired address
181
+ * was replaced (so a caller's message can say
182
+ * "moved to app.viewable.one", not "upgraded from
183
+ * the old command shape"). An existing `glytch`
184
+ * entry at the retired address is rewritten the
185
+ * same way; one at any other url is untouched.
186
+ * {kind:"present"} - already has a `board` server we do not
187
+ * recognise as ours, or already has `glytch`
188
+ * with no stale `board` to clean up; nothing
189
+ * touched
190
+ * {kind:"unreadable"} - there is a file and we cannot extend it; never
191
+ * written over
192
+ */
193
+ export function mergeBoardServer(existing, env = process.env) {
194
+ const server = boardServerFor(env);
195
+ if (existing === null || existing === undefined || existing.trim() === "") {
196
+ return { kind: "create", text: render({ mcpServers: { viewable: server } }) };
197
+ }
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(existing);
201
+ } catch {
202
+ return { kind: "unreadable" };
203
+ }
204
+ if (!isObject(parsed)) return { kind: "unreadable" };
205
+
206
+ const servers = parsed.mcpServers;
207
+ if (servers === undefined) {
208
+ return { kind: "add", text: render({ ...parsed, mcpServers: { viewable: server } }) };
209
+ }
210
+ if (!isObject(servers)) return { kind: "unreadable" };
211
+
212
+ // Read old, write new: keep either existing product entry without duplicating it (#2196).
213
+ if ("viewable" in servers) return { kind: "present" };
214
+ const boardEntry = servers.board;
215
+ const boardIsOurs = boardEntryIsOurs(boardEntry, env);
216
+
217
+ if (!("glytch" in servers)) {
218
+ if (!("board" in servers)) {
219
+ return { kind: "add", text: render({ ...parsed, mcpServers: { ...servers, viewable: server } }) };
220
+ }
221
+ if (!boardIsOurs) return { kind: "present" };
222
+ const { board: _dropped, ...rest } = servers;
223
+ const renamed = isLegacyBoardCommand(boardEntry.command, boardEntry.args) ? server : moveRetiredUrl(boardEntry, env);
224
+ return {
225
+ kind: "legacy",
226
+ ...(entryIsAtRetiredUrl(boardEntry, env) ? { moved: true } : {}),
227
+ text: render({ ...parsed, mcpServers: { ...rest, glytch: renamed } }),
228
+ };
229
+ }
230
+
231
+ // `glytch` is already there. A leftover `board` entry that is ours (from
232
+ // before this rename shipped, or from a folder init'd between the two) is
233
+ // dropped; anything else about the file, including a stranger's own
234
+ // "board" server, is left exactly as it is. A `glytch` entry at the retired
235
+ // address is ours too, and moves to the current one.
236
+ const glytchMoved = entryIsAtRetiredUrl(servers.glytch, env);
237
+ if (!boardIsOurs && !glytchMoved) return { kind: "present" };
238
+ const { board: _dropped, ...rest } = servers;
239
+ const next = boardIsOurs ? rest : { ...servers };
240
+ if (glytchMoved) next.glytch = moveRetiredUrl(servers.glytch, env);
241
+ return { kind: "legacy", ...(glytchMoved ? { moved: true } : {}), text: render({ ...parsed, mcpServers: next }) };
242
+ }
@@ -0,0 +1,165 @@
1
+ /** Board-member authorization around an outbound preview transport. No bearer
2
+ * Supabase session ever leaves the app: this verifies preview-scoped grants. */
3
+ import { createPublicKey, randomBytes, randomUUID, verify } from "node:crypto";
4
+ import { createPreviewGateway } from "./previewGateway.mjs";
5
+ import { startQuickPreviewTunnel } from "./previewTunnel.mjs";
6
+
7
+ export const PREVIEW_COOKIE = "__glytch_preview";
8
+ const AUTH_PATH = "/__glytch_preview/auth";
9
+ const SESSION_PATH = "/__glytch_preview/session";
10
+
11
+ export function previewAuthConfig(env = process.env) {
12
+ if (!env.PREVIEW_VERIFY_JWK || !env.PM_PREVIEW_APP_ORIGINS) {
13
+ throw new Error("Shared preview access is not configured. Set PREVIEW_VERIFY_JWK and PM_PREVIEW_APP_ORIGINS on this runner after deploying preview-grant.");
14
+ }
15
+ let key;
16
+ try {
17
+ const jwk = JSON.parse(env.PREVIEW_VERIFY_JWK);
18
+ if (jwk.kty !== "RSA" || jwk.d) throw new Error("public RSA key required");
19
+ key = createPublicKey({ key: jwk, format: "jwk" });
20
+ if (key.asymmetricKeyDetails.modulusLength < 2048) throw new Error("RSA key too small");
21
+ } catch { throw new Error("PREVIEW_VERIFY_JWK must contain a public RSA JWK of at least 2048 bits."); }
22
+ const appOrigins = env.PM_PREVIEW_APP_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean);
23
+ if (!appOrigins.length || appOrigins.some((s) => {
24
+ try {
25
+ const u = new URL(s);
26
+ return u.origin !== s || u.hostname.includes("*") || (u.protocol !== "https:" && !(u.protocol === "http:" && ["localhost", "127.0.0.1"].includes(u.hostname)));
27
+ } catch { return true; }
28
+ })) throw new Error("PM_PREVIEW_APP_ORIGINS must list exact HTTPS app origins (HTTP loopback is allowed for local development).");
29
+ return { key, appOrigins };
30
+ }
31
+
32
+ export function verifyPreviewGrant(token, key, expected, now = Date.now()) {
33
+ try {
34
+ if (typeof token !== "string" || token.length > 8192) return null;
35
+ const parts = token.split(".");
36
+ if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]+$/.test(p))) return null;
37
+ const [header, claims] = parts.slice(0, 2).map((p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8")));
38
+ if (header.alg !== "RS256" || header.typ !== "JWT" || header.crit) return null;
39
+ if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return null;
40
+ if (claims.iss !== "glytch-preview" || typeof claims.sub !== "string" || !claims.sub) return null;
41
+ for (const field of ["aud", "boardId", "workspaceId", "nodeId", "runKey", "previewId"]) {
42
+ if (typeof expected[field] !== "string" || !expected[field] || claims[field] !== expected[field]) return null;
43
+ }
44
+ if (!Number.isInteger(claims.iat) || !Number.isInteger(claims.exp) || claims.exp * 1000 <= now ||
45
+ claims.iat * 1000 > now + 5000 || claims.exp <= claims.iat || claims.exp - claims.iat > 60) return null;
46
+ return claims;
47
+ } catch { return null; }
48
+ }
49
+
50
+ function cookie(req) {
51
+ const matches = String(req.headers.cookie ?? "").split(";").map((s) => s.trim()).filter((s) => s.startsWith(`${PREVIEW_COOKIE}=`));
52
+ return matches.length === 1 ? matches[0].slice(PREVIEW_COOKIE.length + 1) : "";
53
+ }
54
+
55
+ const json = (res, status, body, extra = {}) => {
56
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...extra });
57
+ res.end(JSON.stringify(body));
58
+ };
59
+
60
+ function authPage(appOrigins, nonce) {
61
+ // Only the dedicated parent iframe can exchange a grant. Neither grant nor
62
+ // the application session token appears in a URL, referrer, or app response.
63
+ const origins = JSON.stringify(appOrigins).replaceAll("<", "\\u003c");
64
+ return `<!doctype html><meta charset="utf-8"><title>Preview access</title><script nonce="${nonce}">
65
+ const origins = ${origins};
66
+ addEventListener('message', async (event) => {
67
+ if (event.source !== parent || !origins.includes(event.origin) || event.data?.type !== 'glytch-preview-grant') return;
68
+ try {
69
+ const response = await fetch('${SESSION_PATH}', {method:'POST', credentials:'include', headers:{'content-type':'application/json'}, body:JSON.stringify({grant:event.data.grant})});
70
+ const result = await response.json();
71
+ if (!response.ok) throw Error();
72
+ const confirmed = await fetch('${SESSION_PATH}', {credentials:'include',cache:'no-store'});
73
+ if (!confirmed.ok) throw Error();
74
+ parent.postMessage({type:'glytch-preview-authorized',expiresAt:result.expiresAt}, event.origin);
75
+ } catch { parent.postMessage({type:'glytch-preview-error'}, event.origin); }
76
+ });
77
+ for (const origin of origins) parent.postMessage({type:'glytch-preview-ready'}, origin);
78
+ </script>`;
79
+ }
80
+
81
+ /** Tests inject transport, not authorization. Production always checks grants. */
82
+ export async function createMemberPreviewTunnel(origin, context, {
83
+ env = process.env, startTransport = startQuickPreviewTunnel, now = Date.now,
84
+ } = {}) {
85
+ context.signal?.throwIfAborted();
86
+ const { key, appOrigins } = previewAuthConfig(env);
87
+ for (const field of ["cloudBoardId", "workspaceId", "nodeId", "runKey"]) {
88
+ if (typeof context[field] !== "string" || !context[field]) throw new Error(`Shared preview needs ${field}.`);
89
+ }
90
+ const previewId = randomUUID();
91
+ let publicOrigin;
92
+ const expected = () => ({ aud: publicOrigin, boardId: context.cloudBoardId, workspaceId: context.workspaceId,
93
+ nodeId: context.nodeId, runKey: context.runKey, previewId });
94
+ const check = (token) => publicOrigin && verifyPreviewGrant(token, key, expected(), now());
95
+ const gateway = await createPreviewGateway({
96
+ target: origin,
97
+ authorize: async (req) => {
98
+ // A partitioned cookie is the primary browser isolation boundary. Also
99
+ // reject cross-origin writes/upgrades for browsers without CHIPS support.
100
+ const callerOrigin = req.headers.origin;
101
+ if (callerOrigin && callerOrigin !== publicOrigin) return false;
102
+ if ((!['GET', 'HEAD', 'OPTIONS'].includes(req.method) || req.headers.upgrade) && callerOrigin !== publicOrigin) return false;
103
+ const grant = check(cookie(req));
104
+ if (!grant) return false;
105
+ if (context.authorizeTarget && !await context.authorizeTarget()) return false;
106
+ // Ownership verification may take time; never forward an expired lease.
107
+ return grant.exp * 1000 > now() ? { expiresAt: grant.exp * 1000 } : false;
108
+ },
109
+ handleAuth: async (req, res) => {
110
+ const path = new URL(req.url, "http://gateway.invalid").pathname;
111
+ if (!path.startsWith("/__glytch_preview/")) return false;
112
+ if (!publicOrigin) { json(res, 503, { error: "Preview is connecting." }); return true; }
113
+ if (path === AUTH_PATH && req.method === "GET") {
114
+ const nonce = randomBytes(16).toString("base64");
115
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
116
+ "referrer-policy": "no-referrer", "x-content-type-options": "nosniff",
117
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${appOrigins.join(" ")}; base-uri 'none'; form-action 'none'` });
118
+ res.end(authPage(appOrigins, nonce)); return true;
119
+ }
120
+ if (path === SESSION_PATH && req.method === "GET") {
121
+ const claims = check(cookie(req));
122
+ json(res, claims ? 200 : 403, claims ? { expiresAt: claims.exp * 1000 } : { error: "Preview access is required." });
123
+ return true;
124
+ }
125
+ if (path !== SESSION_PATH || req.method !== "POST" || req.headers.origin !== publicOrigin ||
126
+ !String(req.headers["content-type"] ?? "").startsWith("application/json")) {
127
+ json(res, 403, { error: "Preview access is required." }); return true;
128
+ }
129
+ const chunks = []; let length = 0;
130
+ for await (const chunk of req) {
131
+ length += chunk.length;
132
+ if (length > 12_000) { json(res, 413, { error: "Grant is too large." }); return true; }
133
+ chunks.push(chunk);
134
+ }
135
+ let grant;
136
+ try { grant = JSON.parse(Buffer.concat(chunks).toString("utf8")).grant; } catch { /* refused below */ }
137
+ const claims = check(grant);
138
+ if (!claims) { json(res, 403, { error: "Preview access expired or does not match this board." }); return true; }
139
+ json(res, 200, { expiresAt: claims.exp * 1000 }, { "set-cookie":
140
+ `${PREVIEW_COOKIE}=${grant}; Path=/; Max-Age=${Math.max(0, Math.floor((claims.exp * 1000 - now()) / 1000))}; Secure; HttpOnly; SameSite=None; Partitioned` });
141
+ return true;
142
+ },
143
+ });
144
+ let transport;
145
+ try {
146
+ transport = await startTransport(gateway.origin, { bin: env.PM_CLOUDFLARED_BIN, signal: context.signal });
147
+ context.signal?.throwIfAborted();
148
+ const remote = new URL(transport.url);
149
+ if (remote.protocol !== "https:" || remote.username || remote.password ||
150
+ ["localhost", "127.0.0.1", "[::1]"].includes(remote.hostname)) throw new Error("Preview transport must supply a public HTTPS origin.");
151
+ publicOrigin = remote.origin;
152
+ const local = new URL(context.fullLocalUrl ?? context.localUrl ?? origin);
153
+ const url = `${publicOrigin}${local.pathname}${local.search}${local.hash}`;
154
+ let stopped = false;
155
+ return { previewId, url, alive: () => !stopped && (transport.alive?.() ?? true), stop: async () => {
156
+ if (stopped) return; stopped = true;
157
+ // Close the authorized gateway first even if the transport refuses to die.
158
+ await gateway.close(); await transport.stop();
159
+ } };
160
+ } catch (error) {
161
+ await gateway.close();
162
+ try { await transport?.stop(); } catch { /* gateway is already unreachable */ }
163
+ throw error;
164
+ }
165
+ }