oxedyne/daimond/www/js/search.js
23.5 KiB, 1 run
created by r2519314175:1437, 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 | /* search.js — which service Daimond searches with, and whose key pays for it. |
| 2 | * |
| 3 | * There was no search tool. The model was handed eight web tools, none of which searched, |
| 4 | * so when it wanted to search it wrote a search URL by hand and fetched it — which is how |
| 5 | * it silently chose Bing. The fix is not a better prompt: it is a tool that takes a QUERY, |
| 6 | * where the ENGINE is the user's setting and not the model's decision. |
| 7 | * |
| 8 | * This file is deliberately `models.js` in miniature. That file already solved |
| 9 | * bring-your-own-key-or-ours for inference — a `KNOWN` registry, a `credits` pseudo- |
| 10 | * provider whose key the gateway holds, and every typed key sealed under the passphrase — |
| 11 | * and a second shape for the same idea is a second thing to keep in step. So the store, |
| 12 | * the sealing, the memory-only plaintext and the pause check in front of the spend are all |
| 13 | * the shapes that file uses, in the same order. |
| 14 | * |
| 15 | * The store, in localStorage: |
| 16 | * |
| 17 | * { |
| 18 | * v: 1, |
| 19 | * engine: 'credits', which service a search goes to |
| 20 | * keys: { <id>: { key, keyEnc } } the user's own key for that service, sealed |
| 21 | * } |
| 22 | * |
| 23 | * THE PLAINTEXT KEY NEVER GOES TO THE STORE UNSEALED, AND NEVER LEAVES THE BROWSER EXCEPT |
| 24 | * IN THE ONE REQUEST THAT PAYS FOR IT. That is the promise the mail password keeps — see |
| 25 | * `mail.js`, which refuses to file a mailbox at all until there is an identity to wrap the |
| 26 | * password under — and it is kept here the same way: `setKey` seals with |
| 27 | * `DaimondIdentity.wrap` or stores nothing. The `key` field is in the record because the |
| 28 | * contract names it and because `models.js` writes a plaintext key there on the |
| 29 | * browser-only path; THIS store never writes it, and reads it only so a record that |
| 30 | * arrived carrying one is not silently unreadable. That is the one place this file |
| 31 | * deliberately does not mirror `models.js`, and the reason is that an inference key buys |
| 32 | * tokens where a search key is one line in a support ticket away from the same account: |
| 33 | * both are bearer credentials, and the newer of the two gets the stricter rule. |
| 34 | * |
| 35 | * WHO PAYS. `credits` is the account's Daimond balance: the gateway holds the operator's |
| 36 | * key, picks the engine, and bills the search. Every other id is the user's own key, used |
| 37 | * for one call and dropped — the gateway stores it no more than `handlers/mail.rs` stores |
| 38 | * an IMAP password. A BYOK search still costs Oxedyne a socket, and the gateway charges |
| 39 | * for the relay rather than pretending it is free. |
| 40 | * |
| 41 | * `serper` IS BRING-YOUR-OWN-KEY ONLY. It resells Google results, so its business is an |
| 42 | * arbitrage that can end without notice. A user taking that risk with their own key is |
| 43 | * their choice; Oxedyne billing for it is Oxedyne's risk, and the answer is no. That is |
| 44 | * enforced here — `byokOnly`, read by the picker and by `search` — as well as in the |
| 45 | * gateway and in the operator console, because a rule about money that lives in one place |
| 46 | * lives in the place that is easiest to route around. |
| 47 | */ |
| 48 | (function () { |
| 49 | 'use strict'; |
| 50 | |
| 51 | /// What the app says. The table lives in i18n/en.js. |
| 52 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 53 | |
| 54 | /// A string from the table, or the English written here when the table has no entry |
| 55 | /// for it yet. The twin of `tOr` in daimond.js and models.js, and for the same reason: |
| 56 | /// the i18n lane fills all eight locales in parallel with this, and a control reading |
| 57 | /// "search.engine" while it waits is worse than one reading English. |
| 58 | function tOr(key, fallback, vars) { |
| 59 | var s = t(key, vars); |
| 60 | if (s !== key) return s; |
| 61 | if (!vars) return fallback; |
| 62 | return String(fallback).replace(/\{(\w+)\}/g, function (whole, k) { |
| 63 | return vars[k] != null ? String(vars[k]) : whole; |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | var KEY = 'daimond-search-v1'; // per account; accounts.js namespaces daimond-* |
| 68 | var CREDITS = 'credits'; // the engine the account's balance buys |
| 69 | var URL = '/api/web/search'; // the gateway route; see the contract §6 |
| 70 | var deps = null; // { onChange } |
| 71 | |
| 72 | /// The engines, and the same five ids everywhere — Rust enum, gateway request, this |
| 73 | /// registry, the settings value, the ledger reason. |
| 74 | /// |
| 75 | /// `url` is where a key is OBTAINED, not an endpoint this file calls: the browser never |
| 76 | /// dials a search engine. The gateway owns the socket, because `resolve_public` and the |
| 77 | /// redirect-per-hop check live there and a module that dialled for itself would bypass |
| 78 | /// both. That is the one field whose meaning differs from `models.js`, where a provider |
| 79 | /// really is called from the page. |
| 80 | /// |
| 81 | /// `keyHint` is a placeholder, and nothing is refused on it here. A vendor with no |
| 82 | /// stable prefix gets an empty one rather than a guess: a wrong hint in a box is worse |
| 83 | /// than none, because it reads as a rule. |
| 84 | /// |
| 85 | /// `kinds` is what the engine will answer at all. `credits` claims all three because |
| 86 | /// the engine behind it is the operator's choice and the browser cannot know which; |
| 87 | /// the gateway answers for it, and a search that returns nothing is free. |
| 88 | /// |
| 89 | /// `free` is roughly how many searches a month that vendor gives away, and it is a |
| 90 | /// NUMBER here rather than a sentence in eight locale files, because a vendor's free |
| 91 | /// tier changes and nobody goes back to re-read eight locale files when it does. One |
| 92 | /// value, one edit, and the sentence around it stays translatable. ZERO means "we do |
| 93 | /// not have a figure we can stand behind", not "there is none" — Exa, Tavily and |
| 94 | /// Serper all offer something and none of them is written down anywhere this file can |
| 95 | /// cite, so the row says nothing about them rather than something vague. The 1,000 is |
| 96 | /// the contract's own figure (§3); if it moves, it moves here. |
| 97 | var KNOWN = { |
| 98 | credits: { name: 'Daimond credits', url: '', keyHint: '', kinds: ['web', 'news', 'academic'], free: 0 }, |
| 99 | brave: { name: 'Brave Search', url: 'https://brave.com/search/api/', keyHint: 'BSA…', kinds: ['web', 'news'], free: 1000 }, |
| 100 | exa: { name: 'Exa', url: 'https://exa.ai/', keyHint: '', kinds: ['web', 'academic'], free: 0 }, |
| 101 | tavily: { name: 'Tavily', url: 'https://tavily.com/', keyHint: 'tvly-…', kinds: ['web', 'news'], free: 0 }, |
| 102 | serper: { name: 'Serper', url: 'https://serper.dev/', keyHint: '', kinds: ['web', 'news', 'academic'], free: 0 }, |
| 103 | }; |
| 104 | |
| 105 | /// The engines the account's balance may NEVER buy. See the file header: this is a |
| 106 | /// decision about Oxedyne's risk, not about the engine's quality. |
| 107 | var BYOK_ONLY = { serper: true }; |
| 108 | |
| 109 | /// The kinds a search may ask for, in the order a picker would list them. |
| 110 | var KINDS = ['web', 'news', 'academic']; |
| 111 | |
| 112 | var store = { v: 1, engine: CREDITS, keys: {} }; |
| 113 | |
| 114 | /// Engine id -> plaintext key, memory only. Filled by `unseal()` once the user has |
| 115 | /// unlocked, emptied by `lock()`. The durable copy is sealed in `store`. |
| 116 | var plain = {}; |
| 117 | |
| 118 | // ── The store ─────────────────────────────────────────────────── |
| 119 | |
| 120 | function load() { |
| 121 | var raw = null; |
| 122 | try { raw = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) { raw = null; } |
| 123 | if (raw && raw.v === 1 && raw.keys && typeof raw.keys === 'object') { |
| 124 | store = { v: 1, engine: KNOWN[raw.engine] ? raw.engine : CREDITS, keys: raw.keys }; |
| 125 | return; |
| 126 | } |
| 127 | store = { v: 1, engine: CREDITS, keys: {} }; |
| 128 | } |
| 129 | |
| 130 | function save() { |
| 131 | try { localStorage.setItem(KEY, JSON.stringify(store)); } catch (e) { /* quota */ } |
| 132 | if (deps && deps.onChange) deps.onChange(); |
| 133 | } |
| 134 | |
| 135 | // ── The engine ────────────────────────────────────────────────── |
| 136 | |
| 137 | /// The configured engine, `credits` by default. |
| 138 | /// |
| 139 | /// Defaulting rather than trusting the store: a value written by a build that knew a |
| 140 | /// sixth engine, or edited by hand, must not become a request naming something the |
| 141 | /// gateway will refuse. |
| 142 | function engine() { |
| 143 | return KNOWN[store.engine] ? store.engine : CREDITS; |
| 144 | } |
| 145 | |
| 146 | /// Choose the engine. Unknown ids are refused rather than stored, and the answer says |
| 147 | /// whether anything moved. |
| 148 | function setEngine(id) { |
| 149 | if (!KNOWN[id] || id === engine()) return false; |
| 150 | store.engine = id; |
| 151 | save(); |
| 152 | return true; |
| 153 | } |
| 154 | |
| 155 | /// Is this engine the user's own key or nothing? See the file header. |
| 156 | function byokOnly(id) { return !!BYOK_ONLY[id]; } |
| 157 | |
| 158 | /// The engine's name for the screen. |
| 159 | /// |
| 160 | /// A vendor's name is a proper noun and is never translated; the one name this app made |
| 161 | /// up for itself — the credits row — is a phrase, and is. The same rule `models.js` |
| 162 | /// follows, and the same reason. |
| 163 | function engineName(id) { |
| 164 | if (id === CREDITS) return tOr('search.credits', 'Daimond credits'); |
| 165 | return (KNOWN[id] && KNOWN[id].name) || id; |
| 166 | } |
| 167 | |
| 168 | // ── Keys ──────────────────────────────────────────────────────── |
| 169 | |
| 170 | /// Decrypt every stored key into memory. Called once the user unlocks: a sealed key is |
| 171 | /// unreadable until then, which is the point of sealing it. |
| 172 | async function unseal() { |
| 173 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return; |
| 174 | for (var id in store.keys) { |
| 175 | var row = store.keys[id]; |
| 176 | if (row && row.keyEnc) { |
| 177 | try { plain[id] = await DaimondIdentity.unwrap(row.keyEnc); } |
| 178 | catch (e) { plain[id] = ''; } |
| 179 | } else if (row && row.key) { |
| 180 | // Never written by this file; read so a record that arrived with one is |
| 181 | // usable rather than mysteriously dead. See the file header. |
| 182 | plain[id] = row.key; |
| 183 | } |
| 184 | } |
| 185 | if (deps && deps.onChange) deps.onChange(); |
| 186 | } |
| 187 | |
| 188 | /// Store a key for an engine, sealed under the passphrase. |
| 189 | /// |
| 190 | /// SEALED OR NOTHING, and the answer says which. There is no plaintext-at-rest fallback |
| 191 | /// here — `models.js` has one for the skippable browser-only path, and this file does |
| 192 | /// not, because the promise in its header is the one the mail password keeps. A caller |
| 193 | /// that gets `false` has a user to tell, not a key to file somewhere else. |
| 194 | /// |
| 195 | /// An empty key REMOVES the record rather than storing an emptiness, which is how the |
| 196 | /// push token behaves and for the same reason: "there is no key" is a state the picker |
| 197 | /// can describe, where "there is a key and it is the empty string" is a refusal from |
| 198 | /// the engine three steps later. |
| 199 | async function setKey(id, k) { |
| 200 | if (!KNOWN[id] || id === CREDITS) return false; |
| 201 | k = String(k == null ? '' : k).trim(); |
| 202 | if (!k) { |
| 203 | delete store.keys[id]; |
| 204 | delete plain[id]; |
| 205 | save(); |
| 206 | return true; |
| 207 | } |
| 208 | if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return false; |
| 209 | var sealed = ''; |
| 210 | try { sealed = await DaimondIdentity.wrap(k); } |
| 211 | catch (e) { return false; } |
| 212 | if (!sealed) return false; |
| 213 | // `key` is written empty, always: the field is in the record because the contract |
| 214 | // names it, and this store keeps the promise in the header. |
| 215 | store.keys[id] = { key: '', keyEnc: sealed }; |
| 216 | plain[id] = k; |
| 217 | save(); |
| 218 | return true; |
| 219 | } |
| 220 | |
| 221 | /// Re-seal every stored search key under the passphrase that has just replaced |
| 222 | /// the old one. |
| 223 | /// |
| 224 | /// **This was missing until 2026-08-14 and the failure was silent and total**: |
| 225 | /// `setKey` seals with `DaimondIdentity.wrap`, nothing re-wrapped it, and a |
| 226 | /// passphrase change therefore left every search key unopenable — `unseal` |
| 227 | /// turning the failed unwrap into an empty string, so the engine simply |
| 228 | /// reported that it had no key. This file is `models.js` in miniature and it |
| 229 | /// inherited that file's hole along with its shape. |
| 230 | /// |
| 231 | /// No read-out phase: like `models.js`, the plaintext is already in `plain` for |
| 232 | /// the length of an unlocked session, so only the wrapping has to be redone. |
| 233 | /// |
| 234 | /// A key that was ALREADY unreadable before the change is told apart from an |
| 235 | /// engine with no key by the ciphertext still being there, and is REPORTED. |
| 236 | /// Without that, `unseal`'s empty string makes a dead key indistinguishable |
| 237 | /// from no key at all, and it gets skipped in silence — which is how one stays |
| 238 | /// dead for ever. |
| 239 | async function resealAfterRekey() { |
| 240 | var failed = [], unread = []; |
| 241 | for (var id in store.keys) { |
| 242 | var row = store.keys[id]; |
| 243 | if (!row) continue; |
| 244 | var k = plain[id]; |
| 245 | if (!k) { |
| 246 | if (row.keyEnc) unread.push(engineName(id)); |
| 247 | continue; |
| 248 | } |
| 249 | try { |
| 250 | row.keyEnc = await DaimondIdentity.wrap(k); |
| 251 | row.key = ''; // never leave a plaintext copy behind |
| 252 | } catch (e) { failed.push(engineName(id)); } |
| 253 | } |
| 254 | save(); |
| 255 | return { ok: !failed.length && !unread.length, failed: failed, unread: unread }; |
| 256 | } |
| 257 | |
| 258 | if (window.DaimondRekey) { |
| 259 | DaimondRekey.register({ |
| 260 | name: 'search', |
| 261 | reseal: resealAfterRekey, |
| 262 | sentence: function (kind, list) { |
| 263 | return kind === 'unread' |
| 264 | ? tOr('changepass.search_not_unsealed', |
| 265 | 'These search services already had unreadable keys before the change, ' |
| 266 | + 'and still need their keys set again: {list}.', { list: list.join(', ') }) |
| 267 | : tOr('changepass.search_not_resealed', |
| 268 | 'These search services could not be re-encrypted under the new ' |
| 269 | + 'passphrase and need their keys again: {list}.', { list: list.join(', ') }); |
| 270 | }, |
| 271 | }); |
| 272 | } |
| 273 | |
| 274 | /// The plaintext key for an engine, or '' when there is none or the app is locked. |
| 275 | function key(id) { |
| 276 | if (plain[id]) return plain[id]; |
| 277 | var row = store.keys[id]; |
| 278 | return (row && row.key) || ''; |
| 279 | } |
| 280 | |
| 281 | /// Whether an engine holds a key at all, sealed or not. An engine with no key is still |
| 282 | /// listed and still choosable; it simply says what it is waiting for. |
| 283 | function hasKey(id) { |
| 284 | var row = store.keys[id]; |
| 285 | return !!(plain[id] || (row && (row.key || row.keyEnc))); |
| 286 | } |
| 287 | |
| 288 | /// Whether the key is present but unreadable because the app is locked. |
| 289 | function isSealed(id) { |
| 290 | var row = store.keys[id]; |
| 291 | return !!(row && row.keyEnc && !plain[id]); |
| 292 | } |
| 293 | |
| 294 | /// Forget every key. The lock does this: a locked Daimond holds no readable key. |
| 295 | function lock() { |
| 296 | plain = {}; |
| 297 | if (deps && deps.onChange) deps.onChange(); |
| 298 | } |
| 299 | |
| 300 | // ── The pause, refused where the money is committed ───────────── |
| 301 | // A pause the widget respects and the network does not is decoration, so it is checked |
| 302 | // HERE, in front of the request, rather than trusted to whatever asked. Cooperative on |
| 303 | // purpose: `gateway.js` wraps `window.fetch` and turns a held spend into a 423, which |
| 304 | // is the guard for callers that do not ask — but its own comment says a caller that |
| 305 | // asked for itself could show the sentence in its own panel instead of taking it off a |
| 306 | // status code, and this is the first caller to do that. |
| 307 | |
| 308 | function ROOT() { return window.DaimondPause ? DaimondPause.ROOT : 'root'; } |
| 309 | |
| 310 | /// The leaf a search is charged to. `root/web` — the same leaf a page fetch spends on, |
| 311 | /// and deliberately not a new one: a leaf with no control is the mistake `root/web` |
| 312 | /// itself made, and a second one would be the same mistake twice. |
| 313 | function node() { |
| 314 | return window.DaimondPause ? DaimondPause.id(ROOT(), 'web') : 'root/web'; |
| 315 | } |
| 316 | |
| 317 | function held(id) { |
| 318 | return !!(id && window.DaimondPause && DaimondPause.isPaused(id)); |
| 319 | } |
| 320 | |
| 321 | /// Is EVERYTHING paused? The global control at the top of the rail is the root of the |
| 322 | /// same tree, so a held root holds a search even where the leaf itself is running. |
| 323 | function allHeld() { |
| 324 | if (!window.DaimondPause) return false; |
| 325 | try { return DaimondPause.state(ROOT()) === 'pause'; } catch (e) { return false; } |
| 326 | } |
| 327 | |
| 328 | /// The refusal a pause produces: an Error naming the node. `paused` marks it so a |
| 329 | /// caller can show a held spend calmly rather than as a fault, and `pauseNode` says |
| 330 | /// which control to point at — which is now a control that exists, in the Web panel |
| 331 | /// header. |
| 332 | function pauseError(id) { |
| 333 | var e = new Error(tOr('pause.refused.web', |
| 334 | '{node} is paused. The page was not fetched and nothing was spent. Press ' |
| 335 | + 'play on it to resume.', |
| 336 | { node: id })); |
| 337 | e.paused = true; |
| 338 | e.pauseNode = id; |
| 339 | return e; |
| 340 | } |
| 341 | |
| 342 | // ── The search ────────────────────────────────────────────────── |
| 343 | |
| 344 | /// One result row, or null when it is not one. |
| 345 | /// |
| 346 | /// `title` and `url` may not be empty and a row missing either is DROPPED rather than |
| 347 | /// passed on hollow: a result the model cannot open is a line of context that costs |
| 348 | /// tokens and answers nothing. `age` is whatever freshness the engine reported, |
| 349 | /// verbatim and unparsed — engines disagree about what it means and a wrong date is |
| 350 | /// worse than no date. |
| 351 | function row(r) { |
| 352 | if (!r || typeof r !== 'object') return null; |
| 353 | var title = String(r.title || '').trim(); |
| 354 | var url = String(r.url || '').trim(); |
| 355 | if (!title || !url) return null; |
| 356 | return { |
| 357 | title: title, |
| 358 | url: url, |
| 359 | snippet: String(r.snippet || ''), |
| 360 | age: String(r.age || ''), |
| 361 | }; |
| 362 | } |
| 363 | |
| 364 | /// Search, and hand back the one result shape the gateway, this file and the wasm all |
| 365 | /// agree on: `{ engine, query, results:[{title, url, snippet, age}] }`. |
| 366 | /// |
| 367 | /// `opts` carries `kind` ('web' | 'news' | 'academic') and `limit`, and NOTHING ELSE. |
| 368 | /// That is the wasm's half of the bargain and it is the whole of it: the tool sends |
| 369 | /// those two, omits `limit` when the model named none, and sends no engine and no key. |
| 370 | /// The engine and the key are added HERE, from the setting. |
| 371 | /// |
| 372 | /// An `engine` named in `opts` is ignored, deliberately and by omission: the engine is |
| 373 | /// the user's setting, and a tool argument that could override it would put the choice |
| 374 | /// back where it was — with a model improvising a search URL. |
| 375 | /// |
| 376 | /// The refusals it can produce, all of them before anything is spent: |
| 377 | /// |
| 378 | /// * the leaf (or the whole tree) is paused; |
| 379 | /// * the engine wants a key and there is none — and for `serper` that is final, |
| 380 | /// since credits may not buy it; |
| 381 | /// * the engine cannot answer that kind at all. |
| 382 | async function search(query, opts) { |
| 383 | opts = opts || {}; |
| 384 | var q = String(query == null ? '' : query).trim(); |
| 385 | if (!q) throw new Error('A search needs something to search for.'); |
| 386 | |
| 387 | var id = engine(); |
| 388 | |
| 389 | // The pause, first: a refusal that costs nothing should cost nothing, including the |
| 390 | // round trip. The leaf, and the root behind it — `gateway.js` charges an |
| 391 | // unattributed spend to the root, and a search is no different. |
| 392 | var stop = held(node()) ? node() : (allHeld() ? ROOT() : ''); |
| 393 | if (stop) throw pauseError(stop); |
| 394 | |
| 395 | // A key, where the engine is not the one Daimond holds the key for. Two refusals, |
| 396 | // because they offer different ways out: an ordinary engine can be swapped for |
| 397 | // credits, and serper cannot. |
| 398 | var mine = ''; |
| 399 | if (id !== CREDITS) { |
| 400 | mine = key(id); |
| 401 | if (!mine) { |
| 402 | throw new Error(byokOnly(id) |
| 403 | ? tOr('search.refused_serper', '{engine} can only be used with your own key.', |
| 404 | { engine: engineName(id) }) |
| 405 | : tOr('search.no_key', 'Add a key for {engine}, or switch to Daimond credits.', |
| 406 | { engine: engineName(id) })); |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | var kind = String(opts.kind || 'web'); |
| 411 | // Defaulted here, REFUSED in the wasm -- and the difference is deliberate. |
| 412 | // `Tool::WebSearch` rejects a kind it does not know rather than quietly |
| 413 | // reading it as `web`, because a model that asked for `images` and silently |
| 414 | // got the web would never learn it had asked for something that does not |
| 415 | // exist. Nothing reaches this line from that path. What does reach it is |
| 416 | // app code, where a bad kind is a typo in our own source and taking the |
| 417 | // default is better than an exception in front of the user. Do not make the |
| 418 | // two agree by loosening the wasm. |
| 419 | if (KINDS.indexOf(kind) === -1) kind = 'web'; |
| 420 | var can = (KNOWN[id] && KNOWN[id].kinds) || ['web']; |
| 421 | if (can.indexOf(kind) === -1) { |
| 422 | throw new Error(engineName(id) + ' does not answer that kind of search. ' |
| 423 | + 'It can do: ' + can.join(', ') + '.'); |
| 424 | } |
| 425 | |
| 426 | var limit = parseInt(opts.limit, 10); |
| 427 | if (!isFinite(limit) || limit < 1) limit = 10; |
| 428 | if (limit > 20) limit = 20; |
| 429 | |
| 430 | // Belt and braces, and the last thing before the request is built. An |
| 431 | // own-key-only engine reaching the gateway WITHOUT a key is a request for the |
| 432 | // balance to pay for it, and nothing above can compose one — this is what makes |
| 433 | // that a property of the module rather than a reading of the lines above it. |
| 434 | if (byokOnly(id) && !mine) { |
| 435 | throw new Error(tOr('search.refused_serper', '{engine} can only be used with your own key.', |
| 436 | { engine: engineName(id) })); |
| 437 | } |
| 438 | |
| 439 | var head = { 'content-type': 'application/json' }; |
| 440 | // Read from gateway.js rather than copied: two constants that must match are two |
| 441 | // constants that will one day not. |
| 442 | if (window.DaimondGateway && DaimondGateway.clientApi) { |
| 443 | head['x-daimond-api'] = String(DaimondGateway.clientApi()); |
| 444 | } |
| 445 | var body = { query: q, kind: kind, limit: limit, engine: id }; |
| 446 | // The one request the key leaves the browser in, and the only one. The gateway uses |
| 447 | // it for this call and drops it. |
| 448 | if (mine) body.key = mine; |
| 449 | |
| 450 | var r = await fetch(URL, { |
| 451 | method: 'POST', |
| 452 | headers: head, |
| 453 | credentials: 'same-origin', |
| 454 | body: JSON.stringify(body), |
| 455 | }); |
| 456 | var j = null; |
| 457 | try { j = await r.json(); } catch (e) { j = null; } |
| 458 | // A search spends, and the reply says what is left. One place owns that number; |
| 459 | // this hands it over rather than letting the header go stale. |
| 460 | if (window.DaimondGateway && DaimondGateway.noteBalance) DaimondGateway.noteBalance(j); |
| 461 | if (!r.ok || !j || j.ok === false) { |
| 462 | var e = new Error((j && (j.error || j.message)) || 'The search could not be run.'); |
| 463 | // 423 is the gateway saying the spend is held. It carries the node, so a caller |
| 464 | // can point at the control rather than report a fault. |
| 465 | if (r.status === 423 && j && j.node) { e.paused = true; e.pauseNode = j.node; } |
| 466 | throw e; |
| 467 | } |
| 468 | var list = Array.isArray(j.results) ? j.results : []; |
| 469 | return { |
| 470 | // The ENGINE is believed from the reply: for `credits` the operator's knob |
| 471 | // decides it and this file genuinely does not know which one answered. |
| 472 | engine: String(j.engine || id), |
| 473 | // The QUERY is not. It is what we asked, never the reply's echo of it -- |
| 474 | // the same rule `answer()` in src/wasm/web.rs holds, and for the same |
| 475 | // reason. The query names the search in the untrusted envelope the model |
| 476 | // reads and in anything this file shows a person; a reply that echoed |
| 477 | // something else would be rewriting the record of what was gone looking |
| 478 | // for. Believing the echo here and not there would also be two answers to |
| 479 | // one question, which is how a seam rots. |
| 480 | query: q, |
| 481 | results: list.map(row).filter(Boolean), |
| 482 | }; |
| 483 | } |
| 484 | |
| 485 | // ── Wiring ────────────────────────────────────────────────────── |
| 486 | |
| 487 | function init(d) { |
| 488 | deps = d || {}; |
| 489 | load(); |
| 490 | } |
| 491 | |
| 492 | // The settings row stays mounted, so a language change redraws it where it stands. |
| 493 | if (window.DaimondI18n) { |
| 494 | DaimondI18n.onChange(function () { if (deps && deps.onChange) deps.onChange(); }); |
| 495 | } |
| 496 | |
| 497 | window.DaimondSearch = { |
| 498 | // The contract's six, in its order. |
| 499 | KNOWN: KNOWN, |
| 500 | engine: engine, |
| 501 | setEngine: setEngine, |
| 502 | key: key, |
| 503 | setKey: setKey, |
| 504 | search: search, |
| 505 | // What the app's own picker and the unlock/lock path need beyond them. |
| 506 | init: init, |
| 507 | unseal: unseal, |
| 508 | /// Re-seal after a passphrase change. Public so a test can drive it; the |
| 509 | /// app itself reaches it only through `DaimondRekey`. |
| 510 | resealAfterRekey: resealAfterRekey, |
| 511 | lock: lock, |
| 512 | hasKey: hasKey, |
| 513 | isSealed: isSealed, |
| 514 | byokOnly: byokOnly, |
| 515 | engineName: engineName, |
| 516 | // The leaf a search is charged to, so a caller can point at its control. |
| 517 | node: node, |
| 518 | CREDITS: CREDITS, |
| 519 | KINDS: KINDS, |
| 520 | }; |
| 521 | })(); |