viewable-one 0.0.0-stage → 0.12.2

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