1// The page's side of accounts (worker/src/auth.ts): who is signed in, and 2// the passkey ceremonies, with @simplewebauthn/browser turning the Worker's 3// options into navigator.credentials calls and the answers back into JSON. 4// The session is an HttpOnly cookie the page never sees; it only asks the 5// Worker who it is (GET /jev/auth/me). 6// 7// Signed in, the listener's Jev calls are charged to their own daily budget 8// instead of the address's per-minute share alone; the relay says how much 9// is used on each answer it charged (`Jev-Budget: used/limit`, read by 10// ask.mjs into `noteBudget`), so the header's count stays current. 11import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser'; 12import { onBudget, resetBackoff } from './ask.mjs'; 13 14export const AUTH = '/jev/auth'; 15 16// { ready, user: { id, displayName } | null, budget: { used, limit, resetsAt } | null } 17let state = { ready: false, user: null, budget: null }; 18const listeners = new Set(); 19const set = (next) => { 20 if ('user' in next && next.user?.id !== state.user?.id) resetBackoff(); 21 state = { ...state, ...next }; 22 for (const listener of listeners) listener(); 23}; 24// Who is signed in is asked for once per page, by the first reader to 25// subscribe: every reader (the jev pane's budget, the you tab, profiles, 26// ⏭'s history) then sees the real account, whichever of them mounts first. 27// It was asked for only by the account control, and once that moved into a 28// panel tab, the page read as signed out until the tab was opened 29// (2026-09-27). 30let asked = false; 31export const account = { 32 subscribe(listener) { 33 if (!asked && typeof window !== 'undefined') { 34 asked = true; 35 refresh(); 36 } 37 listeners.add(listener); 38 return () => listeners.delete(listener); 39 }, 40 get: () => state, 41}; 42 43export const passkeysSupported = () => typeof window !== 'undefined' && browserSupportsWebAuthn(); 44 45async function call(path, body) { 46 const res = await fetch(`${AUTH}/${path}`, { 47 method: body === undefined ? 'GET' : 'POST', 48 headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, 49 body: body === undefined ? undefined : JSON.stringify(body), 50 credentials: 'same-origin', 51 }); 52 const text = await res.text(); 53 let parsed = null; 54 try { 55 parsed = text ? JSON.parse(text) : null; 56 } catch { 57 // a proxy's or the platform's own error page 58 } 59 if (!res.ok) throw new Error(parsed?.error ?? `the site answered ${res.status}`); 60 return parsed; 61} 62 63// Who is signed in, asked again; the page calls it once on load. 64export async function refresh() { 65 try { 66 const { user, budget } = await call('me'); 67 set({ ready: true, user, budget }); 68 } catch { 69 // no Worker (a static host) or it is down: signed out, as before accounts 70 set({ ready: true, user: null, budget: null }); 71 } 72 return state; 73} 74 75// A browser's own refusal (the listener closed the prompt) reads as plain words. 76const why = (e) => 77 e?.name === 'NotAllowedError' ? 'the passkey prompt was closed or timed out' : (e?.message ?? String(e)); 78 79async function ceremony(run) { 80 try { 81 await run(); 82 } catch (e) { 83 throw new Error(why(e)); 84 } 85 return refresh(); 86} 87 88// A new account called `displayName`, with a passkey made on this device. 89export const createAccount = (displayName) => 90 ceremony(async () => { 91 const optionsJSON = await call('register/options', { displayName }); 92 await call('register/verify', await startRegistration({ optionsJSON })); 93 }); 94 95// Sign in with any passkey this site has; the browser lists them. 96export const signIn = () => 97 ceremony(async () => { 98 const optionsJSON = await call('login/options', {}); 99 await call('login/verify', await startAuthentication({ optionsJSON })); 100 }); 101 102// Signed in: another passkey for the same account (another device, or a key). 103export const addPasskey = () => 104 ceremony(async () => { 105 const optionsJSON = await call('register/options', {}); 106 await call('register/verify', await startRegistration({ optionsJSON })); 107 }); 108 109// The AI apps this listener connected through the hosted MCP (/ai/, 110// worker/src/oauth.ts): [{ id, app, redirectHost, published, scope, 111// createdAt }]. Disconnecting one ends its access at once. 112export async function listApps() { 113 const res = await fetch('/jev/me/apps', { credentials: 'same-origin' }); 114 if (!res.ok) throw new Error(`the site answered ${res.status}`); 115 return (await res.json()).apps; 116} 117export async function disconnectApp(id) { 118 const res = await fetch(`/jev/me/apps/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'same-origin' }); 119 if (!res.ok && res.status !== 404) throw new Error(`the site answered ${res.status}`); 120} 121 122export async function signOut() { 123 await call('logout', {}); 124 set({ user: null, budget: null }); 125} 126 127// The relay's `Jev-Budget` header on an answer: `used/limit`, or `spent`. 128export function noteBudget(value) { 129 if (!value || !state.user) return; 130 if (value === 'spent') { 131 if (state.budget) set({ budget: { ...state.budget, used: state.budget.limit } }); 132 return; 133 } 134 const m = /^(\d+)\/(\d+)$/.exec(value); 135 if (m) set({ budget: { ...(state.budget ?? {}), used: Number(m[1]), limit: Number(m[2]) } }); 136} 137onBudget(noteBudget);