Oregami
Repositories/oxedyne/daimond

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