/**
* Hyper's subscription login: the device-code flow the official client uses.
*
* POST /device/auth -> { device_code, user_code, verification_url, interval, expires_in }
* GET /device/auth/<device_code> -> { refresh_token, team_id, team_name, user_id } | { error }
* POST /token/exchange -> { access_token, refresh_token, expiry, expires_in | expires_at }
*/
import { hostname } from "node:os";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import { SITE_URL } from "./hyper.js";
import { HttpError, getJson, postJson } from "./http.js";
const DEVICE_AUTH_URL = `${SITE_URL}/device/auth`;
const TOKEN_EXCHANGE_URL = `${SITE_URL}/token/exchange`;
const DEFAULT_INTERVAL_SECONDS = 5;
const POLL_TIMEOUT_MS = 30_000;
const EXPIRY_BUFFER_MS = 30_000;
const SLOW_DOWN_INCREMENT_MS = 5_000;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function optional(source: unknown, name: string): unknown {
return isRecord(source) ? Object.getOwnPropertyDescriptor(source, name)?.value : undefined;
}
function text(source: unknown, name: string): string {
const value = optional(source, name);
if (typeof value !== "string" || value === "") throw new Error(`Hyper response is missing "${name}"`);
return value;
}
function number(source: unknown, name: string): number | undefined {
const value = optional(source, name);
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
const onAbort = (): void => {
clearTimeout(timer);
reject(new Error("Login cancelled"));
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
interface DeviceAuthorization {
deviceCode: string;
userCode: string;
verificationUrl: string;
intervalMs: number;
deadlineMs: number;
}
function parseDeviceAuthorization(payload: unknown): DeviceAuthorization {
return {
deviceCode: text(payload, "device_code"),
userCode: text(payload, "user_code"),
verificationUrl: text(payload, "verification_url"),
intervalMs: Math.max(1000, (number(payload, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000),
deadlineMs: Date.now() + (number(payload, "expires_in") ?? 300) * 1000,
};
}
/** One poll: the refresh token once the human approves, or how long to wait before asking again. */
async function pollOnce(
authorization: DeviceAuthorization,
): Promise<{ refreshToken: string } | { delayMs: number }> {
const payload = await getJson(
`${DEVICE_AUTH_URL}/${encodeURIComponent(authorization.deviceCode)}`,
undefined,
POLL_TIMEOUT_MS,
);
const refreshToken = optional(payload, "refresh_token");
if (typeof refreshToken === "string" && refreshToken !== "") return { refreshToken };
const error = optional(payload, "error");
if (error === "authorization_pending") return { delayMs: authorization.intervalMs };
if (error === "slow_down") return { delayMs: authorization.intervalMs + SLOW_DOWN_INCREMENT_MS };
const description = optional(payload, "error_description") ?? error;
throw new Error(`Hyper device authorization failed: ${String(description ?? "unknown error")}`);
}
async function awaitApproval(authorization: DeviceAuthorization, signal?: AbortSignal): Promise<string> {
while (Date.now() < authorization.deadlineMs) {
const result = await pollOnce(authorization);
if ("refreshToken" in result) return result.refreshToken;
await sleep(result.delayMs, signal);
}
throw new Error("Hyper device authorization expired; run /login hyper again");
}
/**
* Every exchange returns a **new** refresh token alongside the access token, and the old
* one stops working, so the rotated value has to be persisted. Prime Agent stores whatever
* `refreshToken()` returns, which is why this returns the exchanged refresh token rather
* than the one it was called with.
*/
async function exchange(refreshToken: string): Promise<OAuthCredentials> {
const payload = await postJson(TOKEN_EXCHANGE_URL, { refresh_token: refreshToken }, undefined, POLL_TIMEOUT_MS);
const now = Date.now();
const expiresIn = number(payload, "expires_in");
const expiresAt = number(payload, "expires_at");
const lifetimeMs =
expiresIn !== undefined ? expiresIn * 1000 : expiresAt !== undefined ? expiresAt * 1000 - now : undefined;
if (lifetimeMs === undefined || lifetimeMs <= 0) throw new Error("Hyper token exchange returned no usable expiry");
return {
refresh: typeof optional(payload, "refresh_token") === "string" ? text(payload, "refresh_token") : refreshToken,
access: text(payload, "access_token"),
expires: now + lifetimeMs - Math.min(EXPIRY_BUFFER_MS, Math.floor(lifetimeMs / 2)),
};
}
export async function loginHyper(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const authorization = parseDeviceAuthorization(
await postJson(DEVICE_AUTH_URL, { device_name: `Prime Agent (${hostname()})` }),
);
callbacks.onAuth({
url: authorization.verificationUrl,
instructions: `Enter code: ${authorization.userCode}`,
});
return exchange(await awaitApproval(authorization, callbacks.signal));
}
/** A refresh token Hyper no longer accepts: 401 for a revoked session, 400 for a stale token. */
const rejectedRefresh = (error: unknown): boolean =>
error instanceof HttpError && (error.status === 401 || (error.status === 400 && error.message.includes("refresh_token")));
export async function refreshHyperToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
try {
return await exchange(credentials.refresh);
} catch (error) {
if (rejectedRefresh(error)) {
throw new Error("Hyper no longer accepts this session. Run /login hyper to re-authenticate.", {
cause: error,
});
}
throw error;
}
}