jevstrudel.git / website / src / jev / account.mjs

The page's side of accounts (worker/src/auth.ts): who is signed in, and the passkey ceremonies, with @simplewebauthn/browser turning the Worker's options into navigator.credentials calls and the answers back into JSON. The session is an HttpOnly cookie the page never sees; it only asks the Worker who it is (GET /jev/auth/me).

Signed in, the listener's Jev calls are charged to their own daily budget instead of the address's per-minute share alone; the relay says how much is used on each answer it charged (Jev-Budget: used/limit, read by ask.mjs into noteBudget), so the header's count stays current.

11import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
12import { onBudget, resetBackoff } from './ask.mjs';
14export const AUTH = '/jev/auth';

{ 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};

Who is signed in is asked for once per page, by the first reader to subscribe: every reader (the jev pane's budget, the you tab, profiles, ⏭'s history) then sees the real account, whichever of them mounts first. It was asked for only by the account control, and once that moved into a panel tab, the page read as signed out until the tab was opened (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};
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}

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}

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));
79async function ceremony(run) {
80  try {
81    await run();
82  } catch (e) {
83    throw new Error(why(e));
84  }
85  return refresh();
86}

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  });

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  });

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  });

The AI apps this listener connected through the hosted MCP (/ai/, worker/src/oauth.ts): [{ id, app, redirectHost, published, scope, 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}
122export async function signOut() {
123  await call('logout', {});
124  set({ user: null, budget: null });
125}

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);