Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399import {verifyAccount, requireHandle, offlineError} from "./account-access.mjs";// ac-session.mjs — the shared Aesthetic Computer sign-in.//// Every AC desktop app reads one file, ~/.ac-token, minted by `ac-login` with// Auth0 Authorization-Code + PKCE and a loopback callback. This module reads// and watches that file, refreshes the access token, and can run the same// sign-in flow itself so aesel needs no other checkout. Only the// handle is ever displayed; email and name stay in the file.import { EventEmitter } from "node:events";import { createHash, randomBytes } from "node:crypto";import { spawn } from "node:child_process";import { existsSync, mkdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";import { createServer } from "node:http";import { homedir } from "node:os";import { basename, dirname, join } from "node:path";
export const AUTH_DOMAIN = "hi.aesthetic.computer";export const CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt";export const CALLBACK_PORT = 44233;export const SITE = "https://aesthetic.computer";// Cloudflare fronts aesthetic.computer and rejects a bare user agent (1010).export const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0 Safari/537.36 easel";
const base64url = (buffer) => buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
const LANDING = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Signed in · aesel</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:rgb(70,50,100);color:white;font-family:monospace}main{text-align:center;padding:2em}h1{font-weight:normal;color:rgb(255,100,255)}p{color:rgb(220,180,255)}</style></head><body><main><h1>Signed in</h1><p>Return to aesel. You can close this tab.</p></main></body></html>`;
export function openInBrowser(url) { const command = process.env.BROWSER || (process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"); try { spawn(command, [url], { stdio: "ignore", detached: true }).unref(); return true; } catch { return false; }}
export class ACSession extends EventEmitter { constructor({ file = join(homedir(), ".ac-token"), fetch = globalThis.fetch, openBrowser = openInBrowser, callbackPort = CALLBACK_PORT, site = SITE, authDomain = AUTH_DOMAIN, now = Date.now, } = {}) { super(); this.file = file; this.fetch = fetch; this.openBrowser = openBrowser; this.callbackPort = callbackPort; this.site = site; this.authDomain = authDomain; this.now = now; this.watchers = []; this.debounce = null; this.signingIn = false; }
read() { try { const record = JSON.parse(readFileSync(this.file, "utf8")); return record && typeof record === "object" ? record : null; } catch { return null; } }
get handle() { return String(this.read()?.user?.handle || "").replace(/^@/, ""); }
get signedIn() { return Boolean(this.read()?.access_token); }
get state() { const record = this.read(); if (!record?.access_token) return "signed-out"; return record.user?.handle ? "signed-in" : "no-handle"; }
label() { const state = this.state; if (state === "signed-in") return `@${this.handle}`; if (state === "no-handle") return "signed in · no handle"; return "not signed in"; }
// Watch the file and its directory: ac-login rewrites in place (same inode), // logout deletes, and an atomic replace swaps the inode. Re-arm after every // change so a stale file watcher never goes quiet. watch() { this.unwatch(); const fire = () => { clearTimeout(this.debounce); this.debounce = setTimeout(() => { this.watch(); this.emit("change", this.label()); }, 80); this.debounce.unref?.(); }; try { const directory = watch(dirname(this.file), (_event, name) => { if (!name || name === basename(this.file)) fire(); }); directory.unref?.(); this.watchers.push(directory); } catch {} if (existsSync(this.file)) { try { const file = watch(this.file, fire); file.unref?.(); this.watchers.push(file); } catch {} } return this; }
unwatch() { for (const watcher of this.watchers) { try { watcher.close(); } catch {} } this.watchers = []; }
// A usable access token, refreshed a minute early through the refresh grant. // Auth0 rotates refresh tokens, so the refresh runs under the // `<file>.lock` directory every ~/.ac-token refresher on this Mac takes // (shared/ac-token.mjs, ac-login, Menu Band's ACSession.swift) and re-reads // the file inside it: if another process renewed it meanwhile, use theirs. async token() { const stale = (record) => record.expires_at && this.now() > record.expires_at - 60_000; let record = this.read(); if (!record?.access_token) throw new Error("not signed in — run /login"); if (!stale(record)) return record.access_token; return this.#locked(async () => { record = this.read(); if (!record?.access_token) throw new Error("not signed in — run /login"); if (!stale(record)) return record.access_token; if (!record.refresh_token) throw new Error("session expired — run /login"); let response; try { response = await this.fetch(`https://${this.authDomain}/oauth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: record.refresh_token, }), signal: AbortSignal.timeout(8000), }); } catch (error) { throw offlineError(error); } if (!response.ok) throw new Error(`session refresh failed (HTTP ${response.status}) — run /login`); const next = await response.json(); record.access_token = next.access_token; if (next.refresh_token) record.refresh_token = next.refresh_token; if (next.id_token) record.id_token = next.id_token; record.expires_at = this.now() + (next.expires_in || 3600) * 1000; this.#write(record); return record.access_token; }); }
// mkdir is atomic from Node and Swift alike; a lock older than 30 s // belonged to a process that died. async #locked(work) { const lock = `${this.file}.lock`; const deadline = Date.now() + 20_000; for (;;) { try { mkdirSync(lock); break; } catch (error) { if (error.code !== "EEXIST") throw error; let age = 0; try { age = Date.now() - statSync(lock).mtimeMs; } catch { continue; } if (age > 30_000) { rmSync(lock, { recursive: true, force: true }); continue; } if (Date.now() > deadline) throw new Error("another session refresh is holding ~/.ac-token.lock"); await new Promise((resolve) => setTimeout(resolve, 200)); } } try { return await work(); } finally { rmSync(lock, { recursive: true, force: true }); } }
async requireAccount() { const token = await this.token(); const account = await verifyAccount(token, {fetch:this.fetch, site:this.site, authDomain:this.authDomain, userAgent:USER_AGENT}); const record = this.read(); if (record?.access_token !== token) throw new Error("AC account changed. Retry sign-in."); record.user = {...record.user, sub:account.sub, handle:account.handle}; this.#write(record); return requireHandle(account); }
// Authorization-Code + PKCE against Auth0 with a loopback callback. Resolves // to the @handle (or null when the account has none yet). async login({ forcePrompt = false, timeoutMs = 5 * 60_000, onUrl } = {}) { if (this.signingIn) throw new Error("a sign-in is already waiting on the browser"); this.signingIn = true; const verifier = base64url(randomBytes(32)); const challenge = base64url(createHash("sha256").update(verifier).digest()); const state = randomBytes(16).toString("hex"); const server = createServer(); try { const port = await new Promise((resolve, reject) => { server.once("error", (error) => reject( error.code === "EADDRINUSE" ? new Error(`port ${this.callbackPort} is in use — close any running ac-login and retry`) : error, ), ); server.listen(this.callbackPort, "127.0.0.1", () => resolve(server.address().port)); }); const redirectUri = `http://localhost:${port}/callback`; const authUrl = new URL(`https://${this.authDomain}/authorize`); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", CLIENT_ID); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", "openid profile email offline_access"); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); if (forcePrompt) authUrl.searchParams.set("prompt", "login");
const code = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("sign-in timed out")), timeoutMs); timer.unref?.(); const settle = (fn, value) => { clearTimeout(timer); fn(value); }; server.on("request", (request, response) => { const url = new URL(request.url, redirectUri); if (url.pathname !== "/callback") { response.writeHead(404); response.end("Not found"); return; } const failure = url.searchParams.get("error"); if (failure) { response.writeHead(400, { "content-type": "text/plain" }); response.end("Sign-in failed. Return to aesel."); settle(reject, new Error(url.searchParams.get("error_description") || failure)); return; } if (url.searchParams.get("state") !== state) { response.writeHead(400, { "content-type": "text/plain" }); response.end("State mismatch. Return to aesel and retry."); settle(reject, new Error("sign-in state mismatch — retry /login")); return; } const grant = url.searchParams.get("code"); if (!grant) { response.writeHead(400, { "content-type": "text/plain" }); response.end("Missing authorization code."); settle(reject, new Error("missing authorization code")); return; } response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(LANDING); settle(resolve, grant); }); onUrl?.(authUrl.toString()); this.openBrowser(authUrl.toString()); });
// The browser callback is complete. Stop accepting connections before // exchanging the code; a slow upstream request must not keep this open. server.close(); server.closeIdleConnections();
const exchange = await this.fetch(`https://${this.authDomain}/oauth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: CLIENT_ID, code_verifier: verifier, code, redirect_uri: redirectUri, }), }); if (!exchange.ok) throw new Error(`token exchange failed (HTTP ${exchange.status})`); const tokens = await exchange.json();
const userResponse = await this.fetch(`https://${this.authDomain}/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); if (!userResponse.ok) throw new Error(`userinfo failed (HTTP ${userResponse.status})`); const user = await userResponse.json();
let handle = null; try { const lookup = await this.fetch(`${this.site}/handle?for=${encodeURIComponent(user.sub)}`, { headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, }); const data = await lookup.json(); if (data?.handle) handle = String(data.handle).replace(/^@/, ""); } catch {}
this.#write({ access_token: tokens.access_token, refresh_token: tokens.refresh_token, id_token: tokens.id_token, expires_at: this.now() + (tokens.expires_in || 3600) * 1000, user: { email: user.email, name: user.name, sub: user.sub, picture: user.picture, handle }, }); this.emit("change", this.label()); return handle; } finally { this.signingIn = false; server.close(); server.closeAllConnections(); } }
logout({ browser = false } = {}) { let removed = false; try { unlinkSync(this.file); removed = true; } catch {} if (browser) { const url = new URL(`https://${this.authDomain}/v2/logout`); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("returnTo", this.site); this.openBrowser(url.toString()); } this.emit("change", this.label()); return removed; }
#write(record) { mkdirSync(dirname(this.file), { recursive: true }); writeFileSync(this.file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); }
// Claim an @handle for a signed-in account that has none. // // The rules are the server's, mirrored here only so a typo comes back in the // same second rather than after a round trip: letters and digits, separated // by single dots or underscores, sixteen characters at most. // // The server additionally requires a verified email, which a brand-new // account does not have until the confirmation link is clicked. That refusal // is passed through in the server's own words rather than guessed at, because // "check your email" and "that name is taken" are different problems and the // caller cannot tell them apart from a status code. async claimHandle(name) { const handle = String(name || "").trim().replace(/^@/, ""); if (!/^[a-z0-9]+([._][a-z0-9]+)*$/i.test(handle)) { throw new Error("Handles are letters and digits, joined by single dots or underscores."); } if (handle.length > 16) throw new Error("Handles are at most 16 characters.");
const token = await this.token(); const response = await this.fetch(`${this.site}/handle`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle }), }); let body = null; try { body = await response.json(); } catch {} if (!response.ok) { throw new Error(body?.message || `could not claim @${handle} (HTTP ${response.status})`); }
// Write it into the token file so the header and every later request agree // with the server without waiting for the next sign-in. const record = this.read(); if (record) { record.user = { ...(record.user || {}), handle }; this.#write(record); } this.emit("change", this.state); return handle; }
}