jevstrudel.git / website / src / jev / account.mjs
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);