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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|