unfazed 0.6.0 → 0.6.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.
@@ -0,0 +1,596 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `npx unfazed login`, `whoami` and `logout`: the CLI behind the package's
4
+ * `unfazed` bin. Only `../cli.ts` loads this module — `import "unfazed"`
5
+ * never does — so it may use Node built-ins directly.
6
+ *
7
+ * Login is the OAuth 2.1 authorization code flow with PKCE against the
8
+ * platform's own authorization server (the one MCP clients sign in through):
9
+ *
10
+ * - discovery: RFC 8414 metadata at `/.well-known/oauth-authorization-server`;
11
+ * - a public client registered once per server (RFC 7591) with the loopback
12
+ * redirect `http://127.0.0.1/callback`, cached beside the login. unfazed,
13
+ * as RFC 8252 §7.3 asks, matches a loopback redirect on everything but the
14
+ * port, so every login listens on a fresh random port under one client;
15
+ * - PKCE S256 (RFC 7636), `state`, and RFC 9207's `iss` on the answer;
16
+ * - a one-shot listener on 127.0.0.1 receives the code, which is exchanged
17
+ * for an access and a refresh token, stored 0600 where the SDK reads them
18
+ * (`../credentials.ts`);
19
+ * - when the browser runs on another machine (SSH), the person pastes the
20
+ * address it ended up on — the loopback URL that cannot load there.
21
+ *
22
+ * Logout revokes (RFC 7009) when the server advertises revocation, then
23
+ * deletes the login. Every request names itself in User-Agent: the
24
+ * platform's edge refuses some generic agents.
25
+ */
26
+ import { spawn } from "node:child_process";
27
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
28
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
29
+ import { createServer } from "node:http";
30
+ import { dirname, join } from "node:path";
31
+ import { createInterface } from "node:readline";
32
+ import { DEFAULT_ENDPOINT, ENDPOINT_ENV_VAR, TOKEN_ENV_VAR, UnfazedClient, VERSION } from "../client.js";
33
+ import { LOGIN_COMMAND, credentialsDir, credentialsPath, deleteCredentials, oauthErrorText, postTokenForm, readCredentials, tokenFields, writeCredentials, } from "../credentials.js";
34
+ import { UnfazedAuthError, UnfazedError, UnfazedHTTPError } from "../errors.js";
35
+ export const USER_AGENT = `unfazed-cli/${VERSION} (+https://unfazed.dev/auth.md)`;
36
+ /** What a login asks for: reading and deploying, like an MCP client. */
37
+ export const DEFAULT_SCOPE = "unfz:read unfz:deploy";
38
+ export const CLIENT_NAME = "unfazed CLI";
39
+ export const CALLBACK_PATH = "/callback";
40
+ /** The redirect registered once; each login uses it with its own port. */
41
+ export const REGISTERED_REDIRECT_URI = `http://127.0.0.1${CALLBACK_PATH}`;
42
+ /** Registered clients, per issuer, beside the login. */
43
+ export const CLIENTS_FILE = "oauth-clients.json";
44
+ /** How long a login waits for the browser. */
45
+ export const LOGIN_TIMEOUT_MS = 10 * 60_000;
46
+ /** A login that could not be completed; the message says why and what to do. */
47
+ export class UnfazedLoginError extends UnfazedError {
48
+ }
49
+ const globalFetch = () => globalThis.fetch.bind(globalThis);
50
+ /* ------------------------------------------------------------------ OAuth */
51
+ /** The GraphQL endpoint: given, else `UNFZ_API_URL`, else unfazed.dev's; a bare origin gets `/query`. */
52
+ export function resolveEndpoint(raw, env) {
53
+ const value = (raw ?? env[ENDPOINT_ENV_VAR] ?? DEFAULT_ENDPOINT).trim().replace(/\/+$/, "");
54
+ let url;
55
+ try {
56
+ url = new URL(value);
57
+ }
58
+ catch {
59
+ throw new UnfazedLoginError(`unfazed: ${value} is not a URL. Pass the platform's address, e.g. --endpoint https://unfazed.dev`);
60
+ }
61
+ if (url.protocol !== "https:" && url.protocol !== "http:")
62
+ throw new UnfazedLoginError(`unfazed: ${value} is not an http(s) URL.`);
63
+ // As the SDK reads UNFZ_API_URL: the full /query URL, or an origin.
64
+ return /\/query$|\/graphql$/.test(value) ? value : `${value}/query`;
65
+ }
66
+ /** Whether a URL may carry tokens: https, or plain http to this machine only. */
67
+ export function safeURL(raw) {
68
+ if (typeof raw !== "string")
69
+ return false;
70
+ try {
71
+ const url = new URL(raw);
72
+ if (url.protocol === "https:")
73
+ return true;
74
+ return url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /** Reads and checks the metadata of the server at `origin` (e.g. https://unfazed.dev). */
81
+ export async function discover(origin, fetch) {
82
+ const url = `${origin}/.well-known/oauth-authorization-server`;
83
+ let status;
84
+ let text;
85
+ try {
86
+ const response = await fetch(url, { method: "GET", headers: { Accept: "application/json", "User-Agent": USER_AGENT } });
87
+ status = response.status;
88
+ text = await response.text();
89
+ }
90
+ catch (error) {
91
+ throw new UnfazedLoginError(`unfazed: could not reach ${origin} (${error.message}).`, { cause: error });
92
+ }
93
+ if (status !== 200)
94
+ throw new UnfazedLoginError(`unfazed: ${url} answered HTTP ${status}; is ${origin} an unfazed server?`);
95
+ let metadata;
96
+ try {
97
+ metadata = JSON.parse(text);
98
+ }
99
+ catch {
100
+ throw new UnfazedLoginError(`unfazed: ${url} is not JSON; is ${origin} an unfazed server?`);
101
+ }
102
+ // RFC 8414 §3.3: the issuer must be the one asked, or the metadata is not its.
103
+ if (typeof metadata.issuer !== "string" || metadata.issuer.replace(/\/+$/, "") !== origin) {
104
+ throw new UnfazedLoginError(`unfazed: ${url} names issuer ${JSON.stringify(metadata.issuer)}, not ${origin}.`);
105
+ }
106
+ for (const field of ["authorization_endpoint", "token_endpoint"]) {
107
+ if (!safeURL(metadata[field]))
108
+ throw new UnfazedLoginError(`unfazed: ${url} has no usable ${field}.`);
109
+ }
110
+ for (const field of ["registration_endpoint", "revocation_endpoint"]) {
111
+ if (metadata[field] !== undefined && !safeURL(metadata[field]))
112
+ delete metadata[field];
113
+ }
114
+ if (metadata.code_challenge_methods_supported && !metadata.code_challenge_methods_supported.includes("S256")) {
115
+ throw new UnfazedLoginError(`unfazed: ${origin} does not support PKCE S256, which the login requires.`);
116
+ }
117
+ if (metadata.response_types_supported && !metadata.response_types_supported.includes("code")) {
118
+ throw new UnfazedLoginError(`unfazed: ${origin} does not support the authorization code flow.`);
119
+ }
120
+ return { ...metadata, issuer: origin };
121
+ }
122
+ /** A PKCE verifier and its S256 challenge. */
123
+ export function pkcePair() {
124
+ const verifier = randomBytes(32).toString("base64url");
125
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
126
+ }
127
+ function clientsPath(env) {
128
+ const dir = credentialsDir(env);
129
+ return dir ? join(dir, CLIENTS_FILE) : undefined;
130
+ }
131
+ function readClients(env) {
132
+ const path = clientsPath(env);
133
+ if (!path)
134
+ return {};
135
+ try {
136
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
137
+ return parsed && typeof parsed === "object" ? parsed : {};
138
+ }
139
+ catch {
140
+ // Missing or unreadable: register again, which costs one request.
141
+ return {};
142
+ }
143
+ }
144
+ function writeClients(env, clients) {
145
+ const path = clientsPath(env);
146
+ if (!path)
147
+ return;
148
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
149
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
150
+ try {
151
+ writeFileSync(temp, JSON.stringify(clients, null, 2) + "\n", { mode: 0o600, flag: "wx" });
152
+ chmodSync(temp, 0o600);
153
+ renameSync(temp, path);
154
+ }
155
+ catch (error) {
156
+ try {
157
+ unlinkSync(temp);
158
+ }
159
+ catch {
160
+ // never created, or already renamed
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+ /** The cached client for `issuer` that may ask for `scope`, if any. */
166
+ export function cachedClient(env, issuer, scope) {
167
+ const record = readClients(env)[issuer];
168
+ if (!record || typeof record.client_id !== "string" || record.redirect_uri !== REGISTERED_REDIRECT_URI)
169
+ return undefined;
170
+ const registered = new Set(String(record.scope ?? "").split(/\s+/));
171
+ return scope.split(/\s+/).every((s) => registered.has(s)) ? record : undefined;
172
+ }
173
+ function forgetClient(env, issuer) {
174
+ const clients = readClients(env);
175
+ if (!(issuer in clients))
176
+ return;
177
+ delete clients[issuer];
178
+ writeClients(env, clients);
179
+ }
180
+ /** Registers the CLI as a public client at the server (RFC 7591) and caches it. */
181
+ async function registerClient(metadata, scope, env, fetch) {
182
+ if (!metadata.registration_endpoint) {
183
+ throw new UnfazedLoginError(`unfazed: ${metadata.issuer} offers no client registration, which the login needs.`);
184
+ }
185
+ const response = await fetch(metadata.registration_endpoint, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": USER_AGENT },
188
+ body: JSON.stringify({
189
+ client_name: CLIENT_NAME,
190
+ redirect_uris: [REGISTERED_REDIRECT_URI],
191
+ grant_types: ["authorization_code", "refresh_token"],
192
+ response_types: ["code"],
193
+ token_endpoint_auth_method: "none",
194
+ application_type: "native",
195
+ scope,
196
+ }),
197
+ });
198
+ const body = parseObject(await response.text());
199
+ if (response.status !== 201 && response.status !== 200) {
200
+ throw new UnfazedLoginError(`unfazed: ${metadata.issuer} refused to register the CLI (${oauthErrorText(response.status, body)}).`);
201
+ }
202
+ if (typeof body["client_id"] !== "string" || !body["client_id"]) {
203
+ throw new UnfazedLoginError(`unfazed: ${metadata.issuer} registered the CLI without a client_id.`);
204
+ }
205
+ const record = {
206
+ client_id: body["client_id"],
207
+ redirect_uri: REGISTERED_REDIRECT_URI,
208
+ scope: typeof body["scope"] === "string" && body["scope"] ? body["scope"] : scope,
209
+ registered_at: new Date().toISOString(),
210
+ };
211
+ const clients = readClients(env);
212
+ clients[metadata.issuer] = record;
213
+ writeClients(env, clients);
214
+ return record;
215
+ }
216
+ function authorizeURL(metadata, params) {
217
+ const url = new URL(metadata.authorization_endpoint);
218
+ url.searchParams.set("response_type", "code");
219
+ url.searchParams.set("client_id", params.clientId);
220
+ url.searchParams.set("redirect_uri", params.redirectUri);
221
+ url.searchParams.set("scope", params.scope);
222
+ url.searchParams.set("state", params.state);
223
+ url.searchParams.set("code_challenge", params.challenge);
224
+ url.searchParams.set("code_challenge_method", "S256");
225
+ return url.toString();
226
+ }
227
+ /**
228
+ * One GET of the consent page before a browser sees it. A server that forgot
229
+ * the cached client, or refuses the redirect, answers 400 here, so the CLI can
230
+ * register again or say why instead of leaving the person on an error page.
231
+ */
232
+ async function preflight(url, fetch) {
233
+ const response = await fetch(url, { method: "GET", headers: { Accept: "text/html", "User-Agent": USER_AGENT }, redirect: "manual" });
234
+ const text = await response.text();
235
+ if (response.status < 400)
236
+ return { ok: true };
237
+ return { ok: false, reason: oauthErrorText(response.status, parseObject(text)) };
238
+ }
239
+ async function exchangeCode(metadata, params, fetch) {
240
+ const { status, body } = await postTokenForm(fetch, metadata.token_endpoint, { grant_type: "authorization_code", code: params.code, redirect_uri: params.redirectUri, client_id: params.clientId, code_verifier: params.verifier }, USER_AGENT);
241
+ if (status !== 200 || typeof body["access_token"] !== "string" || !body["access_token"]) {
242
+ throw new UnfazedLoginError(`unfazed: ${metadata.issuer} did not issue tokens (${oauthErrorText(status, body)}). Run \`${LOGIN_COMMAND}\` again.`);
243
+ }
244
+ return body;
245
+ }
246
+ async function revokeToken(endpoint, params, fetch) {
247
+ try {
248
+ const { status } = await postTokenForm(fetch, endpoint, { token: params.token, token_type_hint: params.hint, client_id: params.clientId }, USER_AGENT);
249
+ return status === 200;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ }
255
+ /** Signs in through the browser and stores the login. Resolves to it and where it was stored. */
256
+ export async function login(options = {}) {
257
+ const env = options.env ?? process.env;
258
+ const fetch = options.fetch ?? globalFetch();
259
+ const log = options.log ?? ((line) => process.stderr.write(line + "\n"));
260
+ const scopes = (options.scope ?? DEFAULT_SCOPE).split(/[\s,]+/).filter(Boolean);
261
+ if (scopes.length === 0)
262
+ throw new UnfazedLoginError("unfazed: --scope names no scope.");
263
+ const scope = [...new Set(scopes)].join(" ");
264
+ const endpoint = resolveEndpoint(options.endpoint, env);
265
+ const origin = new URL(endpoint).origin;
266
+ const path = credentialsPath(env);
267
+ if (!path)
268
+ throw new UnfazedLoginError("unfazed: no home directory to store the login in (set HOME or XDG_CONFIG_HOME).");
269
+ const metadata = await discover(origin, fetch);
270
+ const listener = await listen();
271
+ try {
272
+ const redirectUri = `http://127.0.0.1:${listener.port}${CALLBACK_PATH}`;
273
+ const { verifier, challenge } = pkcePair();
274
+ const state = randomBytes(24).toString("base64url");
275
+ // A cached client the server no longer knows is registered again, once.
276
+ let cached = cachedClient(env, metadata.issuer, scope);
277
+ let client;
278
+ let url;
279
+ for (let attempt = 0;; attempt++) {
280
+ client = cached ?? (await registerClient(metadata, scope, env, fetch));
281
+ url = authorizeURL(metadata, { clientId: client.client_id, redirectUri, scope, state, challenge });
282
+ const checked = await preflight(url, fetch);
283
+ if (checked.ok)
284
+ break;
285
+ if (attempt > 0)
286
+ throw new UnfazedLoginError(`unfazed: ${metadata.issuer} refused the sign-in request (${checked.reason}).`);
287
+ forgetClient(env, metadata.issuer);
288
+ cached = undefined;
289
+ }
290
+ if (await options.openBrowser?.(url)) {
291
+ log(`Opening your browser to sign in to ${origin}.\nIf it does not open, visit:\n\n ${url}\n`);
292
+ }
293
+ else {
294
+ log(`Open this URL in a browser to sign in to ${origin}:\n\n ${url}\n`);
295
+ }
296
+ if (options.input) {
297
+ log(`Browser on another machine? Sign in there, then paste the address it ends up on\n` +
298
+ `(http://127.0.0.1:${listener.port}${CALLBACK_PATH}?…, which will not load there) here and press Enter.`);
299
+ }
300
+ log("Waiting for the sign-in…");
301
+ const callback = await waitForCallback(listener, state, options.input, options.timeoutMs ?? LOGIN_TIMEOUT_MS, log);
302
+ try {
303
+ const credentials = await finish(callback.params, { metadata, client, endpoint, redirectUri, verifier, fetch });
304
+ writeCredentials(path, credentials);
305
+ callback.respond?.(200, page("Signed in to unfazed", `as <b>${escapeHTML(accountName(credentials))}</b>. You can close this tab and return to the terminal.`));
306
+ return { credentials, path };
307
+ }
308
+ catch (error) {
309
+ callback.respond?.(400, page("Sign-in failed", `${escapeHTML(error.message)}<br>Return to the terminal.`));
310
+ throw error;
311
+ }
312
+ }
313
+ finally {
314
+ listener.close();
315
+ }
316
+ }
317
+ /** Checks the answer, exchanges the code, and asks the platform who signed in. */
318
+ async function finish(params, ctx) {
319
+ const error = params.get("error");
320
+ if (error) {
321
+ const description = params.get("error_description");
322
+ throw new UnfazedLoginError(`unfazed: the sign-in was not completed (${description ? `${error}: ${description}` : error}).`);
323
+ }
324
+ // RFC 9207: a server that says it sends `iss` must send its own, which
325
+ // rules out a code from another server mixed into this flow.
326
+ const iss = params.get("iss");
327
+ if ((iss !== null || ctx.metadata.authorization_response_iss_parameter_supported) && iss?.replace(/\/+$/, "") !== ctx.metadata.issuer) {
328
+ throw new UnfazedLoginError(`unfazed: the sign-in answer came from ${JSON.stringify(iss)}, not ${ctx.metadata.issuer}.`);
329
+ }
330
+ const code = params.get("code");
331
+ if (!code)
332
+ throw new UnfazedLoginError("unfazed: the sign-in answer carries no code.");
333
+ const tokens = await exchangeCode(ctx.metadata, { code, redirectUri: ctx.redirectUri, clientId: ctx.client.client_id, verifier: ctx.verifier }, ctx.fetch);
334
+ const actor = await whoamiWith(new UnfazedClient({ token: tokens.access_token, endpoint: ctx.endpoint, login: false, userAgent: USER_AGENT, fetch: ctx.fetch }));
335
+ const now = new Date().toISOString();
336
+ return {
337
+ version: 1,
338
+ endpoint: ctx.endpoint,
339
+ issuer: ctx.metadata.issuer,
340
+ token_endpoint: ctx.metadata.token_endpoint,
341
+ ...(ctx.metadata.revocation_endpoint ? { revocation_endpoint: ctx.metadata.revocation_endpoint } : {}),
342
+ client_id: ctx.client.client_id,
343
+ ...tokenFields(tokens),
344
+ account: { kind: actor.kind, user_id: actor.userID, name: actor.name },
345
+ created_at: now,
346
+ updated_at: now,
347
+ };
348
+ }
349
+ async function whoamiWith(client) {
350
+ const data = await client.query("query UnfazedCliWhoami { whoami { kind userID name scopes } }");
351
+ return data.whoami;
352
+ }
353
+ /** Who the SDK acts as here: `UNFZ_API_TOKEN` when set, else the stored login (refreshed as needed). */
354
+ export async function whoami(options = {}) {
355
+ const env = options.env ?? process.env;
356
+ const client = new UnfazedClient({ env, userAgent: USER_AGENT, ...(options.fetch ? { fetch: options.fetch } : {}) });
357
+ let actor;
358
+ try {
359
+ actor = await whoamiWith(client);
360
+ }
361
+ catch (error) {
362
+ if (error instanceof UnfazedHTTPError && error.status === 401) {
363
+ throw new UnfazedAuthError(client.authSource === "token"
364
+ ? `unfazed: the platform refused ${TOKEN_ENV_VAR}. Unset it to use your \`${LOGIN_COMMAND}\`.`
365
+ : `unfazed: the platform refused the stored login. Run \`${LOGIN_COMMAND}\` again.`, { cause: error });
366
+ }
367
+ throw error;
368
+ }
369
+ const path = client.authSource === "login" ? credentialsPath(env) : undefined;
370
+ return { actor, endpoint: client.endpoint, source: client.authSource, ...(path ? { path } : {}) };
371
+ }
372
+ /**
373
+ * Revokes the stored tokens when the server advertises revocation, then
374
+ * deletes the login. The endpoint is read from the server's metadata again,
375
+ * so one the server gained since the login is used.
376
+ */
377
+ export async function logout(options = {}) {
378
+ const env = options.env ?? process.env;
379
+ const fetch = options.fetch ?? globalFetch();
380
+ const none = { signedOut: false, revoked: false, revocationSupported: false };
381
+ const path = credentialsPath(env);
382
+ if (!path)
383
+ return none;
384
+ let credentials;
385
+ try {
386
+ credentials = readCredentials(path);
387
+ }
388
+ catch {
389
+ // An unreadable login is removed all the same.
390
+ return { ...none, signedOut: deleteCredentials(path) };
391
+ }
392
+ if (!credentials)
393
+ return none;
394
+ let endpoint = credentials.revocation_endpoint;
395
+ try {
396
+ endpoint = (await discover(credentials.issuer, fetch)).revocation_endpoint;
397
+ }
398
+ catch {
399
+ // Offline or gone: what the login recorded.
400
+ }
401
+ let revoked = false;
402
+ if (endpoint) {
403
+ const results = [];
404
+ if (credentials.refresh_token) {
405
+ results.push(await revokeToken(endpoint, { token: credentials.refresh_token, hint: "refresh_token", clientId: credentials.client_id }, fetch));
406
+ }
407
+ results.push(await revokeToken(endpoint, { token: credentials.access_token, hint: "access_token", clientId: credentials.client_id }, fetch));
408
+ revoked = results.every(Boolean);
409
+ }
410
+ deleteCredentials(path);
411
+ return { signedOut: true, issuer: credentials.issuer, revoked, revocationSupported: Boolean(endpoint) };
412
+ }
413
+ export function accountName(credentials) {
414
+ return credentials.account?.name || credentials.account?.user_id || "your account";
415
+ }
416
+ /* ---------------------------------------------------------------- browser */
417
+ /**
418
+ * Whether a browser can be expected here. Over SSH, or on Linux without a
419
+ * display, the login prints the URL instead (and takes the pasted answer).
420
+ */
421
+ export function canOpenBrowser(env, platform = process.platform) {
422
+ const custom = env["BROWSER"]?.trim();
423
+ if (custom)
424
+ return custom !== "none";
425
+ if (env["SSH_CONNECTION"] || env["SSH_TTY"])
426
+ return false;
427
+ if (platform === "darwin" || platform === "win32")
428
+ return true;
429
+ return Boolean(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
430
+ }
431
+ /** The command that opens `url` — `$BROWSER`, `open`, `xdg-open` or the Windows URL handler — as argv, never a shell line. */
432
+ export function browserCommand(url, env, platform = process.platform) {
433
+ const custom = env["BROWSER"]?.trim();
434
+ if (custom) {
435
+ const parts = custom.split(/\s+/);
436
+ return parts.some((p) => p.includes("%s")) ? parts.map((p) => p.replaceAll("%s", url)) : [...parts, url];
437
+ }
438
+ if (platform === "darwin")
439
+ return ["open", url];
440
+ if (platform === "win32")
441
+ return ["rundll32", "url.dll,FileProtocolHandler", url];
442
+ return ["xdg-open", url];
443
+ }
444
+ /** Starts the browser on `url`. Resolves to whether it could be started. */
445
+ export function openBrowser(url, env = process.env, platform = process.platform) {
446
+ if (!canOpenBrowser(env, platform))
447
+ return Promise.resolve(false);
448
+ const [command, ...args] = browserCommand(url, env, platform);
449
+ return new Promise((resolve) => {
450
+ try {
451
+ const child = spawn(command, args, { stdio: "ignore", detached: true, env: env });
452
+ child.once("error", () => resolve(false));
453
+ child.once("spawn", () => {
454
+ child.unref();
455
+ resolve(true);
456
+ });
457
+ }
458
+ catch {
459
+ resolve(false);
460
+ }
461
+ });
462
+ }
463
+ /** A one-shot HTTP listener on 127.0.0.1 and a port the OS picks. */
464
+ async function listen() {
465
+ const queue = [];
466
+ let waiter;
467
+ const server = createServer((request, response) => {
468
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
469
+ if (request.method !== "GET" || url.pathname !== CALLBACK_PATH) {
470
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("not found");
471
+ return;
472
+ }
473
+ let answered = false;
474
+ const callback = {
475
+ params: url.searchParams,
476
+ respond: (status, html) => {
477
+ if (answered)
478
+ return;
479
+ answered = true;
480
+ response
481
+ .writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", Connection: "close" })
482
+ .end(html);
483
+ },
484
+ };
485
+ const w = waiter;
486
+ waiter = undefined;
487
+ if (w)
488
+ w(callback);
489
+ else
490
+ queue.push(callback);
491
+ });
492
+ await new Promise((resolve, reject) => {
493
+ server.once("error", reject);
494
+ server.listen(0, "127.0.0.1", () => resolve());
495
+ });
496
+ return {
497
+ port: server.address().port,
498
+ next: () => {
499
+ const queued = queue.shift();
500
+ return queued ? Promise.resolve(queued) : new Promise((resolve) => (waiter = resolve));
501
+ },
502
+ close: () => {
503
+ // Stop listening and drop idle keep-alive sockets (a browser's
504
+ // preconnects); a page still being sent finishes first, and anything
505
+ // left is cut after a moment rather than holding the process open.
506
+ const s = server;
507
+ server.close();
508
+ s.closeIdleConnections?.();
509
+ setTimeout(() => s.closeAllConnections?.(), 2_000).unref();
510
+ },
511
+ };
512
+ }
513
+ /**
514
+ * The first answer that belongs to this sign-in, from the browser or pasted.
515
+ * An answer with another `state` (an older tab, a forgery) is turned away and
516
+ * the wait goes on.
517
+ */
518
+ async function waitForCallback(listener, state, input, timeoutMs, log) {
519
+ let timer;
520
+ const lines = input ? createInterface({ input, terminal: false }) : undefined;
521
+ const pasted = [];
522
+ let wakePasted;
523
+ lines?.on("line", (line) => {
524
+ const params = pastedParams(line);
525
+ if (!params) {
526
+ if (line.trim())
527
+ log(`That is not the address the browser ended up on (it starts with http://127.0.0.1:${listener.port}${CALLBACK_PATH}?).`);
528
+ return;
529
+ }
530
+ pasted.push({ params });
531
+ wakePasted?.();
532
+ });
533
+ try {
534
+ const timeout = new Promise((_, reject) => {
535
+ timer = setTimeout(() => reject(new UnfazedLoginError(`unfazed: no sign-in within ${Math.max(1, Math.round(timeoutMs / 60_000))} min. Run \`${LOGIN_COMMAND}\` again.`)), timeoutMs);
536
+ });
537
+ for (;;) {
538
+ const fromPaste = new Promise((resolve) => {
539
+ const take = () => {
540
+ const next = pasted.shift();
541
+ if (next)
542
+ resolve(next);
543
+ };
544
+ wakePasted = take;
545
+ take();
546
+ });
547
+ const callback = await Promise.race([listener.next(), fromPaste, timeout]);
548
+ if (sameState(callback.params.get("state"), state))
549
+ return callback;
550
+ log("Ignored a sign-in answer that belongs to another attempt (its state does not match).");
551
+ callback.respond?.(400, page("Not this sign-in", "This answer belongs to another sign-in attempt. Use the link the terminal printed last."));
552
+ }
553
+ }
554
+ finally {
555
+ clearTimeout(timer);
556
+ lines?.close();
557
+ }
558
+ }
559
+ function pastedParams(line) {
560
+ try {
561
+ const url = new URL(line.trim());
562
+ if (url.pathname !== CALLBACK_PATH || (!url.searchParams.has("code") && !url.searchParams.has("error")))
563
+ return undefined;
564
+ return url.searchParams;
565
+ }
566
+ catch {
567
+ return undefined;
568
+ }
569
+ }
570
+ function sameState(got, want) {
571
+ if (got === null)
572
+ return false;
573
+ const a = Buffer.from(got);
574
+ const b = Buffer.from(want);
575
+ return a.length === b.length && timingSafeEqual(a, b);
576
+ }
577
+ function parseObject(text) {
578
+ try {
579
+ const parsed = JSON.parse(text);
580
+ return parsed && typeof parsed === "object" ? parsed : {};
581
+ }
582
+ catch {
583
+ return {};
584
+ }
585
+ }
586
+ function page(title, body) {
587
+ return (`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
588
+ `<title>${escapeHTML(title)} · unfazed</title>` +
589
+ `<style>body{font:16px/1.5 system-ui,-apple-system,sans-serif;max-width:34rem;margin:18vh auto;padding:0 1.5rem;color:#111;background:#fff}` +
590
+ `h1{font-size:1.4rem;margin:0 0 .5rem}p{color:#444}@media(prefers-color-scheme:dark){body{color:#eee;background:#111}p{color:#bbb}}</style>` +
591
+ `</head><body><h1>${escapeHTML(title)}</h1><p>${body}</p></body></html>`);
592
+ }
593
+ function escapeHTML(text) {
594
+ return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
595
+ }
596
+ //# sourceMappingURL=login.js.map