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/auth.mjs ADDED
@@ -0,0 +1,1387 @@
1
+ // `glytch login` / `glytch logout`, and the one function everything else in
2
+ // this bridge calls to reach the hosted board (brief 30, W2a, 2026-09-11).
3
+ //
4
+ // THE RULING THIS FILE IS FOR: the local stdio MCP server is retired.
5
+ // Every agent - a session in the linked folder, a
6
+ // script, a child the runner spawns - reaches the board through the HOSTED
7
+ // endpoint from here on (https://app.viewable.one/mcp by default,
8
+ // supabase/functions/board-mcp). Two credential shapes answer that endpoint,
9
+ // per its own auth.mjs: a `pmb_` personal access token, minted by hand in
10
+ // Settings, and a `pmo_` OAuth access token, issued by supabase/functions/
11
+ // board-oauth. This file drives the SAME OAuth flow the endpoint already
12
+ // serves to claude.ai - discovery, dynamic client registration, PKCE, a
13
+ // loopback redirect - so a person on a laptop gets the same one-paste-one-
14
+ // button connect a phone does, without typing a token anywhere.
15
+ //
16
+ // WHAT IS STORED, and why it is more than the three fields that make the
17
+ // token useful. `~/.glytch/auth.json` (or GLYTCH_HOME) carries
18
+ // `{accessToken, refreshToken, accessExpiresAt, appUrl, mcpUrl, clientId,
19
+ // tokenEndpoint}`. `clientId` and `tokenEndpoint` are not optional extras:
20
+ // `refreshTokens` (board-oauth/oauth.mjs) needs the client's own id on every
21
+ // refresh_token grant, and re-discovering the authorization server on every
22
+ // refresh - which happens roughly hourly - would be a network round trip this
23
+ // file can avoid by writing down what login already learned. Mode 0600,
24
+ // because it is a bearer credential and the file has no second reader.
25
+ //
26
+ // THE SCRIPT DOOR. `VIEWABLE_BOARD_TOKEN` (a `pmb_` personal access token from
27
+ // Settings) bypasses this file and its expiry entirely - a script that sets
28
+ // it once needs no browser, no refresh, and no `glytch login`. hostedToken()
29
+ // checks it FIRST, so a script always gets exactly the token it set.
30
+ //
31
+ // THE SAME `pmb_` TOKEN, PERSISTED (brief 43, 2026-09-19). `glytch init
32
+ // --yes` and `glytch login`, given VIEWABLE_BOARD_TOKEN, write it into this SAME
33
+ // file as `{accessToken, personal: true, appUrl, mcpUrl}` instead of leaving
34
+ // it live only in the process that had the variable set - a daemon started
35
+ // after that shell exits inherits none of its environment, and would
36
+ // otherwise come up with nothing to reach the board with. `personal: true` is
37
+ // what tells hostedToken(), hostedStatus() and `glytch doctor` this record is
38
+ // never refreshed and never expires, ahead of the OAuth fields the rest of
39
+ // this file writes; the env var still wins outright when it is actually set,
40
+ // and the stored personal token is what a later process - unsigned in its own
41
+ // environment - falls back to before ever trying the OAuth refresh path.
42
+ //
43
+ // The runner requests the explicit `board runner` grant. Its registered name is
44
+ // display-only; the consented scope selects its longer access-token lifetime.
45
+
46
+ import { randomBytes, createHash } from "node:crypto";
47
+ import { readFile, unlink, mkdir, rm, stat } from "node:fs/promises";
48
+ import { createServer } from "node:http";
49
+ import { spawn } from "node:child_process";
50
+ import { join } from "node:path";
51
+
52
+ import { glytchHome } from "./registry.mjs";
53
+ import { writeFileAtomic } from "./atomic.mjs";
54
+ import { movedAuth } from "./retired-address.mjs";
55
+
56
+ /** The app origin every discovery document, redirect and MCP URL is relative
57
+ * to. `GLYTCH_APP_URL` overrides it (a staging deploy, a local dev server);
58
+ * production is the default so a bare `viewable login` works with nothing set. */
59
+ export const DEFAULT_APP_URL = "https://app.viewable.one";
60
+
61
+ /** The runner's display name (never an authorization credential). Kept
62
+ * here as the one place it is spelled, and cross-referenced (not imported -
63
+ * a Supabase Edge Function deploys as the files in its own directory and
64
+ * nothing may reach out of it) from board-oauth/oauth.mjs. */
65
+ export const RUNNER_CLIENT_NAME = "glytch-runner";
66
+
67
+ /** Ask explicitly for board access and control of connected computers. */
68
+ const SCOPE = "board runner";
69
+
70
+ /** Refresh if the access token is within this long of expiring. A minute is
71
+ * enough slack for the round trip a refresh itself takes; refreshing on
72
+ * every call would cost a network round trip nothing needed. */
73
+ const REFRESH_SKEW_MS = 60_000;
74
+
75
+ /** How long a person has to finish the browser flow before `login()` gives up
76
+ * and closes the loopback server. Five minutes: long enough to find the
77
+ * window and sign in, short enough that a session left in a terminal does
78
+ * not hold a listening socket open forever.
79
+ *
80
+ * IT BOUNDS THE PERSON, NOT THE MACHINE (2026-09-12 review, F2). It used to
81
+ * run until the exchange resolved, so a login whose callback arrived at 4:59
82
+ * was still on this clock while the token endpoint answered: the timer fired
83
+ * mid-exchange, `login()` rejected with "Timed out", and the exchange went on
84
+ * to write auth.json anyway - a CLI saying it failed on a machine that is
85
+ * signed in. It is cleared the moment a callback is claimed; what happens
86
+ * after that is the machine's part, bounded by EXCHANGE_TIMEOUT_MS. */
87
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
88
+
89
+ /** How long the token exchange itself may take once the person is back from
90
+ * the browser. One minute, because nothing here waits on a human any more -
91
+ * it is one HTTPS round trip - and an exchange that hangs would otherwise
92
+ * hold the loopback socket open with no clock on it at all. */
93
+ const EXCHANGE_TIMEOUT_MS = 60_000;
94
+
95
+ const authFilePath = (env = process.env) => join(glytchHome(env), "auth.json");
96
+
97
+ const trimTrailingSlash = (s) => String(s).replace(/\/+$/, "");
98
+
99
+ /** The app origin this run should use: the explicit argument, then
100
+ * `GLYTCH_APP_URL`, then the production default. */
101
+ export function appUrlFrom(explicit, env = process.env) {
102
+ const asked = typeof explicit === "string" && explicit.trim() !== "" ? explicit.trim() : undefined;
103
+ const fromEnv = typeof (env.VIEWABLE_APP_URL ?? env.GLYTCH_APP_URL) === "string" && (env.VIEWABLE_APP_URL ?? env.GLYTCH_APP_URL).trim() !== ""
104
+ ? (env.VIEWABLE_APP_URL ?? env.GLYTCH_APP_URL).trim()
105
+ : undefined;
106
+ return trimTrailingSlash(asked ?? fromEnv ?? DEFAULT_APP_URL);
107
+ }
108
+
109
+ // --------------------------------------------------------------- the file
110
+
111
+ /** The stored credential, or null when there is none (never signed in,
112
+ * `logout` ran, or the file is unreadable JSON - treated the same as absent
113
+ * rather than a crash, because a corrupt file is not this session's to fix). */
114
+ export async function readAuthFile(env = process.env) {
115
+ try {
116
+ const raw = await readFile(authFilePath(env), "utf8");
117
+ const parsed = JSON.parse(raw);
118
+ return parsed && typeof parsed === "object" && typeof parsed.accessToken === "string" ? movedAuth(parsed, DEFAULT_APP_URL) : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function writeAuthFile(data, env = process.env) {
125
+ const home = glytchHome(env);
126
+ await mkdir(home, { recursive: true });
127
+ // mode 0600 at creation; writeFileAtomic renames the temp file (created with
128
+ // this mode) into place, so the final file carries it too rather than
129
+ // whatever the previous file's mode happened to be.
130
+ await writeFileAtomic(authFilePath(env), `${JSON.stringify(data, null, 2)}\n`, "utf8", { mode: 0o600 });
131
+ }
132
+
133
+ /** Sign out: delete the stored credential. Returns whether there was one.
134
+ * Does not revoke the token at the endpoint - that is `oauth/revoke`, and a
135
+ * laptop with no network right now should still be able to forget its own
136
+ * file. */
137
+ export async function logout(env = process.env) {
138
+ // The dead-credential mark goes with it (2026-09-13). It is keyed to a
139
+ // refresh token that is about to stop existing, so leaving it behind would
140
+ // only litter; it is cleared before the credential rather than after, so a
141
+ // logout interrupted half way never leaves a mark with no auth.json to
142
+ // match it against.
143
+ await clearDeadMark(env);
144
+ try {
145
+ await unlink(authFilePath(env));
146
+ return true;
147
+ } catch (error) {
148
+ if (error?.code === "ENOENT") return false;
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ // ------------------------------------------------------------- the token
154
+
155
+ /* ONE REFRESH AT A TIME, ACROSS EVERY CALLER (2026-09-13).
156
+ *
157
+ * THE BUG THIS EXISTS FOR, because it cost a day and looked like three other
158
+ * things first. A refresh token is SINGLE-USE - board-oauth/store.mjs
159
+ * `takeRefresh` rotates it, and presenting a spent one is by definition the
160
+ * stolen-token signal, so `revokeFamily` kills the whole family including the
161
+ * token that was just minted for whoever won the race. `hostedToken` had no
162
+ * lock of any kind, and the daemon runs one watch loop PER REGISTERED FOLDER
163
+ * (multiwatch.mjs, `for (const folder of first) startFolder(folder)`, five of
164
+ * them on his machine). Both `runnerApi.post` and `hosted.callTool` recover
165
+ * from a 401 by calling back in with `forceRefresh: true`. So a single expiry
166
+ * or a single 401 put five callers through here at once, all reading the same
167
+ * `auth.json` and posting the same refresh token: one rotated, four read as
168
+ * reuse, and the family died ~0.2s after being issued.
169
+ *
170
+ * It is self-sustaining, which is why it never recovered: every caller is 401
171
+ * again immediately, forces another refresh, and kills the next family too.
172
+ * `viewable login` could not fix it either - the new family was revoked inside
173
+ * a second by the loops already spinning. Observed twice on 2026-09-13
174
+ * (02:19:54 killed the runner, 09:30:14 killed the recovery login), both
175
+ * times two rows one second apart sharing a family_id with `last_used_at`
176
+ * null on the newer one.
177
+ *
178
+ * TWO LOCKS, because there are two ways to be concurrent here. The in-process
179
+ * map covers the five watch loops, which is the case that actually bit. The
180
+ * lock DIRECTORY covers a second process - a spawned session reaching the
181
+ * board, `viewable doctor`, a second daemon - because `mkdir` is the one
182
+ * filesystem primitive that is atomic and fails EEXIST everywhere, with no
183
+ * dependency. Whoever loses either lock does NOT refresh: it waits, re-reads
184
+ * the file, and uses what the winner wrote.
185
+ *
186
+ * A revoked refresh token is not retried at poll cadence: presenting a spent
187
+ * refresh token is itself what triggers the next `revokeFamily`
188
+ * (board-oauth/oauth.mjs:684-687), so a loop that keeps presenting one holds
189
+ * the alarm down that killed its own family. See THE DEAD GATE below, which
190
+ * is what stops it. */
191
+ const refreshInFlight = new Map();
192
+
193
+ /** How long to wait for another process's refresh before giving up on the
194
+ * lock and using whatever is on disk. A refresh is one HTTPS round trip;
195
+ * ten seconds is far past the point where waiting longer helps. */
196
+ const LOCK_WAIT_MS = 10_000;
197
+
198
+ /** A lock directory older than this belonged to a process that died holding
199
+ * it. Generous against a slow network, short enough that a crash does not
200
+ * wedge a laptop until it reboots. */
201
+ const LOCK_STALE_MS = 60_000;
202
+
203
+ const LOCK_POLL_MS = 50;
204
+
205
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
206
+
207
+ /** Take the cross-process refresh lock, or null if another process holds it
208
+ * and has not finished within LOCK_WAIT_MS, or the filesystem refuses it.
209
+ * Never throws: without the cross-process lock the caller does not refresh;
210
+ * it re-reads the credential and mark, returning a usable cached token or null. */
211
+ async function takeRefreshLock(env, now) {
212
+ const path = `${authFilePath(env)}.refresh.lock`;
213
+ const deadline = now() + LOCK_WAIT_MS;
214
+ for (;;) {
215
+ try {
216
+ await mkdir(path);
217
+ return path;
218
+ } catch (error) {
219
+ if (error?.code !== "EEXIST") return null;
220
+ // Held. If the holder is long gone, clear it and try once more on the
221
+ // next turn of this loop rather than racing to recreate it here.
222
+ try {
223
+ const held = await stat(path);
224
+ if (now() - held.mtimeMs > LOCK_STALE_MS) {
225
+ await rm(path, { recursive: true, force: true });
226
+ continue;
227
+ }
228
+ } catch { /* it went away underneath us; the retry will take it */ }
229
+ if (now() >= deadline) return null;
230
+ await sleep(LOCK_POLL_MS);
231
+ }
232
+ }
233
+ }
234
+
235
+ const dropRefreshLock = async (path) => {
236
+ if (path === null) return;
237
+ try { await rm(path, { recursive: true, force: true }); } catch { /* already gone */ }
238
+ };
239
+
240
+ /* THE DEAD GATE (2026-09-13): a credential that is DEAD, not merely stale.
241
+ *
242
+ * STALE is the case this file was already good at: the access token has aged
243
+ * past its hour (or twelve), the refresh token is fine, and one round trip
244
+ * fixes it invisibly. That path is untouched.
245
+ *
246
+ * DEAD is a different fact. The endpoint has told us, in so many words, that
247
+ * this credential cannot be renewed - `invalid_grant` from a refresh token
248
+ * that was revoked, rotated or has expired, or `invalid_client` from a client
249
+ * registration that no longer exists (board-oauth/oauth.mjs:626-694). No
250
+ * number of retries fixes it and only a person can: `viewable login`.
251
+ *
252
+ * Retrying it at poll cadence is actively harmful rather than merely noisy,
253
+ * which is why this is a gate and not a log line. `revokeFamily` updates every
254
+ * row sharing a family_id, access tokens included (board-oauth/store.mjs:114-
255
+ * 123), so a family killed by one reuse takes the access token with it; and
256
+ * presenting a spent refresh token is itself what triggers the next
257
+ * `revokeFamily`. A loop that keeps presenting one is holding the alarm down.
258
+ *
259
+ * IT COOPERATES WITH THE REFRESH LOCK ABOVE RATHER THAN DUPLICATING IT. The
260
+ * lock's job is "only one of us refreshes at a time"; this gate's job is
261
+ * "none of us refreshes at all for a while". It is checked BEFORE the
262
+ * in-flight map, so a dead credential never takes the lock, never queues
263
+ * behind a refresh, and costs nothing. Permanent refresh refusals never
264
+ * expire; transient failures use a timed backoff shared by every process.
265
+ *
266
+ * IT LIVES ON DISK, beside auth.json and for the same reason the lock is a
267
+ * directory: `viewable doctor`, a spawned session and the daemon are three
268
+ * processes, and a mark only one of them can see would let the other two go
269
+ * on hammering while doctor reported health. It stores a SHA-256 of the
270
+ * refresh token, never the token - enough to answer "is this still the
271
+ * credential that died", with no second copy of a bearer on disk.
272
+ *
273
+ * IT CLEARS ITSELF, which is the part that matters most. `viewable login`
274
+ * writes a new refresh token; a refresh another process wins writes one too.
275
+ * Either way the fingerprint stops matching and the mark is void on the next
276
+ * read - nobody has to know it exists, and no backoff outlives the credential
277
+ * it was about. A successful refresh clears transient-only history.
278
+ * Access-token rejection history follows rotation, so a server that keeps
279
+ * rejecting new tokens cannot reset the cooldown by accepting each refresh.
280
+ * A successful refresh clears carried history older than the maximum backoff,
281
+ * allowing a recovered credential to warn and start at one minute again. */
282
+
283
+ /** First wait after a transient refresh failure or an access-token rejection. */
284
+ const DEAD_BACKOFF_START_MS = 60_000;
285
+
286
+ /** Cap transient retries at one per half hour. Permanent refresh refusals
287
+ * do not retry until login replaces the credential. */
288
+ const DEAD_BACKOFF_MAX_MS = 30 * 60_000;
289
+
290
+ /** The OAuth error codes that mean "a person must sign in again", per RFC
291
+ * 6749 section 5.2 and what board-oauth actually answers. Everything else -
292
+ * a 5xx, a timeout, a proxy returning HTML, an unreadable body - is the
293
+ * network having a bad minute: back off, while still offering a cached
294
+ * access token that has not literally expired. */
295
+ const TERMINAL_REFRESH_ERRORS = new Set([
296
+ "invalid_grant",
297
+ "invalid_client",
298
+ "unauthorized_client",
299
+ "invalid_scope",
300
+ ]);
301
+
302
+ /** The one sentence a person gets. Not a log to read, not a stack: what is
303
+ * wrong and the command that fixes it, in one line, wherever it surfaces -
304
+ * the runner's log, a tool result, or `viewable doctor`'s own row. */
305
+ export const REAUTH_REQUIRED_HINT =
306
+ "Your Viewable sign-in has expired and cannot be renewed: run `viewable login` to sign in again.";
307
+
308
+ /** The personal-token sibling of REAUTH_REQUIRED_HINT (2026-09-19, brief 43
309
+ * review, B2). A `pmb_` token is not refreshed - it is revoked from
310
+ * Settings - so the fix is not `viewable login`, it is a fresh token copied
311
+ * from the same place the old one came from. */
312
+ export const PERSONAL_TOKEN_REJECTED_HINT =
313
+ "The stored personal token was rejected by the board: copy a fresh one from Settings and set VIEWABLE_BOARD_TOKEN, " +
314
+ "or run `viewable login` again.";
315
+
316
+ /** The OAuth-pair mark: keyed by the refresh token, same path and shape this
317
+ * file has always used. Every plain (non-`personal`) record's mark lives
318
+ * here, and so does a merged record's OAuth-side mark. */
319
+ const deadMarkPath = (env = process.env) => `${authFilePath(env)}.dead`;
320
+
321
+ /** The personal-token mark, keyed by the `pmb_` access token itself
322
+ * (2026-09-19, TWO CREDENTIALS below). A SEPARATE FILE, not a second field
323
+ * in the same mark, so the two never overwrite each other and so every
324
+ * existing reader of `auth.json.dead` (doctor, the test suite) keeps seeing
325
+ * exactly the OAuth-pair mark it always did. */
326
+ const personalDeadMarkPath = (env = process.env) => `${authFilePath(env)}.dead.personal`;
327
+
328
+ const markPathFor = (kind, env) => (kind === "personal" ? personalDeadMarkPath(env) : deadMarkPath(env));
329
+
330
+ const credentialFingerprint = (token) =>
331
+ createHash("sha256").update(String(token)).digest("hex");
332
+
333
+ /** Whether a merged record's OAuth pair is actually usable - present AND
334
+ * complete enough to refresh with. Named once because `hostedToken`,
335
+ * `reauthRequired` and `hostedStatus` all ask it. */
336
+ const hasOAuthPair = (stored) =>
337
+ typeof stored?.refreshToken === "string" && stored.refreshToken !== "" &&
338
+ typeof stored?.tokenEndpoint === "string" && typeof stored?.clientId === "string";
339
+
340
+ /** TWO CREDENTIALS, TWO MARKS (2026-09-19). A merged record - `personal:
341
+ * true` written by `login()` over an existing OAuth credential, which keeps
342
+ * the OAuth pair alongside the personal token rather than discarding it
343
+ * (B7) - carries two DISTINCT bearers an endpoint can reject independently:
344
+ * the `pmb_` personal token, and the OAuth pair behind its refresh token.
345
+ * Fingerprinting the mark against whichever one the caller means, rather
346
+ * than always preferring the refresh token, is what the rest of this file
347
+ * is for.
348
+ *
349
+ * THE BUG THIS REPLACES. A single mark, fingerprinted by
350
+ * `fingerprintSubject` (refresh token whenever one was on file), meant that
351
+ * on a merged record a REJECTED PERSONAL TOKEN wrote a mark keyed by the
352
+ * REFRESH TOKEN. `hostedToken`'s `personal: true` branch read that same
353
+ * mark, saw it live, and fell through to `oauthAccessToken(...,
354
+ * forceRefresh: true)` on every single call - not just the first one after
355
+ * the rejection - which rotated the refresh token every poll. Worse, a
356
+ * successful refresh cleared the one mark that existed, so the very next
357
+ * poll offered the revoked personal token again, got rejected again, and
358
+ * the whole cycle repeated at poll cadence forever: a rotation burned every
359
+ * cycle, `PERSONAL_TOKEN_REJECTED_HINT` never settling.
360
+ *
361
+ * `subjectFor("personal", stored)` is the `pmb_` token itself (only when
362
+ * `stored.personal === true` - a plain OAuth record has no personal
363
+ * credential to mark). `subjectFor("oauth", stored)` is the refresh token,
364
+ * with a fallback to the access token for the rare pre-brief-43 record that
365
+ * has neither `personal` nor a refresh token (the ordinary case this file
366
+ * already handled before merged records existed). */
367
+ const subjectFor = (kind, stored) => {
368
+ if (!stored) return null;
369
+ if (kind === "personal") {
370
+ return stored.personal === true && typeof stored.accessToken === "string" && stored.accessToken !== ""
371
+ ? stored.accessToken
372
+ : null;
373
+ }
374
+ if (typeof stored.refreshToken === "string" && stored.refreshToken !== "") return stored.refreshToken;
375
+ return stored.personal !== true && typeof stored.accessToken === "string" && stored.accessToken !== ""
376
+ ? stored.accessToken
377
+ : null;
378
+ };
379
+
380
+ /** Say it once, to stderr, and never again for the same credential - the
381
+ * `notified` flag on the mark is what makes it once rather than once per
382
+ * call. Injectable so the suite can read it without a captured stream. */
383
+ const writeToStderr = (message) => {
384
+ try { process.stderr.write(`${message}\n`); } catch { /* no stderr to write to */ }
385
+ };
386
+
387
+ /** The mark on file for THIS credential of THIS kind, or null. `kind` is
388
+ * `"personal"` or `"oauth"` (default) - see subjectFor and markPathFor.
389
+ * Null also when the mark describes a different token than the one on file
390
+ * now, in which case it is deleted on the way past: that is the
391
+ * self-clearing half, and it is why signing in again needs no other step
392
+ * (a rotated refresh token voids the oauth mark; a fresh personal token
393
+ * voids the personal one - independently, since each lives in its own
394
+ * file). Transient cooldowns share this storage but are hidden from
395
+ * status/reauth readers unless explicitly requested. */
396
+ async function matchingDeadMark(stored, env, kind = "oauth", includeTransient = false) {
397
+ const subject = subjectFor(kind, stored);
398
+ if (subject === null) return null;
399
+ let mark;
400
+ try {
401
+ mark = JSON.parse(await readFile(markPathFor(kind, env), "utf8"));
402
+ } catch {
403
+ return null;
404
+ }
405
+ if (!mark || typeof mark !== "object" || typeof mark.fingerprint !== "string") return null;
406
+ if (mark.fingerprint !== credentialFingerprint(subject)) {
407
+ await clearDeadMark(env, kind);
408
+ return null;
409
+ }
410
+ return !includeTransient && mark.reason === "refresh_transient" ? null : mark;
411
+ }
412
+
413
+ /** Clears one mark (`kind` given) or both (omitted - `logout()`'s case: the
414
+ * whole record is going away, personal and OAuth marks alike). */
415
+ async function clearDeadMark(env, kind) {
416
+ const paths = kind ? [markPathFor(kind, env)] : [deadMarkPath(env), personalDeadMarkPath(env)];
417
+ await Promise.all(paths.map(async (path) => {
418
+ try { await unlink(path); } catch { /* already gone */ }
419
+ }));
420
+ }
421
+
422
+ /** Record a credential failure: permanent for refresh refusals, otherwise
423
+ * double the wait. `prior` is the mark already on file for the same credential, so a
424
+ * refusal that keeps repeating backs further off instead of resetting to a
425
+ * minute each time. Never throws: a home directory that refuses the mark
426
+ * falls back to the old behaviour rather than turning a refused token into
427
+ * a crash. */
428
+ async function markCredentialDead(stored, env, now, reason, prior, notify, kind = "oauth") {
429
+ // A recovered credential may still carry history when its next refresh
430
+ // fails. Expire that history here too, before deriving the new failure.
431
+ if (prior && prior.reason !== "refresh_refused" && now() - Number(prior.until) > DEAD_BACKOFF_MAX_MS) {
432
+ prior = null;
433
+ }
434
+ const keepRefreshWait = reason === "rejected" && prior?.reason === "refresh_transient" && now() < Number(prior.until);
435
+ const tries = Number(prior?.tries ?? 0) + (keepRefreshWait ? 0 : 1);
436
+ const wait = Math.min(DEAD_BACKOFF_START_MS * 2 ** (tries - 1), DEAD_BACKOFF_MAX_MS);
437
+ const alreadyToldThem = prior?.notified === true;
438
+ const mark = {
439
+ fingerprint: credentialFingerprint(subjectFor(kind, stored)),
440
+ reason,
441
+ tries,
442
+ since: prior?.since ?? new Date(now()).toISOString(),
443
+ until: reason === "refresh_refused" ? null : keepRefreshWait ? prior.until : now() + wait,
444
+ notified: alreadyToldThem || reason !== "refresh_transient",
445
+ };
446
+ try {
447
+ await mkdir(glytchHome(env), { recursive: true });
448
+ await writeFileAtomic(markPathFor(kind, env), `${JSON.stringify(mark, null, 2)}\n`, "utf8", { mode: 0o600 });
449
+ } catch { /* see above: the mark is an optimisation, not a precondition */ }
450
+ // The personal token gets its own sentence (B2): it is rejected, not
451
+ // expired, and the fix is a fresh token from Settings, not `glytch login`.
452
+ if (reason !== "refresh_transient" && !alreadyToldThem) {
453
+ (notify ?? writeToStderr)(kind === "personal" ? PERSONAL_TOKEN_REJECTED_HINT : REAUTH_REQUIRED_HINT);
454
+ }
455
+ return mark;
456
+ }
457
+
458
+ /** Whether the credential on file is one the endpoint has refused - the
459
+ * question `viewable doctor` asks, and the one a caller asks before deciding
460
+ * between "you were never signed in" and REAUTH_REQUIRED_HINT. Costs one
461
+ * small file read and no network, so it keeps the promise hostedStatus
462
+ * already makes. Deliberately ignores `until`: that bounds when the next
463
+ * access-token probe is allowed. A permanent refresh refusal never probes.
464
+ *
465
+ * `viewable login` IS THE FIX FOR THE OAUTH PAIR, NEVER FOR THE PERSONAL TOKEN
466
+ * (2026-09-19). A merged record with a dead personal token but a live OAuth
467
+ * pair is not asking for `viewable login` - it still has a working credential,
468
+ * and PERSONAL_TOKEN_REJECTED_HINT already said what to do about the other
469
+ * one. So this checks the OAuth mark whenever there IS an OAuth pair to fall
470
+ * back on, and only checks the personal mark when there is nothing else -
471
+ * exactly `hostedToken`'s own branching, below. */
472
+ export async function reauthRequired(env = process.env) {
473
+ const stored = await readAuthFile(env);
474
+ if (stored === null) return false;
475
+ if (stored.personal === true && !hasOAuthPair(stored)) {
476
+ return (await matchingDeadMark(stored, env, "personal")) !== null;
477
+ }
478
+ return (await matchingDeadMark(stored, env, "oauth")) !== null;
479
+ }
480
+
481
+ /** A 401 the endpoint gave us on a token a forced refresh could not fix -
482
+ * called by lib/hosted.mjs and lib/runnerApi.mjs, which are where that fact
483
+ * is actually observed. It is separate evidence from a refused REFRESH: a
484
+ * revoked family answers 401 on the access token while the copy on disk is
485
+ * still minutes from its local expiry, so without this the gate would let a
486
+ * loop keep posting a token the server has already rejected.
487
+ *
488
+ * `token` IS THE BEARER THAT WAS ACTUALLY SENT (2026-09-19) - the caller's
489
+ * own record of what went out on the wire, not a guess re-derived from
490
+ * `auth.json` after the fact. That matters on a merged record: the personal
491
+ * token and the OAuth access token are two different bearers, and only the
492
+ * caller knows which one this particular 401 was about. Omitting `token`
493
+ * (kept for the handful of direct test calls this file's own suite makes) is
494
+ * read as "the record's own primary credential" - the personal token for a
495
+ * `personal: true` record, the OAuth pair otherwise - which is right for a
496
+ * merged record's personal-token rejection but WOULD mismark an OAuth-pair
497
+ * rejection on one, so every real caller now passes `token`. */
498
+ export async function noteCredentialRejected({ env = process.env, now = () => Date.now(), notify, token } = {}) {
499
+ const stored = await readAuthFile(env);
500
+ if (stored === null) return false;
501
+ const kind = rejectedKind(stored, token);
502
+ if (kind === null) return false;
503
+ // The forced refresh ran after this access-token rejection. Preserve its
504
+ // retry count and deadline while also suppressing the rejected access token.
505
+ const prior = await matchingDeadMark(stored, env, kind, true);
506
+ if (prior?.reason !== "refresh_refused") {
507
+ await markCredentialDead(stored, env, now, "rejected", prior, notify, kind);
508
+ }
509
+ return true;
510
+ }
511
+
512
+ /** Which credential a 401 was about: the caller's own `token` when it says
513
+ * so, matched against the two bearers a merged record can carry
514
+ * (`stored.accessToken`, the personal token, and `stored.oauthAccessToken`,
515
+ * the cached OAuth access token - see `hostedToken`'s `fields` below); for
516
+ * a plain OAuth record, its single `accessToken` is always the OAuth one.
517
+ * Returns null when `token` matches neither - stale by the time the 401
518
+ * came back, most likely because another process already refreshed - since
519
+ * there is nothing on file to mark it against. */
520
+ function rejectedKind(stored, token) {
521
+ if (typeof token === "string" && token !== "") {
522
+ if (stored.personal === true && token === stored.accessToken) return "personal";
523
+ if (stored.personal === true) return typeof stored.oauthAccessToken === "string" && token === stored.oauthAccessToken ? "oauth" : null;
524
+ return token === stored.accessToken ? "oauth" : null;
525
+ }
526
+ if (stored.personal === true) return "personal";
527
+ if (typeof stored.refreshToken === "string" && stored.refreshToken !== "") return "oauth";
528
+ return null;
529
+ }
530
+
531
+ /** A valid access token, or null when there is genuinely none to be had.
532
+ *
533
+ * `VIEWABLE_BOARD_TOKEN` wins outright: a script that sets a personal access
534
+ * token means exactly that token, forever, with no file and no refresh.
535
+ *
536
+ * Otherwise, the stored OAuth token is returned as-is unless it is within a
537
+ * minute of expiring (or `forceRefresh` asks anyway), in which case a refresh
538
+ * is attempted and the file is rewritten with the new pair. A refresh that
539
+ * fails transiently falls back to the token on file if it has not
540
+ * literally expired yet - a network blip should not turn a live token into a
541
+ * refusal - and only returns null once there is truly nothing left to offer.
542
+ *
543
+ * Concurrent callers do not each refresh; see the block above. */
544
+ export async function hostedToken({
545
+ env = process.env,
546
+ forceRefresh = false,
547
+ fetchImpl = fetch,
548
+ now = () => Date.now(),
549
+ notify,
550
+ log = () => {},
551
+ } = {}) {
552
+ // Compatibility read: old installs may still set GLYTCH_BOARD_TOKEN (#2196).
553
+ const scripted = typeof (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN) === "string" ? (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN).trim() : "";
554
+ if (scripted !== "") return scripted;
555
+
556
+ const stored = await readAuthFile(env);
557
+ if (!stored) return null;
558
+
559
+ // A STORED PERSONAL TOKEN (brief 43, 2026-09-19) IS NOT AN OAUTH RECORD -
560
+ // unless `login()` has merged an OAuth pair alongside it (B7, below).
561
+ // `login()` writes `personal: true` when it sees VIEWABLE_BOARD_TOKEN in ITS
562
+ // OWN environment - the paste-and-run install sets the variable for one
563
+ // shell, and the daemon that keeps running after that shell is gone needs
564
+ // the same bearer without the variable. On its own the personal token has
565
+ // no `accessExpiresAt` to compare against (it would read as expired, being
566
+ // `undefined`), no `refreshToken` to spend, and no refresh attempt - a
567
+ // `pmb_` token is revoked from Settings, not refreshed.
568
+ //
569
+ // IT STILL GOES THROUGH THE DEAD GATE (2026-09-19, brief 43 review, B2).
570
+ // There is no refresh to fail here, but there is still a 401 a caller can
571
+ // observe and report through `noteCredentialRejected` - and before this fix
572
+ // that call never wrote a mark for a personal record at all. `subjectFor`
573
+ // now fingerprints a personal record's OWN mark against the access token
574
+ // itself, in its own file, so a personal-only record gets a mark of its
575
+ // own no matter what else is (or is not) on the record beside it.
576
+ if (stored.personal === true) {
577
+ const oauthPair = hasOAuthPair(stored);
578
+ // PERSONAL-TOKEN DEAD GATE. Checked against the personal mark ONLY -
579
+ // 2026-09-19, the fix for the loop described at subjectFor's own
580
+ // comment. A rejected personal token must stop being offered for the
581
+ // WHOLE backoff window regardless of what the OAuth pair beside it is
582
+ // doing, and an OAuth-pair rejection (case b, below) must never silence
583
+ // the personal token - they are two credentials, and this gate only
584
+ // ever asks about one of them.
585
+ const personalDead = await matchingDeadMark(stored, env, "personal");
586
+ const personalIsDead = personalDead !== null && now() < Number(personalDead.until);
587
+ if (!personalIsDead) return stored.accessToken;
588
+ if (!oauthPair) return null;
589
+ // FALL THROUGH TO THE OAUTH PAIR (2026-09-19, brief 43 review, B7;
590
+ // 2026-09-19 fix). `login()` with VIEWABLE_BOARD_TOKEN over an existing
591
+ // OAuth credential keeps both in the same record, so a personal token
592
+ // with a live dead mark falls through to the OAuth pair rather than
593
+ // handing back null for the whole backoff window while a working
594
+ // credential sits right beside the refused one - but it is an ORDINARY
595
+ // refresh here, not `forceRefresh: true`: the merged record's cached
596
+ // OAuth access token lives in its OWN fields (`oauthAccessToken`,
597
+ // `oauthAccessExpiresAt`, below), never in `accessToken`/
598
+ // `accessExpiresAt` (those describe the personal token and only the
599
+ // personal token, so nothing here has to guess which credential a
600
+ // shared field is about). Once that OAuth access token is fresh, N
601
+ // more polls with the personal mark still live read it straight off
602
+ // disk - no refresh, no rotation - until the caller's own `forceRefresh`
603
+ // or a real 401 on the OAuth side asks for one.
604
+ return oauthAccessToken(stored, {
605
+ env, fetchImpl, now, notify, forceRefresh, log,
606
+ fields: { token: "oauthAccessToken", expires: "oauthAccessExpiresAt" },
607
+ });
608
+ }
609
+
610
+ // THE DEAD GATE, and it sits BEFORE the freshness check on purpose - see the
611
+ // block above. Local freshness is a claim about a clock; the mark is what
612
+ // the endpoint actually said. They disagree in exactly the case that hurts:
613
+ // `revokeFamily` kills the access token alongside the refresh token
614
+ // (board-oauth/store.mjs:114-123), so a revoked credential goes on LOOKING
615
+ // fresh here for the rest of its nominal life - up to twelve hours for the
616
+ // runner's own client. Gated after the freshness check, the one caller shape
617
+ // that actually loops would have been the one shape that still hammered.
618
+ return oauthAccessToken(stored, { env, fetchImpl, now, notify, forceRefresh, log });
619
+ }
620
+
621
+ /** The freshness check, the single-flight refresh and its lock - the part of
622
+ * `hostedToken` shared by an ordinary OAuth record and a merged
623
+ * `personal: true` record that has fallen through to its OAuth pair (B7).
624
+ *
625
+ * `fields` says WHERE the OAuth access token and its expiry live on the
626
+ * record - `{token: "accessToken", expires: "accessExpiresAt"}` (the
627
+ * default) for a plain OAuth record, where that pair IS the whole
628
+ * credential; `{token: "oauthAccessToken", expires: "oauthAccessExpiresAt"}`
629
+ * for a merged record falling through from its personal token, where
630
+ * `accessToken`/`accessExpiresAt` are the PERSONAL token's own fields and
631
+ * must never be read or overwritten here (2026-09-19 fix - this is the
632
+ * answer to "does `accessExpiresAt` on a merged record describe the OAuth
633
+ * access token or the personal one": it is always the personal one on a
634
+ * merged record, which is exactly why the OAuth side gets its own two
635
+ * fields rather than sharing). The dead mark this function writes on a
636
+ * terminal refusal is always the `"oauth"` kind - refusing to renew the
637
+ * refresh token is a fact about the OAuth pair regardless of which field
638
+ * shape asked for the refresh. */
639
+ async function oauthAccessToken(stored, {
640
+ env, forceRefresh, fetchImpl, now, notify,
641
+ fields = { token: "accessToken", expires: "accessExpiresAt" },
642
+ log = () => {},
643
+ }) {
644
+ // Both ordinary credentials and a personal-token fallback obey this gate.
645
+ // Older finite refusals get one re-check after their cooldown.
646
+ const dead = await matchingDeadMark(stored, env, "oauth", true);
647
+ if (dead?.reason === "refresh_refused" && dead.until === null) return null;
648
+ if (dead !== null && now() < Number(dead.until)) {
649
+ const t = stored[fields.token];
650
+ return dead.reason === "refresh_transient" && typeof t === "string" && t !== "" &&
651
+ Number(stored[fields.expires]) > now() ? t : null;
652
+ }
653
+
654
+ const fresh = (row) => {
655
+ const t = row[fields.token];
656
+ return typeof t === "string" && t !== "" && Number(row[fields.expires]) - now() >= REFRESH_SKEW_MS;
657
+ };
658
+ if (!forceRefresh && fresh(stored)) return stored[fields.token];
659
+
660
+ if (!stored.refreshToken || !stored.tokenEndpoint || !stored.clientId) {
661
+ // Nothing to refresh WITH. Still usable if it has not actually expired.
662
+ const t = stored[fields.token];
663
+ return typeof t === "string" && t !== "" && Number(stored[fields.expires]) > now() ? t : null;
664
+ }
665
+
666
+ // Keyed by the auth file rather than by process: a test with its own
667
+ // GLYTCH_HOME, and a person with two, are genuinely separate credentials
668
+ // and must not queue behind each other. A given file only ever uses ONE
669
+ // `fields` shape at a time (a record is either personal or is not), so
670
+ // the path alone is still the right key.
671
+ const key = authFilePath(env);
672
+ const running = refreshInFlight.get(key);
673
+ if (running) return running;
674
+
675
+ const attempt = (async () => {
676
+ const lock = await takeRefreshLock(env, now);
677
+ let source = stored;
678
+ try {
679
+ // DOUBLE-CHECKED, and the check is "did the token CHANGE", not "is it
680
+ // fresh now". A 401 forces a refresh on a token that may still be
681
+ // minutes from expiry, so freshness would send us to the endpoint with
682
+ // the very token the winner already spent. A different access token on
683
+ // disk means somebody refreshed while we waited, and their result is
684
+ // the answer to both questions.
685
+ const current = await readAuthFile(env);
686
+ if (current && current[fields.token] !== stored[fields.token]) return current[fields.token];
687
+ source = current ?? stored;
688
+ // A process ahead of us may have recorded a failure while we waited.
689
+ const mark = await matchingDeadMark(source, env, "oauth", true);
690
+ if (mark?.reason === "refresh_refused" && mark.until === null) return null;
691
+ if (lock === null || (mark !== null && now() < Number(mark.until))) {
692
+ const t = source[fields.token];
693
+ return (mark === null || mark.reason === "refresh_transient") && typeof t === "string" &&
694
+ t !== "" && Number(source[fields.expires]) > now() ? t : null;
695
+ }
696
+ // A REFRESH IS ABOUT TO HAPPEN OVER THE NETWORK (2026-09-21, the
697
+ // claim-to-spawn gap). Every path above this line returns early -
698
+ // nothing on file to refresh, already fresh, another process just
699
+ // refreshed - so reaching here means an HTTPS round trip is about to
700
+ // block whoever called `hostedToken`, silently until now.
701
+ const refreshStartedAt = now();
702
+ const refreshed = await refreshStoredTokens(source, { fetchImpl, now });
703
+ log(`refreshed hosted token (${Math.round(now() - refreshStartedAt)}ms)`);
704
+ const updated = {
705
+ ...source,
706
+ [fields.token]: refreshed.accessToken,
707
+ // Rotation: a refresh token is single-use (board-oauth's own rule),
708
+ // shared by both field shapes because there is only ever one
709
+ // refresh token on a record no matter which access-token field it
710
+ // is renewing.
711
+ refreshToken: refreshed.refreshToken,
712
+ [fields.expires]: refreshed.accessExpiresAt,
713
+ };
714
+ await writeAuthFile(updated, env);
715
+ // The new access token can be tried, but a successful refresh is not
716
+ // proof that the board will accept it. Carry rejection history across
717
+ // rotation as an expired transient mark: no gate or sign-in warning,
718
+ // while the next 401 continues the backoff instead of resetting it.
719
+ // There is no board-acceptance hook here. A successful refresh after
720
+ // the carried deadline has been quiet for more than the maximum wait
721
+ // resets history, so a recovered outage cannot poison future failures.
722
+ // An active 401 loop retries at its deadline and keeps its history.
723
+ // Ordinary transient-only recovery still clears its history.
724
+ // The PERSONAL mark, if any, is untouched - a working OAuth
725
+ // fallback does not un-reject the personal token that got rejected;
726
+ // see hostedToken's own comment for why offering it again is the bug
727
+ // this whole file exists to fix.
728
+ if (mark?.notified === true && mark.reason !== "refresh_refused" &&
729
+ now() - Number(mark.until) <= DEAD_BACKOFF_MAX_MS) {
730
+ try {
731
+ await writeFileAtomic(deadMarkPath(env), `${JSON.stringify({
732
+ ...mark, fingerprint: credentialFingerprint(updated.refreshToken),
733
+ reason: "refresh_transient", until: now(),
734
+ }, null, 2)}\n`, "utf8", { mode: 0o600 });
735
+ } catch { /* history is best-effort; the new credential is already saved */ }
736
+ } else {
737
+ await clearDeadMark(env, "oauth");
738
+ }
739
+ return updated[fields.token];
740
+ } catch (error) {
741
+ const current = await readAuthFile(env);
742
+ // A login during the request must not inherit the old token's failure.
743
+ if (current && current.refreshToken !== source.refreshToken) return null;
744
+ const prior = await matchingDeadMark(source, env, "oauth", true);
745
+ await markCredentialDead(source, env, now,
746
+ error?.terminal === true ? "refresh_refused" : "refresh_transient",
747
+ prior, notify, "oauth");
748
+ if (error?.terminal === true) return null;
749
+ const t = source[fields.token];
750
+ return typeof t === "string" && t !== "" && Number(source[fields.expires]) > now() ? t : null;
751
+ } finally {
752
+ await dropRefreshLock(lock);
753
+ }
754
+ })();
755
+
756
+ refreshInFlight.set(key, attempt);
757
+ try {
758
+ return await attempt;
759
+ } finally {
760
+ refreshInFlight.delete(key);
761
+ }
762
+ }
763
+
764
+ /** The bare refresh_token grant: posts it, and returns just the new pair
765
+ * (never the rest of `stored`) so the caller decides which fields on the
766
+ * full record it belongs in - `accessToken`/`accessExpiresAt` for a plain
767
+ * OAuth record, `oauthAccessToken`/`oauthAccessExpiresAt` for a merged
768
+ * one's fallback (see oauthAccessToken's `fields`). */
769
+ async function refreshStoredTokens(stored, { fetchImpl, now }) {
770
+ const response = await fetchImpl(stored.tokenEndpoint, {
771
+ method: "POST",
772
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
773
+ body: new URLSearchParams({
774
+ grant_type: "refresh_token",
775
+ refresh_token: stored.refreshToken,
776
+ client_id: stored.clientId,
777
+ }),
778
+ });
779
+ const text = await response.text();
780
+ let body;
781
+ try {
782
+ body = JSON.parse(text);
783
+ } catch {
784
+ throw new Error(`refresh: the token endpoint answered ${response.status} with unreadable JSON`);
785
+ }
786
+ if (!response.ok || typeof body.access_token !== "string") {
787
+ const failure = new Error(`refresh: ${body.error_description ?? body.error ?? `HTTP ${response.status}`}`);
788
+ // TERMINAL vs TRANSIENT, and the OAuth error code is the only honest way
789
+ // to tell them apart. `invalid_grant` means this refresh token will never
790
+ // work again; a 503 or a dropped connection means try later. Marked on
791
+ // the error rather than decided here, because the caller is the one
792
+ // holding the refresh lock and the one that has to record it.
793
+ if (TERMINAL_REFRESH_ERRORS.has(body.error)) failure.terminal = true;
794
+ throw failure;
795
+ }
796
+ return {
797
+ accessToken: body.access_token,
798
+ // Rotation: a refresh token is single-use (board-oauth's own rule), so the
799
+ // new one replaces the old one. A server that omits it (should not
800
+ // happen) leaves the prior one in place rather than dropping the ability
801
+ // to refresh again.
802
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : stored.refreshToken,
803
+ accessExpiresAt: now() + Math.max(0, Number(body.expires_in) || 0) * 1000,
804
+ };
805
+ }
806
+
807
+ /** Where the child's own header points: the MCP url and the app origin it was
808
+ * derived from. Reads the stored file when there is one (login already
809
+ * discovered the real mcpUrl); otherwise derives the default shape from
810
+ * `GLYTCH_APP_URL` / the production origin, same as a fresh login would
811
+ * before ever calling the endpoint. */
812
+ export async function hostedConfig({ env = process.env } = {}) {
813
+ const stored = await readAuthFile(env);
814
+ if (stored && typeof stored.mcpUrl === "string" && typeof stored.appUrl === "string") {
815
+ return { appUrl: stored.appUrl, mcpUrl: stored.mcpUrl };
816
+ }
817
+ const appUrl = appUrlFrom(undefined, env);
818
+ return { appUrl, mcpUrl: `${appUrl}/mcp` };
819
+ }
820
+
821
+ /** Cheap, no-network status for `viewable doctor`'s one row: is there a
822
+ * credential on file (or a script token in the environment) at all, and is
823
+ * it one the endpoint has already refused. Does NOT refresh or validate
824
+ * against the endpoint - doctor rows are meant to be read constantly, and a
825
+ * network call per `doctor` run for one row is not the bargain the rest of
826
+ * doctor keeps.
827
+ *
828
+ * `dead` JOINED IT 2026-09-13, and it is the reason the row was misleading
829
+ * before. "Signed in" used to mean no more than "auth.json exists", so the
830
+ * one row a person checks when their runner cannot authenticate read green
831
+ * on exactly the machine that could not authenticate. The dead-credential
832
+ * mark is written next to auth.json by whichever process hit the refusal
833
+ * (the daemon, usually), so doctor can report it from another process
834
+ * without a network call of its own. */
835
+ export async function hostedStatus(env = process.env) {
836
+ // Compatibility read: old installs may still set GLYTCH_BOARD_TOKEN (#2196).
837
+ if (typeof (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN) === "string" && (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN).trim() !== "") {
838
+ return { signedIn: true, via: "VIEWABLE_BOARD_TOKEN" };
839
+ }
840
+ const stored = await readAuthFile(env);
841
+ if (stored === null) return { signedIn: false, via: null };
842
+ // A stored personal token (brief 43) DOES have a dead mark to check, since
843
+ // 2026-09-19's B2 fix - it is a distinct `via` so `glytch doctor` can word
844
+ // this row unlike the OAuth case ("rejected" rather than "expired and
845
+ // cannot be renewed": a `pmb_` token is revoked from Settings, never
846
+ // refreshed, so the fix is a fresh one from there, not `glytch login`).
847
+ //
848
+ // TWO MARKS TO CHECK ON A MERGED RECORD (2026-09-19 fix): the personal
849
+ // token's own mark surfaces first - it is the credential this row is
850
+ // named after (`via: "personal token"`), and a live one is worth flagging
851
+ // even while the OAuth pair carries the actual traffic. Only when the
852
+ // personal token is NOT what's dead does the row fall back to the
853
+ // OAuth-pair mark, so a merged record whose fallback has itself run dry
854
+ // still reads as `dead: true` rather than silently green.
855
+ if (stored.personal === true) {
856
+ const personalMark = await matchingDeadMark(stored, env, "personal");
857
+ const mark = personalMark ?? (hasOAuthPair(stored) ? await matchingDeadMark(stored, env, "oauth") : null);
858
+ return {
859
+ signedIn: true,
860
+ via: "personal token",
861
+ ...(mark !== null ? { dead: true, deadSince: mark.since, deadReason: mark.reason } : {}),
862
+ };
863
+ }
864
+ const mark = await matchingDeadMark(stored, env);
865
+ return {
866
+ signedIn: true,
867
+ via: "viewable login",
868
+ ...(mark !== null ? { dead: true, deadSince: mark.since, deadReason: mark.reason } : {}),
869
+ };
870
+ }
871
+
872
+ // ---------------------------------------------------------------- login
873
+
874
+ const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
875
+
876
+ async function fetchJson(fetchImpl, url, init) {
877
+ const response = await fetchImpl(url, init);
878
+ const text = await response.text();
879
+ let body;
880
+ try {
881
+ body = JSON.parse(text);
882
+ } catch {
883
+ throw new Error(`${url}: answered ${response.status} with unreadable JSON: ${text.slice(0, 200)}`);
884
+ }
885
+ if (!response.ok) {
886
+ throw new Error(`${url}: ${body.error_description ?? body.error ?? `HTTP ${response.status}`}`);
887
+ }
888
+ return body;
889
+ }
890
+
891
+ /** RFC 9728: what resource a token from this app is actually issued FOR, and
892
+ * which authorization server issues it. The app's own origin is NOT the
893
+ * answer - board-oauth's RESOURCE is server config (index.ts's OAUTH_ISSUER
894
+ * / OAUTH_RESOURCE), not derived from the request - so an app running on
895
+ * `http://localhost:5273` (GLYTCH_APP_URL pointed at a dev server mirroring
896
+ * routes.json's rewrites) still proxies this exact document to the SAME
897
+ * deployed board-oauth function, which names the PRODUCTION resource
898
+ * regardless of what origin asked. Tried as `/mcp` first - RFC 9728's
899
+ * per-resource path, and the one routes.json actually rewrites - then the
900
+ * bare well-known path; a deploy with neither (older server, before this
901
+ * document existed) falls back to the old guess, `${base}/mcp`, and says so,
902
+ * rather than failing a login outright.
903
+ *
904
+ * THE BUG THIS REPLACES: `login()` used to derive the RFC 8707 `resource` as
905
+ * `${base}/mcp` unconditionally, so pointing it at a local dev server asked
906
+ * for `http://localhost:5273/mcp` - a resource board-oauth was never
907
+ * configured to issue tokens for - and the authorize step refused it outright
908
+ * ("This server issues tokens for https://prompt.glytch.ai/mcp only."). The
909
+ * resource a token is FOR is whatever the protected-resource metadata names,
910
+ * never the app origin a person happened to paste. */
911
+ async function discoverResource(fetchImpl, base, log) {
912
+ for (const path of ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"]) {
913
+ try {
914
+ const response = await fetchImpl(`${base}${path}`);
915
+ if (!response.ok) continue;
916
+ const body = JSON.parse(await response.text());
917
+ if (typeof body.resource !== "string" || body.resource === "") continue;
918
+ const authorizationServerBase =
919
+ Array.isArray(body.authorization_servers) && typeof body.authorization_servers[0] === "string"
920
+ ? trimTrailingSlash(body.authorization_servers[0])
921
+ : base;
922
+ return { resource: body.resource, authorizationServerBase };
923
+ } catch {
924
+ // Try the next path, or fall through to the guess below.
925
+ }
926
+ }
927
+ log(
928
+ `${base}: no protected-resource metadata found there (an older deploy?) - guessing ${base}/mcp as the ` +
929
+ `resource. If sign-in is refused, the app's real resource lives elsewhere.`,
930
+ );
931
+ return { resource: `${base}/mcp`, authorizationServerBase: base };
932
+ }
933
+
934
+ /** Open a URL in the person's default browser. Best-effort and silent on
935
+ * failure - the caller always prints the URL too, which is the fallback that
936
+ * matters on a headless box or an unfamiliar platform. */
937
+ export function openInBrowser(url) {
938
+ try {
939
+ const platform = process.platform;
940
+ const [cmd, args] =
941
+ platform === "darwin"
942
+ ? ["open", [url]]
943
+ : platform === "win32"
944
+ ? ["cmd", ["/c", "start", "", url]]
945
+ : ["xdg-open", [url]];
946
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
947
+ child.on("error", () => {});
948
+ child.unref();
949
+ return true;
950
+ } catch {
951
+ return false;
952
+ }
953
+ }
954
+
955
+ /** Where the loopback callback sends the browser once it is done: the app's
956
+ * own `/connected` page (brief 31 follow-up, 2026-09-12 - the user's ruling
957
+ * from a screenshot of the bare browser-default page a bearer code used to
958
+ * sit in the address bar of). `client=glytch` is what lets `/connected`
959
+ * address "Glytch is signed in on this machine" rather than something
960
+ * generic; `errorCode`, when given, is a short machine word the page turns
961
+ * into a sentence - never the OAuth error text itself, and never the code. */
962
+ function connectedUrl(appUrl, errorCode) {
963
+ const target = new URL(`${trimTrailingSlash(appUrl)}/connected`);
964
+ target.searchParams.set("client", "glytch");
965
+ if (errorCode) target.searchParams.set("error", errorCode);
966
+ return target.toString();
967
+ }
968
+
969
+ /** Wait for exactly one GET to `/callback` on this server. Unlike the old
970
+ * inline-HTML page, this one exchanges the code and writes the credential
971
+ * (via `exchange`) *before* it answers, because the answer - always a 302 to
972
+ * `${appUrl}/connected` - has to reflect the real outcome: `?client=glytch`
973
+ * alone on success, plus `&error=<state_mismatch|missing_code|denied|
974
+ * exchange_failed>` on a failure. The authorization code is exchanged for a
975
+ * token and then discarded; it is never placed in the Location this browser
976
+ * is sent to. A minimal text/plain body rides behind the redirect for a
977
+ * client that does not follow it. Resolves with what `exchange` returned, or
978
+ * rejects with the same messages `login()` always threw for these cases; a
979
+ * timeout or a server error does the same. The server is closed either way.
980
+ *
981
+ * EXACTLY ONE CALLBACK RUNS THE FLOW (2026-09-12 review, F1). A browser does
982
+ * not promise to request a redirect target once: a reload, a back button or
983
+ * an address-bar prefetch can GET `/callback` again with the SAME single-use
984
+ * authorization code. This listener used to run `handle` for every one of
985
+ * them, so a second exchange of a code the endpoint has already burned raced
986
+ * the first - and whichever lost decided what the CLI printed, while the
987
+ * winner had already written auth.json. So the first callback that passes the
988
+ * state and code checks CLAIMS the flow: `claimed` is set synchronously,
989
+ * before the first `await`, and every later request is answered out of what
990
+ * the claim produced - the same `/connected` target once the outcome is
991
+ * known, one plain line while it is still in flight - and never reaches
992
+ * `exchange` at all. */
993
+ function waitForCallback(
994
+ server,
995
+ { timeoutMs = LOGIN_TIMEOUT_MS, exchangeTimeoutMs = EXCHANGE_TIMEOUT_MS, appUrl, expectedState, exchange },
996
+ ) {
997
+ return new Promise((resolve, reject) => {
998
+ let settled = false;
999
+ // THE CLAIM (F1): set synchronously by the first callback that passes the
1000
+ // checks, so a second request sees it before any await can yield.
1001
+ let claimed = false;
1002
+ // What the claiming request answered with, so a later one is told the
1003
+ // same story about the same sign-in. `answered` true with `answeredError`
1004
+ // undefined is the success answer.
1005
+ let answered = false;
1006
+ let answeredError;
1007
+ const finish = (fn, value) => {
1008
+ if (settled) return;
1009
+ settled = true;
1010
+ clearTimeout(timer);
1011
+ server.close();
1012
+ fn(value);
1013
+ };
1014
+ const timer = setTimeout(
1015
+ () => finish(reject, new Error("Timed out waiting for the browser sign-in to finish.")),
1016
+ timeoutMs,
1017
+ );
1018
+ if (timer.unref) timer.unref();
1019
+ server.on("error", (error) => finish(reject, error));
1020
+
1021
+ const answer = (res, errorCode) => {
1022
+ answered = true;
1023
+ answeredError = errorCode;
1024
+ res.writeHead(302, {
1025
+ Location: connectedUrl(appUrl, errorCode),
1026
+ "Content-Type": "text/plain; charset=utf-8",
1027
+ });
1028
+ res.end(
1029
+ errorCode
1030
+ ? "Sign-in did not complete. You can close this tab and go back to your terminal."
1031
+ : "Signed in to Viewable. You can close this tab and go back to your terminal.",
1032
+ );
1033
+ };
1034
+
1035
+ /** A reload, a prefetch or a second tab arriving once the flow is
1036
+ * claimed: it gets the claiming request's own answer and nothing else
1037
+ * happens. While the exchange is still in flight there is no outcome to
1038
+ * redirect to yet, so this one says so in a line rather than sending the
1039
+ * browser to a `/connected` page that would have to guess. */
1040
+ const alreadyHandled = (res) => {
1041
+ if (answered) {
1042
+ answer(res, answeredError);
1043
+ return;
1044
+ }
1045
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1046
+ res.end("This sign-in is already being completed. You can close this tab and go back to your terminal.");
1047
+ };
1048
+
1049
+ async function handle(params, res) {
1050
+ if (params.get("error")) {
1051
+ const description = params.get("error_description") ?? params.get("error");
1052
+ answer(res, "denied");
1053
+ finish(reject, new Error(`Sign-in was not approved: ${description}`));
1054
+ return;
1055
+ }
1056
+ if (params.get("state") !== expectedState) {
1057
+ answer(res, "state_mismatch");
1058
+ finish(reject, new Error("The sign-in reply's state did not match. Try again."));
1059
+ return;
1060
+ }
1061
+ const code = params.get("code");
1062
+ if (!code) {
1063
+ answer(res, "missing_code");
1064
+ finish(reject, new Error("The sign-in reply carried no authorization code."));
1065
+ return;
1066
+ }
1067
+ // CLAIMED, synchronously, ahead of the await below: from here on this
1068
+ // request owns the flow and no other one may exchange this code.
1069
+ claimed = true;
1070
+ // F2: the browser part is done - the person came back - so the clock
1071
+ // that bounded it comes off HERE rather than after the exchange, and
1072
+ // the exchange runs on a shorter one of its own.
1073
+ clearTimeout(timer);
1074
+ const exchangeTimer = setTimeout(
1075
+ () => finish(reject, new Error("The sign-in code could not be exchanged in time. Try `viewable login` again.")),
1076
+ exchangeTimeoutMs,
1077
+ );
1078
+ if (exchangeTimer.unref) exchangeTimer.unref();
1079
+ try {
1080
+ // `stillWanted` is how the caller's own exchange knows this flow is
1081
+ // still the live one: nothing may be written to disk after `finish`
1082
+ // has already rejected (see login's exchange below).
1083
+ const stored = await exchange(code, { stillWanted: () => !settled });
1084
+ clearTimeout(exchangeTimer);
1085
+ if (settled) {
1086
+ // The bound above already answered the CLI. The browser is still
1087
+ // waiting for something, and it is not a success.
1088
+ answer(res, "exchange_failed");
1089
+ return;
1090
+ }
1091
+ answer(res);
1092
+ finish(resolve, stored);
1093
+ } catch (error) {
1094
+ clearTimeout(exchangeTimer);
1095
+ answer(res, "exchange_failed");
1096
+ finish(reject, error);
1097
+ }
1098
+ }
1099
+
1100
+ server.on("request", (req, res) => {
1101
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1102
+ if (url.pathname !== "/callback") {
1103
+ res.writeHead(404).end();
1104
+ return;
1105
+ }
1106
+ // `settled` covers the checks that fail and finish synchronously (a
1107
+ // denied consent, a bad state, no code at all): those never claim, but
1108
+ // their outcome is already decided and a second request must not
1109
+ // re-decide it. `claimed` covers the one that is mid-exchange.
1110
+ if (claimed || settled) {
1111
+ alreadyHandled(res);
1112
+ return;
1113
+ }
1114
+ void handle(url.searchParams, res);
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ /** The whole OAuth 2.1 + PKCE + Dynamic Client Registration flow, driven
1120
+ * against the SAME endpoints board-oauth serves to claude.ai
1121
+ * (supabase/functions/board-oauth/README.md has the wire-level sequence).
1122
+ * Returns the stored credential on success; writes it to `~/.glytch/auth.json`
1123
+ * (or GLYTCH_HOME) before returning.
1124
+ *
1125
+ * `openUrl` and `fetchImpl` are injected so the test drives the whole flow -
1126
+ * discovery, registration, the loopback redirect, the token exchange - against
1127
+ * a stub, with no real browser and no real network. */
1128
+ export async function login({
1129
+ appUrl,
1130
+ env = process.env,
1131
+ openUrl = openInBrowser,
1132
+ fetchImpl = fetch,
1133
+ log = () => {},
1134
+ now = () => Date.now(),
1135
+ // The two clocks, injected for the same reason everything else here is:
1136
+ // the test proves what a timer firing at the wrong moment does without
1137
+ // waiting five real minutes for one.
1138
+ timeoutMs = LOGIN_TIMEOUT_MS,
1139
+ exchangeTimeoutMs = EXCHANGE_TIMEOUT_MS,
1140
+ } = {}) {
1141
+ // THE SCRIPT DOOR PERSISTS ITSELF (brief 43, 2026-09-19, "the install,
1142
+ // optimised"). `VIEWABLE_BOARD_TOKEN` already answers every board call with
1143
+ // no file at all - hostedToken() checks it first, above - but that is true
1144
+ // only for THIS process's environment. The paste-and-run install sets the
1145
+ // variable for exactly the one shell that runs `glytch init --yes`, and the
1146
+ // daemon `init` goes on to start (or the one already watching this folder)
1147
+ // is a SEPARATE process with no such variable of its own: unsigned the
1148
+ // moment that shell exits, unless the token lands on disk here. So `login()`
1149
+ // - and therefore `init`'s own sign-in step, which calls this - writes it as
1150
+ // a STORED PERSONAL TOKEN (see hostedToken's `personal: true` branch above)
1151
+ // the instant it sees the variable, opens no browser, waits on nobody, and
1152
+ // returns.
1153
+ // Compatibility read: old installs may still set GLYTCH_BOARD_TOKEN (#2196).
1154
+ const scripted = typeof (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN) === "string" ? (env.VIEWABLE_BOARD_TOKEN ?? env.GLYTCH_BOARD_TOKEN).trim() : "";
1155
+ if (scripted !== "") {
1156
+ const base = appUrlFrom(appUrl, env);
1157
+ // KEEP THE OAUTH PAIR, DO NOT OVERWRITE IT WHOLE (2026-09-19, brief 43
1158
+ // review, B7). This used to write `{accessToken, personal: true, appUrl,
1159
+ // mcpUrl}` unconditionally, so a machine already signed in with `glytch
1160
+ // login` and then run once with VIEWABLE_BOARD_TOKEN set (a CI box, an
1161
+ // install script re-run) lost its refresh token outright - the only
1162
+ // thing left once the personal token was later revoked was
1163
+ // REAUTH_REQUIRED_HINT with nothing to refresh. An existing record's
1164
+ // `refreshToken`, `clientId` and `tokenEndpoint` now ride along under the
1165
+ // new personal token; `hostedToken`'s own `personal: true` branch prefers
1166
+ // this OAuth pair once the personal token gets a live dead mark (B7's
1167
+ // other half).
1168
+ //
1169
+ // THE OAUTH ACCESS TOKEN MOVES INTO ITS OWN FIELDS, NOT JUST THE REFRESH
1170
+ // TOKEN (2026-09-19 fix). B7 above kept `refreshToken` alive but left the
1171
+ // still-valid OAuth ACCESS token to be overwritten by `accessToken:
1172
+ // scripted` with no home to move to, and left the old OAuth
1173
+ // `accessExpiresAt` sitting under the new personal token's `accessToken`
1174
+ // - a timestamp that describes a credential no longer in that field. The
1175
+ // merged record's `personal: true` branch in `hostedToken` reads the
1176
+ // OAuth pair from `oauthAccessToken`/`oauthAccessExpiresAt` (added by the
1177
+ // dead-mark fix, same day), never from `accessToken`/`accessExpiresAt`
1178
+ // once `personal: true` - those describe the personal token and only the
1179
+ // personal token. So the still-live OAuth access token has to land there
1180
+ // BEFORE the personal token takes over `accessToken`, or the fallback
1181
+ // finds nothing cached and spends a refresh rotation on a credential that
1182
+ // was already fresh on disk.
1183
+ //
1184
+ // `existing` MAY ALREADY BE A MERGED RECORD (a second `VIEWABLE_BOARD_TOKEN`
1185
+ // login over one written by an earlier such login): its `accessToken` is
1186
+ // already the personal token, not the OAuth one, so moving it into
1187
+ // `oauthAccessToken` here would clobber a live OAuth access token with a
1188
+ // stale personal one. Only a record that was NOT already `personal: true`
1189
+ // has an OAuth access token sitting in the shared `accessToken` field.
1190
+ //
1191
+ // A personal token has no expiry of its own to compare against
1192
+ // (`hostedToken`'s `personal: true` branch never reads `accessExpiresAt`
1193
+ // for it - see that branch's own comment), so `accessExpiresAt` is
1194
+ // cleared rather than left describing the credential it no longer names.
1195
+ const existing = await readAuthFile(env);
1196
+ const keepsOAuth = typeof existing?.refreshToken === "string" && existing.refreshToken !== "";
1197
+ const result = keepsOAuth
1198
+ ? {
1199
+ ...existing,
1200
+ accessToken: scripted,
1201
+ accessExpiresAt: undefined,
1202
+ personal: true,
1203
+ appUrl: base,
1204
+ mcpUrl: `${base}/mcp`,
1205
+ oauthAccessToken: existing.personal === true ? existing.oauthAccessToken : existing.accessToken,
1206
+ oauthAccessExpiresAt: existing.personal === true ? existing.oauthAccessExpiresAt : existing.accessExpiresAt,
1207
+ }
1208
+ : { accessToken: scripted, personal: true, appUrl: base, mcpUrl: `${base}/mcp` };
1209
+ await writeAuthFile(result, env);
1210
+ log(
1211
+ keepsOAuth
1212
+ ? "VIEWABLE_BOARD_TOKEN is set - saved it to ~/.glytch/auth.json as a personal access token, keeping the " +
1213
+ "OAuth sign-in already there beside it. No browser needed."
1214
+ : "VIEWABLE_BOARD_TOKEN is set - saved it to ~/.glytch/auth.json as a personal access token. No browser needed.",
1215
+ );
1216
+ return result;
1217
+ }
1218
+
1219
+ const base = appUrlFrom(appUrl, env);
1220
+
1221
+ // 1. RESOURCE DISCOVERY (RFC 9728). What this login is FOR, and where its
1222
+ // authorization server actually lives - see discoverResource's own comment
1223
+ // for why `${base}/mcp` was never a safe guess. `mcpUrl` is stored as this
1224
+ // resource, so board calls afterwards go to the endpoint the token is
1225
+ // actually valid for; `appUrl` stays `base`, the configured app, for
1226
+ // `/connected` and for the next discovery.
1227
+ const { resource, authorizationServerBase } = await discoverResource(fetchImpl, base, log);
1228
+ const mcpUrl = resource;
1229
+
1230
+ // 2. AUTHORIZATION-SERVER DISCOVERY (RFC 8414), against the server the
1231
+ // resource document named - not necessarily `base`: a local dev app's
1232
+ // protected-resource document still names the PRODUCTION authorization
1233
+ // server, and that is who has to answer this. Board-mcp's own 401 points a
1234
+ // bare bearer through this same two-hop chain (resource_metadata ->
1235
+ // authorization_servers[0] -> this document).
1236
+ const metadata = await fetchJson(fetchImpl, `${authorizationServerBase}/.well-known/oauth-authorization-server`);
1237
+ const { authorization_endpoint, token_endpoint, registration_endpoint } = metadata;
1238
+ if (!authorization_endpoint || !token_endpoint || !registration_endpoint) {
1239
+ throw new Error(`${base}: the authorization-server document is missing an endpoint.`);
1240
+ }
1241
+
1242
+ // 3. THE LOOPBACK SERVER, bound to 127.0.0.1 only (never 0.0.0.0 - this is
1243
+ // where the authorization code lands, and it should not be reachable from
1244
+ // anywhere but this machine), on a random port so two logins never collide.
1245
+ const server = createServer();
1246
+ await new Promise((resolve, reject) => {
1247
+ server.once("error", reject);
1248
+ server.listen(0, "127.0.0.1", resolve);
1249
+ });
1250
+ const port = server.address().port;
1251
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1252
+
1253
+ try {
1254
+ // 4. DYNAMIC CLIENT REGISTRATION (RFC 7591). Public client - no secret,
1255
+ // PKCE is the whole proof - registered under RUNNER_CLIENT_NAME so
1256
+ // board-oauth's issueTokens recognises this as the bridge's own client and
1257
+ // gives it the longer access-token lifetime a long-running spawned session
1258
+ // needs (see the file header).
1259
+ const registration = await fetchJson(fetchImpl, registration_endpoint, {
1260
+ method: "POST",
1261
+ headers: { "Content-Type": "application/json" },
1262
+ body: JSON.stringify({
1263
+ client_name: RUNNER_CLIENT_NAME,
1264
+ redirect_uris: [redirectUri],
1265
+ grant_types: ["authorization_code", "refresh_token"],
1266
+ response_types: ["code"],
1267
+ token_endpoint_auth_method: "none",
1268
+ }),
1269
+ });
1270
+ const clientId = registration.client_id;
1271
+ if (typeof clientId !== "string" || clientId === "") {
1272
+ throw new Error(`${registration_endpoint}: registration did not return a client_id.`);
1273
+ }
1274
+
1275
+ // 5. PKCE. 32 random bytes base64url-encoded is 43 characters, exactly the
1276
+ // floor of the S256 verifier length board-oauth's oauth.mjs requires
1277
+ // (43-128, RFC 7636); the challenge is the same encoding of its sha256.
1278
+ const verifier = b64url(randomBytes(32));
1279
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
1280
+ const state = b64url(randomBytes(16));
1281
+
1282
+ const authorizeUrl = new URL(authorization_endpoint);
1283
+ authorizeUrl.searchParams.set("response_type", "code");
1284
+ authorizeUrl.searchParams.set("client_id", clientId);
1285
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
1286
+ authorizeUrl.searchParams.set("code_challenge", challenge);
1287
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
1288
+ authorizeUrl.searchParams.set("state", state);
1289
+ authorizeUrl.searchParams.set("scope", SCOPE);
1290
+ // RFC 8707: names the resource this token is FOR - discovered above, not
1291
+ // guessed from `base` - matched by board-oauth against its own configured
1292
+ // RESOURCE.
1293
+ authorizeUrl.searchParams.set("resource", new URL("/functions/v1/board-runner", mcpUrl).toString());
1294
+
1295
+ log(`Open this URL to sign in to Viewable:\n\n ${authorizeUrl.toString()}\n`);
1296
+ const opened = openUrl(authorizeUrl.toString());
1297
+ if (!opened) log("Could not open a browser automatically - paste the URL above into one.");
1298
+
1299
+ // 6. WAIT FOR THE REDIRECT. The state check, the code check, the 7.
1300
+ // EXCHANGE (form-encoded, per RFC 6749 - the same shape board-oauth's
1301
+ // readParams accepts from every real client) and writing the credential
1302
+ // all happen inside `exchange`, called from within waitForCallback -
1303
+ // because the loopback's own answer to the browser is a redirect to
1304
+ // `/connected` that has to reflect whichever of those actually happened,
1305
+ // and by the time this call returns the browser already has it.
1306
+ const stored = await waitForCallback(server, {
1307
+ appUrl: base,
1308
+ expectedState: state,
1309
+ timeoutMs,
1310
+ exchangeTimeoutMs,
1311
+ exchange: async (code, { stillWanted = () => true } = {}) => {
1312
+ const tokenResponse = await fetchJson(fetchImpl, token_endpoint, {
1313
+ method: "POST",
1314
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1315
+ body: new URLSearchParams({
1316
+ grant_type: "authorization_code",
1317
+ code,
1318
+ redirect_uri: redirectUri,
1319
+ client_id: clientId,
1320
+ code_verifier: verifier,
1321
+ // RFC 8707 again, at the token step: the same resource named on
1322
+ // the authorize URL, not re-derived from `base`.
1323
+ resource: new URL("/functions/v1/board-runner", mcpUrl).toString(),
1324
+ }),
1325
+ });
1326
+ if (typeof tokenResponse.access_token !== "string") {
1327
+ throw new Error(`${token_endpoint}: no access_token in the response.`);
1328
+ }
1329
+ const freshOAuth = {
1330
+ refreshToken: typeof tokenResponse.refresh_token === "string" ? tokenResponse.refresh_token : null,
1331
+ appUrl: base,
1332
+ mcpUrl,
1333
+ clientId,
1334
+ tokenEndpoint: token_endpoint,
1335
+ };
1336
+ const freshAccessExpiresAt = now() + Math.max(0, Number(tokenResponse.expires_in) || 0) * 1000;
1337
+ // THE REVERSE MERGE (2026-09-19 fix). `glytch login` over an existing
1338
+ // STORED PERSONAL TOKEN (`VIEWABLE_BOARD_TOKEN`, brief 43) must keep
1339
+ // that token rather than discarding it - the same shape the other
1340
+ // direction (B7, above) keeps: `accessToken`/`personal: true` name
1341
+ // the personal credential, `oauthAccessToken`/`oauthAccessExpiresAt`
1342
+ // name this fresh OAuth pair's access token, and `refreshToken` /
1343
+ // `clientId` / `tokenEndpoint` sit at the top level either way. A
1344
+ // personal record with no OAuth pair yet reads `existing.personal`
1345
+ // straight; one already merged (a second `glytch login` refreshing
1346
+ // the OAuth side of a record `VIEWABLE_BOARD_TOKEN` already merged
1347
+ // into) hits the same branch, since it is still `personal: true`.
1348
+ const existing = await readAuthFile(env);
1349
+ const keepsPersonal = existing?.personal === true && typeof existing.accessToken === "string" && existing.accessToken !== "";
1350
+ const result = keepsPersonal
1351
+ ? {
1352
+ ...existing,
1353
+ ...freshOAuth,
1354
+ personal: true,
1355
+ accessToken: existing.accessToken,
1356
+ accessExpiresAt: existing.accessExpiresAt,
1357
+ oauthAccessToken: tokenResponse.access_token,
1358
+ oauthAccessExpiresAt: freshAccessExpiresAt,
1359
+ }
1360
+ : { ...freshOAuth, accessToken: tokenResponse.access_token, accessExpiresAt: freshAccessExpiresAt };
1361
+ // NOTHING LANDS ON DISK AFTER THIS LOGIN HAS ALREADY FAILED (F2). A
1362
+ // bound that fired, or a server error, rejects the caller while this
1363
+ // round trip is still in the air; writing the credential afterwards
1364
+ // would leave a machine signed in by a command that said it was not.
1365
+ if (!stillWanted()) {
1366
+ throw new Error("The sign-in finished after this login had already given up. Run `viewable login` again.");
1367
+ }
1368
+ await writeAuthFile(result, env);
1369
+ // THE HUMAN ACT THE DEAD MARK ASKS FOR HAS HAPPENED (2026-09-13), so
1370
+ // the OAuth mark goes, unconditionally - a new grant means this
1371
+ // credential is no longer the one any live mark describes. The
1372
+ // PERSONAL mark, if this was a reverse merge, is left alone: the
1373
+ // personal token kept alongside was not replaced by this sign-in, so
1374
+ // whatever `PERSONAL_TOKEN_REJECTED_HINT` already said about it still
1375
+ // stands (2026-09-19 fix - see `keepsPersonal` above).
1376
+ await clearDeadMark(env, keepsPersonal ? "oauth" : undefined);
1377
+ return result;
1378
+ },
1379
+ });
1380
+ return stored;
1381
+ } finally {
1382
+ // waitForCallback already closes the server on its own path; this covers
1383
+ // every earlier throw (discovery, registration, PKCE) where it never got
1384
+ // that far.
1385
+ server.close();
1386
+ }
1387
+ }