Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/gateway.js

70.7 KiB, 24 runs

created by r2519314175:1367, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* gateway.js — Daimond's account and credits, against the Daimond gateway.
2 *
3 * The gateway (a Steel app-side binary) already implements account binding,
4 * a device-key challenge/response, a credit ledger and Stripe Checkout, and it
5 * was proven end to end against Stripe's sandbox. Nothing in the client ever
6 * called it: `DaimondIdentity.sign()` and `publicKeyRaw()` — the exact primitives
7 * its auth expects — sat implemented and unused. This is that wiring.
8 *
9 * There is no password anywhere. The device keypair IS the credential: the
10 * gateway binds an account to the public key, then proves possession with a
11 * signed nonce. So the account follows the passphrase, and the passphrase never
12 * leaves the browser.
13 *
14 * Endpoints are same-origin (`/api/*`); Steel front-proxies them to the gateway
15 * on loopback, so the session cookie is a plain same-origin cookie.
16 */
17(function () {
18 'use strict';
19
20 var ACCOUNT_MSG = 'daimond-gw-account:v1:';
21 var state = {
22 authed: false,
23 credits: null, // minor units (cents), or null when unknown
24 currency: 'usd',
25 entries: [],
26 offline: false, // the gateway could not be reached
27 // The gateway ANSWERED and said no. `'beta_only'` while the beta is
28 // closed, `'unavailable'` when it could not read its own gate; null
29 // otherwise. Never both this and `offline`: a refusal is the opposite of
30 // silence, and reporting one as the other sends somebody to look at
31 // their network for a decision the server took on purpose.
32 refused: null,
33 // The gateway's own English sentence behind that refusal, kept verbatim.
34 // The app says the refusal in the user's language off `refused`; this is
35 // what is shown when the gateway names a reason this build has never
36 // heard of, so a refusal added on the server is still legible here.
37 refusal: '',
38 pro: null, // Pro GRANTING right now? null until asked.
39 proPriceMinor: null,// the monthly Pro price, from the gateway.
40 // The subscription's standing, so a paused or past-due state can be shown
41 // warmly rather than as a bare lapse. `proStatus` is the gateway's word --
42 // 'active', 'past_due', 'paused', 'canceled' or 'none'; `proPeriodEnd` is
43 // when the current paid month ends (Unix seconds, 0 when none); `proWarned`
44 // says the gateway has flagged an approaching auto-pause, so the client
45 // shows the notice once; `proNowTs` is the GATEWAY's clock at the moment it
46 // answered, so any countdown is drawn against it and not the device's.
47 proStatus: 'none',
48 proPeriodEnd: null,
49 proWarned: false,
50 proNowTs: null,
51 // The console role, once asked for. `undefined` means not yet asked;
52 // null means asked and the answer was no. Switching account clears it,
53 // because it is an answer about whoever is signed in now.
54 role: undefined,
55 // This account's standing in the closed test, as the GATEWAY sees it on
56 // this bootstrap. `beta` is membership and `wave` is which intake.
57 //
58 // Read from the server on every unlock rather than remembered here,
59 // which is the whole point of them: a passcode revoked in the console
60 // takes the account's status with it, so the next bootstrap says false
61 // and the browser has nothing to re-arm a telemetry recorder from. A
62 // membership cached on the device would be a membership that outlived
63 // its own revocation.
64 beta: false,
65 wave: 0,
66 // Which account this device is signed in as, as the gateway names it.
67 // Carried because a consent is given by an ACCOUNT and not by a device:
68 // two people sharing a laptop must not inherit each other's answer, and
69 // the only thing that tells them apart here is this.
70 accountId: '',
71 };
72
73 /// Report one of the module's twenty events, naming an offer from its fixed
74 /// table. Never throws, never waits, never changes anything -- the same three
75 /// rules every `tel()` in daimond.js keeps, and the reason these two lines
76 /// can be read past.
77 function telemetry(name, offer) {
78 try {
79 var T = window.DaimondTelemetry;
80 if (T) T.emit(name, T.ordinal(T.OFFERS || [], offer));
81 } catch (e) { /* telemetry may never break a checkout */ }
82 }
83
84 /// The credit packs the gateway will accept. The price is server-owned; this
85 /// is only what we offer, and the gateway re-checks it against its allowlist.
86 var PACKS = [1000, 2000, 5000, 10000];
87
88 /// The API contract version this build speaks. Bumped in lockstep with the
89 /// gateway's GATEWAY_API/MIN_CLIENT_API (gateway/src/handlers/common.rs)
90 /// whenever the HTTP contract changes in a way an old tab cannot survive.
91 /// Sent on every call so the gateway can refuse a tab too old to serve.
92 ///
93 /// Exported as `clientApi()`, because a caller outside this file needs the
94 /// number and must not carry its own copy: two constants that have to match
95 /// are two constants that will eventually not. `models.js` mints inference
96 /// keys against `/api/inference-key` and reads it from here.
97 var CLIENT_API = 2; // v2: the lease moved to its own door; v1 tabs are evicted.
98
99 /// Every gateway reply advertises the gateway's version and the oldest client
100 /// it will serve. If this tab is below that floor -- or a call was refused
101 /// with 426 -- it is out of date: tell the updater, which reloads onto the
102 /// current build. Checked on success and failure alike, so a tab notices the
103 /// moment it falls behind, not only when a call breaks.
104 function probeVersion(r) {
105 var stale = r.status === 426;
106 if (!stale) {
107 var min = parseInt(r.headers.get(HDR_MIN_API), 10);
108 if (isFinite(min) && min > CLIENT_API) stale = true;
109 }
110 if (stale) { try { window.dispatchEvent(new Event('daimond:stale')); } catch (e) {} }
111 }
112 var HDR_MIN_API = 'x-daimond-min-api';
113
114 /// A compact IANA-timezone → ISO-3166 alpha-2 table, the fallback when the
115 /// browser locale carries no region subtag. Not exhaustive — a few hundred
116 /// common zones — and an unknown zone simply yields no country, which the
117 /// gateway stores as "". Only ever used to shade the operator's usage map.
118 var TZ_COUNTRY = {
119 'Africa/Cairo':'EG','Africa/Johannesburg':'ZA','Africa/Lagos':'NG','Africa/Nairobi':'KE',
120 'Africa/Casablanca':'MA','Africa/Algiers':'DZ','Africa/Accra':'GH','Africa/Addis_Ababa':'ET',
121 'Africa/Tunis':'TN','Africa/Khartoum':'SD','Africa/Dar_es_Salaam':'TZ','Africa/Kampala':'UG',
122 'America/New_York':'US','America/Chicago':'US','America/Denver':'US','America/Los_Angeles':'US',
123 'America/Phoenix':'US','America/Anchorage':'US','America/Detroit':'US','Pacific/Honolulu':'US',
124 'America/Toronto':'CA','America/Vancouver':'CA','America/Edmonton':'CA','America/Winnipeg':'CA',
125 'America/Halifax':'CA','America/Mexico_City':'MX','America/Monterrey':'MX','America/Tijuana':'MX',
126 'America/Bogota':'CO','America/Lima':'PE','America/Santiago':'CL','America/Caracas':'VE',
127 'America/Sao_Paulo':'BR','America/Fortaleza':'BR','America/Manaus':'BR','America/Argentina/Buenos_Aires':'AR',
128 'America/Montevideo':'UY','America/Asuncion':'PY','America/La_Paz':'BO','America/Guayaquil':'EC',
129 'America/Panama':'PA','America/Costa_Rica':'CR','America/Guatemala':'GT','America/Havana':'CU',
130 'America/Santo_Domingo':'DO','America/Puerto_Rico':'PR','America/Jamaica':'JM',
131 'Asia/Dubai':'AE','Asia/Qatar':'QA','Asia/Riyadh':'SA','Asia/Kuwait':'KW','Asia/Bahrain':'BH',
132 'Asia/Muscat':'OM','Asia/Baghdad':'IQ','Asia/Tehran':'IR','Asia/Jerusalem':'IL','Asia/Amman':'JO',
133 'Asia/Beirut':'LB','Asia/Damascus':'SY','Asia/Istanbul':'TR','Europe/Istanbul':'TR',
134 'Asia/Karachi':'PK','Asia/Kolkata':'IN','Asia/Calcutta':'IN','Asia/Colombo':'LK','Asia/Dhaka':'BD',
135 'Asia/Kathmandu':'NP','Asia/Yangon':'MM','Asia/Bangkok':'TH','Asia/Ho_Chi_Minh':'VN',
136 'Asia/Phnom_Penh':'KH','Asia/Vientiane':'LA','Asia/Jakarta':'ID','Asia/Makassar':'ID',
137 'Asia/Kuala_Lumpur':'MY','Asia/Singapore':'SG','Asia/Manila':'PH','Asia/Hong_Kong':'HK',
138 'Asia/Taipei':'TW','Asia/Shanghai':'CN','Asia/Urumqi':'CN','Asia/Seoul':'KR','Asia/Tokyo':'JP',
139 'Asia/Ulaanbaatar':'MN','Asia/Almaty':'KZ','Asia/Tashkent':'UZ','Asia/Baku':'AZ','Asia/Tbilisi':'GE',
140 'Asia/Yerevan':'AM','Asia/Yekaterinburg':'RU','Asia/Novosibirsk':'RU','Asia/Vladivostok':'RU',
141 'Europe/London':'GB','Europe/Dublin':'IE','Europe/Lisbon':'PT','Europe/Madrid':'ES',
142 'Europe/Paris':'FR','Europe/Brussels':'BE','Europe/Amsterdam':'NL','Europe/Luxembourg':'LU',
143 'Europe/Berlin':'DE','Europe/Zurich':'CH','Europe/Vienna':'AT','Europe/Rome':'IT',
144 'Europe/Copenhagen':'DK','Europe/Oslo':'NO','Europe/Stockholm':'SE','Europe/Helsinki':'FI',
145 'Europe/Warsaw':'PL','Europe/Prague':'CZ','Europe/Bratislava':'SK','Europe/Budapest':'HU',
146 'Europe/Bucharest':'RO','Europe/Sofia':'BG','Europe/Athens':'GR','Europe/Zagreb':'HR',
147 'Europe/Belgrade':'RS','Europe/Ljubljana':'SI','Europe/Vilnius':'LT','Europe/Riga':'LV',
148 'Europe/Tallinn':'EE','Europe/Kyiv':'UA','Europe/Kiev':'UA','Europe/Minsk':'BY',
149 'Europe/Moscow':'RU','Europe/Reykjavik':'IS',
150 'Australia/Sydney':'AU','Australia/Melbourne':'AU','Australia/Brisbane':'AU','Australia/Perth':'AU',
151 'Australia/Adelaide':'AU','Australia/Hobart':'AU','Australia/Darwin':'AU',
152 'Pacific/Auckland':'NZ','Pacific/Fiji':'FJ','Pacific/Port_Moresby':'PG','Pacific/Guam':'GU',
153 };
154
155 /// Derive a 2-letter country for this browser, or `undefined` when nothing
156 /// reliable is available. The locale's region subtag is tried first
157 /// (`en-AU` → `AU`); failing that, the IANA time zone is looked up. An
158 /// undefined result is simply omitted from the registration body.
159 function deriveCountry() {
160 try {
161 var langs = [];
162 if (navigator.languages && navigator.languages.length) langs = navigator.languages.slice();
163 if (navigator.language) langs.push(navigator.language);
164 for (var i = 0; i < langs.length; i++) {
165 var m = /[-_]([A-Za-z]{2})(?:$|[-_])/.exec(langs[i] || '');
166 if (m) return m[1].toUpperCase();
167 }
168 } catch (e) {}
169 try {
170 var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
171 if (tz && TZ_COUNTRY[tz]) return TZ_COUNTRY[tz];
172 } catch (e) {}
173 return undefined;
174 }
175
176 /// Minor units as money. The display currency is applied in i18n.js, which
177 /// is the only place in the app that decides what a figure looks like.
178 function fmtMoney(minor, currency) {
179 return DaimondI18n.moneyMinor(minor, currency);
180 }
181
182 /// The same figure at a point where the user is actually charged: US
183 /// dollars, said out loud, with the converted figure beside it.
184 function fmtBilled(minor, currency) {
185 return DaimondI18n.billedMinor(minor, currency);
186 }
187
188 /// The header, the Spending panel and anything else watching money get told once, here,
189 /// rather than each of them polling. A page with no `window` (a test harness evaluating this
190 /// file) simply does not hear it.
191 ///
192 /// Only ever called for a figure that MOVED. The Spending panel refreshes on this event and
193 /// its refresh fetches the balance, so an unconditional dispatch closed a loop: every reply
194 /// refreshed the panel, every refresh produced a reply, and an open panel drove the gateway
195 /// at hundreds of requests a second for as long as it stayed on screen.
196 function announce() {
197 try {
198 window.dispatchEvent(new CustomEvent('daimond:credits', {
199 detail: { credits: state.credits, currency: state.currency },
200 }));
201 } catch (e) { /* no window to tell */ }
202 }
203
204 /// Take the balance out of any gateway reply that carries one.
205 ///
206 /// Nearly every credit-spending endpoint already returns the resulting balance in
207 /// `credits_minor`, and the app was throwing all of them away — so the account figure in the
208 /// header stayed at whatever the last explicit `/api/balance` call had said, and a page that
209 /// fetched twenty web pages showed the balance it started with until something happened to
210 /// re-ask. Every reply is now read, and each one that says refreshes the number.
211 ///
212 /// Silent about anything else: an absent field, a null, a string. `state.credits` is `null`
213 /// for "unknown", and writing that from a reply that simply did not mention money would
214 /// erase a figure the app legitimately holds.
215 ///
216 /// ## WHY THE FIGURE SITS STILL WHILE A CHAT RUNS, AND WHY THAT IS NOT THIS FILE'S FAULT
217 ///
218 /// Reported as a bug twice, and looked for twice in the client, so it is written down here
219 /// where the next reader will be standing.
220 ///
221 /// Daimond does not proxy inference — that is the privacy claim, not an implementation
222 /// detail. The gateway sells a spend-capped key at the model host and the browser talks to
223 /// the host directly, so the gateway is not in the request path and learns nothing about a
224 /// turn as it happens. The account's ledger is debited only when that key is RECONCILED,
225 /// which `/api/inference-key` does at the top of a mint (gateway `handlers::inference_key`,
226 /// `reconcile`) — that is, when the key's float is exhausted, or when the daily sweep finds
227 /// it. `/api/balance` folds the ledger, so between reconciliations it returns the same figure
228 /// however often it is asked, and `noteBalance` correctly says nothing about a number that
229 /// has not moved. The session/week/month meters below it move every turn because they are
230 /// built from what the PROVIDER reported, on the device, and never touch the ledger at all.
231 ///
232 /// So a client-side poll cannot fix this and no amount of it will: the balance really has not
233 /// changed where the balance lives. Two things do fix it, in this order — `/api/inference-key`
234 /// carries the freshly reconciled figure in its reply and its caller must hand it to this
235 /// function rather than keeping a private copy; and `/api/balance` should reconcile before it
236 /// folds, which `reconcile()` is already re-entrant and non-fatal enough to allow.
237 ///
238 /// # Arguments
239 /// * `j` - A parsed gateway reply, or anything at all.
240 function noteBalance(j) {
241 if (!j || typeof j !== 'object') return;
242 if (typeof j.credits_minor !== 'number' || !isFinite(j.credits_minor)) return;
243 var moved = state.credits !== j.credits_minor;
244 state.credits = j.credits_minor;
245 if (typeof j.currency === 'string' && j.currency && j.currency !== state.currency) {
246 state.currency = j.currency;
247 moved = true;
248 }
249 if (moved) announce();
250 }
251
252 // ── The pause, refused where credits are committed ─────────
253 //
254 // A pause the widget respects and the network does not is decoration, so the
255 // gateway routes that COST are refused here rather than in the interface. The
256 // refusal is an ordinary 423 with `ok: false` and a sentence, so every caller
257 // shows it through the error path it already has and none of them needs to
258 // learn a second one.
259 //
260 // SYNC IS DELIBERATELY ABSENT from this table, and that is a decision rather
261 // than an omission. `/api/sync` is how a paired device catches up; a device
262 // that stopped syncing while paused would come back holding stale work,
263 // resolve it against the parcel, and the failure — a device stranded, or a
264 // merge fought out days later — is worse than the fraction of a cent a parcel
265 // costs. Pause is a control on what SPENDS on the user's behalf while they
266 // are not looking; a sync is the account being itself.
267 //
268 // Reading is likewise absent: `/api/mail/folders`, `/api/mail/accounts` and
269 // `/api/balance` list what is already there, and a paused Diamond still
270 // opens.
271
272 /// What the app says. The table lives in i18n/en.js.
273 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
274
275 /// The words for a refusal: the table's sentence, or the whole English one the
276 /// caller wrote, so nothing ever shows a bare key.
277 ///
278 /// IT APPENDS NOTHING. It used to add "Press play on it to resume." to every
279 /// fallback, and that is how the app came to give advice it could not honour:
280 /// `root/web` had no control anywhere, so a user who paused Everything was
281 /// told to press a play button that did not exist, and their only way back was
282 /// to resume everything — which also resumed a Diamond that ships paused on
283 /// purpose. The control exists now and the sentence would be true again, which
284 /// is exactly why the assembly is still wrong: a clause bolted on here is a
285 /// claim about a node this function knows nothing about, and it will be wrong
286 /// again the next time a leaf arrives before its control does. Each caller
287 /// writes its own complete sentence, and owns whether it can promise a button.
288 /// `english` is the WHOLE sentence, `{node}` included, so it is a byte-for-byte
289 /// copy of the catalogue entry rather than a second assembly of one. A fallback
290 /// stitched together here drifted from `en.js` the first time the wording was
291 /// revised, and nothing noticed because it only shows when the table is absent.
292 function pauseWords(key, node, english) {
293 var s = t(key, { node: node });
294 return (s === key) ? english.replace(/\{node\}/g, node) : s;
295 }
296
297 /// Is this node paused? A leaf's own flag, and cheap enough to sit in front
298 /// of every request.
299 function held(node) {
300 return !!(node && window.DaimondPause && DaimondPause.isPaused(node));
301 }
302
303 /// A node id, escaped the way the tree escapes one. Only ever reached with
304 /// the module present; `spendRefusal` answers null before it gets here.
305 function pid() {
306 return DaimondPause.id.apply(null, arguments);
307 }
308
309 /// Is EVERYTHING paused? The global control at the top of the rail is the
310 /// root of the same tree, so this is what it means when a spend cannot be
311 /// attributed to a leaf of its own.
312 function allHeld() {
313 if (!window.DaimondPause) return false;
314 return DaimondPause.state(DaimondPause.ROOT) === 'pause';
315 }
316
317 /// The body a request carries, parsed, or an empty object. Every body in
318 /// this app is a JSON string; anything else simply names no node.
319 function bodyOf(opts) {
320 try {
321 var b = opts && opts.body;
322 return (typeof b === 'string' && b) ? (JSON.parse(b) || {}) : {};
323 } catch (e) { return {}; }
324 }
325
326 /// Whether this request is refused by the pause tree, and in whose name.
327 /// Returns `{ node, message }`, or null for the great majority of calls that
328 /// cost nothing and are never held.
329 ///
330 /// The path is matched on its own, before the query: `path === query` is a
331 /// mistake this tree has made before.
332 function spendRefusal(path, opts) {
333 if (!window.DaimondPause) return null;
334 var p = String(path || '').split('?')[0];
335 if (p.indexOf('/api/mail/') === 0 || p.indexOf('/api/web/') === 0) {
336 var b = bodyOf(opts);
337 if (p === '/api/mail/sync' || p === '/api/mail/send') {
338 var own = pid(DaimondPause.ROOT, 'mail', b.address, 'self');
339 // A sync is the FOLDER's spend; a send, and a poll that names no
340 // folder, is the mailbox's own. Either leaf holds it: a paused
341 // mailbox must not go on reaching the server one folder at a time.
342 var leaf = (p === '/api/mail/sync' && b.mailbox)
343 ? pid(DaimondPause.ROOT, 'mail', b.address, b.mailbox)
344 : own;
345 var stop = held(leaf) ? leaf : (held(own) ? own : '');
346 if (stop) {
347 // The whole sentence, clause included: every mail leaf has had a
348 // control on it since phase G, so this one can promise a button.
349 return { node: stop, message: pauseWords('pause.refused.mail', stop,
350 '{node} is paused. The mailbox was not contacted and nothing was spent. '
351 + 'Press play on it to resume.') };
352 }
353 return null;
354 }
355 // Reaching out of the browser, however it is done: a page fetch, the
356 // HEAD that asks whether a site will frame, and a SEARCH. One leaf
357 // governs all three, because they are one question — may this app
358 // contact the outside world on the user's behalf right now.
359 //
360 // The search route was missing here, and its absence was total: it
361 // matched no arm, so it fell out of the bottom of this function and
362 // was governed by NOTHING — not the Web leaf, not a Diamond's, and not
363 // the global control. A user who paused Everything this morning to stop
364 // outbound requests would have gone on searching, and paying for it.
365 if (p === '/api/web/fetch' || p === '/api/web/head' || p === '/api/web/search') {
366 // Charged to whoever asked for it, when the caller says so — and
367 // otherwise to the Web panel's own leaf, with the global control
368 // behind that. That leaf now has a control of its own, in the Web
369 // panel header, so a refusal here points at something a user can
370 // press rather than at the whole tree.
371 var who = (typeof b.node === 'string' && b.node) ? b.node
372 : pid(DaimondPause.ROOT, 'web');
373 var hold = held(who) ? who : (allHeld() ? DaimondPause.ROOT : '');
374 if (hold) {
375 // One key for one leaf, and the sentence names the leaf rather
376 // than the route: `pause.refused.web` is translated into eight
377 // languages already, and a second key saying almost the same
378 // thing would be a second thing to keep in step for the sake of
379 // one noun. What was held is the web, whichever door was tried.
380 return { node: hold, message: pauseWords('pause.refused.web', hold,
381 '{node} is paused. The page was not fetched and nothing was spent. '
382 + 'Press play on it to resume.') };
383 }
384 }
385 }
386 return null;
387 }
388
389 /// The refusal as a reply, so a caller reads it the way it reads every other
390 /// refusal: a status, `ok: false`, and a sentence. 423 Locked, because the
391 /// request was well formed and the door is shut on purpose — and because
392 /// nothing in this app treats a 423 as anything but its message (401 renews,
393 /// 426 reloads, and both would be wrong here).
394 function refusedReply(r) {
395 return new Response(JSON.stringify({ ok: false, paused: true, node: r.node, error: r.message }), {
396 status: 423,
397 headers: { 'content-type': 'application/json' },
398 });
399 }
400
401 // ── A LOCAL FAILURE IS NOT INFORMATION ABOUT THE WORLD ─────
402 //
403 // A tool's network call can fail two ways and they are opposite things. "The remote host
404 // refused you", "that page is 404", "the API returned an error" are RESULTS: facts about the
405 // world, and a model should read them and adapt. "Your user's phone went to sleep and the
406 // request never left the device" is not a result at all — it is an infrastructure event, and
407 // handing it to a model as though it were a fact is what makes the model apologise for the
408 // platform. On iOS a home-screen PWA is put in the back/forward cache on every app switch, so
409 // every in-flight request dies; the owner watched a turn come back "I can't get through to the
410 // web right now to look this up", and that sentence is now a permanent assistant turn in his
411 // transcript, re-sent to the model on every turn after it.
412 //
413 // THE DISTINCTION IS MADE HERE BECAUSE THIS IS WHERE IT IS KNOWN. A rejected `fetch` is the
414 // road; a reply that arrived and said no is the remote. One line further out the two are
415 // indistinguishable, and a page away they are two sentences that have to be told apart by
416 // their prose — which cannot be done safely. `BROWSER_ROAD` in js/daimond.js holds `refused`,
417 // for "connection refused", and the tool layer is full of sentences containing that word for
418 // an entirely different reason (`refusal_line` in src/tools.rs prefixes some twenty of them).
419 // A prose classifier over tool results would read a permission refusal as a dead road, retry
420 // it eight times and then kill the turn. So the mark is POSITIVE and is set at the only point
421 // that cannot be wrong about it.
422 //
423 // It rides on the error as a property AND as a sentence in front of the message. The property
424 // is for JavaScript; the sentence is for the engine, because a JS `Error` crosses the wasm
425 // boundary as its `message` and nothing else — the same reason `TransportErr::crossed`
426 // (src/llm.rs) puts its reason in front of the error rather than beside it.
427
428 /// The sentence that marks a request which never got an answer.
429 ///
430 /// QUOTED VERBATIM in `src/tools.rs` as `ROAD_MARK`, and `dev/verify_toolroad.mjs` asserts the
431 /// two are the same string. It is deliberately a sentence nothing else in this application
432 /// says: the engine tests for it EXACTLY, not by pattern, so a phrase that happened to appear
433 /// in a page's error body cannot be mistaken for one of these.
434 var ROAD_MARK = 'daimond-road: the request never left this device';
435
436 /// Mark a rejected `fetch` as a road failure, and hand back the same rejection.
437 ///
438 /// A `TypeError` is the whole of what a page gets when a request dies before its headers —
439 /// no status, no response, and a message that is the vendor's own (`Failed to fetch` in
440 /// Chromium, `Load failed` in WebKit). What it is NOT is an answer, and this says so.
441 function roadMark(e) {
442 try {
443 if (e && e.daimondRoad) return e; // already marked; do not say it twice.
444 var was = (e && e.message) ? String(e.message) : String(e);
445 var out = (e instanceof Error) ? e : new Error(was);
446 out.daimondRoad = true;
447 out.message = ROAD_MARK + ': ' + was;
448 return out;
449 } catch (e2) { return e; } // a frozen or exotic rejection: unchanged, so unmarked.
450 }
451
452 /// The last point before a request leaves the page, for the two callers that
453 /// hold their own `fetch` rather than coming through `gwFetch`: the Web
454 /// panel's `gw()` (web.js) and anything added beside it. Narrow on purpose —
455 /// a path not in the spend table is handed straight to the real `fetch`,
456 /// untouched and unwrapped.
457 ///
458 /// Cooperative would be better and is the follow-up: a caller that asked
459 /// `spendRefusal` itself could show the sentence in its own panel instead of
460 /// taking it off a 423. This is the guard that holds until then, and it is
461 /// here because the alternative — a spend boundary that only the honest
462 /// callers respect — is the decoration this whole phase exists to avoid.
463 function guardFetch() {
464 if (typeof window === 'undefined' || !window.fetch || window.fetch.__daimondPause) return;
465 var real = window.fetch;
466 var wrapped = function (input, init) {
467 var url = '';
468 try { url = (typeof input === 'string') ? input : (input && input.url) || ''; } catch (e) { url = ''; }
469 var p = url;
470 // Same-origin absolute forms reduce to their path; anything else is
471 // somebody else's host and is not ours to hold.
472 if (p.indexOf('http') === 0) {
473 try { var u = new URL(p); p = (u.origin === location.origin) ? u.pathname : ''; }
474 catch (e) { p = ''; }
475 }
476 if (p.indexOf('/api/') === 0) {
477 var r = spendRefusal(p, init || (typeof input === 'object' ? input : null));
478 if (r) return Promise.resolve(refusedReply(r));
479 }
480 return real.apply(window, arguments).catch(function (e) { throw roadMark(e); });
481 };
482 wrapped.__daimondPause = true;
483 window.fetch = wrapped;
484 }
485
486 // ── A session that has gone ────────────────────────────────
487 //
488 // The gateway's session lives an hour and nothing ever renewed it. The only
489 // thing that has ever minted one is `bootstrap()`, called once per unlock, so
490 // an hour into a sitting every call became a 401 -- and every caller in this
491 // file swallowed it. `state.authed` stayed true, so the app went on saying it
492 // was connected while sync's pushes were being refused, one an hour after
493 // another, with the user's work never leaving the device.
494 //
495 // So a 401 is now acted on. The device key is the credential and it is still
496 // in the page, so the session can simply be taken again -- which also covers a
497 // gateway that restarted and a session ended from another tab.
498 //
499 // SINGLE-FLIGHT. Several callers are refused in the same moment -- sync's
500 // push, its wake channel, the balance -- and each one minting its own session
501 // would be a burst of signatures for one thing that needs doing once. They all
502 // wait on the same attempt.
503 var reauthing = null; // the attempt in flight, if there is one.
504 var bootstrapping = null; // the bootstrap in flight, if there is one.
505 var reauthTimer = null; // the standing retry, while the identity is unlocked.
506 var reauthGen = 0; // bumped by logout, so a deliberate exit is not undone.
507 var REAUTH_MIN_MS = 5000; // first retry after a failed renewal.
508 var REAUTH_MAX_MS = 120000; // and no slower than this, ever.
509 var reauthWait = REAUTH_MIN_MS;
510
511 /// The calls that ARE the authentication, and so cannot answer a 401 by
512 /// authenticating again.
513 function isAuthPath(path) {
514 return path.indexOf('/api/account') === 0 || path.indexOf('/api/auth/') === 0;
515 }
516
517 // ── A CALL THE BOOTSTRAP IS MAKING ITSELF ──────────────────
518 //
519 // Such a call must never answer a 401 by renewing, because the renewal it
520 // would join is the one it is running inside: `reauth()` is single-flight, so
521 // the call awaits `reauthing`, `reauthing` is awaiting the bootstrap, and the
522 // bootstrap is awaiting the call. Nothing settles, ever.
523 //
524 // THE SYMPTOM THAT DEADLOCK PRODUCES, so nobody undoes this by tidying: a
525 // page that has been open an hour goes completely silent. Not slow, not
526 // erroring -- silent. No request leaves it and no timer fires, because every
527 // panel that meets the expired session joins the same parked `reauthing` and
528 // parks with it, and the standing retry in `armReauth()` is never reached to
529 // arm itself. Only a reload brings the tab back. `dev/verify_gwretry.mjs`
530 // hung on exactly this for two days and reported it as a closed browser.
531 //
532 // OWNERSHIP TRAVELS WITH THE CALL, and that is the fix. It used to be
533 // answered from an `authing` boolean plus a list of paths -- true while a
534 // bootstrap was running anywhere, and `/api/balance` and `/api/licence`
535 // treated as its own while it was. One flag cannot describe TWO bootstraps,
536 // and two is an ordinary state: `reauth()` starts one while a panel calls
537 // `DaimondGateway.bootstrap()` directly (tools.js, mail.js, passcode.js all
538 // do). The first to finish cleared the flag, and the second one's balance and
539 // licence reads -- still in flight, still its own -- were then no longer
540 // recognised as its own. They renewed, joined the promise they were inside,
541 // and the tab went silent. Measured, at four milliseconds between the two.
542 //
543 // So `bootstrap()` is single-flight (below) and every call it makes carries
544 // `own`. A flag another caller can clear is not evidence about THIS call, and
545 // no future read of one can go wrong the same way.
546
547 /// Take a session again after one was refused, once, however many callers ask.
548 ///
549 /// Guarded on the identity: the account IS the device key, so with the app
550 /// locked there is nothing to sign with and nothing to renew. That guard is
551 /// also what stops a stray in-flight call resurrecting a session the user has
552 /// just logged out of -- every logout path locks the identity first.
553 ///
554 /// Returns whether there is a session now.
555 ///
556 /// The attempt in flight is cleared in a `finally`, and that is not tidiness.
557 /// It used to be cleared on the way past a value, so an attempt that THREW
558 /// left the rejected promise standing as `reauthing` -- and every later call
559 /// took the `if (reauthing)` arm and re-threw the same failure, for the life
560 /// of the page. One renewal that fell over meant no session again, ever,
561 /// short of a reload. `bootstrap()` catches broadly, but the six lines before
562 /// its `try` -- `isUnlocked()`, `publicKeyB64url()`, a `localStorage` read --
563 /// are outside it, and localStorage throws outright where storage access is
564 /// denied. So the wedge is reachable, and it is one keystroke away from being
565 /// reachable again whatever those lines become next.
566 ///
567 /// IT DOES NOT CLEAR A SESSION SOMEBODY ELSE IS TAKING. `bootstrap()` is
568 /// single-flight, so where one is already running this renewal JOINS it
569 /// rather than starting another -- and that attempt has very likely set
570 /// `state.authed` true already, because it does so the moment the verify
571 /// returns and before its balance and licence reads. Clearing the flag on
572 /// the way in would then be clearing a session that exists, on an attempt
573 /// this call cannot re-run: the joiner gets `true` back and the false stands.
574 /// Measured on a held balance read: `authed` false, `pro` null, the gateway
575 /// serving that very session 200. It is what put `verify_redeem` at "the code
576 /// was spent and the app never took the account it bought", with the whole
577 /// round green in the gateway's own log.
578 ///
579 /// So the flag is cleared only where this call is the one about to go and
580 /// ask, and it is SET FROM THE ANSWER either way. A renewal that reports a
581 /// session and leaves the app saying it has none is the same lie in the other
582 /// direction, and the tail of a bootstrap is a wide enough window to hit.
583 async function reauth() {
584 if (reauthing) return await reauthing;
585 // True from here would be a lie, whatever follows -- but only where there
586 // is no attempt in flight whose own answer is about to say.
587 if (!bootstrapping) state.authed = false;
588 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return false;
589 var gen = reauthGen;
590 reauthing = (async function () {
591 var got = await bootstrap();
592 if (gen !== reauthGen) { state.authed = false; return false; } // logged out under us.
593 state.authed = !!got; // the answer, not the assumption.
594 if (got) {
595 reauthWait = REAUTH_MIN_MS;
596 if (reauthTimer) { clearTimeout(reauthTimer); reauthTimer = null; }
597 // The same event a first unlock raises, for the same reason: there
598 // is a session now. Sync hears it and reconciles; without it a
599 // device whose renewal only worked on the third go would hold a
600 // live session and never use it.
601 try { window.dispatchEvent(new Event('daimond:authed')); } catch (e) { /* no window */ }
602 } else {
603 armReauth();
604 }
605 return got;
606 })();
607 try { return await reauthing; }
608 finally { reauthing = null; }
609 }
610
611 /// Come back to a renewal that did not work, after a wait that grows.
612 ///
613 /// Without this a gateway that was down for a minute would leave the tab
614 /// signed out for the rest of the day: every trigger in the app that would
615 /// have retried is itself gated on there being a session. Jittered, so a
616 /// gateway restart does not bring every device back in the same millisecond.
617 function armReauth() {
618 if (reauthTimer) return;
619 var wait = reauthWait * (0.5 + Math.random());
620 reauthWait = Math.min(REAUTH_MAX_MS, reauthWait * 2);
621 reauthTimer = setTimeout(function () {
622 reauthTimer = null;
623 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return;
624 reauth();
625 }, wait);
626 }
627
628 /// One gateway request, with the one refusal the app can put right itself.
629 ///
630 /// THE ONE COPY. Four sibling files -- mail, tools, pairing, passkey -- each
631 /// held an identical private version of this, and sync a fifth in a different
632 /// shape; five copies of a rule that has to be the same rule in all five
633 /// places. It lives here now, beside the renewal it drives, and every caller
634 /// reaches it through `DaimondGateway.gwFetch`.
635 ///
636 /// RENEW ONCE, RETRY ONCE, AND OTHERWISE HAND BACK THE ORIGINAL 401. An
637 /// identity that genuinely cannot authenticate must surface rather than spin
638 /// against a door that is not going to open, and when it does not come back
639 /// `reauth()` has already cleared `state.authed` -- which is what the Admin
640 /// drawer's Account row and the sync chip draw "not signed in" from. Nothing
641 /// is raised over the app from here.
642 ///
643 /// SAFE TO REPEAT. Every session-authed handler in the gateway takes the
644 /// session before it does anything else: `common::authed_account` is the
645 /// first statement after the method check in `pro_impl`, `credits_impl`,
646 /// `pack_impl`, `card_impl`, `tools_impl`, `create_impl`, the mail handlers
647 /// and the passkey blob's write and delete. So a 401 is proof that nothing
648 /// happened -- no body parsed, no code minted, no charge, no message sent --
649 /// and the second attempt cannot duplicate a side effect the first never had.
650 /// The options object is reused as given, which holds because every body in
651 /// this app is a string rather than a stream.
652 ///
653 /// NOT FOR EVERY PATH. `/api/pair/redeem` and the passkey blob READ take no
654 /// session at all and run on a device mid-adoption that has no identity to
655 /// authenticate with; a redeem code is single-use besides, so a blanket retry
656 /// is exactly the retry that must not exist. Both deliberately call `fetch`
657 /// directly -- see the notes at `redeem()` in pairing.js and `getBlob()` in
658 /// passkey.js.
659 /// The forge's own refusal vocabulary, from the Improve-panel contract §3.1.
660 ///
661 /// Only the two that arrive as 401 are listed, because this is asked ONLY of a
662 /// 401 -- a wider list would invite the next reader to use this for something
663 /// it was not measured for.
664 var FORGE_401 = ['unvoiced', 'unknown'];
665
666 /// Is this 401 the FORGE refusing a voice, rather than the gateway refusing
667 /// our session?
668 ///
669 /// Answered from the body, on a clone, so the caller still gets an unread
670 /// one. Anything that does not parse, or parses without one of the two
671 /// tokens, is treated as ours -- the safe direction, since the cost of
672 /// renewing unnecessarily is a round trip and the cost of NOT renewing when
673 /// we should is the silent refusal this whole mechanism was built to end.
674 async function isForgeRefusal(r) {
675 var body = null;
676 try { body = await r.clone().json(); } catch (e) { return false; }
677 return !!(body && typeof body.error === 'string'
678 && FORGE_401.indexOf(body.error) !== -1);
679 }
680
681 /// The one gateway request, as described above.
682 ///
683 /// # Arguments
684 /// * `path` - The gateway path, query and all.
685 /// * `opts` - The `fetch` options, reused as given on the retry.
686 /// * `own` - True when `bootstrap()` is making this call ITSELF, which is
687 /// the one case that must not renew. See the note above.
688 async function gwFetch(path, opts, own) {
689 // A paused node never reaches the network. Here as well as in the guard
690 // over `fetch`, because this is the one copy of the gateway rule and a
691 // reader looking for what a call does looks here.
692 var stop = spendRefusal(path, opts);
693 if (stop) return refusedReply(stop);
694 var r = await fetch(path, opts);
695 probeVersion(r);
696 // A call the bootstrap is making itself cannot answer a 401 by
697 // bootstrapping, and neither can a call that IS the authentication.
698 // Everything else may.
699 if (r.status !== 401 || own || isAuthPath(path)) return r;
700 // NOT EVERY 401 IS OURS. `/api/improve` forwards a tester's VOICE to the
701 // Oregami forge, which answers 401 in its own right -- `unvoiced` for a
702 // missing credential, `unknown` for one it does not recognise. Renewing
703 // this app's session cannot make a wrong voice right, so without this the
704 // forge's refusal costs a pointless signature round trip AND SENDS THE
705 // WRITE A SECOND TIME. Measured at two requests for one refused post.
706 //
707 // Told apart by the forge's own vocabulary rather than by the path,
708 // because the token is the thing that means "this 401 was not about your
709 // session" wherever it arrives from. The body is read off a CLONE: the
710 // caller is handed the original and must still be able to read it.
711 if (await isForgeRefusal(r)) return r;
712 var back = false;
713 try { back = !!(await reauth()); } catch (e) { back = false; }
714 if (!back) return r;
715 r = await fetch(path, opts);
716 probeVersion(r);
717 return r;
718 }
719
720 /// The reply, or an error. Through `gwFetch`, so a lapsed session costs the
721 /// round a renewal and not the round itself.
722 ///
723 /// This file used to renew and then throw the result away: the 401 arm called
724 /// `reauth()` and fell straight through to the `throw`, so every caller here
725 /// lost its answer at the hour mark having just paid for a new session. Two
726 /// of those losses were worse than a blank: `state.pro` going null HIDES the
727 /// Pro row rather than showing it unbought, and `operatorRole()` caches its
728 /// null for the rest of the unlock, so a signed-in operator's console entry
729 /// disappeared until they locked and unlocked again.
730 async function post(path, body, own) {
731 var r = await gwFetch(path, {
732 method: 'POST',
733 headers: { 'content-type': 'application/json', 'x-daimond-api': String(CLIENT_API) },
734 credentials: 'same-origin',
735 body: JSON.stringify(body || {}),
736 }, own);
737 var j = null;
738 try { j = await r.json(); } catch (e) { j = null; }
739 if (!r.ok || !j || j.ok === false) {
740 var msg = (j && (j.error || j.message)) || ('HTTP ' + r.status);
741 throw new Error(msg);
742 }
743 noteBalance(j);
744 return j;
745 }
746
747 async function get(path, own) {
748 var r = await gwFetch(path, {
749 credentials: 'same-origin',
750 headers: { 'x-daimond-api': String(CLIENT_API) },
751 }, own);
752 var j = null;
753 try { j = await r.json(); } catch (e) { j = null; }
754 if (!r.ok || !j || j.ok === false) {
755 var msg = (j && (j.error || j.message)) || ('HTTP ' + r.status);
756 throw new Error(msg);
757 }
758 noteBalance(j);
759 return j;
760 }
761
762 // ── A registration the gateway REFUSED ─────────────────────
763 //
764 // `/api/account` can answer three ways that are not "here is your account",
765 // and until this landed the client could tell none of them apart: `post()`
766 // throws a bare `Error` and `bootstrap()`'s catch turned every one of them
767 // into `offline: true`. So a stranger the beta had deliberately refused was
768 // dropped into BYOK-only mode and told the account service could not be
769 // reached -- which is untrue, unactionable, and points at their network for
770 // something the server decided. The `reason` field exists precisely so the
771 // browser can say which; this is the code that reads it.
772 //
773 // Two reasons are read by name because they are decisions rather than
774 // faults, and each has a different answer:
775 //
776 // `beta_only` 403 -- the beta is closed. There IS a way in: a passcode.
777 // `unavailable` 503 -- the gateway could not read its own gate, so it
778 // minted nothing. Temporary; the answer is to ask again.
779 //
780 // Anything else -- a malformed body, a signature it would not take, a
781 // gateway that did not answer at all -- stays `offline`, exactly as before.
782
783 /// The registration round, with its refusal read rather than thrown away.
784 ///
785 /// Not through `post()`, and that is the whole point: `post` reduces every
786 /// failure to a message string, and the message is the one part of a refusal
787 /// the app must NOT act on -- `reason` is.
788 ///
789 /// Through `gwFetch` all the same, so the shared 401 rule still applies.
790 /// `/api/account` is an auth path, so `isBootstrapOwn` answers true for it
791 /// and no renewal can be re-entered from here.
792 async function register(body) {
793 var r = await gwFetch('/api/account', {
794 method: 'POST',
795 headers: { 'content-type': 'application/json', 'x-daimond-api': String(CLIENT_API) },
796 credentials: 'same-origin',
797 body: JSON.stringify(body),
798 }, true); // the bootstrap's own, always
799 var j = null;
800 try { j = await r.json(); } catch (e) { j = null; }
801 if (r.ok && j && j.ok !== false) {
802 noteBalance(j);
803 // The account's own facts, carried out rather than dropped. This
804 // returned a bare `{ok:true}` and the reply's other fields died
805 // here, which is why the beta wave could not reach the browser on
806 // any round except the redemption itself.
807 return {
808 ok: true,
809 account: typeof j.account_id === 'string' ? j.account_id : '',
810 beta: j.beta === true,
811 wave: typeof j.wave === 'number' ? j.wave : 0,
812 };
813 }
814 return {
815 ok: false,
816 status: r.status,
817 reason: (j && j.reason) || '',
818 error: (j && (j.error || j.message)) || ('HTTP ' + r.status),
819 };
820 }
821
822 /// Whether a refusal is one the app can say something useful about.
823 function isRefusal(reason) {
824 return reason === 'beta_only' || reason === 'unavailable';
825 }
826
827 /// Tell the app a registration was refused, and why.
828 ///
829 /// An event rather than a call into a panel, because what the refusal is
830 /// SHOWN in is not this file's business: js/passcode.js listens for it and
831 /// puts the sentence and the way past it on screen. A page with no `window`
832 /// -- a test harness evaluating this file -- simply does not hear it.
833 function announceRefusal() {
834 try {
835 window.dispatchEvent(new CustomEvent('daimond:refused', {
836 detail: { reason: state.refused, error: state.refusal },
837 }));
838 } catch (e) { /* no window to tell */ }
839 }
840
841 /// Forget a refusal. Called wherever the answer stops being true: a
842 /// registration that took, a redemption, a logout.
843 function forgetRefusal() {
844 state.refused = null;
845 state.refusal = '';
846 }
847
848 /// Bind this device's public key to an account, then authenticate.
849 ///
850 /// Both steps are signed with the device key, so this only works while the
851 /// identity is unlocked — which is why it hangs off `afterUnlock()` and not
852 /// off boot.
853 ///
854 /// SINGLE-FLIGHT, for the same reason `reauth()` is. Five callers reach this
855 /// -- daimond.js at unlock, tools.js and mail.js when a panel opens on no
856 /// session, passcode.js after a redemption, and `reauth()` itself -- and two
857 /// of them landing together used to run two whole registrations: two
858 /// signatures, two challenges, two sessions minted for one unlock, the second
859 /// quietly replacing the first. Worse, the two tails then straddled: whichever
860 /// finished first said no bootstrap was running, and the other one's own
861 /// balance and licence reads deadlocked the tab on the renewal they were part
862 /// of. See the note above `gwFetch`. They share one attempt now.
863 async function bootstrap() {
864 if (bootstrapping) return await bootstrapping;
865 bootstrapping = bootstrapOnce();
866 try { return await bootstrapping; }
867 finally { bootstrapping = null; }
868 }
869
870 /// One bootstrap, start to finish. Never called directly: `bootstrap()` is
871 /// the door, and it is the thing that keeps there being only one of these.
872 async function bootstrapOnce() {
873 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return false;
874 state.role = undefined; // a new unlock is a new question
875 state.pro = null; // re-asked for whoever unlocked now
876 // And so is the beta standing. Cleared BEFORE the round rather than
877 // overwritten after it: a bootstrap that fails halfway must leave this
878 // device holding no membership, not the last one it heard about.
879 state.beta = false;
880 state.wave = 0;
881 state.accountId = '';
882 forgetLicenceTerm(); // and so are its dates
883 var pub = DaimondIdentity.publicKeyB64url();
884 if (!pub) return false;
885 var alg = localStorage.getItem('daimond-id-alg') || 'Ed25519';
886
887 try {
888 // Register (idempotent: an existing binding is simply re-confirmed).
889 var ts = Math.floor(Date.now() / 1000);
890 var sig = await DaimondIdentity.sign(ACCOUNT_MSG + pub + ':' + ts);
891 // NO COUNTRY IS DERIVED. It used to be guessed from the browser's
892 // locale and time zone so the operator's map had something to shade,
893 // which meant the one item taken without the user's involvement was
894 // the one nobody had asked about. The privacy policy now says the
895 // country is optional, entered by the user, and never worked out
896 // from the browser: `deriveCountry` is kept, unused, until a field
897 // exists to pass one here deliberately.
898 var body = { pubkey: pub, alg: alg, ts: ts, sig: sig };
899 var reg = await register(body);
900 if (!reg.ok) {
901 state.authed = false;
902 if (isRefusal(reg.reason)) {
903 // It ANSWERED. Saying "offline" here is the defect this
904 // replaces: it is not true, and it hides the one thing the
905 // person can act on.
906 state.offline = false;
907 state.refused = reg.reason;
908 state.refusal = reg.error;
909 announceRefusal();
910 } else {
911 state.offline = true;
912 forgetRefusal();
913 }
914 return false;
915 }
916 forgetRefusal();
917 // This account's standing in the closed test, straight off the
918 // registration reply. Read before the session is taken, because it
919 // describes the account and not the session, and a `false` from a
920 // gateway that answered is worth having even if the round below
921 // fails.
922 state.beta = reg.beta === true;
923 state.wave = (typeof reg.wave === 'number' && isFinite(reg.wave) && reg.wave > 0)
924 ? Math.floor(reg.wave) : 0;
925 state.accountId = reg.account || '';
926
927 // Prove possession of the key and take a session.
928 // `own` on every one of these: they are this bootstrap's own calls,
929 // and a 401 on one of them must be handed back rather than answered
930 // by re-entering the renewal this is running inside.
931 var ch = await post('/api/auth/challenge', { pubkey: pub, alg: alg }, true);
932 var chSig = await DaimondIdentity.sign(ch.challenge);
933 await post('/api/auth/verify', { challenge_id: ch.challenge_id, sig: chSig }, true);
934
935 state.authed = true;
936 state.offline = false;
937 await refreshBalance(true);
938 await refreshLicence(true);
939 return true;
940 } catch (e) {
941 // The gateway is optional: Daimond is fully usable on a BYOK key with no
942 // account at all, so a gateway that is down must not break the app.
943 state.authed = false;
944 state.offline = true;
945 // A refusal read on the LAST attempt is not evidence about this one:
946 // the beta may have been opened, or this failure may be the network
947 // rather than the door. Held over, it would leave the app offering a
948 // passcode field against a gateway nobody has heard from.
949 forgetRefusal();
950 return false;
951 }
952 }
953
954 // ── The one door through a closed beta ─────────────────────
955
956 /// What a refused redemption is called, in the reader's own language.
957 ///
958 /// FOUR ANSWERS AND NOT ONE. The gateway distinguishes a code it never
959 /// issued from one already used from one that has run out, and the whole
960 /// reason it bothers is that they send a person somewhere different: check
961 /// what you typed, ask whoever gave you the code who else has it, ask for
962 /// another. A single friendly "that did not work" would be easier to write
963 /// and would throw all of that away, so nothing here softens which happened.
964 ///
965 /// A reason this build has never heard of falls through to the gateway's own
966 /// English sentence rather than to a shrug, so a refusal added on the server
967 /// is still legible in an old tab.
968 function redeemWords(reason, j, status) {
969 switch (reason) {
970 case 'unknown': return t('beta.err_unknown');
971 case 'spent': return t('beta.err_spent');
972 case 'expired': return t('beta.err_expired');
973 case 'throttled': return t('beta.err_throttled');
974 default: return (j && (j.error || j.message)) || t('beta.err_generic')
975 + ' (HTTP ' + status + ')';
976 }
977 }
978
979 /// Redeem a beta passcode onto THIS device, and come out signed in.
980 ///
981 /// Redemption IS the registration. The gateway takes the code and the same
982 /// device-binding proof `/api/account` takes, and writes the account inside
983 /// the one critical section that spends the code -- so a code that turns out
984 /// to be spent leaves nothing behind. There is nothing to do first and
985 /// nothing to do after except what an ordinary registration does, which is
986 /// why this ends by calling `bootstrap()` rather than by inventing a second
987 /// way to be signed in.
988 ///
989 /// DELIBERATELY NOT THROUGH `gwFetch`, for the reasons `redeem()` in
990 /// pairing.js gives about its own: this endpoint takes no session, the
991 /// device making the call has none and may have no account at all, so a 401
992 /// here could not be a session that lapsed and renewing could not change the
993 /// answer. And a passcode is single-use -- a blanket retry on a refusal is
994 /// exactly the retry that must not exist here.
995 ///
996 /// # Arguments
997 /// * `code` - What the user typed. Sent as typed: the gateway folds case and
998 /// drops the grouping separators, so `A1B2-C3D4-E5F6` and `a1b2c3d4e5f6`
999 /// are one code and neither has to be cleaned up here.
1000 ///
1001 /// # Returns
1002 /// `{ created, pro, wave, handle, authed }`. `authed` is whether the session
1003 /// that follows was actually taken: the code is spent by then either way, so
1004 /// a redemption is never reported as having failed because the round after
1005 /// it did.
1006 async function redeemPasscode(code) {
1007 code = String(code || '').trim();
1008 if (!code) throw new Error(t('beta.err_enter_code'));
1009 if (!window.DaimondIdentity || !DaimondIdentity.exists()) {
1010 throw new Error(t('beta.err_no_identity'));
1011 }
1012 if (!DaimondIdentity.isUnlocked()) throw new Error(t('beta.err_locked'));
1013 var pub = DaimondIdentity.publicKeyB64url();
1014 if (!pub) throw new Error(t('beta.err_no_identity'));
1015 var alg = localStorage.getItem('daimond-id-alg') || 'Ed25519';
1016 var ts = Math.floor(Date.now() / 1000);
1017 // The same string, signed the same way, by the same signer the ordinary
1018 // registration uses. There is one device signer in this app and this is
1019 // not a second one.
1020 var sig = await DaimondIdentity.sign(ACCOUNT_MSG + pub + ':' + ts);
1021
1022 var r;
1023 try {
1024 r = await fetch('/api/passcode/redeem', {
1025 method: 'POST',
1026 credentials: 'same-origin',
1027 headers: { 'content-type': 'application/json', 'x-daimond-api': String(CLIENT_API) },
1028 body: JSON.stringify({ code: code, pubkey: pub, alg: alg, ts: ts, sig: sig }),
1029 });
1030 } catch (e) {
1031 // The request never arrived, so the code was NOT spent -- and that is
1032 // the part the person needs, because they hold exactly one. A message
1033 // about the passcode would be a claim about a credential nothing here
1034 // has learned anything about.
1035 throw new Error(t('beta.err_unreachable'));
1036 }
1037 probeVersion(r);
1038 var j = null;
1039 try { j = await r.json(); } catch (e) { j = null; }
1040 if (!r.ok || !j || j.ok === false) {
1041 var reason = (j && j.reason) || '';
1042 var err = new Error(redeemWords(reason, j, r.status));
1043 // Carried so a caller can act on WHICH refusal it was rather than on
1044 // the sentence, which is translated and is not a contract.
1045 err.reason = reason;
1046 throw err;
1047 }
1048
1049 // The account exists now, so whatever the gateway last refused is no
1050 // longer true. Cleared BEFORE the bootstrap, so the round below starts
1051 // from the state it would have had on a device that was never refused.
1052 forgetRefusal();
1053 var authed = await bootstrap();
1054 if (authed) {
1055 // The event `reauth()` raises for the same fact: there is a session
1056 // now. Sync hears it and reconciles, pairing reveals its link button.
1057 // A first `bootstrap()` at unlock does not raise it -- daimond.js
1058 // drives that path by hand -- but nothing drives this one, and a
1059 // device that redeemed and then never synced would be the whole
1060 // point of the account it just got.
1061 try { window.dispatchEvent(new Event('daimond:authed')); } catch (e) { /* no window */ }
1062 }
1063 return {
1064 created: j.created === true,
1065 pro: j.pro === true,
1066 wave: typeof j.wave === 'number' ? j.wave : 0,
1067 handle: j.handle || '',
1068 authed: authed,
1069 // WHOSE agreement it would be, if they say yes to the question that
1070 // follows this reply. An agreement is scoped to an account and not to
1071 // a device, so the card that asks needs the id as much as the wave.
1072 account: j.account_id || '',
1073 };
1074 }
1075
1076 /// Ask for the balance outright, and keep the recent ledger entries with it.
1077 ///
1078 /// The figure and the currency are adopted by `noteBalance` inside `get`, and are NOT written
1079 /// again here. They used to be, and the second write was not a duplicate: `j.credits_minor ||
1080 /// 0` turns a reply that said nothing about money into an explicit zero balance, which is the
1081 /// one reading a credit figure must never invent.
1082 ///
1083 /// `own` is true only where `bootstrap()` calls this as its own last-but-one
1084 /// step; see the note above `gwFetch` for what that word buys.
1085 async function refreshBalance(own) {
1086 if (!state.authed) return null;
1087 try {
1088 var j = await get('/api/balance', own); // `get` adopts the figure through `noteBalance`
1089 state.entries = j.entries || [];
1090 } catch (e) {
1091 // Unknown is a change like any other, and the row that shows this says "—" for it.
1092 // Without the announcement the figure was set to null here and nothing was told, so
1093 // the header went on showing money the app had stopped believing in until something
1094 // unrelated repainted it. Only on the way from a figure to none, so a gateway that
1095 // stays down is silent after the first failure.
1096 if (state.credits !== null) { state.credits = null; announce(); }
1097 }
1098 return state.credits;
1099 }
1100
1101 /// Start a hosted Stripe Checkout for a credit pack. The gateway owns the
1102 /// price; we send only which pack, and it validates that against its
1103 /// allowlist before creating the session.
1104 async function buyCredits(packMinor) {
1105 if (!state.authed) {
1106 var ok = await bootstrap();
1107 if (!ok) throw new Error(t('gateway.acct_unreachable'));
1108 }
1109 var j = await post('/api/checkout/credits', { pack_minor: packMinor });
1110 if (!j.url) throw new Error(t('gateway.session_no_url'));
1111 // Reaching for the offer, which is the signal; buying is the next one and
1112 // is reported on the way back from Stripe. Which offer, as a number --
1113 // never the amount, which is a fact about this person's money.
1114 telemetry('buy.open', 'credits');
1115 window.location = j.url;
1116 }
1117
1118 /// Put a card on file, charging nothing.
1119 ///
1120 /// The same hosted Stripe page as a purchase, in `setup` mode: the card is collected and
1121 /// checked by Stripe and attached to a customer this account owns. No card detail ever
1122 /// reaches Daimond -- the gateway learns the brand and the last four digits, off the webhook,
1123 /// and nothing else.
1124 async function saveCard() {
1125 if (!state.authed) {
1126 var ok = await bootstrap();
1127 if (!ok) throw new Error(t('gateway.acct_unreachable'));
1128 }
1129 var j = await post('/api/card/setup', {});
1130 if (!j.url) throw new Error(t('gateway.card_no_url'));
1131 window.location = j.url;
1132 }
1133
1134 /// Start a hosted Stripe Checkout for the one-time Pro unlock. The gateway
1135 /// owns the price and refuses a second purchase, so the client sends nothing
1136 /// but the intent to buy.
1137 ///
1138 /// Through `gwFetch`, like everything else here. This was a bare `fetch` with
1139 /// no answer to a 401 at all, so an expired session ended the purchase where
1140 /// it stood: the button reported the gateway's own "No valid session.", which
1141 /// says nothing the buyer can act on, and its fallback line -- "The checkout
1142 /// session came back without a URL." -- is a sentence about a URL for a
1143 /// problem about a session, reached whenever the refusal arrives without a
1144 /// body of its own. On the one screen where being wrong costs a sale.
1145 ///
1146 /// A RETRY ON A PAYMENT PATH, AND WHY IT IS SOUND. `pro_impl`
1147 /// (gateway/src/handlers/checkout.rs) takes the session immediately after the
1148 /// method check -- before the licence lookup, before Stripe is configured,
1149 /// long before a session is created -- so a 401 is proof that no hosted
1150 /// checkout exists and no money has moved. And should the two attempts ever
1151 /// both reach Stripe, they carry the same idempotency key over the same form,
1152 /// which is what makes a second attempt land on the first session rather than
1153 /// a second charge.
1154 async function buyPro() {
1155 if (!state.authed) {
1156 var ok = await bootstrap();
1157 if (!ok) throw new Error(t('gateway.acct_unreachable'));
1158 }
1159 var r = await gwFetch('/api/checkout/pro', {
1160 method: 'POST',
1161 credentials: 'same-origin',
1162 headers: { 'content-type': 'application/json', 'x-daimond-api': String(CLIENT_API) },
1163 body: '{}',
1164 });
1165 var j = null; try { j = await r.json(); } catch (e) {}
1166 // Already held is not an error to shout about: reflect it and stop.
1167 if (r.status === 409) { state.pro = true; return { held: true }; }
1168 if (!r.ok || !j || !j.url) throw new Error((j && j.error) || t('gateway.session_no_url'));
1169 telemetry('buy.open', 'pro');
1170 window.location = j.url;
1171 return { held: false };
1172 }
1173
1174 /// Drop what is known about THIS account's subscription standing.
1175 ///
1176 /// One account's status must not sit on screen under the next person's
1177 /// session, and a stale date is worse than none: a notice drawn from it would
1178 /// name a day that means nothing to whoever is looking at it.
1179 function forgetLicenceTerm() {
1180 state.proStatus = 'none';
1181 state.proPeriodEnd = null;
1182 state.proWarned = false;
1183 state.proNowTs = null;
1184 }
1185
1186 /// Whether this account holds Pro, asked of the gateway. Sets `state.pro`
1187 /// and returns it, or leaves it null when the gateway cannot be reached.
1188 ///
1189 /// Pro is a monthly SUBSCRIPTION, so `held` is the live answer the gateway
1190 /// gives from the same check the sync, storage and mail doors ask before
1191 /// opening -- true while the subscription grants (active, or past-due inside
1192 /// Stripe's grace window). The status is carried beside it, because a pause is
1193 /// not a lapse and has to be shown as what it is: the ethical auto-pause stops
1194 /// billing an idle account and resumes it the moment the account is used, so a
1195 /// paused subscription is drawn warmly, not as an expiry.
1196 ///
1197 /// `own` as for `refreshBalance` above: set only by the bootstrap that makes
1198 /// this call as part of itself.
1199 async function refreshLicence(own) {
1200 if (!state.authed) { state.pro = null; return null; }
1201 try {
1202 var j = await get('/api/licence', own);
1203 state.pro = !!(j && j.held);
1204 state.proStatus = (j && typeof j.status === 'string') ? j.status : 'none';
1205 state.proPeriodEnd = (j && typeof j.current_period_end === 'number' && j.current_period_end > 0)
1206 ? j.current_period_end : null;
1207 state.proWarned = !!(j && j.warned);
1208 if (j && typeof j.now_ts === 'number') state.proNowTs = j.now_ts;
1209 if (j && typeof j.pro_price_minor === 'number') state.proPriceMinor = j.pro_price_minor;
1210 if (j && j.currency) state.currency = j.currency;
1211 } catch (e) {
1212 state.pro = null;
1213 }
1214 return state.pro;
1215 }
1216
1217 /// Act on this account's own subscription: cancel it at the period end, or
1218 /// resume a paused or cancel-pending one. Refreshes what is known after, so
1219 /// the app redraws from the gateway's answer rather than a guess. Returns the
1220 /// new status string, or throws with a message the caller can show.
1221 async function subscriptionAction(action) {
1222 if (!state.authed) throw new Error(t('gateway.acct_unreachable'));
1223 var r = await gwFetch('/api/subscription', {
1224 method: 'POST',
1225 credentials: 'same-origin',
1226 headers: { 'content-type': 'application/json', 'x-daimond-api': String(CLIENT_API) },
1227 body: JSON.stringify({ action: action }),
1228 });
1229 var j = null; try { j = await r.json(); } catch (e) {}
1230 if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || t('gateway.session_no_url'));
1231 await refreshLicence(true);
1232 return (j && j.status) || state.proStatus;
1233 }
1234 function cancelPro() { return subscriptionAction('cancel'); }
1235 function resumePro() { return subscriptionAction('resume'); }
1236
1237 /// The whole categorised credit ledger, for the spending view: every
1238 /// movement, newest first, each tagged with a `category` the breakdown
1239 /// groups by. Returns the entries array, or an empty one when there is no
1240 /// account or the gateway is unreachable -- the view degrades to "nothing
1241 /// spent here yet" rather than an error.
1242 async function ledger() {
1243 if (!state.authed) return [];
1244 try {
1245 var j = await get('/api/ledger');
1246 return Array.isArray(j.entries) ? j.entries : [];
1247 } catch (e) {
1248 return [];
1249 }
1250 }
1251
1252 /// The account's auto-reload settings, and the card behind them.
1253 async function autoReload() {
1254 if (!state.authed) return null;
1255 try { return await get('/api/autoreload'); }
1256 catch (e) { return null; }
1257 }
1258
1259 /// Save the standing instruction. The gateway refuses anything that cannot work -- on with no
1260 /// card, a budget under one top-up -- and says why, so the message is shown rather than
1261 /// second-guessed here.
1262 async function setAutoReload(s) {
1263 return await post('/api/autoreload', {
1264 enabled: !!s.enabled,
1265 threshold_minor: s.threshold_minor | 0,
1266 topup_minor: s.topup_minor | 0,
1267 monthly_budget_minor: s.monthly_budget_minor | 0,
1268 });
1269 }
1270
1271 /// Read the marker Stripe sends us back with, and clear it from the URL so a reload does not
1272 /// re-announce it. `buy` is a purchase; `card` is a card saved with nothing charged.
1273 function consumeReturn() {
1274 var q = new URLSearchParams(location.search);
1275 var buy = q.get('buy');
1276 var card = q.get('card');
1277 if (!buy && !card) return null;
1278 q.delete('buy'); q.delete('card');
1279 var url = location.pathname + (q.toString() ? '?' + q : '');
1280 history.replaceState({}, '', url);
1281 // 'credits' | 'cancel' | 'pro' | 'card:saved' | 'card:cancel'
1282 return buy || ('card:' + card);
1283 }
1284
1285 /// The console role this account holds, or null if it holds none.
1286 ///
1287 /// Asked once per unlock and remembered, so drawing the Home panel does not
1288 /// hit the network. Every failure -- offline, no session, a gateway that
1289 /// does not know the view -- is the same answer as "no role": the console
1290 /// is offered only on a definite yes, because an entry that leads to a
1291 /// locked door is worse than no entry at all.
1292 ///
1293 /// THE MEMO IS NOT WRITTEN BEFORE THE ANSWER. `state.role` was set to null on the way
1294 /// IN, as a placeholder, so a second caller arriving while the first ask was still in
1295 /// flight was answered "no role" by a request that had not come back. Home draws its
1296 /// console entry hidden and reveals it on that answer, and `renderHome` runs more than
1297 /// once around an unlock -- so whichever draw lost the race kept a hidden entry for the
1298 /// rest of the session while `state.role` went on to hold 'owner'. An owner then loses
1299 /// the console until they lock and unlock. Measured 2026-08-21: seven runs of
1300 /// `dev/verify_operator_button` in thirty-eight failed exactly that way, every ask
1301 /// answered 200.
1302 ///
1303 /// A SECOND CALLER MAKES ITS OWN ASK, AND THAT IS DELIBERATE. Sharing one promise
1304 /// between callers was tried and reverted the same day: it is the shape the block above
1305 /// `reauth()` exists to forbid -- ownership travels with the call, and a promise another
1306 /// caller can join is not evidence about THIS call. `whoami` can meet a 401 and renew,
1307 /// so a joined ask can be a bootstrap's own call and a stranger's at once, which is the
1308 /// state that parked `reauthing` and took a tab silent for two days. One duplicate
1309 /// request is the cheaper mistake.
1310 async function operatorRole() {
1311 if (state.role !== undefined) return state.role;
1312 var got = null;
1313 try {
1314 var j = await get('/api/admin?view=whoami');
1315 got = (j && j.role) || null;
1316 } catch (e) { got = null; }
1317 // A lock in the middle of the ask has already cleared the memo, and the answer
1318 // belongs to the session that has gone, so it is not written back.
1319 if (state.authed) state.role = got;
1320 return got;
1321 }
1322
1323 /// End this device's session on the gateway.
1324 ///
1325 /// Called when the app is locked or the identity forgotten. Best effort by
1326 /// design: a gateway that cannot be reached must not stop a person locking
1327 /// their own app, and the session expires on its own within the hour. But it
1328 /// is awaited where the caller can afford to, because the whole point is that
1329 /// the door is shut before the person walks away.
1330 async function logout() {
1331 state.authed = false;
1332 state.role = undefined;
1333 // The beta standing belongs to whoever was signed in. It goes with them,
1334 // and so does anything recording for them: a recorder left armed across
1335 // a sign-out would carry the NEXT person's session under the LAST
1336 // person's wave.
1337 //
1338 // `withdraw()` forgets the agreement as well as stopping the recorder,
1339 // and that is the behaviour wanted here even though signing out is not
1340 // itself a withdrawal. The alternative is a second function that stops
1341 // without forgetting, and two near-identical ways to stop is how one of
1342 // them ends up being the one a later release calls. The cost is that
1343 // signing back in asks again; the Credits drawer carries the same
1344 // question with the same words, so there is somewhere to say yes.
1345 state.beta = false;
1346 state.wave = 0;
1347 state.accountId = '';
1348 try {
1349 if (window.DaimondTelemetry) DaimondTelemetry.withdraw();
1350 } catch (e) { /* no telemetry client in this build */ }
1351 // A refusal is an answer about the identity that was signed in. It must
1352 // not follow the next one onto the screen.
1353 forgetRefusal();
1354 var had = state.credits !== null;
1355 state.credits = null;
1356 state.pro = null;
1357 forgetLicenceTerm();
1358 // One account's money must not sit on screen under the next person's session, and the
1359 // header only repaints when it is told to.
1360 if (had) announce();
1361 // A person leaving is not a session that lapsed, so the renewal above must
1362 // not put back what they have just ended: the standing retry is cancelled
1363 // and anything mid-flight is told, by the generation, to drop its result.
1364 reauthGen++;
1365 reauthWait = REAUTH_MIN_MS;
1366 if (reauthTimer) { clearTimeout(reauthTimer); reauthTimer = null; }
1367 try {
1368 await fetch('/api/auth/logout', {
1369 method: 'POST',
1370 credentials: 'same-origin',
1371 headers: { 'x-daimond-api': String(CLIENT_API) },
1372 });
1373 return true;
1374 } catch (e) { return false; }
1375 }
1376
1377 window.DaimondGateway = {
1378 bootstrap: bootstrap,
1379 /// Take a session again after one was refused. Single-flight, so a file
1380 /// holding its own `fetch` -- sync, the Web panel, mail -- answers its own
1381 /// 401 through the one renewal rather than starting another.
1382 reauth: reauth,
1383 /// One gateway request that answers its own lapsed session: renew once,
1384 /// retry once, and otherwise hand back the original 401. Every file that
1385 /// holds its own gateway call -- mail, tools, pairing, passkey, sync --
1386 /// goes through this rather than carrying a copy of the rule. See the
1387 /// note on `gwFetch` for which paths must NOT use it.
1388 gwFetch: gwFetch,
1389 /// Redeem a beta passcode onto this device and come out signed in. The
1390 /// screen that collects the code is js/passcode.js; the contract is
1391 /// here, beside the registration it IS.
1392 redeemPasscode: redeemPasscode,
1393 refreshBalance: refreshBalance,
1394 /// Read a balance out of a reply this file did not make itself — the Web panel, the mail
1395 /// panel and the inference mint each hold their own `fetch` wrapper, and their replies
1396 /// carry the balance too. There is one place that owns `state.credits`, and this is how
1397 /// they reach it. The mint's reply is the one that matters most: `/api/inference-key`
1398 /// reconciles the account before it answers, so its figure is the only one in an ordinary
1399 /// chat session that has actually moved. See the note at `noteBalance`.
1400 noteBalance: noteBalance,
1401 ledger: ledger,
1402 buyCredits: buyCredits,
1403 buyPro: buyPro,
1404 cancelPro: cancelPro,
1405 resumePro: resumePro,
1406 refreshLicence: refreshLicence,
1407 saveCard: saveCard,
1408 autoReload: autoReload,
1409 setAutoReload: setAutoReload,
1410 consumeReturn: consumeReturn,
1411 operatorRole: operatorRole,
1412 logout: logout,
1413 fmtMoney: fmtMoney,
1414 fmtBilled: fmtBilled,
1415 packs: function () { return PACKS.slice(); },
1416 state: function () { return Object.assign({}, state); },
1417 /// The contract version this build speaks, for a caller making its own
1418 /// gateway request. There is one copy of this number and it lives here.
1419 clientApi: function () { return CLIENT_API; },
1420 /// Whether the pause tree refuses this call, and in whose name. For a
1421 /// caller that holds its own `fetch` and would rather show the sentence
1422 /// in its own panel than read it off a 423.
1423 spendRefusal: spendRefusal,
1424 /// The sentence a request that never left the device is marked with, so a
1425 /// reader can test for it rather than quoting it a second time.
1426 ROAD_MARK: ROAD_MARK,
1427 /// Is this rejection the road rather than an answer? Reads the mark
1428 /// `guardFetch` set, and never the prose.
1429 isRoad: function (e) {
1430 if (!e) return false;
1431 if (e.daimondRoad) return true;
1432 var s = (e && e.message) ? String(e.message) : String(e);
1433 return s.indexOf(ROAD_MARK) === 0;
1434 },
1435 };
1436
1437 guardFetch();
1438})();