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