import {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 = `
Signed in · aesel
Signed in
Return to aesel. You can close this tab.
`;
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
// `.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;
}
}