diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6f6a7..4a8052c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Conventional Commits by release-please. ## Unreleased +- Security: a web page that managed to run code inside Kotiko's page script could change + Kotiko's settings, because browsers let that script write Kotiko's storage. It could turn + Kotiko off, pause sites, hide languages, change the words pages show, queue a word to be + looked up with your own AI key and saved, pick another model, or change the languages + your server's Telegram bot answers in. Kotiko now keeps the real copy of all of these + where page scripts can't reach, puts back anything changed elsewhere, and only its own + pages change settings. Nothing to do on your side; your settings move over on update. + In Firefox (and Chrome before 140), the languages you read no longer follow from + Kotiko in your other browsers: set them in each one. + - Connect OpenRouter with one click, without copying a key: "Connect OpenRouter (free)" is now the first choice on the welcome page, and Settings, Word lookups has a "Connect OpenRouter" button. OpenRouter's sign-in opens in a new tab; when you're done, it sends diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c0d7f85..e41e56c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,7 +23,7 @@ The extension works in one of two modes, chosen in its settings (`wordsHome` in **Server mode**: your words live on a Kotiko server you run yourself. ``` - web page ── content script ◀── storage.local (the words pages need) + web page ── content script ◀── storage.local (a copy of the words and settings pages need) ▲ popup, dashboard, welcome ──▶ background ──HTTP + token──▶ Kotiko server ──▶ model API │ (Elixir, ──▶ en.wiktionary.org @@ -37,7 +37,7 @@ itself, with your own key for a model service or a model on your machine. No ser needed. ``` - web page ── content script ◀── storage.local (the words pages need) + web page ── content script ◀── storage.local (a copy of the words and settings pages need) ▲ popup, dashboard, welcome ──▶ background ──▶ IndexedDB "kotiko" (words, keys) │ @@ -72,6 +72,10 @@ code at runtime; every script is a classic script that attaches one namespace to shadow root. [`content.js`](../extension/content.js) ties them together. Content scripts read only `storage.local`; keys and the server token live in the background's own store, out of their reach (an older install's token is moved there on upgrade). + Browsers let content scripts write `storage.local` too, so it is only a mirror: the + background keeps the real copy of every setting, of the pages' word list and of the add + jobs in its IndexedDB store, reads only that, and puts back any change it didn't make + ([`lib/settings.js`](../extension/lib/settings.js), SCR-448). - **The background** is [`background.js`](../extension/background.js): a service worker in Chrome, an event page in Firefox (the manifest lists both forms). It is the only part that opens the word store, reads secrets, calls the server or a model, and runs @@ -89,8 +93,10 @@ code at runtime; every script is a classic script that attaches one namespace to type to a handler that names who may call it: `page` (an extension page) or `content` (a content script in a web page), plus `docs` (a content script on Kotiko's docs site, only for the "Connect OpenRouter" sign-in code). Content scripts may only ask for a sync - and the sensitive-site list. Adding, editing and deleting words, and anything that - touches keys, are for extension pages only; a content script asking gets `forbidden`. + and the sensitive-site list, and put one word on the never-swap list (`neverSwap`, the + word card's action). Adding, editing and deleting words, changing settings + (`settings.set`, `settings.restore`) and anything that touches keys are for extension + pages only; a content script asking gets `forbidden`. - **Local mode** ([`lib/local-mode.js`](../extension/lib/local-mode.js)): settings, the one-time upgrade of older installs, and the word routes over the extension's own store ([`lib/store.js`](../extension/lib/store.js)). The lookup client @@ -176,7 +182,7 @@ Where it is kept: | Mode | Words | Keys and tokens | |---|---|---| -| Server | SQLite at `/kotiko.db` (default `$XDG_DATA_HOME/kotiko`, else `~/.local/share/kotiko`); the extension keeps a copy of the words pages need in `storage.local` | The server's token in `/api-token` or `.env`; model and Telegram keys in `.env`. Any of them can be in its own file instead (`NAME_FILE`, docs/reference/configuration.md). In the extension, the server token is in the IndexedDB store | +| Server | SQLite at `/kotiko.db` (default `$XDG_DATA_HOME/kotiko`, else `~/.local/share/kotiko`); the extension keeps a copy of the words pages need in its IndexedDB store, mirrored to `storage.local` | The server's token in `/api-token` or `.env`; model and Telegram keys in `.env`. Any of them can be in its own file instead (`NAME_FILE`, docs/reference/configuration.md). In the extension, the server token is in the IndexedDB store | | Local | IndexedDB database `kotiko`, opened only by the background ([`lib/store.js`](../extension/lib/store.js)); a projection of the active words in `storage.local` for content scripts ([`lib/projection.js`](../extension/lib/projection.js)) | Model keys in the same IndexedDB store's `secrets`, which content scripts can't reach | The server also keeps a lookup cache, kept add responses (24 hours) and background-job @@ -217,8 +223,8 @@ answers with a card: `add …` saves at once with Undo; a question shows Add and **Sync (server mode).** The background asks the server for the words every minute (a browser alarm), on page loads and after a change, keeps one request running at a time, -checks the answer, and writes the words to `storage.local`. Content scripts pick up the -change. The page sync still reads the older `GET /api/words` route; the dashboard reads +checks the answer, and keeps the words in its own store, mirrored to `storage.local`. +Content scripts pick up the change. The page sync still reads the older `GET /api/words` route; the dashboard reads and edits through `/api/v1`. **Swapping words on a page.** The content script works out which parts of the page are @@ -233,7 +239,8 @@ content. Kotiko treats these as untrusted and checks what crosses them: - the web page and its scripts (the page DOM is read as text and never written as HTML); -- content scripts, which may only send the messages `messages.js` allows them; +- content scripts, which may only send the messages `messages.js` allows them, and whose + writes to `storage.local` and `storage.sync` the background never acts on; - the network between the extension and the server (bearer token, Host check; plain HTTP only if you set `BIND` to a network address, with a warning at startup); - the model's answers, which are checked against the spec before anything is saved; diff --git a/docs/privacy/inventory.md b/docs/privacy/inventory.md index 8e29bd5..9fc35f3 100644 --- a/docs/privacy/inventory.md +++ b/docs/privacy/inventory.md @@ -11,7 +11,7 @@ and the store forms in the same pull request.** `test/unit/store-readiness.test. fails when a network call, a host name or a permission appears in the extension without a line here or in the store form. -Last checked against the code: 2026-10-05 (extension 0.2.0, spec 2.0.0). +Last checked against the code: 2026-10-06 (extension 0.2.0, spec 2.0.0). ## 1. Data, where it's kept, and where it goes @@ -19,15 +19,15 @@ Last checked against the code: 2026-10-05 (extension 0.2.0, spec 2.0.0). |---|---|---|---|---| | Text of the pages you read, and their addresses | Not kept. Read in memory to find words to swap; the address seeds which language a word shows in (slice 18) and is checked against your paused sites | Never | Nobody | `content.js`, `content/engine.js`, `lib/precedence.js` | | Your words (the word, its meaning in your languages, pronunciation, your note, the text you typed to add it, status) | In this browser: IndexedDB (`kotiko` database) and a copy in `storage.local` that Kotiko's page scripts read. Or on your own Kotiko server, if you connect one | Only if you connect a server | Your own Kotiko server (sync every minute, and every add, edit and delete) | `lib/store.js`, `lib/projection.js`, `background.js` `request()` | -| Text you type or paste to add words (the popup, the dashboard, the welcome page, bulk add), plus your base languages, up to five of your recent target languages and a language hint | Add jobs in `storage.local` (kept 7 days after they finish, at most 20); the checked result in a lookup cache in IndexedDB (30 days, at most 5,000) | Yes, when you add a word that needs a lookup. Never for "word = meaning" lines | The provider you chose (OpenRouter by default; OpenAI, Anthropic, Google Gemini, Groq, a local Ollama or LM Studio, or any address you enter), or your Kotiko server, which asks its own provider | `lib/llm/client.js` `call()`, `background.js` `lookupJob()` | +| Text you type or paste to add words (the popup, the dashboard, the welcome page, bulk add), plus your base languages, up to five of your recent target languages and a language hint | Add jobs in IndexedDB (`meta`), with a copy in `storage.local` for the popup (kept 7 days after they finish, at most 20); the checked result in a lookup cache in IndexedDB (30 days, at most 5,000) | Yes, when you add a word that needs a lookup. Never for "word = meaning" lines | The provider you chose (OpenRouter by default; OpenAI, Anthropic, Google Gemini, Groq, a local Ollama or LM Studio, or any address you enter), or your Kotiko server, which asks its own provider | `lib/llm/client.js` `call()`, `background.js` `lookupJob()` | | Your saved words (word, meaning, sense, base language), to write their pronunciations | Nothing new | Yes, for words saved without a pronunciation (added by hand, imported, or from an older version), at most twice per word, in the background, when words live in this browser and a provider is set up | The provider you chose | `lib/refresh-job.js` → `client.respell()` | | A word you add in a language with word stress (Russian, Ukrainian, Spanish and others in `spec/pronunciation.json`): the word only | The Wiktionary page's pronunciation section, 30 days, in the lookup cache | Yes, when you add the word with a lookup or as "word = meaning", and once in the background for words saved earlier | The Wikimedia Foundation (en.wiktionary.org), with a `Api-User-Agent` header naming Kotiko and `hello@scriptkittyos.com` | `background.js` `wiktionaryPage()`, `lib/wiktionary-pass.js` | | Your provider key and your server's access token | IndexedDB (`secrets`), which only the background reads. Never in `storage.local`, `storage.sync` or `storage.session`; never shown again in full (the settings show the first six and last four characters) | Only to the service it belongs to, and only at an address chosen on one of Kotiko's own pages (slice 28 §7) | Your provider; your Kotiko server | `background.js` `secret()`, `secretFor()`, `routeAllows()` | -| Which provider, model and server address you use; whether a key is saved (yes or no, never the key) | `storage.local` | Each request goes to that address | Your provider or your server | `lib/local-mode.js` `readSettings()` | +| Which provider, model and server address you use; whether a key is saved (yes or no, never the key) | IndexedDB (`meta`), with a copy in `storage.local` that pages read | Each request goes to that address | Your provider or your server | `lib/local-mode.js` `readSettings()` | | The model list (any provider whose models Kotiko lists) and your remaining free lookups (OpenRouter only) | IndexedDB `meta`; the list refreshed at most daily, the count at most every 5 minutes while lookups run | The request itself, with your key | Your provider (`/models`); OpenRouter (`/key`) | `lib/llm/client.js`, `lib/llm/catalog.js` | | The Test button's lookup | Nothing | The word "hello" and your base languages | Your provider | `lib/llm/client.js` `test()` | -| Settings: Kotiko on or off, paused sites (host names you paused), words you chose never to swap, languages hidden, amount, speech, celebrations, add jobs | `storage.local` | Never | Nobody | `popup.js`, `dashboard.js`, `content.js` | -| Your base languages, Kotiko's interface language, and a random 32-character value that keeps word choices the same across your devices (`seedSalt`) | `storage.sync` | Through your browser's own sync, if you turned it on | Google or Mozilla, under their sync terms. Stays in your browser account after an uninstall | `background.js` `ensureSeedSalt()`, `welcome.js`, `dashboard.js` | +| Settings: Kotiko on or off, paused sites (host names you paused), words you chose never to swap, languages hidden, amount, speech, celebrations, add jobs | IndexedDB (`meta`), with a copy in `storage.local` that content scripts and pages read | Never | Nobody | `lib/settings.js`, `background.js` `saveSettings()` | +| Your base languages, Kotiko's interface language, and a random 32-character value that keeps word choices the same across your devices (`seedSalt`) | `storage.sync`, and IndexedDB (`meta`) with a copy in `storage.local` | Through your browser's own sync, if you turned it on | Google or Mozilla, under their sync terms. Stays in your browser account after an uninstall | `background.js` `ensureSeedSalt()`, `welcome.js`, `dashboard.js` | | The browser's languages (to suggest your base languages) | Your base languages, above | Never | Nobody | `background.js` `detectBrowserBases()` | | A word you ask to hear | Not kept | Only if you turn on "Online voices" in Settings (off by default); then your browser may send it to its speech service | Your browser's or operating system's speech service (for example Google, Microsoft or Apple) | `lib/speak.js` (`localService`) | | "Try it on a page" on the welcome page | Nothing | Only if you click it: it opens a Wikipedia search for your word in a new tab | Wikipedia (the Wikimedia Foundation), as any link you open | `lib/welcome-model.js` `wikipediaUrl()` | @@ -55,32 +55,56 @@ dictionaries, the model list and words are data, checked by the word spec before ## 3. What Kotiko's own page scripts (content scripts) can reach Content scripts run inside every page you open, so they are the part of Kotiko a hostile -page is closest to. They can read and write `storage.local` and `storage.sync`, and send -two messages to the background: `sync` and `sensitiveSites`. +page is closest to. Browsers let them read and write `storage.local` and `storage.sync` +(Chrome 140 and later can close an area to them with `setAccessLevel`; Firefox can't, see +[bug 1724754](https://bugzil.la/1724754)). They can send three messages to the background: +`sync`, `sensitiveSites` and `neverSwap` (the word card's "Don't swap this word", one word +at a time). - They can read your words, your add jobs (the text you typed, for 7 days) and your settings, which they need to swap words. - They can't read your key or your token: those are only in the background's IndexedDB, which content scripts can't open (they get the page's IndexedDB, not Kotiko's), and - `storage.session` is not used. + `storage.session` is not used for them. +- They can't change your settings, your word list or your add queue (SCR-448). The real + copy of every setting, of the pages' word list and of the add jobs is in the + background's IndexedDB (`meta`, `area:` rows, `lib/settings.js`); `storage.local` + holds only a copy of it for content scripts and Kotiko's pages to read. The background + reads nothing it acts on from `storage.local`; Kotiko's pages change settings with the + `settings.set` message, which only they may send. A value a content script writes to + `storage.local` is put back as soon as the background sees the change (and at every + start of the background, for changes made while it wasn't running), and a key it adds is + removed. So a content script can't turn Kotiko off, pause sites, hide languages, change + the list pages show, queue an add (which would spend the learner's lookups and change + their list), or choose another model, data policy or words home + (`test/bg/settings.test.mjs`, `test/e2e/privacy.spec.mjs`). +- Your base languages and interface language go to your server's Telegram bot only from + the background's copy, which only Kotiko's pages change. In Chrome 140 and later Kotiko + closes `storage.sync` to content scripts, so a change there came from Kotiko on this or + another of your browsers and is taken. In Firefox, and older Chrome, a content script + could write there, so this browser keeps its own copy: languages changed in Kotiko on + another device don't follow until you change them here. - They can't make Kotiko send anything somewhere new. Where requests go is kept in the background's IndexedDB (`meta`): the server's address, the lookup service the learner chose, and each service's address. Only Kotiko's own pages change them (by naming an address or choosing a service). Every request checks its address against them and sends - nothing on a mismatch, and the background puts the chosen values back into - `storage.local`. So a rewritten server address, service or service address gets no - request, no typed word and no key; 0.2-era `token`/`serverUrl` keys written later are - removed unused, and ones found on a brand-new install are dropped - (`test/bg/privacy.test.mjs`, `test/e2e/privacy.spec.mjs`). -- **Still open.** Content scripts can still change the other settings in `storage.local`. - None of these sends data anywhere new, but they affect what Kotiko does: - - turn Kotiko off, pause sites or hide languages; - - change the cached word list a page shows until the next update; - - add an entry to the add queue (`addJobs`), which Kotiko would then look up with the - learner's own service and save as a word. - - And an install updating from a version before this one trusts the addresses its - settings hold at that moment, once. + nothing on a mismatch. 0.2-era `token`/`serverUrl` keys written later are removed + unused, and ones found on a brand-new install are dropped with everything else found + there (`test/bg/privacy.test.mjs`, `test/e2e/privacy.spec.mjs`). +- **Still open.** + - "Don't swap this word" is a content-script message by design (the word card is on the + page), so a subverted content script could add words to that list, one at a time. The + list is in Settings, where each can be taken off. + - Between a content script's write and the background putting it back, other open + pages can briefly act on the written value (for example, take their swaps off and put + them back). One that writes again and again keeps them doing so; nothing is saved or + sent. + - An install updating from a build before this one takes what its `storage.local` holds + at that moment, once, as Kotiko's own (there is no way to tell what a content script + wrote before). There have been no releases yet, so this concerns only builds run from + source. Updating from before slice 28, an address saved for a hosted service, where no + page shows an address field, is dropped; a server address and the address of a + service on your computer or "custom" are visible in Settings and are kept. ## 4. Hosts in the code diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index 7dea3e9..cf1e994 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -36,7 +36,8 @@ its argument and evidence. | Words stay out of logs at the default level | Lookup input and model answers are logged only with `LOG_LOOKUPS=true`, at debug; save failures log the field names, not the values | [`llm.ex`](../../server/lib/kotiko/llm.ex) `log_lookups?/0`; [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex) `result_json/1`; [`llm_test.exs`](../../server/test/kotiko/llm_test.exs) "no path logs the input or a returned word at the default level" | | Bad settings stop the server; secrets aren't echoed | Every variable is parsed by one function that returns a problem; any problem halts with status 78 and one message; secret problems are labelled "(value hidden)" | [`config.ex`](../../server/lib/kotiko/config.ex) `parse/2`, `secret_error/2`; [`config_test.exs`](../../server/test/kotiko/config_test.exs) "a short API_TOKEN is refused without echoing it" | | Text never becomes HTML in the extension | ESLint bans `innerHTML`, `outerHTML` and `insertAdjacentHTML` in `extension/` and runs `eslint-plugin-no-unsanitized`; CI fails on any finding | [`eslint.config.js`](../../eslint.config.js); [`ci.yml`](../../.github/workflows/ci.yml) `npm run lint` | -| Pages can't use the extension to change words | One message router checks each sender's kind (extension page or content script) against the handler's allowed list; content scripts may only `sync` and read `sensitiveSites`; `oauth.code` is accepted only from the docs site's callback page (`https://kotiko.org/connect/` with a code; the site's own test checks that page has no script) | [`lib/messages.js`](../../extension/lib/messages.js) `senderKinds`, `createMessageRouter`; [`background.js`](../../extension/background.js) handler table; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "disallowed senders get forbidden"; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "content scripts can't set, describe or remove secrets, change modes, write words or move them" | +| Pages can't use the extension to change words | One message router checks each sender's kind (extension page or content script) against the handler's allowed list; content scripts may only `sync`, read `sensitiveSites` and mark one word never to swap (`neverSwap`); `oauth.code` is accepted only from the docs site's callback page (`https://kotiko.org/connect/` with a code; the site's own test checks that page has no script) | [`lib/messages.js`](../../extension/lib/messages.js) `senderKinds`, `createMessageRouter`; [`background.js`](../../extension/background.js) handler table; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "disallowed senders get forbidden"; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "content scripts can't set, describe or remove secrets, change modes, write words or move them" | +| Pages can't change your settings, word list or add queue | Content scripts can write `storage.local` and `storage.sync` (Firefox can't stop them). The background keeps the real copy of every setting, of the pages' word list and of the add jobs in its IndexedDB, reads only that, and mirrors it to `storage.local`; a change it didn't make there is put back and an unknown key removed. Pages change settings with `settings.set` (pages only), checked against an allowlist of keys and shapes; content scripts may only add or remove one never-swap word. Chrome 140+ closes `storage.sync` to content scripts (`setAccessLevel`), and only then are changes there taken | [`lib/settings.js`](../../extension/lib/settings.js) `createArea`, `edit`; [`background.js`](../../extension/background.js) `area`, `adoptOnce`, `adoptSync`, `saveSettings`; [`settings.test.mjs`](../../test/bg/settings.test.mjs) (one test per attack); [`settings.test.mjs`](../../test/unit/settings.test.mjs); [`privacy.spec.mjs`](../../test/e2e/privacy.spec.mjs) "settings, the add queue and the bases written from a content script are put back and never used" | | Key and token out of content scripts' reach | Secrets live in the extension origin's IndexedDB, which content scripts can't open; the upgrade moves an old token out of `storage.local` | [`lib/store.js`](../../extension/lib/store.js) `secrets`; [`lib/local-mode.js`](../../extension/lib/local-mode.js) migration step 6; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "the key and the server token are in no storage area a content script can read" | | Answers are checked before use | Server word lists pass `validateWordsResponse` (shape, types, lengths, counts); model answers pass the shared word spec in both runtimes, against the same fixtures | [`lib/validate-words.js`](../../extension/lib/validate-words.js); [`lib/wordspec.js`](../../extension/lib/wordspec.js); [`word_spec.ex`](../../server/lib/kotiko/word_spec.ex) `process/2`; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "validateWordsResponse"; [`wordspec.test.mjs`](../../test/unit/wordspec.test.mjs), [`word_spec_test.exs`](../../server/test/kotiko/word_spec_test.exs) | | No telemetry | The only network calls are to the user's server, the model provider, OpenRouter's key exchange (when the user signs in there), Telegram and Wiktionary, as listed above and in [`background.js`](../../extension/background.js) and [`lib/llm/client.js`](../../extension/lib/llm/client.js) | Code reading; slice 28 writes the full data inventory (planned) | @@ -93,6 +94,7 @@ its argument and evidence. |---|---|---|---| | Web page to content script | Page text and DOM events | Text nodes only, never HTML; pages in languages the learner doesn't read are left alone | [`eslint.config.js`](../../eslint.config.js); [`content.js`](../../extension/content.js) | | Content script to background | Runtime messages | Sender kind per handler | [`lib/messages.js`](../../extension/lib/messages.js) | +| Content script to `storage.local` and `storage.sync` | Writes the browser allows (Firefox can't forbid them) | Not trusted: the background acts only on its IndexedDB copy and puts the mirror back; `storage.sync` closed to content scripts where the browser can (Chrome 140+) | [`lib/settings.js`](../../extension/lib/settings.js); [`background.js`](../../extension/background.js) `storage.onChanged` listener | | Extension pages to background | Runtime messages, including secrets | Allowed only from the extension's own URL; payload checks before handlers run | [`lib/messages.js`](../../extension/lib/messages.js) `checks`; [`background.js`](../../extension/background.js) | | Extension to server | HTTP with `Authorization: Bearer` | Token check, Host check, body cap; the extension validates the address and every response | [`router.ex`](../../server/lib/kotiko/router.ex); [`lib/url.js`](../../extension/lib/url.js); [`lib/validate-words.js`](../../extension/lib/validate-words.js) | | Server or extension to model provider | The learner's typed text, the key | HTTPS to an address the owner set; answers parsed as JSON and checked by the word spec; deadline and attempt cap per lookup | [`llm.ex`](../../server/lib/kotiko/llm.ex) `interpret/3`; [`spec/models.json`](../../spec/models.json) budgets; [`lib/llm/client.js`](../../extension/lib/llm/client.js) | @@ -111,7 +113,7 @@ The eight principles of Saltzer and Schroeder, plus two the badge criteria add. | Complete mediation | The token check runs on every request before routing, on the raw path, and is tested over encoded and dotted spellings and through a real socket | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs); [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) | | Open design | The code is public; the token and keys are the only secrets | [LICENSE](../../LICENSE) | | Separation of privilege | Weakly applied. One bearer token grants everything on the server; there is no second factor. The bot needs both the bot token and an allowlisted sender ID. Per-user tokens are planned (slice 48) | [`bot.ex`](../../server/lib/kotiko/bot.ex) `handle/1` | -| Least privilege | Content scripts may only ask for a sync and the sensitive-site list; secrets sit where content scripts can't read them; CI jobs get a read-only token. The extension asks for `` because it works on any page; a permission review is planned (slice 28 section 6); a sandboxed service unit is planned (slice 40) | [`background.js`](../../extension/background.js); [`ci.yml`](../../.github/workflows/ci.yml) `permissions: contents: read`; [`manifest.json`](../../extension/manifest.json) | +| Least privilege | Content scripts may only ask for a sync and the sensitive-site list (and mark one word never to swap), and change no setting; secrets sit where content scripts can't read them; CI jobs get a read-only token. The extension asks for `` because it works on any page; a permission review is planned (slice 28 section 6); a sandboxed service unit is planned (slice 40) | [`background.js`](../../extension/background.js); [`ci.yml`](../../.github/workflows/ci.yml) `permissions: contents: read`; [`manifest.json`](../../extension/manifest.json) | | Least common mechanism | Each person runs their own server with their own data folder and bot; there is no shared service run by the project | [DECISIONS.md](../../slices/DECISIONS.md) 2026-10-02 (own bot, no hosted service) | | Psychological acceptability | Plain-language startup warnings and error messages that name the fix; a pairing string so the token is pasted, not retyped; settings errors list every problem at once | [`exposure.ex`](../../server/lib/kotiko/exposure.ex); [`config.ex`](../../server/lib/kotiko/config.ex) `format_problems/3`; [`mix kotiko.token`](../../server/lib/mix/tasks/kotiko.token.ex) | | Limited attack surface | One port, loopback by default; a small route list; no CORS; the Telegram bot long-polls, so it needs no public URL or webhook | [`router.ex`](../../server/lib/kotiko/router.ex), [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex); [`bot.ex`](../../server/lib/kotiko/bot.ex) | @@ -152,6 +154,8 @@ a small JSON API. | Wiktionary pages | Pronunciation lookups | Only the IPA spans of the language's section are read, as text; the respelling written from them is checked like any pronunciation | [`pronounce.ex`](../../server/lib/kotiko/pronounce.ex); [`lib/pronounce.js`](../../extension/lib/pronounce.js) | | Telegram updates | The bot | Sender ID against the allowlist; then the same lookup pipeline as the popup. Bot text is not run through the 200-character input check | [`bot.ex`](../../server/lib/kotiko/bot.ex) | | Extension messages | The background | Sender kind, then the handler's payload `check` | [`lib/messages.js`](../../extension/lib/messages.js); [`background.js`](../../extension/background.js) | +| Settings a page changes (`settings.set`, `settings.restore`) | The background | Allowlist of keys; per-key shape and size (booleans, host and language-tag lists with caps, objects up to a byte cap, the four `ui` fields); a restore takes only what `settingsPatch` allows, never addresses or the service | [`lib/settings.js`](../../extension/lib/settings.js) `edit`; [`lib/backup.js`](../../extension/lib/backup.js) `settingsPatch` | +| `storage.local` and `storage.sync` as read back | The background | Never trusted after the one-time adoption: compared with the IndexedDB copy and put back; `storage.sync` changes taken only where content scripts can't write there | [`background.js`](../../extension/background.js) `adoptOnce`, `adoptSync` | | Server responses in the extension | Sync | Status, content type, JSON shape, word fields and counts | [`lib/validate-words.js`](../../extension/lib/validate-words.js) | | The server address the learner types | Extension settings | http(s) only, no user info in the URL, https assumed for non-local names | [`lib/url.js`](../../extension/lib/url.js) | | Pasted lists and dropped files | Bulk add | Parsed as text in the extension; each row then saved through the word checks | [`bulk/parse.js`](../../extension/bulk/parse.js); a backup file is read by [`lib/backup.js`](../../extension/lib/backup.js) `read`, which checks every word against the word spec before anything is restored | @@ -172,6 +176,20 @@ These match "What you can't expect" in the [requirements](requirements.md). warns; it does not refuse. - **The model provider sees every lookup**, under its own terms. - **Pages can see swapped words** in their own DOM. +- **A subverted content script** (code running in Kotiko's content-script world) can't + change settings, the word list or the add queue (SCR-448), but it can still: add words + to the never-swap list one at a time (the word card's action lives in the page; the list + is in Settings); make other open pages act briefly on a value it wrote, until the + background puts it back, and keep doing so by writing in a loop (nothing is saved or + sent); in Firefox, write `storage.sync`, which Kotiko then doesn't take, so base + languages changed on another device don't follow there. It can read what content + scripts read: words, settings and the add jobs' typed text. +- **One-time trust at upgrade.** An install updating from a build before SCR-448 takes its + `storage.local` as it is, once; one from before slice 28 also takes its server address + and the address of a local or "custom" lookup service. These were writable by content + scripts then and can't be checked. An address for a hosted service, which no page shows + a field for, is dropped. There have been no releases, so only builds from source are + affected. - **One token, full access.** Anyone with it can read, change and delete every word. - **Model answers are checked, not verified**; a plausible wrong meaning can be saved. - **No rate limit** for requests that carry the token. diff --git a/docs/security/requirements.md b/docs/security/requirements.md index 318e284..8f81cf5 100644 --- a/docs/security/requirements.md +++ b/docs/security/requirements.md @@ -56,9 +56,15 @@ Kotiko has two parts: the browser extension, and an optional server you run your `textContent` and DOM methods; the linter fails the build on `innerHTML` and similar. - **Web pages can't use the extension to change your words.** Messages are checked by sender: content scripts running in web pages may only ask for a sync and for the list of - sensitive sites (and, on Kotiko's own docs site only, hand over the "Connect OpenRouter" - sign-in code); adding, editing and deleting words, and setting keys, are allowed only + sensitive sites, and put one word on the never-swap list from its word card (and, on + Kotiko's own docs site only, hand over the "Connect OpenRouter" sign-in code); adding, editing and deleting words, and setting keys, are allowed only from the extension's own pages. +- **Web pages can't change your settings, your word list or what you're adding.** Even if + a page subverted Kotiko's own script in it, Kotiko keeps the real copy of its settings, + of the words pages show and of the words waiting to be looked up where that script can't + write, and puts back anything it changes. Only Kotiko's own pages change settings. (In + Firefox, base languages you change in Kotiko on another device don't follow until you + change them in that browser too.) - **Your model key and server token are out of reach of content scripts.** They are kept in the extension's own database, which content scripts can't open. An older install that kept the token where content scripts could read it is moved on upgrade. diff --git a/extension/background.js b/extension/background.js index 8870f18..469a073 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,7 +6,10 @@ // storage.local, as before). New words are looked up by the learner's own provider, by // the server, or typed with their meaning, through add jobs that never block a page. // Secrets (the lookup key, the server token) live only in the store, which content -// scripts can't reach, and only this file reads them. +// scripts can't reach, and only this file reads them. So do the real copies of every +// setting and of the add queue (lib/settings.js): storage.local, which content scripts can +// write, is only a mirror of them, and a change made there by anything but this file is +// put back. // // The libraries load through importScripts in Chrome's service worker, and through the // manifest's background.scripts list (before this file) in Firefox's event page. @@ -14,7 +17,7 @@ if (!globalThis.SyncController && typeof importScripts === "function") { importScripts( "lib/url.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", "spec/spec.js", "lib/text.js", "lib/lang.js", "lib/words-v1.js", "lib/wordspec.js", "lib/word-merge.js", "lib/store.js", - "lib/backup.js", "lib/projection.js", "lib/llm/policy.js", "lib/llm/catalog.js", "lib/llm/client.js", "lib/add-queue.js", + "lib/settings.js", "lib/backup.js", "lib/projection.js", "lib/llm/policy.js", "lib/llm/catalog.js", "lib/llm/client.js", "lib/add-queue.js", "lib/refresh-job.js", "lib/local-mode.js", "lib/pkce.js", "lib/celebrations.js", "lib/welcome-model.js", "lib/pronounce.js", "lib/wiktionary-pass.js", ); @@ -30,6 +33,7 @@ const { badgeFor, OFF_COLOR } = globalThis.KotikoBadge; const { t } = globalThis.KotikoI18n; const { createWordHandlers } = globalThis.KotikoWordsV1; const Local = globalThis.KotikoLocal; +const Settings = globalThis.KotikoSettings; const ALARM = "kotiko-sync"; // The sync alarm's name before the rename; cleared on update. Remove in the next release. @@ -74,10 +78,21 @@ let readyP = null; function ready() { readyP ??= (async () => { const store = await getStore(); - const r = await Local.migrate({ store, storage: ext.storage.local, uiLanguage: uiLanguage() }); + // A brand-new store (an install, or after "delete everything") takes nothing from + // storage.local: it is marked before the upgrade counts as done. + const r = await Local.migrate({ + store, + storage: trustedCopy, + legacy: ext.storage.local, + uiLanguage: uiLanguage(), + beforeDone: async (from) => from === "new" && (await store.meta.get(TRUSTED)) !== true && store.meta.set(TRUSTED, "fresh"), + }); + await adoptOnce(store); if (r.migrated && r.home === "local" && r.seeded) projector.schedule(); if (r.migrated) refresh.nudge().catch(() => {}); await bindRoutesOnce(store, { fresh: r.migrated && (await store.meta.get("migratedFrom")) === "new" }); + // Whatever was written to storage.local while no worker was listening. + trustedCopy.heal().catch(() => {}); })().catch((e) => { readyP = null; console.warn("Kotiko couldn't open its word store:", e?.message ?? e); @@ -101,15 +116,148 @@ async function setSecret(id, value) { if (value) await store.secrets.set(id, value); else await store.secrets.remove(id); secretCache.set(id, value || null); - const s = await Local.readSettings(ext.storage.local); + const s = await Local.readSettings(area); const keys = { server: s.keys.server, providers: { ...s.keys.providers } }; if (id === "server") keys.server = !!value; else keys.providers[id.slice("provider:".length)] = !!value; - await ext.storage.local.set({ keys }); + await area.set({ keys }); +} + +// ── settings only Kotiko may change (SCR-448) ──────────────────────────────────── + +// The trusted copy of everything Kotiko keeps in storage.local, in the store's `meta`, +// mirrored to storage.local for content scripts and pages to read. Every read below is from +// here, never from storage.local, which content scripts can write. +const trustedCopy = Settings.createArea({ + meta: { + entries: async (prefix) => (await getStore()).meta.entries(prefix), + write: async (puts, deletes) => (await getStore()).meta.write(puts, deletes), + }, + mirror: ext.storage.local, + onChange: (keys) => settingsChanged(keys), +}); +// Everything outside the upgrade itself waits for it, so an install from before this +// version never reads its settings before they were taken over (`adoptOnce`). +const area = Object.fromEntries(["get", "set", "remove", "heal", "has"].map((op) => [op, async (...a) => (await ready().catch(() => {}), trustedCopy[op](...a))])); +area.reset = () => trustedCopy.reset(); + +// What Kotiko keeps in storage.local; an install from before the trusted copy has these to +// take once (`adoptOnce`). The 0.2 `token` and `serverUrl` are the storage upgrade's. +const ADOPT = new Set([ + "words", "wordsVersion", "baseLangs", "baseRules", "baseRulesFor", "sensitiveSites", "enabled", "pausedHosts", "hiddenLangs", "mixing", "prefs", "speech", + "onboarding", "celebrations", "backupSnooze", "backupSince", "lastBackupAt", "lookupStatus", "syncError", "lastSync", "syncWarnings", "keys", "wordsHome", + "lookup", "server", "addJobs", "recentlyDeleted", "seedSalt", +]); +const TRUSTED = "settingsTrusted"; +const isTag = (tag) => globalThis.KotikoLang.canonical(tag).ok; +const validSalt = (s) => typeof s === "string" && /^[0-9a-f]{32}$/.test(s); +// A value of a key Kotiko's pages may change, if it has the shape they write. +const pageValue = (key, value) => Settings.edit({}, { set: { [key]: value } }, { isTag }).patch?.[key]; + +// Once per store. An install from before this version kept everything in storage.local, +// where its pages and its background wrote, and where a content script could have too; the +// two can't be told apart, so what is there now is taken as it is (the keys Kotiko writes), +// the same moment slice 28 takes the addresses. A new store takes nothing from +// storage.local, only the languages synced from the learner's other browsers (slice 50), +// as the first run always has. +const UI_FIELDS = ["uiLang", "baseLangs", "baseLangsDetected", "baseLangsConfirmed"]; +async function adoptOnce(store) { + const mark = await store.meta.get(TRUSTED); + if (mark === true) return; + const patch = {}; + if (mark !== "fresh") { + const raw = await ext.storage.local.get(null); + for (const [k, v] of Object.entries(raw)) if (ADOPT.has(k) && !(await trustedCopy.has(k))) patch[k] = v; + } + try { + const synced = await ext.storage.sync.get({ ui: null, seedSalt: null }); + const ui = synced.ui && typeof synced.ui === "object" && !Array.isArray(synced.ui) ? Object.fromEntries(UI_FIELDS.filter((f) => synced.ui[f] !== undefined).map((f) => [f, synced.ui[f]])) : null; + if (ui && !(await trustedCopy.has("ui"))) patch.ui = ui; + if (validSalt(synced.seedSalt) && !(await trustedCopy.has("seedSalt"))) patch.seedSalt = synced.seedSalt; + } catch { + // no storage.sync here + } + await trustedCopy.set(patch); + await store.meta.set(TRUSTED, true); +} + +// Chrome 140 and later can keep content scripts out of storage.sync (`setAccessLevel`; +// before 140, and in Firefox, they can write it). Then a change there was made by Kotiko, +// here or in the learner's other browsers, and is taken; otherwise it is left alone, and +// this browser keeps its own copy. The level is kept by the browser, so asking again at +// each start is harmless. +const syncLocked = (async () => { + if (typeof ext.storage.sync?.setAccessLevel !== "function") return false; + try { + await ext.storage.sync.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }); + return true; + } catch { + return false; + } +})(); + +async function adoptSync(changes) { + if (!(await syncLocked)) return; + await ready(); + const patch = {}; + const current = await area.get({ ui: null, seedSalt: null }); + const ui = changes.ui ? pageValue("ui", changes.ui.newValue) : undefined; + if (ui && !Settings.same(ui, current.ui)) { + patch.ui = ui; + if (ui.baseLangs) patch.baseLangs = ui.baseLangs; + } + const salt = changes.seedSalt?.newValue; + if (validSalt(salt) && salt !== current.seedSalt) patch.seedSalt = salt; + await area.set(patch); +} + +// storage.sync's `ui` (slice 50) follows the trusted copy, for the learner's other browsers. +async function saveUi(ui) { + await area.set({ ui }); + await Promise.resolve().then(() => ext.storage.sync.set({ ui })).catch(() => {}); +} + +// Reactions to a change of the trusted copy, whoever in this file made it. +function settingsChanged(keys) { + const k = new Set(keys); + if (k.has("ui")) { + projector.schedule(); + mirrorBaseRules().catch(() => {}); + } + if (k.has("wordsHome")) home().then((h) => h === "local" && projector.schedule()).catch(() => {}); + if (k.has("enabled") || k.has("pausedHosts")) updateAllBadges(); + if (k.has("words")) area.get({ words: [] }).then(({ words }) => Array.isArray(words) && words.length && noteFirstWord()).catch(() => {}); +} + +// A page's change (`settings.set`), one at a time: checked against the allowlist and the +// shapes in lib/settings.js, applied to the trusted values, then mirrored. Bases changed +// in `ui` are copied to the pages' `baseLangs`, and go to storage.sync. +let settingsChain = Promise.resolve(); +function saveSettings(m) { + const run = settingsChain.then(async () => { + await ready(); + const names = new Set([...Object.keys(m.set ?? {}), ...Object.keys(m.merge ?? {}), ...[...Object.keys(m.add ?? {}), ...Object.keys(m.remove ?? {})].map((p) => p.split(".")[0])]); + const r = Settings.edit(await area.get([...names]), m, { isTag }); + if (r.error) throw codedError("invalid_message", r.error); + const patch = r.patch; + if (patch.ui?.baseLangs && !patch.baseLangs) patch.baseLangs = patch.ui.baseLangs; + const { ui } = patch; + delete patch.ui; + await area.set(patch); + if (ui) await saveUi(ui); + if (patch.baseLangs) { + projector.schedule(); + mirrorBaseRules().catch(() => {}); + } + return { ok: true }; + }); + settingsChain = run.catch(() => {}); + return run; } // Where requests may go (slice 28 §7). The settings that say where Kotiko sends words and -// keys live in storage.local, which content scripts can write as well as read. So each +// keys lived in storage.local, which content scripts can write as well as read; they now +// live in the trusted copy above, and the routes stay as a second check. Each // route's trusted address is kept in the store (`meta`, out of their reach): "server", and // "lookup:" for each lookup service. Only Kotiko's own pages set it, by naming an // address (`server.connect`, `backend.set`, the OpenRouter sign-in), plus once at the @@ -199,17 +347,22 @@ async function secretFor(id, url) { // The upgrade to routes, once, right after the storage upgrade. A fresh install trusts only // the built-in addresses (the default server, each preset's own), never what storage.local // says, since a content script may already have written there. An install from before -// this version trusts the addresses its settings hold at that moment, which only its own -// pages could have chosen until now. +// this version trusts the addresses its settings hold at that moment, where its pages and +// content scripts could both write, except where no page could have: Kotiko's pages show +// an address field only for a service on the learner's computer and "custom", so an +// address saved for a hosted service (OpenRouter, OpenAI and the like) that isn't its own +// is dropped (SCR-448). async function bindRoutesOnce(store, { fresh = false } = {}) { if ((await store.meta.get("routesBound")) === true) return; if (fresh) { await bindRoute("server", Local.DEFAULT_SERVER); } else { - const s = await Local.readSettings(ext.storage.local); + const s = await Local.readSettings(trustedCopy); await bindRoute("server", s.server.url); const ep = globalThis.KotikoLLMClient.endpoint(s.lookup); - if (s.lookup.baseUrl) await bindRoute(`lookup:${ep.preset.id}`, ep.baseUrl); + const typed = ep.preset.local || ep.preset.id === "custom"; + if (s.lookup.baseUrl && typed) await bindRoute(`lookup:${ep.preset.id}`, ep.baseUrl); + else if (s.lookup.baseUrl) await trustedCopy.set({ lookup: { ...s.lookup, baseUrl: null } }); await chooseProvider(ep.preset.id); } await store.meta.set("routesBound", true); @@ -223,20 +376,16 @@ function mask(v) { const settings = async () => { await ready().catch(() => {}); - return Local.readSettings(ext.storage.local); + return Local.readSettings(area); }; const home = async () => (await settings()).wordsHome ?? "local"; -// The learner's base languages: slice 50's synced list when it exists, else the local -// copy, else the browser's language. +// The learner's base languages: slice 50's list (`ui`, the trusted copy of what +// storage.sync holds) when it exists, else the pages' copy, else the browser's language. async function currentBases() { - try { - const { ui } = await ext.storage.sync.get({ ui: {} }); - if (Array.isArray(ui?.baseLangs) && ui.baseLangs.length) return ui.baseLangs.slice(0, 4); - } catch { - // no storage.sync here - } - const { baseLangs } = await ext.storage.local.get({ baseLangs: null }); + await ready().catch(() => {}); + const { ui, baseLangs } = await area.get({ ui: null, baseLangs: null }); + if (Array.isArray(ui?.baseLangs) && ui.baseLangs.length) return ui.baseLangs.slice(0, 4); if (Array.isArray(baseLangs) && baseLangs.length) return baseLangs.slice(0, 4); return Local.detectBases(uiLanguage()); } @@ -275,7 +424,7 @@ async function mirrorBaseRules({ force = false } = {}) { const spec = globalThis.KOTIKO_SPEC; const bases = await currentBases(); const stamp = `${ext.runtime.getManifest?.().version ?? ""}|${spec?.version ?? ""}|${bases.join(",")}`; - const { baseRulesFor } = await ext.storage.local.get({ baseRulesFor: null }); + const { baseRulesFor } = await area.get({ baseRulesFor: null }); if (!force && baseRulesFor === stamp) return; const imported = await importedStopwords(); const out = {}; @@ -290,7 +439,7 @@ async function mirrorBaseRules({ force = false } = {}) { } // Content scripts read the sensitive-sites list here too; it changes only with a release. const sites = await sensitiveSites(); - await ext.storage.local.set({ baseRules: out, baseRulesFor: sites ? stamp : null, ...(sites ? { sensitiveSites: sites } : {}) }); + await area.set({ baseRules: out, baseRulesFor: sites ? stamp : null, ...(sites ? { sensitiveSites: sites } : {}) }); } // ── the server connection ─────────────────────────────────────────────────── @@ -300,7 +449,7 @@ async function connection() { await ready().catch(() => {}); const stored = await secret("server").catch(() => null); if (!stored) throw codedError("server_key_rejected", "Paste your API token to connect.", { reason: "no_token" }); - const n = normalizeServerUrl((await Local.readSettings(ext.storage.local)).server.url); + const n = normalizeServerUrl((await Local.readSettings(area)).server.url); if (!n.ok) throw codedError(n.code, n.hint, { hint: n.hint }); // Only the address a Kotiko page named gets the token (slice 28 §7). const token = await secretFor("server", n.url); @@ -354,10 +503,10 @@ async function refreshLookupStatus() { quota: s.quota && typeof s.quota === "object" ? s.quota : null, at: Date.now(), }; - await ext.storage.local.set({ lookupStatus: status }); + await area.set({ lookupStatus: status }); return status; } catch (e) { - if (e?.code === "server_outdated") await ext.storage.local.set({ lookupStatus: null }); + if (e?.code === "server_outdated") await area.set({ lookupStatus: null }); throw e; } } @@ -368,7 +517,7 @@ async function lookupStatus() { if (s.lookup.kind === "server") return refreshLookupStatus(); const st = await client.status(); const status = { provider: s.lookup.kind === "provider" ? st.provider : null, quota: s.lookup.kind === "provider" ? st.quota : null, ready: s.lookup.kind === "provider" && st.ready, at: Date.now() }; - await ext.storage.local.set({ lookupStatus: status }); + await area.set({ lookupStatus: status }); return status; } @@ -413,12 +562,7 @@ async function sendProfile() { try { await ready().catch(() => {}); if (!(await secret("server").catch(() => null))) return { ok: true, skipped: "no_server" }; - let ui = {}; - try { - ({ ui = {} } = await ext.storage.sync.get({ ui: {} })); - } catch { - // no storage.sync here - } + const { ui } = await area.get({ ui: {} }); const body = { base_langs: await currentBases(), ui_lang: typeof ui?.uiLang === "string" && ui.uiLang !== "auto" ? ui.uiLang : null }; const stamp = JSON.stringify([(await connection()).base, body]); const store = await getStore(); @@ -457,11 +601,11 @@ const sync = createSyncController({ if ((await home()) === "local") return; if (!result.ok) { const { code, message, details } = result; - await ext.storage.local.set({ syncError: { code, message, details, at: Date.now() } }); + await area.set({ syncError: { code, message, details, at: Date.now() } }); return; } // Only write words when they changed, so open tabs don't redo work every minute. - const { words: old = [] } = await ext.storage.local.get("words"); + const { words: old = [] } = await area.get("words"); const patch = { lastSync: Date.now(), syncError: null, @@ -469,7 +613,7 @@ const sync = createSyncController({ }; if (JSON.stringify(old) !== JSON.stringify(result.words)) patch.words = result.words; if (result.dropped) console.warn("Skipped words the server sent that can't be shown:", result.reasons); - await ext.storage.local.set(patch); + await area.set(patch); pushProfile(); }, }); @@ -485,12 +629,11 @@ async function requestSync(opts) { const projector = globalThis.KotikoProjection.createProjector({ list: async () => (await getStore()).list(), - storage: ext.storage.local, + storage: area, bases: currentBases, enabled: async () => !wiped && (await home()) === "local", onError: (e) => console.warn("Kotiko couldn't update the page word list:", e?.message ?? e), }); -let lastBases = null; const client = globalThis.KotikoLLMClient.createClient({ fetch: (...a) => fetch(...a), @@ -501,7 +644,7 @@ const client = globalThis.KotikoLLMClient.createClient({ key: (providerId, baseUrl) => secretFor(`provider:${providerId}`, baseUrl).catch(() => null), now, onQuota: (quota) => { - settings().then((s) => s.lookup.kind === "provider" && ext.storage.local.set({ lookupStatus: { provider: s.lookup.provider, quota, ready: true, at: Date.now() } })).catch(() => {}); + settings().then((s) => s.lookup.kind === "provider" && area.set({ lookupStatus: { provider: s.lookup.provider, quota, ready: true, at: Date.now() } })).catch(() => {}); }, }); @@ -572,7 +715,7 @@ async function lookupJob(job, signal) { const base = job.baseLangs[0] ?? (await currentBases())[0]; if (parsed) { // The hint, else Focus on one language (18), else the recent languages (24 §7). - const { mixing } = await ext.storage.local.get({ mixing: null }); + const { mixing } = await area.get({ mixing: null }); const focus = Array.isArray(mixing?.focus) && mixing.focus.length === 1 ? mixing.focus[0] : null; const lang = Local.manualLang(parsed.native, { hintLang: job.hintLang ?? focus, recent: await store.recentLangs(5), base }); const word = lang ? Local.manualWord(parsed, { lang, base, text: job.text }) : null; @@ -648,7 +791,7 @@ async function saveOrOffline(job, words) { } const queue = globalThis.KotikoAddQueue.createAddQueue({ - storage: ext.storage.local, + storage: area, lookup: lookupOrOffline, save: saveOrOffline, isFunctionWord, @@ -714,7 +857,7 @@ async function toServer() { } await underRoutes(async () => { const s = await settings(); - await ext.storage.local.set({ wordsHome: "server", lookup: { ...s.lookup, kind: s.lookup.kind === "none" ? "server" : s.lookup.kind } }); + await area.set({ wordsHome: "server", lookup: { ...s.lookup, kind: s.lookup.kind === "none" ? "server" : s.lookup.kind } }); }); await sync.credentialsChanged(); return { ok: true, total: words.length, ...counts }; @@ -732,20 +875,16 @@ async function toLocal({ forget = false, serverLookups = false } = {}) { await underRoutes(async () => { const { lookup } = await settings(); const kind = lookup.kind === "server" && !(serverLookups && !forget) ? "none" : lookup.kind; - await ext.storage.local.set({ wordsHome: "local", lookup: { ...lookup, kind }, baseLangs: nextBases, syncError: null }); + await area.set({ wordsHome: "local", lookup: { ...lookup, kind }, baseLangs: nextBases, syncError: null }); }); - // Slice 50's synced list, when there is one, is what currentBases() reads first. - try { - const { ui } = await ext.storage.sync.get({ ui: null }); - if (Array.isArray(ui?.baseLangs) && JSON.stringify(ui.baseLangs) !== JSON.stringify(nextBases)) await ext.storage.sync.set({ ui: { ...ui, baseLangs: nextBases } }); - } catch { - // no storage.sync here - } + // Slice 50's list, when there is one, is what currentBases() reads first. + const { ui } = await area.get({ ui: null }); + if (Array.isArray(ui?.baseLangs) && JSON.stringify(ui.baseLangs) !== JSON.stringify(nextBases)) await saveUi({ ...ui, baseLangs: nextBases }); if (forget) { await setSecret("server", null); await underRoutes(async () => { await bindRoute("server", Local.DEFAULT_SERVER); - await ext.storage.local.set({ server: { url: Local.DEFAULT_SERVER } }); + await area.set({ server: { url: Local.DEFAULT_SERVER } }); }); } await projector.flush(); @@ -755,6 +894,32 @@ async function toLocal({ forget = false, serverLookups = false } = {}) { // ── backups and "delete everything" (slice 12) ───────────────────────────── +// A backup file's settings: what lib/backup.js `settingsPatch` takes from it (never where +// requests go, nor the lookup service), each checked as a page's change would be. +async function restoreSettings(s) { + await ready(); + return underRoutes(async () => { + const current = await area.get(null); + const { local, sync: ui } = Backup.settingsPatch(s, { current }); + const patch = {}; + for (const [k, v] of Object.entries(local)) { + const value = k === "lookup" || k === "seedSalt" ? v : pageValue(k, v); + if (value !== undefined) patch[k] = value; + } + if (Object.keys(ui).length) { + const next = pageValue("ui", { uiLang: "auto", ...current.ui, ...ui, ...(ui.baseLangs ? { baseLangsConfirmed: true } : {}) }); + if (next) await saveUi(next); + } + await area.set(patch); + if (patch.lookup) lookupChanged(); + if (patch.baseLangs) { + projector.schedule(); + mirrorBaseRules().catch(() => {}); + } + return { ok: true }; + }); +} + const Backup = globalThis.KotikoBackup; const RESTORE_OPS = 1000; // words.write's limit per call // What the dashboard may change on a word when a backup or an undo patches it on a server. @@ -789,7 +954,7 @@ async function existingRecords() { if ((await home()) === "local") return (await getStore()).all(); const live = await serverWords(); const ids = new Set(live.map((w) => w.id)); - const { recentlyDeleted = [] } = await ext.storage.local.get({ recentlyDeleted: [] }); + const { recentlyDeleted = [] } = await area.get({ recentlyDeleted: [] }); const gone = (Array.isArray(recentlyDeleted) ? recentlyDeleted : []) .filter((e) => e?.word?.id && !ids.has(e.word.id)) .map((e) => ({ ...e.word, deleted_at: e.word.deleted_at ?? new Date(e.at ?? now()).toISOString() })); @@ -865,7 +1030,7 @@ async function backupRestore(m) { sync.request({ reason: "edit", force: true }).catch?.(() => {}); out = { ok: true, home: "server", counts: r.counts, restoreDeleted: r.restoreDeleted }; } - await ext.storage.local.set({ lastBackupAt: now() }); + await area.set({ lastBackupAt: now() }); return out; } @@ -889,7 +1054,7 @@ async function backupUndo() { } async function backupStatus() { - const { lastBackupAt = null } = await ext.storage.local.get({ lastBackupAt: null }); + const { lastBackupAt = null } = await area.get({ lastBackupAt: null }); const last = await (await getStore()).meta.get("lastImport").catch(() => null); const fresh = last && now() - last.at <= 86_400_000; return { lastBackupAt, lastImport: fresh ? { at: last.at, label: last.label ?? null, counts: last.counts ?? null, home: last.home ?? "local" } : null }; @@ -913,7 +1078,7 @@ async function dataDescribe() { const s = await settings(); const store = await getStore(); const ids = await store.secrets.ids().catch(() => []); - const words = s.wordsHome === "local" ? await store.count() : ((await ext.storage.local.get({ words: [] })).words ?? []).length; + const words = s.wordsHome === "local" ? await store.count() : ((await area.get({ words: [] })).words ?? []).length; let serverCount = null; if (s.keys.server) { try { @@ -961,6 +1126,7 @@ async function wipeEverything(m) { store?.close(); storeP = null; readyP = null; + area.reset(); secretCache.clear(); client.reset(); await globalThis.KotikoStore.wipe({ indexedDB: globalThis.indexedDB }); @@ -983,19 +1149,22 @@ ensureAlarm(); mirrorBaseRules().catch(() => {}); // Slice 18's seed salt: 32 random hex characters, made once and synced, so a page shows the -// same languages on every device; content scripts read the copy in storage.local. +// same languages on every device; content scripts read the copy in storage.local. The +// synced one wins where storage.sync takes only Kotiko's writes (`syncLocked`); elsewhere +// this browser keeps its own, and fills storage.sync only when it has none. async function ensureSeedSalt() { - const valid = (s) => typeof s === "string" && /^[0-9a-f]{32}$/.test(s); + await ready(); let synced = null; try { ({ seedSalt: synced } = await ext.storage.sync.get({ seedSalt: null })); } catch { // no storage.sync here } - const { seedSalt: local } = await ext.storage.local.get({ seedSalt: null }); - const salt = valid(synced) ? synced : valid(local) ? local : [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join(""); - if (salt !== synced) await ext.storage.sync.set({ seedSalt: salt }).catch(() => {}); - if (salt !== local) await ext.storage.local.set({ seedSalt: salt }); + const { seedSalt: local } = await area.get({ seedSalt: null }); + const takeSynced = validSalt(synced) && (!validSalt(local) || (await syncLocked)); + const salt = takeSynced ? synced : validSalt(local) ? local : [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join(""); + if (!validSalt(synced)) await Promise.resolve().then(() => ext.storage.sync.set({ seedSalt: salt })).catch(() => {}); + if (salt !== local) await area.set({ seedSalt: salt }); } ensureSeedSalt().catch(() => {}); @@ -1003,18 +1172,18 @@ ensureSeedSalt().catch(() => {}); // so nobody is reminded on day one. async function ensureBackupClock() { if (wiped) return; - const { backupSince } = await ext.storage.local.get({ backupSince: null }); - if (typeof backupSince !== "number") await ext.storage.local.set({ backupSince: now() }); + const { backupSince } = await area.get({ backupSince: null }); + if (typeof backupSince !== "number") await area.set({ backupSince: now() }); } ensureBackupClock().catch(() => {}); // Slice 08: languages hidden under an old code stay hidden under the canonical one, so a // hidden "cmn" is a hidden "zh" once the server re-tags its words. async function migrateHiddenLangs() { - const { hiddenLangs = [] } = await ext.storage.local.get({ hiddenLangs: [] }); + const { hiddenLangs = [] } = await area.get({ hiddenLangs: [] }); const canonical = (tag) => globalThis.KotikoLang.canonical(tag); const mapped = [...new Set(hiddenLangs.map((tag) => (canonical(tag).ok ? canonical(tag).tag : tag)))]; - if (JSON.stringify(mapped) !== JSON.stringify(hiddenLangs)) await ext.storage.local.set({ hiddenLangs: mapped }); + if (JSON.stringify(mapped) !== JSON.stringify(hiddenLangs)) await area.set({ hiddenLangs: mapped }); } // At every worker start: finish the upgrade, pick up jobs a stopped worker left behind. @@ -1066,7 +1235,8 @@ async function detectBrowserBases() { // welcome tab. Never on update: existing learners keep what they have. // A new install has nothing from an older version, so a 0.2 token, address or word list // the one-time upgrade found was written by a web page's content script in the moments -// before the upgrade ran (slice 28 §7): drop all of it. +// before the upgrade ran (slice 28 §7): drop all of it, with every address and setting +// taken along with it (SCR-448). Only the synced languages stay. async function discardPlanted() { const store = await getStore(); const from = await store.meta.get("migratedFrom"); @@ -1075,33 +1245,30 @@ async function discardPlanted() { await store.replaceAll([]); await underRoutes(async () => { await bindRoute("server", Local.DEFAULT_SERVER); - const { lookup } = await settings(); - await ext.storage.local.set({ wordsHome: "local", server: { url: Local.DEFAULT_SERVER }, lookup: { ...lookup, kind: lookup.kind === "server" ? "none" : lookup.kind }, words: [] }); + await store.meta.remove(CHOSEN); + for (const p of globalThis.KOTIKO_SPEC.providers.providers) await store.meta.remove(routeKey(`lookup:${p.id}`)); + await area.remove(Object.keys(await area.get(null)).filter((k) => k !== "ui")); + await area.set({ wordsHome: "local", server: { url: Local.DEFAULT_SERVER }, lookup: { ...Local.DEFAULT_LOOKUP }, keys: { server: false, providers: {} }, words: [] }); }); await store.meta.set("migratedFrom", "new"); sync.credentialsChanged(); + ensureSeedSalt().catch(() => {}); } async function firstInstall() { await ready().catch(() => {}); await discardPlanted().catch((e) => console.warn("Kotiko install:", e?.message ?? e)); const bases = await detectBrowserBases(); - let ui; - try { - ({ ui = {} } = await ext.storage.sync.get({ ui: {} })); - } catch { - ui = null; - } - // storage.sync can outlive an uninstall; a learner who confirmed before keeps their list. + // storage.sync can outlive an uninstall (the new store took its `ui`, adoptOnce); a + // learner who confirmed before keeps their list. + const { ui } = await area.get({ ui: {} }); const keep = ui?.baseLangsConfirmed === true && Array.isArray(ui.baseLangs) && ui.baseLangs.length; const next = keep ? ui.baseLangs.slice(0, 4) : bases; - if (ui) { - await ext.storage.sync.set({ ui: { uiLang: "auto", ...ui, baseLangs: next, baseLangsDetected: bases, baseLangsConfirmed: !!keep } }).catch(() => {}); - } - const { onboarding } = await ext.storage.local.get({ onboarding: null }); + await saveUi({ uiLang: "auto", ...ui, baseLangs: next, baseLangsDetected: bases, baseLangsConfirmed: !!keep }); + const { onboarding } = await area.get({ onboarding: null }); const patch = { baseLangs: next }; if (!onboarding) patch.onboarding = { completedAt: null, skipped: false, version: 2 }; - await ext.storage.local.set(patch); + await area.set(patch); projector.schedule(); await openWelcome(); } @@ -1112,16 +1279,11 @@ async function firstInstall() { // A learner who has a setting keeps it untouched. async function upgradeBases() { await ready().catch(() => {}); - let ui; - try { - ({ ui = {} } = await ext.storage.sync.get({ ui: {} })); - } catch { - ui = null; - } + const { ui } = await area.get({ ui: null }); if (Array.isArray(ui?.baseLangs) && ui.baseLangs.length) return; const detected = await detectBrowserBases(); const records = await getStore().then((s) => s.list()).catch(() => []); - const { words = [] } = await ext.storage.local.get({ words: [] }); + const { words = [] } = await area.get({ words: [] }); const present = []; for (const w of [...records, ...(Array.isArray(words) ? words : [])]) { const b = w?.base_lang ?? (w?.english ? "en" : null); @@ -1133,8 +1295,8 @@ async function upgradeBases() { // Over four: drop detected languages no word uses, from the end. for (let i = next.length - 1; next.length > 4 && i >= 0; i--) if (!present.includes(next[i])) next.splice(i, 1); const bases = next.slice(0, 4); - if (ui) await ext.storage.sync.set({ ui: { uiLang: "auto", ...ui, baseLangs: bases, baseLangsDetected: detected, baseLangsConfirmed: false } }).catch(() => {}); - await ext.storage.local.set({ baseLangs: bases }); + await saveUi({ uiLang: "auto", ...ui, baseLangs: bases, baseLangsDetected: detected, baseLangsConfirmed: false }); + await area.set({ baseLangs: bases }); projector.schedule(); } @@ -1142,7 +1304,7 @@ async function upgradeBases() { // a learner who already has words never gets a first-word or first-swap celebration. async function upgradeOnboarding() { await ready().catch(() => {}); - const { onboarding, celebrations, words } = await ext.storage.local.get({ onboarding: null, celebrations: null, words: [] }); + const { onboarding, celebrations, words } = await area.get({ onboarding: null, celebrations: null, words: [] }); const patch = {}; if (!onboarding) patch.onboarding = { completedAt: now(), skipped: false, version: 2, upgraded: true }; const stored = await getStore().then((s) => s.count()).catch(() => 0); @@ -1150,15 +1312,15 @@ async function upgradeOnboarding() { const next = Celebrations.markDone(celebrations, ["vocab:first", "page:first-swap"], now()); if (JSON.stringify(next) !== JSON.stringify(celebrations)) patch.celebrations = next; } - if (Object.keys(patch).length) await ext.storage.local.set(patch); + if (Object.keys(patch).length) await area.set(patch); } // The first word saved anywhere (the popup too) finishes the first run. let finishing = Promise.resolve(); function noteFirstWord() { finishing = finishing.then(async () => { - const { onboarding } = await ext.storage.local.get({ onboarding: null }); - if (onboarding && !onboarding.completedAt) await ext.storage.local.set({ onboarding: { ...onboarding, completedAt: now() } }); + const { onboarding } = await area.get({ onboarding: null }); + if (onboarding && !onboarding.completedAt) await area.set({ onboarding: { ...onboarding, completedAt: now() } }); }).catch(() => {}); return finishing; } @@ -1167,9 +1329,9 @@ function noteFirstWord() { let claiming = Promise.resolve(); function claimMilestone(key) { const run = claiming.then(async () => { - const { celebrations, prefs } = await ext.storage.local.get({ celebrations: null, prefs: {} }); + const { celebrations, prefs } = await area.get({ celebrations: null, prefs: {} }); const r = Celebrations.claim(celebrations, key, { now: now() }); - if (r.claimed) await ext.storage.local.set({ celebrations: r.next }); + if (r.claimed) await area.set({ celebrations: r.next }); return { claimed: r.claimed, reason: r.reason ?? null, celebrate: r.claimed && Celebrations.enabled(prefs) }; }); claiming = run.catch(() => {}); @@ -1252,7 +1414,7 @@ const PRESETS = () => globalThis.KOTIKO_SPEC.providers.providers.map(({ id, labe const serverHandlers = createWordHandlers({ call: apiV1, - storage: ext.storage.local, + storage: area, afterWrite: () => sync.request({ reason: "edit", force: true }), }); const localHandlers = Local.createLocalWordHandlers({ @@ -1267,7 +1429,7 @@ const localHandlers = Local.createLocalWordHandlers({ recentLangs: async (n) => (await getStore()).recentLangs(n), }, client, - storage: ext.storage.local, + storage: area, refresh, now, }); @@ -1318,6 +1480,25 @@ ext.runtime.onMessage.addListener( from: ["content"], run: async () => ({ sites: (await sensitiveSites()) ?? [] }), }, + // "Don't swap this word" from the word card on a page (slice 16 §5): the one setting + // a content script may change, one word at a time. + neverSwap: { + from: ["content"], + check: (m) => (typeof m.key === "string" && m.key.length >= 1 && m.key.length <= 200 && typeof m.on === "boolean" ? null : "key must name a word, and on be true or false"), + run: (m) => saveSettings({ [m.on ? "add" : "remove"]: { "prefs.neverSwap": [m.key] } }), + }, + // Settings Kotiko's pages change (SCR-448): checked, kept in the trusted copy, mirrored. + "settings.set": { + from: ["page"], + check: (m) => (["set", "merge", "add", "remove"].some((o) => m[o] !== undefined) ? null : "nothing to change"), + run: saveSettings, + }, + // A backup's settings (slice 12 §7), as lib/backup.js picks them. + "settings.restore": { + from: ["page"], + check: (m) => (m.settings && typeof m.settings === "object" && !Array.isArray(m.settings) ? null : "settings must be an object"), + run: (m) => restoreSettings(m.settings), + }, // The free lookups left today, for the popup and the dashboard (slice 10). llmStatus: { from: ["page"], @@ -1361,9 +1542,9 @@ ext.runtime.onMessage.addListener( if (typeof msg.jobId === "string") await queue.markUndo(msg.jobId, msg.id, "done"); await sync.update( async () => { - const { words = [] } = await ext.storage.local.get("words"); + const { words = [] } = await area.get("words"); const rest = words.filter((w) => !sameId(w.id, msg.id)); - if (rest.length !== words.length) await ext.storage.local.set({ words: rest }); + if (rest.length !== words.length) await area.set({ words: rest }); }, { reason: "remove" }, ); @@ -1457,7 +1638,7 @@ ext.runtime.onMessage.addListener( await bindRoute(`lookup:${ep.preset.id}`, ep.baseUrl); await chooseProvider(ep.preset.id); } - await ext.storage.local.set({ lookup: next }); + await area.set({ lookup: next }); lookupChanged(); return { ok: true, lookup: next }; }), @@ -1485,7 +1666,7 @@ ext.runtime.onMessage.addListener( if (typeof m.url === "string") { await underRoutes(async () => { await bindRoute("server", m.url.trim() || Local.DEFAULT_SERVER); - await ext.storage.local.set({ server: { url: m.url.trim() || Local.DEFAULT_SERVER } }); + await area.set({ server: { url: m.url.trim() || Local.DEFAULT_SERVER } }); }); } if (typeof m.token === "string") await setSecret("server", m.token.trim() || null); @@ -1496,7 +1677,7 @@ ext.runtime.onMessage.addListener( if (s.keys.server) { await underRoutes(async () => { const { lookup } = await settings(); - await ext.storage.local.set({ wordsHome: "server", lookup: { ...lookup, kind: lookup.kind === "provider" ? "provider" : "server" } }); + await area.set({ wordsHome: "server", lookup: { ...lookup, kind: lookup.kind === "provider" ? "provider" : "server" } }); }); } } @@ -1528,7 +1709,7 @@ ext.runtime.onMessage.addListener( "backup.saved": { from: ["page"], async run() { - await ext.storage.local.set({ lastBackupAt: now() }); + await area.set({ lastBackupAt: now() }); return { ok: true }; }, }, @@ -1568,7 +1749,7 @@ ext.runtime.onMessage.addListener( await bindRoute("lookup:openrouter", globalThis.KotikoLLMClient.endpoint({ provider: "openrouter" }).baseUrl); await chooseProvider("openrouter"); const s = await settings(); - await ext.storage.local.set({ lookup: { ...s.lookup, kind: "provider", provider: "openrouter", baseUrl: null } }); + await area.set({ lookup: { ...s.lookup, kind: "provider", provider: "openrouter", baseUrl: null } }); }); lookupChanged(); return { ok: true }; @@ -1582,7 +1763,7 @@ ext.runtime.onMessage.addListener( // When it succeeds, the old job's word is removed (retireReplaced); undoing the new one // brings the old one back. async function relang(m) { - const { addJobs = [] } = await ext.storage.local.get({ addJobs: [] }); + const { addJobs = [] } = await area.get({ addJobs: [] }); const old = addJobs.find((j) => j.id === m.id); if (!old) return { error: "That add is gone.", code: "job_gone" }; const id = globalThis.KotikoStore.uuid7(now()); @@ -1642,9 +1823,9 @@ function cacheWord(w) { return out; } async function replaceCached(gone, added = []) { - const { words = [] } = await ext.storage.local.get("words"); + const { words = [] } = await area.get("words"); const rest = words.filter((w) => !gone.some((g) => sameWord(g, w))); - if (rest.length !== words.length || added.length) await ext.storage.local.set({ words: [...added, ...rest] }); + if (rest.length !== words.length || added.length) await area.set({ words: [...added, ...rest] }); } // Undo for one word of a finished add job (slice 24 §5), on every record the job saved @@ -1656,7 +1837,7 @@ function checkJobWord(m) { } const RESTORABLE = ["gloss", "forms", "romanization", "native_vocalized", "pronunciation", "pronunciation_careful", "pronunciation_source", "note", "status", "sense"]; const jobWords = async (id, key) => { - const { addJobs = [] } = await ext.storage.local.get({ addJobs: [] }); + const { addJobs = [] } = await area.get({ addJobs: [] }); const job = addJobs.find((j) => j.id === id); return (job?.results ?? []).filter((r) => r.word && globalThis.KotikoAddQueue.keyOf(r.word) === key); }; @@ -1684,7 +1865,7 @@ async function undoWord(id, key) { if (removed.length && (await home()) === "server") await sync.update(() => replaceCached(removed), { reason: "remove" }); await queue.setUndo(id, key, bad ? { undo: "failed", undoError: { code: bad.code, details: bad.details ?? {} } } : { undo: "done", undoError: null }); // Undoing a re-add in another language brings back the word it replaced (24 §6). - const { addJobs = [] } = await ext.storage.local.get({ addJobs: [] }); + const { addJobs = [] } = await area.get({ addJobs: [] }); const job = addJobs.find((j) => j.id === id); if (!bad && job?.replaces) { await redoWord(job.replaces.id, job.replaces.key); @@ -1723,17 +1904,9 @@ ext.commands?.onCommand?.addListener((command, tab) => { else Promise.resolve(ext.tabs.query({ active: true, currentWindow: true })).then(([active]) => active?.id && send(active.id), () => {}); }); -// The address and token of a 0.2 install are adopted once, by the storage upgrade -// (lib/local-mode.js `migrate`). After an update no page from before it is left, so these -// keys written later can only come from a content script: they are removed, never used. -async function dropLegacy() { - await ready().catch(() => {}); - await ext.storage.local.remove(["token", "serverUrl"]); -} - -// Kotiko's own code writes `server` and `lookup` only after setting their routes, so a -// copy in storage.local that disagrees was written by something else: put the trusted -// address back, so the settings show what Kotiko really uses, and wake waiting adds. +// Kotiko's own code writes `server` and `lookup` only after setting their routes, so +// settings that disagree with the routes come from a path that forgot to (a bug): put the +// trusted address back, so the settings show what Kotiko really uses, and wake waiting adds. let healing = null; function healRoutes() { healing ??= underRoutes(healOnce).finally(() => { @@ -1743,7 +1916,7 @@ function healRoutes() { } async function healOnce() { await ready().catch(() => {}); - const s = await Local.readSettings(ext.storage.local); + const s = await Local.readSettings(area); const patch = {}; const server = await trustedUrl("server"); if (server && !sameAddress("server", s.server.url, server)) patch.server = { url: server.startsWith(RAW) ? server.slice(RAW.length) : server }; @@ -1755,30 +1928,26 @@ async function healOnce() { patch.lookup = { ...s.lookup, provider: chosen, baseUrl: trusted && trusted !== own ? trusted : null }; } if (!Object.keys(patch).length) return; - await ext.storage.local.set(patch); + await area.set(patch); client.reset(); queue.wake(); if (patch.server) sync.credentialsChanged(); } -ext.storage.onChanged.addListener((changes, area) => { +// storage.local is the trusted copy's mirror: a change the background didn't make is put +// back, and a key it doesn't keep (a 0.2 `token` or `serverUrl` written after the upgrade, +// say) is removed unused. storage.sync's changes are taken only where content scripts +// can't write there (`adoptSync`). +ext.storage.onChanged.addListener((changes, areaName) => { if (wiped) return; - if (area === "sync" && changes.ui) { - projector.schedule(); - mirrorBaseRules().catch(() => {}); - } - if (area === "sync" && changes.seedSalt) ensureSeedSalt().catch(() => {}); - if (area !== "local") return; - if (changes.token?.newValue !== undefined || changes.serverUrl?.newValue !== undefined) dropLegacy().catch(() => {}); - if (changes.server || changes.lookup) healRoutes().catch(() => {}); - // Bases changed by a page (not the projection's own mirror): project again. - if (changes.baseLangs && JSON.stringify(changes.baseLangs.newValue) !== lastBases) { - lastBases = JSON.stringify(changes.baseLangs.newValue); - projector.schedule(); - mirrorBaseRules().catch(() => {}); - } - if (changes.wordsHome && changes.wordsHome.newValue === "local") projector.schedule(); - if (Array.isArray(changes.words?.newValue) && changes.words.newValue.length) noteFirstWord(); + if (areaName === "sync") adoptSync(changes).catch(() => {}); + if (areaName !== "local") return; + ready() + .then(() => area.heal(changes)) + .then((r) => { + if (r.restored.length || r.removed.length) console.warn("Kotiko put back settings changed outside its pages:", [...r.restored, ...r.removed].join(", ")); + }) + .catch(() => {}); }); // The toolbar badge and tooltip per tab (slice 20 §5): "off" when Kotiko is off everywhere @@ -1788,7 +1957,7 @@ async function updateBadge(tab) { const action = ext.action; if (!action?.setBadgeText || !tab?.id) return; try { - const s = await ext.storage.local.get({ enabled: true, pausedHosts: [] }); + const s = await area.get({ enabled: true, pausedHosts: [] }); const b = badgeFor({ enabled: s.enabled, pausedHosts: s.pausedHosts, url: tab.url ?? "" }); await action.setBadgeText({ tabId: tab.id, text: b.text ? t("badge_off") : "" }); if (b.text) { @@ -1815,10 +1984,9 @@ ext.tabs?.onActivated?.addListener(({ tabId }) => { ext.tabs?.onUpdated?.addListener((_id, change, tab) => { if (change.url || change.status === "loading") updateBadge(tab); }); -ext.storage.onChanged.addListener((changes, area) => { - if (area === "local" && (changes.enabled || changes.pausedHosts)) updateAllBadges(); -}); updateAllBadges(); // For tests: the parts a test drives directly. -globalThis.__kotiko = { ensureSeedSalt, ready, getStore, queue, refresh, projector, client, settings, currentBases, mirrorBaseRules, injectOpenTabs, toServer, toLocal, openWelcome, claimMilestone, onInstalled }; +// `seed` writes the trusted copy, as Kotiko's own code does (tests can't write storage.local +// and have it stay, any more than a content script can). +globalThis.__kotiko = { ensureSeedSalt, adoptSync, ready, getStore, queue, refresh, projector, client, settings, currentBases, mirrorBaseRules, injectOpenTabs, toServer, toLocal, openWelcome, claimMilestone, onInstalled, area, seed: async (items) => (await ready(), area.set(items)) }; diff --git a/extension/bulk/sheet.js b/extension/bulk/sheet.js index 2b25e1f..f4d414a 100644 --- a/extension/bulk/sheet.js +++ b/extension/bulk/sheet.js @@ -294,7 +294,8 @@ const pending = st.rows.filter((r) => r.state === "looking"); if (pending.length) await later(pending); const batch = { id: globalThis.crypto.randomUUID(), at: Date.now(), results: results.map((r) => ({ id: r.word?.id, result: r.result, previous: r.previous ?? null, updated_at: r.word?.updated_at ?? null })) }; - await ctx.ext.storage.local.set({ bulkBatch: batch }).catch(() => {}); + // storage.session: kept by the browser for this session, out of content scripts' reach. + await Promise.resolve(ctx.ext.storage.session?.set({ bulkBatch: batch })).catch(() => {}); if (st.pron && results.some((r) => r.result === "created" && !r.word?.pronunciation)) ctx.source.refreshJob?.("start").catch(() => {}); const count = (k) => results.filter((r) => r.result === k).length; st.summary = { batch, created: count("created"), updated: count("updated"), unchanged: count("unchanged"), pending: pending.length, lang: st.target, error }; @@ -313,7 +314,7 @@ ); const failed = []; for (let i = 0; i < ops.length; i += 1000) failed.push(...(await ctx.source.write(ops.slice(i, i + 1000))).filter((x) => !x.ok && x.code !== "word_gone")); - await ctx.ext.storage.local.remove("bulkBatch").catch(() => {}); + await Promise.resolve(ctx.ext.storage.session?.remove("bulkBatch")).catch(() => {}); st.summary = { ...st.summary, undone: true, undoFailed: failed.length }; render(); } diff --git a/extension/content.js b/extension/content.js index a071506..b15e56d 100644 --- a/extension/content.js +++ b/extension/content.js @@ -315,13 +315,11 @@ } // "Don't swap this word" (slice 16 §5): the form's key joins the never-swap list, with an - // undo. The storage change re-applies every page. + // undo. The background changes the list (content scripts write no settings, SCR-448); the + // storage change re-applies every page. async function setNeverSwap(key, on) { - const { prefs: p = {} } = await ext.storage.local.get({ prefs: {} }); - const list = new Set(Array.isArray(p.neverSwap) ? p.neverSwap : []); - if (on) list.add(key); - else list.delete(key); - await ext.storage.local.set({ prefs: { ...p, neverSwap: [...list] } }); + const r = await ext.runtime.sendMessage({ type: "neverSwap", key, on }); + if (!r?.ok) throw new Error("not saved"); } const popoverActions = [ diff --git a/extension/dashboard.js b/extension/dashboard.js index d4b8085..179fa8c 100644 --- a/extension/dashboard.js +++ b/extension/dashboard.js @@ -98,7 +98,7 @@ testing: false, lookupTest: null, move: null, // {to, count, server, busy, error} - ui: {}, // storage.sync `ui`: baseLangs, baseLangsDetected, uiLang (slice 50) + ui: {}, // `ui` (slice 50: baseLangs, baseLangsDetected, uiLang), the background's copy in storage.local langList: null, // every language a learner can read in, named in the interface language }; @@ -135,6 +135,12 @@ node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node?.isContentEditable === true; // After "delete everything" this page asks for nothing more, so nothing is recreated. const send = (msg) => (state.wiped && msg.type !== "data.deleteAll" && msg.type !== "welcome.open" ? Promise.reject(Object.assign(new Error("Everything was deleted."), { code: "storage_full", details: { reason: "deleted" } })) : Promise.resolve().then(() => ext.runtime.sendMessage(msg))); + // A settings change, made by the background, which keeps the real copy (SCR-448): + // storage.local is only its mirror. {set, merge, add, remove}. + async function saveSettings(change) { + const res = await send({ type: "settings.set", ...change }); + if (res?.error) throw Object.assign(new Error(typeof res.error === "string" ? res.error : res.error.code), { code: res.code ?? "internal" }); + } // Slice 11: where words live and whether new ones can be looked up. const legacyToken = (s) => !!String(s?.token ?? "").trim(); @@ -679,13 +685,13 @@ } async function toggleHidden(lang) { - const { hiddenLangs = [] } = await ext.storage.local.get({ hiddenLangs: [] }); - const set = new Set(hiddenLangs); - if (set.has(lang)) set.delete(lang); - else set.add(lang); + const set = new Set(state.s.hiddenLangs ?? []); + const hide = !set.has(lang); + if (hide) set.add(lang); + else set.delete(lang); state.s.hiddenLangs = [...set]; renderShelf(); - await ext.storage.local.set({ hiddenLangs: [...set] }); + await saveSettings({ [hide ? "add" : "remove"]: { hiddenLangs: [lang] } }); } // The one confirmed action (§6): one click affecting a whole language. @@ -2515,11 +2521,9 @@ } async function setMixing(patch) { - const { mixing } = await ext.storage.local.get({ mixing: null }); - const next = { ...mixing, ...patch }; - state.s.mixing = next; + state.s.mixing = { ...state.s.mixing, ...patch }; renderSettings(); - await ext.storage.local.set({ mixing: next }); + await saveSettings({ merge: { mixing: patch } }); } // Slice 16: sensitive sites (on by default) and the ones the learner runs Kotiko on @@ -2564,8 +2568,9 @@ // --- Languages you read in (slice 50 §2) ------------------------------------------------ // Kotiko swaps words on pages in these languages, primary first. Changes save at once to - // storage.sync `ui` (the background projects the words again) and to the local mirror - // content scripts read. Removing a language keeps its meanings; they stop swapping. + // the background's `ui` (storage.sync's, which it projects the words again for) and the + // local copy content scripts read. Removing a language keeps its meanings; they stop + // swapping. const LANG_README = "https://github.com/ScriptKittyOS/kotiko/blob/main/spec/lang/README.md"; @@ -2575,13 +2580,8 @@ async function writeBases(next) { state.ui = { uiLang: "auto", ...state.ui, baseLangs: next.slice(), baseLangsConfirmed: true }; - try { - await ext.storage.sync.set({ ui: state.ui }); - } catch { - // no storage.sync: the local copy below still works - } state.s.baseLangs = next.slice(); - await ext.storage.local.set({ baseLangs: next.slice() }); + await saveSettings({ merge: { ui: { uiLang: state.ui.uiLang, baseLangs: next.slice(), baseLangsConfirmed: true } } }); syncProfile(); rebuild(); renderWords(); @@ -2776,21 +2776,14 @@ const renderSegmented = (box, options, current, onPick) => renderRadios(box, options.map(([v, k]) => [v, t(k)]), current, onPick, "segment"); async function setPref(key, value) { - const { prefs = {} } = await ext.storage.local.get({ prefs: {} }); - const next = { ...prefs, [key]: value }; - state.s.prefs = next; + state.s.prefs = { ...state.s.prefs, [key]: value }; renderSettings(); - await ext.storage.local.set({ prefs: next }); + await saveSettings({ merge: { prefs: { [key]: value } } }); } async function setUiLang(value) { state.uiLang = value; - try { - const { ui = {} } = await ext.storage.sync.get({ ui: {} }); - await ext.storage.sync.set({ ui: { ...ui, uiLang: value } }); - } catch { - // storage.sync unavailable: this page still switches - } + await saveSettings({ merge: { ui: { uiLang: value } } }).catch(() => {}); syncProfile(); await applyUiLang(value); } @@ -3135,12 +3128,11 @@ } async function setOnlineVoices(on) { - const { speech = LOCAL_DEFAULTS.speech } = await ext.storage.local.get({ speech: LOCAL_DEFAULTS.speech }); - const next = { ...LOCAL_DEFAULTS.speech, ...speech, allowOnline: on }; + const next = { ...LOCAL_DEFAULTS.speech, ...state.s.speech, allowOnline: on }; state.s.speech = next; Speak?.configure?.(next); $("onlineVoices").setAttribute("aria-checked", String(on)); - await ext.storage.local.set({ speech: next }); + await saveSettings({ merge: { speech: next } }); } // --------------------------------------------------------------------------------- @@ -3259,7 +3251,7 @@ function onStorage(changes, area) { if (state.wiped) return; - if (area === "sync" && changes.ui) { + if (area === "local" && changes.ui) { const prev = JSON.stringify(state.ui?.baseLangs ?? null); state.ui = changes.ui.newValue ?? {}; const v = state.ui.uiLang ?? "auto"; @@ -3271,7 +3263,6 @@ renderWords(); if (state.route.view === "settings") renderBases(); } - return; } if (area !== "local") return; let words = false; @@ -3330,11 +3321,7 @@ const skeleton = setTimeout(showSkeleton, SKELETON_MS); Object.assign(state.s, await ext.storage.local.get(LOCAL_DEFAULTS)); - try { - state.ui = (await ext.storage.sync.get({ ui: {} })).ui ?? {}; - } catch { - state.ui = {}; - } + state.ui = (await ext.storage.local.get({ ui: {} })).ui ?? {}; Speak?.configure?.(state.s.speech); $("search").addEventListener("input", (e) => setParams({ q: e.target.value || null })); diff --git a/extension/data-tools.js b/extension/data-tools.js index 9ce6092..8369af2 100644 --- a/extension/data-tools.js +++ b/extension/data-tools.js @@ -59,15 +59,10 @@ // ── exports ──────────────────────────────────────────────────────── + // storage.local holds the background's copy of every setting, `ui` (storage.sync's) too. async function settingsNow() { const local = await ext.storage.local.get(null); - let sync = {}; - try { - sync = await ext.storage.sync.get({ ui: {} }); - } catch { - // no storage.sync here - } - return B().settingsOf({ local, sync }); + return B().settingsOf({ local, sync: { ui: local.ui ?? {} } }); } // The JSON backup of every word (section 2), straight from where the words live. @@ -277,18 +272,9 @@ go.focus(); } + // The background applies them to its own copy (SCR-448), as lib/backup.js picks them. async function restoreSettings(s) { - const current = await ext.storage.local.get(null); - const { local, sync } = B().settingsPatch(s, { current }); - if (Object.keys(local).length) await ext.storage.local.set(local); - if (Object.keys(sync).length) { - try { - const { ui = {} } = await ext.storage.sync.get({ ui: {} }); - await ext.storage.sync.set({ ui: { uiLang: "auto", ...ui, ...sync, ...(sync.baseLangs ? { baseLangsConfirmed: true } : {}) } }); - } catch { - // no storage.sync: the local copy of the bases still works - } - } + await send({ type: "settings.restore", settings: s }); } async function undoRestore() { diff --git a/extension/lib/i18n.js b/extension/lib/i18n.js index 86c320a..7fec594 100644 --- a/extension/lib/i18n.js +++ b/extension/lib/i18n.js @@ -18,7 +18,8 @@ // them), falling back to `_other`. // // The interface-language override (50 §8, "Kotiko's language" in the dashboard's settings, -// stored as `ui.uiLang` in storage.sync): `await KotikoI18n.loadPreference()` reads it and, +// stored as `ui.uiLang` in storage.sync, of which the background keeps the copy pages read in +// storage.local): `await KotikoI18n.loadPreference()` reads it and, // when it names a shipped locale, fetches `_locales//messages.json` once and looks // keys up itself (the browser's getMessage can't switch at runtime); missing keys fall // back to English. Pages that await it before rendering follow the setting; the rest @@ -195,10 +196,11 @@ return true; } - // The stored preference (storage.sync `ui.uiLang`): "auto" or a shipped locale. + // The stored preference (`ui.uiLang`, the background's copy in storage.local, which only + // Kotiko's own pages change; SCR-448): "auto" or a shipped locale. async function preference() { try { - const { ui } = await ext.storage.sync.get({ ui: {} }); + const { ui } = await ext.storage.local.get({ ui: {} }); return typeof ui?.uiLang === "string" ? ui.uiLang : "auto"; } catch { return "auto"; diff --git a/extension/lib/local-mode.js b/extension/lib/local-mode.js index e635ffa..91c0059 100644 --- a/extension/lib/local-mode.js +++ b/extension/lib/local-mode.js @@ -6,14 +6,18 @@ // the background's word routes over the extension's own store. No DOM, so it runs in the // background (globalThis.KotikoLocal) and in Node tests (module.exports). // -// Settings (storage.local is the working copy; slice 39 syncs the non-secret ones): +// Settings (the background's trusted copy, lib/settings.js, mirrored to storage.local; +// slice 39 syncs the non-secret ones): // wordsHome "local" (this browser keeps the words) | "server" (a Kotiko server does) // lookup {kind: "provider" | "server" | "none", provider, baseUrl, model, dataCollection} // server {url} the token is a secret, never here // keys {server: bool, providers: {id: bool}} which secrets exist (no key material) // // await KotikoLocal.readSettings(storage) -// await KotikoLocal.migrate({ store, storage, uiLanguage }) idempotent; meta.schema = 1 +// await KotikoLocal.migrate({ store, storage, legacy, uiLanguage, beforeDone }) +// idempotent; meta.schema = 1. Reads a 0.2 +// install's keys from `legacy` (storage.local) +// and writes the settings to `storage` // KotikoLocal.parseManual("犬 = perro") -> {native, gloss, romanization, pronunciation} // KotikoLocal.createLocalWordHandlers({...}) -> MessageRouter handlers (words.*, job.refresh) (() => { @@ -46,9 +50,13 @@ // Slice 11 section 8. Runs on install and update, and on any worker start until // meta.schema is set, so an interrupted run resumes. Every step is idempotent; no step // deletes a word. - async function migrate({ store, storage, uiLanguage = "en", log = () => {} }) { + // `storage` gets the settings (the background's trusted copy); `legacy` is where an older + // install kept them (storage.local; the same area when they are one). `beforeDone(from)` + // runs before the upgrade is marked done, so whatever it records is redone if the worker + // stops in between. + async function migrate({ store, storage, legacy: legacyArea = storage, uiLanguage = "en", beforeDone = async () => {}, log = () => {} }) { if ((await store.meta.get("schema")) === SCHEMA) return { migrated: false }; - const legacy = await storage.get({ token: "", serverUrl: null, words: [], wordsHome: null, lookup: null, server: null, baseLangs: null, keys: null }); + const legacy = await legacyArea.get({ token: "", serverUrl: null, words: [], wordsHome: null, lookup: null, server: null, baseLangs: null, keys: null }); const token = String(legacy.token ?? "").trim(); const patch = {}; // Step 2: a token means today's setup: words on that server, which also looks them up. @@ -71,15 +79,17 @@ if (home === "local") { let bases = Array.isArray(legacy.baseLangs) && legacy.baseLangs.length ? legacy.baseLangs : detectBases(uiLanguage); if (seeded && !bases.includes("en")) bases = [...bases, "en"]; - if (JSON.stringify(bases) !== JSON.stringify(legacy.baseLangs)) patch.baseLangs = bases; + patch.baseLangs = bases; } const keys = { server: !!token || legacy.keys?.server === true, providers: { ...(legacy.keys?.providers ?? {}) } }; patch.keys = keys; await storage.set(patch); // Step 6: the token and address leave the area content scripts can read. - await storage.remove(["token", "serverUrl"]); + await legacyArea.remove(["token", "serverUrl"]); + const from = token ? "server" : legacy.words?.length ? "cache" : "new"; + await beforeDone(from); await store.meta.set("schema", SCHEMA); - await store.meta.set("migratedFrom", token ? "server" : legacy.words?.length ? "cache" : "new"); + await store.meta.set("migratedFrom", from); log(`Kotiko storage ready (${home}; ${seeded} cached word(s) kept)`); return { migrated: true, home, seeded }; } diff --git a/extension/lib/settings.js b/extension/lib/settings.js new file mode 100644 index 0000000..1f67638 --- /dev/null +++ b/extension/lib/settings.js @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// Settings and state only Kotiko itself may change (SCR-448). Chrome and Firefox let +// content scripts write `storage.local` and `storage.sync`, and Firefox has no way to stop +// them (`setAccessLevel` isn't implemented there). So the background keeps the real copy of +// everything it acts on in its own IndexedDB store, which content scripts can't open, and +// `storage.local` is only a mirror of it for content scripts and pages to read: +// +// - every read in the background comes from the trusted copy, never from storage.local; +// - every write goes to the trusted copy first, then to the mirror; +// - a change to the mirror that the background didn't make (a content script's) is put +// back (`heal`), and keys it doesn't hold are removed. +// +// Kotiko's pages change settings with the `settings.set` message (pages only), checked +// here by `edit` against an allowlist of keys and their shapes. No DOM, so it runs in the +// background (globalThis.KotikoSettings) and in Node tests (module.exports). +// +// const area = KotikoSettings.createArea({ meta, mirror: ext.storage.local, onChange }); +// await area.get({ enabled: true }) like storage.local.get, from the trusted copy +// await area.set({ enabled: false }) trusted copy, then mirror +// await area.heal(changes) puts back what someone else wrote +// KotikoSettings.edit(current, msg, { isTag }) -> { patch } | { error } +// KotikoSettings.same(a, b) equal as storage keeps them (key order aside) +(() => { + // Trusted copies live in the store's `meta` under this prefix, one row per key. + const PREFIX = "area:"; + + const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); + const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + + // JSON with object keys sorted: Chrome hands objects back from storage with their keys + // in its own order, so two copies of one value can differ only in that. + function canon(v) { + if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? "null" : canon(x))).join(",")}]`; + if (isObj(v)) { + const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`; + } + return JSON.stringify(v ?? null); + } + const same = (a, b) => canon(a) === canon(b); + + // storage.get's query forms: null (everything), a key, a list of keys, or defaults. + function pick(all, query) { + if (query == null) return clone(Object.fromEntries(all)); + if (typeof query === "string" || Array.isArray(query)) { + const out = {}; + for (const k of [query].flat()) if (all.has(k)) out[k] = clone(all.get(k)); + return out; + } + const out = {}; + for (const [k, d] of Object.entries(query)) out[k] = all.has(k) ? clone(all.get(k)) : clone(d); + return out; + } + + // meta: {entries(prefix), write(puts, deletes)} over the store's `meta` (lib/store.js). + // mirror: the storage area content scripts read (storage.local). + // onChange(keys): after the trusted copy changed (not after a heal). + function createArea({ meta, mirror, onChange = () => {} }) { + let cache = null; + // Each trusted value's canonical form, made once per write (the word list is large). + const canons = new Map(); + const canonOf = (k) => { + if (!canons.has(k)) canons.set(k, canon(cache.get(k))); + return canons.get(k); + }; + const matches = (k, v) => canon(v) === canonOf(k); + let chain = Promise.resolve(); + // One write or heal at a time, so a heal never puts back a value a write just replaced. + const serial = (fn) => { + const p = chain.then(fn); + chain = p.catch(() => {}); + return p; + }; + + async function load() { + if (cache) return cache; + const rows = await meta.entries(PREFIX); + cache = new Map(rows.map((r) => [r.key.slice(PREFIX.length), r.value])); + return cache; + } + + async function get(query) { + return pick(await load(), query); + } + + function set(items) { + const entries = Object.entries(items ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => [k, clone(v)]); + if (!entries.length) return Promise.resolve(); + return serial(async () => { + const all = await load(); + await meta.write(entries.map(([k, v]) => ({ key: PREFIX + k, value: v })), []); + for (const [k, v] of entries) { + all.set(k, v); + canons.delete(k); + } + await mirror.set(Object.fromEntries(entries)); + onChange(entries.map(([k]) => k)); + }); + } + + function remove(keys) { + const list = [keys].flat().filter((k) => typeof k === "string"); + if (!list.length) return Promise.resolve(); + return serial(async () => { + const all = await load(); + await meta.write([], list.map((k) => PREFIX + k)); + for (const k of list) { + all.delete(k); + canons.delete(k); + } + await mirror.remove(list); + onChange(list); + }); + } + + // Puts the mirror back where it differs from the trusted copy: values restored, keys the + // background doesn't hold removed. With `changes` (a storage.onChanged event) only those + // keys are checked, and a change that matches the trusted copy (the background's own + // write) costs one comparison; without, the whole mirror is checked. + function heal(changes = null) { + return serial(async () => { + const all = await load(); + let keys; + if (changes) { + keys = Object.keys(changes).filter((k) => !(all.has(k) ? matches(k, changes[k].newValue) : changes[k].newValue === undefined)); + if (!keys.length) return { restored: [], removed: [] }; + } + // The event may be stale (written over since): compare with what is there now. + const now = await mirror.get(keys ?? null); + if (!keys) keys = [...new Set([...Object.keys(now), ...all.keys()])]; + const restore = {}; + const removed = []; + for (const k of keys) { + if (!all.has(k)) { + if (k in now) removed.push(k); + } else if (!(k in now) || !matches(k, now[k])) restore[k] = clone(all.get(k)); + } + if (removed.length) await mirror.remove(removed); + if (Object.keys(restore).length) await mirror.set(restore); + return { restored: Object.keys(restore), removed }; + }); + } + + return { + get, + set, + remove, + heal, + // Whether the trusted copy holds a key (the one-time adoption takes only missing ones). + has: async (k) => (await load()).has(k), + // After "delete everything": the next read loads from the new, empty store. + reset: () => { + cache = null; + canons.clear(); + }, + }; + } + + // ── what Kotiko's pages may change (`settings.set`) ──────────────────────────── + + const TAG = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/; + const bytes = (v) => JSON.stringify(v ?? null).length; + const strings = (v, { max, len, test = () => true, min = 0 }) => + Array.isArray(v) && v.length >= min && v.length <= max && v.every((x) => typeof x === "string" && x.length >= 1 && x.length <= len && test(x)); + const plain = (v, max) => isObj(v) && bytes(v) <= max; + + // One rule per key: `ok(value, ctx)` for a whole value, `lists` for the lists inside an + // object that `add` and `remove` may change one item at a time. + const KEYS = { + enabled: { ok: (v) => typeof v === "boolean" }, + pausedHosts: { ok: (v) => strings(v, { max: 1000, len: 253, test: (h) => !/[\s/]/.test(h) }), list: { len: 253 } }, + hiddenLangs: { ok: (v, c) => strings(v, { max: 200, len: 35, test: c.isTag }), list: { len: 35, tag: true } }, + baseLangs: { ok: (v, c) => strings(v, { min: 1, max: 4, len: 35, test: c.isTag }) }, + mixing: { ok: (v) => v === null || plain(v, 4096), object: true }, + prefs: { + ok: (v) => v === null || (plain(v, 65536) && ["neverSwap", "sensitiveAllowed"].every((k) => v[k] === undefined || strings(v[k], { max: 5000, len: 253 }))), + object: true, + lists: { neverSwap: { len: 200 }, sensitiveAllowed: { len: 253 } }, + }, + speech: { ok: (v) => v === null || plain(v, 8192), object: true }, + onboarding: { ok: (v) => v === null || plain(v, 2048), object: true }, + backupSnooze: { ok: (v) => v === null || (typeof v === "number" && Number.isFinite(v)) }, + // storage.sync `ui` (slice 50): only these fields. + ui: { + ok: (v, c) => + isObj(v) && + Object.keys(v).every((k) => ["uiLang", "baseLangs", "baseLangsDetected", "baseLangsConfirmed"].includes(k)) && + (v.uiLang === undefined || (typeof v.uiLang === "string" && (v.uiLang === "auto" || (v.uiLang.length <= 35 && c.isTag(v.uiLang.replace(/_/g, "-")))))) && + (v.baseLangs === undefined || strings(v.baseLangs, { min: 1, max: 4, len: 35, test: c.isTag })) && + (v.baseLangsDetected === undefined || strings(v.baseLangsDetected, { max: 4, len: 35, test: c.isTag })) && + (v.baseLangsConfirmed === undefined || typeof v.baseLangsConfirmed === "boolean"), + object: true, + }, + }; + const PAGE_KEYS = Object.freeze(Object.keys(KEYS)); + + // `name` for a whole key, `name.list` for a list inside an object key. + function listOf(path) { + const [key, sub, extra] = String(path).split("."); + const rule = KEYS[key]; + if (!rule || extra !== undefined) return null; + if (sub === undefined) return rule.list ? { key, sub: null, rule: rule.list } : null; + return rule.lists?.[sub] ? { key, sub, rule: rule.lists[sub] } : null; + } + + // The change a page asked for, applied to `current` (the trusted values of the keys it + // names): {patch} with the new values, or {error}. Operations, in this order: + // set: {key: value} replaces the value + // merge: {key: {field: value}} for object keys; null fields are kept as null + // add: {path: [items]} adds to a list (no duplicates); path "key" or "key.list" + // remove: {path: [items]} takes items out of a list + function edit(current, msg, { isTag = (t) => TAG.test(t) } = {}) { + const ctx = { isTag }; + const ops = ["set", "merge", "add", "remove"]; + if (!isObj(msg) || !ops.some((o) => msg[o] !== undefined)) return { error: "nothing to change" }; + for (const o of ops) if (msg[o] !== undefined && !isObj(msg[o])) return { error: `${o} must be an object` }; + const next = {}; + const value = (k) => (k in next ? next[k] : clone(current?.[k])); + for (const [k, v] of Object.entries(msg.set ?? {})) { + if (!KEYS[k]) return { error: `${k} can't be changed here` }; + next[k] = clone(v); + } + for (const [k, v] of Object.entries(msg.merge ?? {})) { + if (!KEYS[k]?.object) return { error: `${k} can't be merged` }; + if (!isObj(v)) return { error: `merge.${k} must be an object` }; + const base = value(k); + next[k] = { ...(isObj(base) ? base : {}), ...clone(v) }; + } + for (const op of ["add", "remove"]) { + for (const [path, items] of Object.entries(msg[op] ?? {})) { + const l = listOf(path); + if (!l) return { error: `${path} isn't a list` }; + if (!strings(items, { min: 1, max: 100, len: l.rule.len, test: l.rule.tag ? isTag : () => true })) return { error: `${op}.${path} must list 1 to 100 items` }; + const holder = l.sub ? value(l.key) : null; + const list = l.sub ? (isObj(holder) && Array.isArray(holder[l.sub]) ? holder[l.sub] : []) : Array.isArray(value(l.key)) ? value(l.key) : []; + const out = op === "add" ? [...list, ...items.filter((x) => !list.includes(x))] : list.filter((x) => !items.includes(x)); + next[l.key] = l.sub ? { ...(isObj(holder) ? holder : {}), [l.sub]: out } : out; + } + } + for (const [k, v] of Object.entries(next)) if (!KEYS[k].ok(v, ctx)) return { error: `${k} isn't a valid value` }; + return { patch: next }; + } + + const api = { PREFIX, PAGE_KEYS, createArea, edit, canon, same }; + globalThis.KotikoSettings = api; + if (typeof module === "object" && module.exports) module.exports = api; +})(); diff --git a/extension/lib/store.js b/extension/lib/store.js index 4e96304..bd9feed 100644 --- a/extension/lib/store.js +++ b/extension/lib/store.js @@ -11,7 +11,7 @@ // store.changesSince(iso) / recentLangs(5) / replaceAll(words) / seed(words) // store.importWords(backupWords, {restoreDeleted}) / undoImport() (slice 12) // store.secrets.get(id) / set(id, value) / remove(id) / ids() -// store.meta.get(key) / set(key, value) +// store.meta.get(key) / set(key, value) / remove(key) / entries(prefix) / write(puts, deletes) // store.cache.get(key) / put(key, value) / prune() // store.onCommit(fn) fn({reason, by}) after any committed word write // KotikoStore.wipe({ indexedDB }) deletes the whole database (slice 12's delete everything) @@ -459,6 +459,14 @@ get: async (key) => (await tx("meta", "readonly", ({ meta: m }) => request(m.get(key))))?.value ?? null, set: (key, value) => tx("meta", "readwrite", ({ meta: m }) => request(m.put({ key, value }))), remove: (key) => tx("meta", "readwrite", ({ meta: m }) => request(m.delete(key))), + // Every row whose key starts with `prefix` (lib/settings.js keeps one per setting). + entries: (prefix) => tx("meta", "readonly", ({ meta: m }) => request(m.getAll(globalThis.IDBKeyRange.bound(prefix, `${prefix}￿`)))), + // Rows put and removed in one transaction. + write: (puts, deletes) => + tx("meta", "readwrite", async ({ meta: m }) => { + for (const row of puts) await request(m.put(row)); + for (const key of deletes) await request(m.delete(key)); + }), }; // The lookup cache (slice 10 section 5): checked results only, 30 days, the 5,000 used diff --git a/extension/manifest.json b/extension/manifest.json index a979353..1daf506 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -11,7 +11,7 @@ "service_worker": "background.js", "scripts": [ "lib/url.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", - "spec/spec.js", "lib/text.js", "lib/lang.js", "lib/words-v1.js", "lib/wordspec.js", "lib/word-merge.js", "lib/store.js", + "spec/spec.js", "lib/text.js", "lib/lang.js", "lib/words-v1.js", "lib/wordspec.js", "lib/word-merge.js", "lib/store.js", "lib/settings.js", "lib/backup.js", "lib/projection.js", "lib/llm/policy.js", "lib/llm/catalog.js", "lib/llm/client.js", "lib/add-queue.js", "lib/refresh-job.js", "lib/local-mode.js", "lib/pkce.js", "lib/celebrations.js", "lib/welcome-model.js", "lib/pronounce.js", "lib/wiktionary-pass.js", "background.js" diff --git a/extension/popup.js b/extension/popup.js index 700b140..e4f8b1b 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -6,8 +6,10 @@ // banners (slice 25). Every string comes from _locales through KotikoI18n. // // Talks to the background (`add`, `remove`, `sync`, `jobs.*`, `server.connect`) and reads -// storage.local (slice 11 adds wordsHome, lookup, server, keys and addJobs). The AI key is -// never typed here (DECISIONS). Hooks for later slices are marked with their number. +// storage.local (slice 11 adds wordsHome, lookup, server, keys and addJobs), the +// background's mirror of its settings: changes go to it as `settings.set` (SCR-448). The +// AI key is never typed here (DECISIONS). Hooks for later slices are marked with their +// number. (() => { const ext = globalThis.browser ?? globalThis.chrome; const I18n = globalThis.KotikoI18n; @@ -198,6 +200,12 @@ return p; } + // A settings change, made by the background (SCR-448): {set, merge, add, remove}. + async function save(change) { + const res = await send({ type: "settings.set", ...change }); + if (res?.error) throw new Error(typeof res.error === "string" ? res.error : res.error.code); + } + // Sends at once; a failure to reach the background becomes an ordinary error answer. function send(msg) { const failed = (e) => ({ error: String(e?.message ?? e), code: "internal" }); @@ -242,7 +250,7 @@ case "backupNow": return el("button", { class: "btn btn-secondary btn-sm", type: "button", onclick: () => openDashboardAt("#settings/data/backup"), "data-action": "backup-now" }, t("popup_backup_now")); case "notNow": - return el("button", { class: "link link-quiet", type: "button", onclick: () => ext.storage.local.set({ backupSnooze: Date.now() + BACKUP_DAYS * DAY_MS }), "data-action": "not-now" }, t("popup_backup_not_now")); + return el("button", { class: "link link-quiet", type: "button", onclick: () => save({ set: { backupSnooze: Date.now() + BACKUP_DAYS * DAY_MS } }).catch(() => {}), "data-action": "not-now" }, t("popup_backup_not_now")); default: return null; } @@ -411,10 +419,7 @@ const stale = s.hiddenLangs.filter((x) => !all.includes(x)); const trusted = s.lastSync && !s.syncError; if (stale.length && trusted && !inFlight.has("hiddenLangs")) { - write(["hiddenLangs"], async () => { - const { hiddenLangs } = await ext.storage.local.get({ hiddenLangs: [] }); - await ext.storage.local.set({ hiddenLangs: hiddenLangs.filter((x) => !stale.includes(x)) }); - }); + write(["hiddenLangs"], () => save({ remove: { hiddenLangs: stale } })); } const kept = focus?.filter((x) => all.includes(x)); if (kept?.length < focus?.length && trusted && !inFlight.has("mixing")) saveFocus(kept.length ? kept : null); @@ -907,13 +912,7 @@ else hidden.add(lang); state.s.hiddenLangs = [...hidden]; renderLangs(); - return write(["hiddenLangs"], async () => { - const { hiddenLangs } = await ext.storage.local.get({ hiddenLangs: [] }); - const set = new Set(hiddenLangs); - if (set.has(lang)) set.delete(lang); - else set.add(lang); - await ext.storage.local.set({ hiddenLangs: [...set] }); - }); + return write(["hiddenLangs"], () => save({ [hidden.has(lang) ? "add" : "remove"]: { hiddenLangs: [lang] } })); } // Focus on one language (18); again on the only focused one stops. @@ -926,34 +925,28 @@ const since = focus ? (focusOf(state.s) && state.s.mixing.focusSince) || new Date().toISOString() : null; state.s.mixing = { ...state.s.mixing, focus, focusSince: since }; renderLangs(); - return write(["mixing"], async () => { - const { mixing } = await ext.storage.local.get({ mixing: null }); - await ext.storage.local.set({ mixing: { ...mixing, focus, focusSince: since } }); - }); + return write(["mixing"], () => save({ merge: { mixing: { focus, focusSince: since } } })); } function showAll() { state.s.hiddenLangs = []; renderLangs(); $("chips").querySelector('.chip[tabindex="0"]')?.focus(); - return write(["hiddenLangs"], () => ext.storage.local.set({ hiddenLangs: [] })); + return write(["hiddenLangs"], () => save({ set: { hiddenLangs: [] } })); } // "Swap words here anyway" (16 §4). function allowSensitive() { state.pageStatus = { ...state.pageStatus, sensitive: null }; renderPage(); - return write(["prefs"], async () => { - const { prefs } = await ext.storage.local.get({ prefs: {} }); - await ext.storage.local.set({ prefs: { ...prefs, sensitiveAllowed: [...new Set([...(prefs.sensitiveAllowed ?? []), state.host])] } }); - }); + return write(["prefs"], () => save({ add: { "prefs.sensitiveAllowed": [state.host] } })); } function setEnabled(on) { state.s.enabled = on; renderHeader(); renderBanners(); - return write(["enabled"], () => ext.storage.local.set({ enabled: on })); + return write(["enabled"], () => save({ set: { enabled: on } })); } function setPaused(paused) { @@ -965,13 +958,7 @@ state.s.pausedHosts = [...set]; renderPage(); (paused ? $("resume") : $("pauseRow")).focus(); - return write(["pausedHosts"], async () => { - const { pausedHosts } = await ext.storage.local.get({ pausedHosts: [] }); - const next = new Set(pausedHosts); - if (paused) next.add(host); - else next.delete(host); - await ext.storage.local.set({ pausedHosts: [...next] }); - }); + return write(["pausedHosts"], () => save({ [paused ? "add" : "remove"]: { pausedHosts: [host] } })); } async function syncNow() { @@ -1040,10 +1027,7 @@ function setOnlineVoices(on) { state.s.speech = { ...DEFAULTS.speech, ...state.s.speech, allowOnline: on }; renderVoices(); - return write(["speech"], async () => { - const { speech } = await ext.storage.local.get({ speech: DEFAULTS.speech }); - await ext.storage.local.set({ speech: { ...DEFAULTS.speech, ...speech, allowOnline: on } }); - }); + return write(["speech"], () => save({ merge: { speech: { ...DEFAULTS.speech, ...state.s.speech, allowOnline: on } } })); } function renderSettingsFields() { diff --git a/extension/welcome.js b/extension/welcome.js index fa64060..77e3860 100644 --- a/extension/welcome.js +++ b/extension/welcome.js @@ -184,15 +184,16 @@ n.textContent = key ? t(key) : ""; } + // Settings are changed by the background, which keeps the real copy (SCR-448). + async function saveSettings(change) { + const res = await call({ type: "settings.set", ...change }); + if (failed(res)) throw Object.assign(new Error(String(res?.error?.message ?? res?.error ?? "settings")), { code: codeOf(res) }); + } + async function writeBases() { const bases = state.bases.slice(); state.ui = { uiLang: "auto", ...state.ui, baseLangs: bases, baseLangsDetected: state.detected }; - try { - await ext.storage.sync.set({ ui: state.ui }); - } catch { - // no storage.sync: the local copy below still works - } - await ext.storage.local.set({ baseLangs: bases }); + await saveSettings({ merge: { ui: { uiLang: state.ui.uiLang, baseLangs: bases, baseLangsDetected: state.detected.slice() } }, set: { baseLangs: bases } }); // A connected server's Telegram bot follows them (slice 41 §9). call({ type: "profile.sync" }); } @@ -851,13 +852,9 @@ async function finishOnboarding(skipped) { const { onboarding } = await ext.storage.local.get({ onboarding: null }); - if (!onboarding?.completedAt || skipped) await ext.storage.local.set({ onboarding: { ...(onboarding ?? {}), completedAt: Date.now(), skipped: !!skipped, version: 2 } }); state.ui = { uiLang: "auto", ...state.ui, baseLangs: state.bases.slice(), baseLangsDetected: state.detected, baseLangsConfirmed: true }; - try { - await ext.storage.sync.set({ ui: state.ui }); - } catch { - // no storage.sync - } + const done = !onboarding?.completedAt || skipped ? { merge: { onboarding: { completedAt: Date.now(), skipped: !!skipped, version: 2 } } } : { merge: {} }; + await saveSettings({ ...done, merge: { ...done.merge, ui: { uiLang: state.ui.uiLang, baseLangs: state.bases.slice(), baseLangsDetected: state.detected.slice(), baseLangsConfirmed: true } } }).catch(() => {}); } function miniCard(rec) { @@ -975,9 +972,8 @@ async function celebrationsOff() { state.confetti?.stop(); - const { prefs = {} } = await ext.storage.local.get({ prefs: {} }); - state.prefs = { ...prefs, celebrations: false }; - await ext.storage.local.set({ prefs: state.prefs }); + state.prefs = { ...state.prefs, celebrations: false }; + await saveSettings({ merge: { prefs: { celebrations: false } } }).catch(() => {}); $("celebrationsOff").hidden = true; $("celebrationsOffDone").hidden = false; announce(t("celebrations_off_done")); @@ -1052,12 +1048,8 @@ } async function loadBases(local) { - let ui; - try { - ({ ui = {} } = await ext.storage.sync.get({ ui: {} })); - } catch { - ui = {}; - } + // The background's copy of storage.sync's `ui` (SCR-448). + const { ui } = await ext.storage.local.get({ ui: {} }); state.ui = ui && typeof ui === "object" ? ui : {}; let bases = Array.isArray(ui.baseLangs) && ui.baseLangs.length ? ui.baseLangs : Array.isArray(local.baseLangs) && local.baseLangs.length ? local.baseLangs : null; let detected = Array.isArray(ui.baseLangsDetected) && ui.baseLangsDetected.length ? ui.baseLangsDetected : null; diff --git a/slices/11-local-first-mode/SPEC.md b/slices/11-local-first-mode/SPEC.md index 7631f27..30938af 100644 --- a/slices/11-local-first-mode/SPEC.md +++ b/slices/11-local-first-mode/SPEC.md @@ -634,6 +634,12 @@ a fresh profile looks a word up with the fake model and swaps it, a manual word network, an add finishing with the popup closed, a worker stopped mid-lookup, a server install upgraded with nothing changed). Screenshots: `node test/visual/local-screenshots.mjs `. +*2026-10-06 (SCR-448).* §2's table: `setAccessLevel` restricts `storage.local` from Chrome +140, not 102 (102 was `session` only); Firefox still has none. Settings, the pages' word +list and `addJobs` now have their real copy in the store's `meta` (`area:` rows, +`lib/settings.js`), with `storage.local` as a mirror the background puts back; see slice +28's notes. + *2026-10-06, Connect OpenRouter shown (branch `feat/connect-openrouter-and-screenshots`).* The docs site is live: `curl https://kotiko.org/connect/` answers 200 over HTTPS with `#kotiko-connect-status` (`role="status"`), no script and its own CSP ``; `/connect` diff --git a/slices/24-add-flow-safety/SPEC.md b/slices/24-add-flow-safety/SPEC.md index 582b5e1..f784817 100644 --- a/slices/24-add-flow-safety/SPEC.md +++ b/slices/24-add-flow-safety/SPEC.md @@ -427,6 +427,10 @@ Built 2026-10-05 (§§1-5, from slice 11's queue): - Cancel on a lookup. The direct path (`state.jobs`, `runJob`) is gone. +`addJobs` lives in the background's IndexedDB store since SCR-448 (2026-10-06), with a +copy in `storage.local` that the popup renders from and that content scripts can no longer +change: a job written there is put back and never run (slice 28's notes). + Built 2026-10-05 (§§6-9): - **Wrong language (§6).** The language on a created line is a chip. It opens the picker: diff --git a/slices/28-privacy-and-store-readiness/SPEC.md b/slices/28-privacy-and-store-readiness/SPEC.md index ac80c6e..863a1b8 100644 --- a/slices/28-privacy-and-store-readiness/SPEC.md +++ b/slices/28-privacy-and-store-readiness/SPEC.md @@ -520,12 +520,87 @@ The fix binds the destination, not the secret: - **Proof.** `test/bg/privacy.test.mjs` (9 tests) and the e2e cases fail on the previous code. -**Still open.** Content scripts can still write the other settings in `storage.local`: -turn Kotiko off, pause sites, edit the cached page list, or add an entry to `addJobs`, -which the queue would look up with the learner's own service and save. None sends -anything to a new destination; fixing them means moving those settings and the queue -out of `storage.local` (slices 11 and 24's design). An install updating from a version -before this one trusts its settings' addresses once, at that moment. +**Settings content scripts could change, fixed after (SCR-448, 2026-10-06).** The fix +above left the other settings in `storage.local` writable by content scripts: turn Kotiko +off, pause sites, hide languages, edit the cached page list, add an entry to `addJobs` +(which the queue would look up with the learner's own service and save), or change the +bases and trigger a sync that pushes them as the profile to the learner's server. Also +the lookup's model, data policy and kind, and `wordsHome`. + +Primary sources, checked 2026-10-06: + +- Chrome's [storage API](https://developer.chrome.com/docs/extensions/reference/api/storage) + and Chromium's schema (`extensions/common/api/storage.json`): `local` and `sync` are + open to content scripts by default; `setAccessLevel` sets an area's level, stored in the + extension's prefs (`storage_utils.cc` `SetAccessLevelForArea`), so it outlasts restarts. +- MDN [browser-compat-data](https://github.com/mdn/browser-compat-data/blob/main/webextensions/api/storage.json) + (mdn/browser-compat-data#29536, from [crbug 40949182](https://issues.chromium.org/issues/40949182)): + `setAccessLevel` works on `session` from Chrome 102 and on every area from Chrome 140. + Slice 11 §2's "Chrome 102+" for `local` was about `session` only. +- Firefox: not implemented ([bug 1724754](https://bugzil.la/1724754), open); its schema + (`toolkit/components/extensions/schemas/storage.json`) allows the `storage` namespace + in content scripts and limits only `session` to privileged contexts. So content + scripts can write `local` and `sync` in Firefox, and nothing can stop them. + +Design: one that works in both, since Firefox can't close `storage.local` and content +scripts need to read it. + +- **The trusted copy.** `lib/settings.js` `createArea`: + - every key Kotiko keeps in `storage.local` is a row `area:` in the store's `meta` + (no new database version); the background reads only from there; + - a write goes to `meta` first, then to `storage.local`; + - `heal` compares `storage.local` with it, puts back differing values, removes keys it + doesn't hold, and restores ones removed. It runs on every `storage.onChanged` and at + each start of the background. Values are compared with sorted keys, since Chrome hands + objects back in its own key order; equal values are left alone, so it can't loop. + - Writes and heals run one at a time, so a heal never puts back a value a write just + replaced. Reads wait for the one-time adoption below. +- **Page changes.** `settings.set` (pages only): `set`, `merge`, `add`, `remove` on an + allowlist (`enabled`, `pausedHosts`, `hiddenLangs`, `baseLangs`, `mixing`, `prefs`, + `speech`, `onboarding`, `backupSnooze`, `ui`), each with its shape and size checked + (`edit`). The popup, the dashboard, the welcome page and the backup restore + (`settings.restore`, through `settingsPatch`) use it; the bulk sheet's undo record moved + to `storage.session`. Bases changed in `ui` are written to the pages' `baseLangs` and to + `storage.sync`. Pages read `ui` from `storage.local` (the background's copy), not from + `storage.sync`. +- **Content scripts** write nothing; the word card's "Don't swap this word" is the + `neverSwap` message, one word at a time. +- **storage.sync.** Chrome 140+: the background closes it to content scripts + (`setAccessLevel({accessLevel: "TRUSTED_CONTEXTS"})`, at each start); a change there is + then Kotiko's, here or on another device, and is taken (`adoptSync`). Elsewhere it isn't: + this browser keeps its own `ui` and seed salt, and never overwrites another device's. +- **Adoption, once** (`adoptOnce`, marker `settingsTrusted`): + - a new store (an install, or after "delete everything") takes nothing from + `storage.local`, only `ui` and the seed salt from `storage.sync`, as the first run did; + - an install from before takes `storage.local`'s Kotiko keys as they are. + - A brand-new install where planted 0.2 keys made the upgrade adopt (`discardPlanted`) + drops everything adopted, every lookup route and the chosen service, not only the + server's. +- **The one-time trust at upgrade.** What an older install's `storage.local` holds can't + be told apart from what a content script wrote, so it is taken once (as the route + upgrade above takes addresses). + - What could be closed is: a pre-28 install's lookup address for a hosted service is + dropped, since no page shows an address field for one (dashboard and welcome show it + only for local services and "custom"); + - a server address and a local or custom service's address are visible in Settings and + kept. + - No release exists, so only builds from source take this path. + +Tests: `test/bg/settings.test.mjs` (a test per attack, each failing on the previous code; +pages still can; upgrades), `test/unit/settings.test.mjs`, and a Chromium e2e case in +`test/e2e/privacy.spec.mjs` from the real content-script world (values put back, no +rewrite loop, the planted job spends no lookup, `storage.sync` refuses the write). Tests +that set up state now go through `settings.set` or the background's `seed` hook +(`globalThis.__kotiko.seed`, which only the background's own context can reach), and DOM +tests without a background get `settings.set` answered by the fake chrome. The +`test/visual/` scripts seed the same way. + +**Still open.** + +- "Don't swap this word" stays a content-script message by design. +- Other open pages can act for a moment on a value a content script wrote, until it is put + back (repeatedly, if it writes in a loop); nothing is saved or sent. +- In Firefox, base languages changed on another device don't follow. **§8 Listing and assets.** `store/listing/en.json` (description opening with slice 05's short listing line, five captions, promo text); `store/assets.md` (sizes, what exists, diff --git a/test/bg/background.test.mjs b/test/bg/background.test.mjs index 6671ca8..cb3507a 100644 --- a/test/bg/background.test.mjs +++ b/test/bg/background.test.mjs @@ -52,9 +52,10 @@ function loadBackground({ local = {}, fetch, alarms = null, globals = {} } = {}) local: { serverUrl: "http://127.0.0.1:4999", token: "good-token", ...local }, }); if (alarms) for (const a of alarms) fake.alarms.set(a.name, a); - runInVm("background.js", { chrome: fake.chrome, fetch, Date: fake.clock.Date, ...globals }); + const ctx = runInVm("background.js", { chrome: fake.chrome, fetch, Date: fake.clock.Date, ...globals }); return { fake, + k: ctx.__kotiko, store: fake.store.local, // Content-script sender by default, like a page load; pass POPUP for popup actions. send: (msg, sender) => fake.deliver(msg, sender), @@ -127,12 +128,16 @@ describe("sync", () => { test("doesn't rewrite unchanged words, so open tabs don't redo work", async () => { const { fetch } = serverWith(WORDS); - const { send, fake } = loadBackground({ fetch, local: { words: WORDS } }); + const { send, fake, k } = loadBackground({ fetch, local: { words: WORDS } }); + // The one-time upgrade copies the words it found into the trusted store and back. + await k.ready(); + await fake.idle(); + const start = fake.calls.set.length; await send({ type: "sync", force: true }); - // (The one-time upgrade writes its settings too; none of those writes is `words`.) - const writes = fake.calls.set.filter((c) => c.area === "local" && "lastSync" in c.items); + const since = fake.calls.set.slice(start); + const writes = since.filter((c) => c.area === "local" && "lastSync" in c.items); assert.equal(writes.length, 1); - assert.equal(fake.calls.set.some((c) => "words" in c.items), false); + assert.equal(since.some((c) => "words" in c.items), false); }); test("skips a page-load sync within 5 s of the last one, unless forced", async () => { @@ -232,7 +237,7 @@ describe("the learner's languages on the server (slice 41 §9)", () => { test("a sync sends the bases and the interface language once; a page's change sends them again", async () => { const { fetch, profile } = serverWith(WORDS); const { send, fake } = loadBackground({ fetch }); - await fake.chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["es", "en"] } }); + assert.deepEqual(await send({ type: "settings.set", merge: { ui: { uiLang: "auto", baseLangs: ["es", "en"] } } }, PAGE), { ok: true }); await send({ type: "sync", force: true }); await settle(fake); assert.deepEqual(sentProfiles(profile), [["PUT", "http://127.0.0.1:4999/api/v1/profile", { base_langs: ["es", "en"], ui_lang: null }]]); @@ -245,7 +250,7 @@ describe("the learner's languages on the server (slice 41 §9)", () => { assert.equal(profile.length, 1); // The dashboard changed them: it asks, and the new ones go out. - await fake.chrome.storage.sync.set({ ui: { uiLang: "es", baseLangs: ["ja"] } }); + await send({ type: "settings.set", merge: { ui: { uiLang: "es", baseLangs: ["ja"] } } }, PAGE); assert.deepEqual(await send({ type: "profile.sync" }, PAGE), { ok: true }); assert.deepEqual(JSON.parse(profile[1].body), { base_langs: ["ja"], ui_lang: "es" }); assert.deepEqual(await send({ type: "profile.sync" }, PAGE), { ok: true, unchanged: true }); @@ -956,13 +961,16 @@ describe("language tags (slice 08)", () => { test("on update, languages hidden under an old code stay hidden under the canonical one", async () => { const { fake, store } = loadBackground({ fetch: () => Promise.reject(new TypeError("offline")), local: { hiddenLangs: ["cmn", "iw", "zh", "ja", "not a tag"] } }); await fake.fireInstalled({ reason: "update", previousVersion: "0.2.0" }); - await fake.idle(); - await sleep(10); + for (let i = 0; i < 500 && store.hiddenLangs?.[0] !== "zh"; i++) { + await fake.idle(); + await sleep(5); + } assert.deepEqual(store.hiddenLangs, ["zh", "he", "ja", "not a tag"]); }); test("a fresh install leaves hidden languages alone", async () => { - const { fake, store } = loadBackground({ fetch: () => Promise.reject(new TypeError("offline")), local: { hiddenLangs: ["cmn"] } }); + const { fake, store, k } = loadBackground({ fetch: () => Promise.reject(new TypeError("offline")), local: { token: "", serverUrl: null } }); + await k.seed({ hiddenLangs: ["cmn"] }); await fake.fireInstalled({ reason: "install" }); await fake.idle(); await sleep(10); @@ -1004,18 +1012,26 @@ describe("toolbar badge (slice 20 §5)", () => { test("pausing a site marks its tabs off, and turning Kotiko off marks every tab", async () => { const { fake, badges, titles } = withAction(); - await fake.idle(); - await sleep(10); + const POPUP_PAGE = { id: EXT_ID, url: `chrome-extension://${EXT_ID}/popup.html` }; + const until = async (fn) => { + for (let i = 0; i < 500 && !fn(); i++) { + await fake.idle(); + await sleep(5); + } + assert.ok(fn(), "timed out waiting"); + }; + await until(() => badges.get(1) === ""); + // A content script's write changes nothing (SCR-448), and is put back. + await fake.chrome.storage.local.set({ enabled: false }); + await until(() => !("enabled" in fake.store.local)); assert.equal(badges.get(1), ""); - await fake.chrome.storage.local.set({ pausedHosts: ["en.wikipedia.org"] }); - await fake.idle(); - await sleep(10); - assert.equal(badges.get(1), "off"); + // The popup's do. + await fake.deliver({ type: "settings.set", add: { pausedHosts: ["en.wikipedia.org"] } }, POPUP_PAGE); + await until(() => badges.get(1) === "off"); assert.equal(badges.get(2), ""); assert.equal(titles.get(1), "Kotiko · paused on en.wikipedia.org"); - await fake.chrome.storage.local.set({ enabled: false, pausedHosts: [] }); - await fake.idle(); - await sleep(10); + await fake.deliver({ type: "settings.set", set: { enabled: false, pausedHosts: [] } }, POPUP_PAGE); + await until(() => badges.get(2) === "off"); assert.deepEqual([badges.get(1), badges.get(2)], ["off", "off"]); assert.equal(titles.get(2), "Kotiko · off"); }); diff --git a/test/bg/backup.test.mjs b/test/bg/backup.test.mjs index 2306186..96c776b 100644 --- a/test/bg/backup.test.mjs +++ b/test/bg/backup.test.mjs @@ -99,11 +99,11 @@ describe("delete everything (§6)", () => { async function full({ server = false } = {}) { const bg = loadBackground({ - local: { baseLangs: ["en"], prefs: { theme: "dark" }, hiddenLangs: ["ja"] }, sync: { ui: { baseLangs: ["en"], uiLang: "auto" } }, session: { addDraft: "hola", addHint: "es" }, }); await bg.k.ready(); + await bg.k.seed({ baseLangs: ["en"], prefs: { theme: "dark" }, hiddenLangs: ["ja"] }); await bg.send({ type: "secrets.set", id: "provider:openrouter", value: KEY }); await bg.send({ type: "backup.restore", words: backupWords("multi-script.json") }); await bg.send({ type: "add", text: "gracias = thanks", hintLang: "es" }); diff --git a/test/bg/base-rules.test.mjs b/test/bg/base-rules.test.mjs index f8e6b03..7ada065 100644 --- a/test/bg/base-rules.test.mjs +++ b/test/bg/base-rules.test.mjs @@ -10,8 +10,10 @@ import assert from "node:assert/strict"; import { createFakeChrome } from "../helpers/fake-chrome.mjs"; import { runInVm, sleep } from "../helpers/load-script.mjs"; -function loadBackground({ local = {}, sync = {} } = {}) { - const fake = createFakeChrome({ local, sync }); +const PAGE = { id: "fake-extension-id", url: "chrome-extension://fake-extension-id/dashboard.html" }; + +function loadBackground({ local = {}, sync = {}, accessLevels = false } = {}) { + const fake = createFakeChrome({ local, sync, accessLevels }); fake.chrome.i18n = { getUILanguage: () => "en-US", getMessage: () => "" }; const fetches = []; const ctx = runInVm("background.js", { chrome: fake.chrome, fetch: (u) => (fetches.push(String(u)), Promise.reject(new TypeError("offline"))) }); @@ -48,7 +50,7 @@ describe("base rules for content scripts", () => { const first = bg.store.baseRules; await bg.k.mirrorBaseRules(); assert.equal(bg.store.baseRules, first, "the same languages: not written"); - await bg.fake.chrome.storage.sync.set({ ui: { baseLangs: ["en", "zh-Hant"], baseLangsConfirmed: true } }); + await bg.fake.deliver({ type: "settings.set", merge: { ui: { baseLangs: ["en", "zh-Hant"], baseLangsConfirmed: true } } }, PAGE); await bg.until(() => bg.store.baseRules?.["zh-Hant"]); assert.equal(bg.store.baseRules["zh-Hant"].boundaries.spaces, false); assert.ok(bg.store.baseRules["zh-Hant"].stopwords.length > 100, "zh-Hant falls back to zh's list"); @@ -96,11 +98,23 @@ describe("the seed salt", () => { assert.equal(bg.store.seedSalt, first, "never replaced"); }); - test("another device's salt wins", async () => { + test("another device's salt wins where only Kotiko can write storage.sync (Chrome 140+)", async () => { const other = "0123456789abcdef0123456789abcdef"; - const bg = loadBackground({ sync: { seedSalt: other, ui: { baseLangs: ["en"], baseLangsConfirmed: true } }, local: { seedSalt: "ffffffffffffffffffffffffffffffff" } }); + const bg = loadBackground({ accessLevels: true, sync: { seedSalt: other, ui: { baseLangs: ["en"], baseLangsConfirmed: true } }, local: { seedSalt: "ffffffffffffffffffffffffffffffff" } }); await bg.until(() => bg.store.seedSalt === other); + assert.deepEqual(bg.fake.calls.accessLevel, [{ area: "sync", accessLevel: "TRUSTED_CONTEXTS" }]); await bg.fake.chrome.storage.sync.set({ seedSalt: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }); await bg.until(() => bg.store.seedSalt === "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); }); + + test("where content scripts can write storage.sync (Firefox), a new install takes it once, then keeps its own", async () => { + const other = "0123456789abcdef0123456789abcdef"; + const bg = loadBackground({ sync: { seedSalt: other, ui: { baseLangs: ["en"], baseLangsConfirmed: true } }, local: { seedSalt: "ffffffffffffffffffffffffffffffff" } }); + await bg.until(() => bg.store.seedSalt === other); + await bg.fake.chrome.storage.sync.set({ seedSalt: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }); + await bg.k.ensureSeedSalt(); + await bg.fake.idle(); + assert.equal(bg.store.seedSalt, other); + assert.equal(bg.fake.store.sync.seedSalt, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "and leaves the synced one alone"); + }); }); diff --git a/test/bg/local-mode.test.mjs b/test/bg/local-mode.test.mjs index ac909d3..be4c918 100644 --- a/test/bg/local-mode.test.mjs +++ b/test/bg/local-mode.test.mjs @@ -129,8 +129,9 @@ describe("a fresh install keeps words in this browser (slice 11)", () => { }); test("Focus on one language decides a 'native = meaning' word's language", async () => { - const bg = loadBackground({ local: { baseLangs: ["en"], mixing: { focus: ["it"] } } }); + const bg = loadBackground({ local: { baseLangs: ["en"] } }); await bg.k.ready(); + await bg.send({ type: "settings.set", set: { mixing: { focus: ["it"] } } }); const res = await bg.send({ type: "add", text: "gatto = cat" }, POPUP); const job = await bg.until(() => bg.store.addJobs?.find((j) => j.id === res.job.id && j.state === "done")); assert.equal(job.results[0].word.lang, "it"); @@ -182,14 +183,11 @@ describe("a fresh install keeps words in this browser (slice 11)", () => { test("a job left looking up by a stopped worker runs again with the same id and saves once", async () => { const id = "01900000-0000-7000-8000-00000000abcd"; - const local = { - baseLangs: ["en"], - wordsHome: "local", - lookup: { kind: "provider", provider: "openrouter", baseUrl: srv.llmUrl, model: null, dataCollection: "allow" }, - addJobs: [{ id, surface: "popup", text: "sobaka", hintLang: null, baseLangs: ["en"], manual: null, state: "looking_up", createdAt: Date.now() - 1000, startedAt: Date.now() - 900, attempts: 1, waits: 0, error: null, results: [], rejected: [], missingBases: [], retryAt: null, seen: false }], - }; - const bg = loadBackground({ local }); + const bg = loadBackground({ local: { baseLangs: ["en"] } }); await bg.k.ready(); + // What a worker that stopped mid-lookup left in the store; the next one resumes it. + await bg.k.seed({ addJobs: [{ id, surface: "popup", text: "sobaka", hintLang: null, baseLangs: ["en"], manual: null, state: "looking_up", createdAt: Date.now() - 1000, startedAt: Date.now() - 900, attempts: 1, waits: 0, error: null, results: [], rejected: [], missingBases: [], retryAt: null, seen: false }] }); + await bg.k.queue.resume(); // The settings page names the address (slice 28 §7: one only in storage.local isn't trusted). await bg.send({ type: "backend.set", lookup: { kind: "provider", provider: "openrouter", baseUrl: srv.llmUrl } }); await bg.send({ type: "secrets.set", id: "provider:openrouter", value: KEY }); @@ -216,11 +214,11 @@ describe("a fresh install keeps words in this browser (slice 11)", () => { await bg.k.projector.flush(); assert.deepEqual(bg.store.words.map((w) => w.base_lang).sort(), ["en", "es"]); // Removing en drops its records from the projection and keeps them in the store. - await bg.fake.chrome.storage.local.set({ baseLangs: ["es"] }); + await bg.send({ type: "settings.set", set: { baseLangs: ["es"] } }); await bg.until(() => bg.store.words.length === 1); assert.equal(bg.store.words[0].gloss, "perro"); assert.equal((await store.list()).length, 2); - await bg.fake.chrome.storage.local.set({ baseLangs: ["es", "en"] }); + await bg.send({ type: "settings.set", set: { baseLangs: ["es", "en"] } }); await bg.until(() => bg.store.words.length === 2); }); diff --git a/test/bg/privacy.test.mjs b/test/bg/privacy.test.mjs index 9d0b38f..13e60f7 100644 --- a/test/bg/privacy.test.mjs +++ b/test/bg/privacy.test.mjs @@ -48,14 +48,14 @@ function loadBackground({ local = {}, fetch: f = offline } = {}) { } describe("who may send what (slice 28 §7 item 4)", () => { - test("content scripts may only ask for a sync and the sensitive-sites list; every other type refuses them", async () => { + test("content scripts may only ask for a sync and the sensitive-sites list, and mark a word never to swap; every other type refuses them", async () => { const bg = loadBackground(); await bg.k.ready(); const [listener] = bg.fake.chrome.runtime.onMessage.listeners; const routes = listener.routes; assert.ok(routes && Object.keys(routes).length > 30, "the router lists its routes"); const allowed = (kind) => Object.keys(routes).filter((t) => routes[t].includes(kind)).sort(); - assert.deepEqual(allowed("content"), ["sensitiveSites", "sync"]); + assert.deepEqual(allowed("content"), ["neverSwap", "sensitiveSites", "sync"]); assert.deepEqual(allowed("docs"), ["oauth.code"]); for (const type of Object.keys(routes)) { if (!routes[type].includes("content")) assert.deepEqual(await bg.send({ type }, CONTENT), { error: { code: "forbidden" } }, `content: ${type}`); diff --git a/test/bg/settings.test.mjs b/test/bg/settings.test.mjs new file mode 100644 index 0000000..7d7fab9 --- /dev/null +++ b/test/bg/settings.test.mjs @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// SCR-448 in the real background.js (vm, fake chrome, fake-indexeddb): content scripts can +// write storage.local and storage.sync (Firefox has no way to stop them), so the background +// keeps the real copy of every setting and of the add queue in its own store, and +// storage.local is only a mirror it puts back. One test per thing a subverted content script +// could do before: turn Kotiko off, pause sites, hide languages; edit the pages' word list; +// queue an add; change the bases the server's bot uses; pick another model or data policy. +// And Kotiko's pages still can change what they could. +import { after, before, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { createFakeChrome } from "../helpers/fake-chrome.mjs"; +import { startFixtureServer } from "../helpers/fixture-server.mjs"; +import { runInVm, sleep } from "../helpers/load-script.mjs"; +import { IDBFactory } from "fake-indexeddb"; + +const EXT_ID = "fake-extension-id"; +const PAGE = { id: EXT_ID, url: `chrome-extension://${EXT_ID}/dashboard.html`, tab: { id: 3, url: `chrome-extension://${EXT_ID}/dashboard.html` } }; +const POPUP = { id: EXT_ID, url: `chrome-extension://${EXT_ID}/popup.html` }; +const CONTENT = { id: EXT_ID, url: "https://example.com/", tab: { id: 1, url: "https://example.com/" } }; +const KEY = "sk-or-v1-0123456789abcdef0123456789abcdefa1b2"; +const JOB = "01900000-0000-7000-8000-00000000c0de"; + +const plain = (v) => JSON.parse(JSON.stringify(v)); + +// Every request the background makes, with its body, passed on to `next`. +function recorder(next = () => Promise.reject(new TypeError("offline"))) { + const requests = []; + const fetch = async (url, init = {}) => { + requests.push({ url: String(url), method: init.method ?? "GET", body: typeof init.body === "string" ? JSON.parse(init.body) : null }); + return next(url, init); + }; + return { fetch, requests }; +} + +function loadBackground({ local = {}, sync = {}, fetch, indexedDB = new IDBFactory(), accessLevels = false } = {}) { + const fake = createFakeChrome({ runtimeId: EXT_ID, local, sync, accessLevels }); + const tabs = []; + fake.chrome.action = { setBadgeText: async ({ tabId, text }) => void tabs.push([tabId, text]), setBadgeBackgroundColor: async () => {}, setTitle: async () => {} }; + const ctx = runInVm("background.js", { chrome: fake.chrome, fetch, indexedDB }); + return { + fake, + k: ctx.__kotiko, + indexedDB, + badges: tabs, + store: fake.store.local, + send: (msg, sender = PAGE) => fake.deliver(msg, sender), + // What a content script can do: write storage.local or storage.sync. + async plant(patch, area = "local") { + await fake.chrome.storage[area].set(patch); + await fake.idle(); + }, + async until(fn, ms = 5000) { + const end = Date.now() + ms; + for (;;) { + await fake.idle(); + const v = await fn(); + if (v) return v; + if (Date.now() > end) throw new Error("timed out waiting"); + await sleep(10); + } + }, + }; +} + +// Waits until storage.local holds `want` for `key` again (or no key when `want` is undefined). +const putBack = (bg, key, want) => bg.until(() => JSON.stringify(bg.store[key]) === JSON.stringify(want)); + +describe("what a content script writes to storage.local changes nothing and is put back", () => { + test("Kotiko off, a paused site, hidden languages: put back, and the toolbar badge never says off", async () => { + const bg = loadBackground(); + await bg.k.ready(); + await bg.send({ type: "settings.set", set: { hiddenLangs: ["ja"] } }); + await bg.plant({ enabled: false, pausedHosts: ["example.com"], hiddenLangs: ["ru", "ja", "ar"] }); + await putBack(bg, "enabled", undefined); + await putBack(bg, "pausedHosts", undefined); + await putBack(bg, "hiddenLangs", ["ja"]); + assert.deepEqual(plain(await bg.k.area.get({ enabled: true, pausedHosts: [], hiddenLangs: [] })), { enabled: true, pausedHosts: [], hiddenLangs: ["ja"] }); + assert.ok(bg.badges.every(([, text]) => text === ""), "the badge never turned off"); + }); + + test("a key Kotiko doesn't keep is removed, and one it keeps but a content script removed comes back", async () => { + const bg = loadBackground(); + await bg.k.ready(); + await bg.send({ type: "settings.set", set: { enabled: false } }); + await bg.plant({ somethingElse: "x".repeat(1000) }); + await putBack(bg, "somethingElse", undefined); + await bg.fake.chrome.storage.local.remove("enabled"); + await putBack(bg, "enabled", false); + }); + + test("the pages' word list (words in this browser) comes back as the store has it", async () => { + const bg = loadBackground({ local: { baseLangs: ["en"] } }); + await bg.k.ready(); + const res = await bg.send({ type: "add", text: "犬 = dog" }, POPUP); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === res.job.id && j.state === "done")); + const words = await bg.until(() => bg.store.words?.length === 1 && plain(bg.store.words)); + // The projection the add caused, written now. + await bg.k.projector.flush(); + await bg.plant({ words: [{ ...words[0], id: "planted", native: "猫", gloss: "dog", forms: ["dog"] }] }); + await putBack(bg, "words", words); + }); + + test("the pages' word list from a server: a planted word never joins the cache an add or a remove rewrites", async () => { + const srv = await startFixtureServer(); + try { + const bg = loadBackground({ fetch: (u, i) => fetch(u, i) }); + await bg.k.ready(); + assert.equal((await bg.send({ type: "server.connect", url: srv.kotikoUrl, token: srv.token })).ok, true); + const synced = await bg.until(() => bg.store.words?.length && plain(bg.store.words)); + await bg.plant({ words: [...synced, { id: 999_999, lang: "ru", native: "подделка", english: "fake", forms: ["fake"] }] }); + await putBack(bg, "words", synced); + assert.ok(!(await bg.k.area.get({ words: [] })).words.some((w) => w.native === "подделка")); + } finally { + await srv.close(); + } + }); +}); + +describe("the add queue is out of content scripts' reach", () => { + let srv; + before(async () => { + srv = await startFixtureServer(); + }); + after(() => srv.close()); + + test("a planted add job is never looked up or saved, and is taken back out", async () => { + const rec = recorder((u, i) => fetch(u, i)); + const bg = loadBackground({ local: { baseLangs: ["en"] }, fetch: rec.fetch }); + await bg.k.ready(); + await bg.send({ type: "backend.set", lookup: { kind: "provider", provider: "openrouter", baseUrl: srv.llmUrl } }); + await bg.send({ type: "secrets.set", id: "provider:openrouter", value: KEY }); + rec.requests.length = 0; + const planted = { id: JOB, surface: "popup", text: "shukran", hintLang: null, baseLangs: ["en"], manual: null, state: "queued", createdAt: Date.now(), startedAt: null, attempts: 0, waits: 0, error: null, results: [], rejected: [], missingBases: [], retryAt: null, seen: false }; + await bg.plant({ addJobs: [planted] }); + // Everything that runs the queue: the alarm, a wake, a worker start. + await bg.fake.fireAlarm("kotiko-sync"); + await bg.k.queue.kick(); + await bg.k.queue.resume(); + await putBack(bg, "addJobs", undefined); + // Nothing started: the queue reads only its own copy, which has no job. + assert.equal(bg.k.queue.running(), 0); + assert.equal(await bg.k.queue.busy(), false); + assert.deepEqual(rec.requests.filter((r) => r.url.includes("/chat/completions")), [], "no lookup spent"); + assert.deepEqual(plain(await (await bg.k.getStore()).list()), [], "no word saved"); + assert.deepEqual(plain((await bg.k.area.get({ addJobs: [] })).addJobs), []); + }); + + test("a content script can't add one by message either", async () => { + const bg = loadBackground(); + await bg.k.ready(); + assert.deepEqual(await bg.send({ type: "add", text: "shukran" }, CONTENT), { error: { code: "forbidden" } }); + assert.deepEqual(await bg.send({ type: "settings.set", set: { addJobs: [] } }, PAGE), { error: "addJobs can't be changed here", code: "invalid_message" }); + }); +}); + +describe("the bases your server's bot uses (slice 41 §9)", () => { + test("bases planted in storage.local and storage.sync are never sent as the profile", async () => { + const srv = await startFixtureServer(); + try { + const rec = recorder((u, i) => fetch(u, i)); + const bg = loadBackground({ fetch: rec.fetch }); + await bg.k.ready(); + await bg.send({ type: "settings.set", merge: { ui: { baseLangs: ["es"], uiLang: "auto" } } }); + await bg.send({ type: "server.connect", url: srv.kotikoUrl, token: srv.token }); + await bg.until(() => srv.state.profile?.base_langs?.join() === "es"); + // Firefox: a content script writes both copies, then asks for a sync (which it may). + await bg.plant({ ui: { uiLang: "ru", baseLangs: ["ru"] } }, "sync"); + await bg.plant({ ui: { uiLang: "ru", baseLangs: ["ru"] }, baseLangs: ["ru"] }); + await bg.send({ type: "sync", force: true }, CONTENT); + await putBack(bg, "baseLangs", ["es"]); + await bg.send({ type: "sync", force: true }, CONTENT); + await bg.fake.idle(); + const sent = rec.requests.filter((r) => r.url.endsWith("/api/v1/profile") && r.method === "PUT").map((r) => r.body); + assert.ok(sent.length >= 1); + assert.ok(sent.every((b) => b.base_langs.join() === "es" && b.ui_lang === null), JSON.stringify(sent)); + assert.deepEqual(plain(srv.state.profile.base_langs), ["es"]); + assert.equal(await bg.k.currentBases().then((b) => b.join()), "es"); + } finally { + await srv.close(); + } + }); + + test("Chrome 140+: storage.sync is closed to content scripts, so another browser's change is taken; Firefox: it isn't", async () => { + const chrome = loadBackground({ accessLevels: true }); + await chrome.k.ready(); + await chrome.until(() => chrome.fake.calls.accessLevel.length); + assert.deepEqual(chrome.fake.calls.accessLevel, [{ area: "sync", accessLevel: "TRUSTED_CONTEXTS" }], "storage.local stays readable for content scripts"); + await chrome.plant({ ui: { uiLang: "auto", baseLangs: ["de"] } }, "sync"); + await chrome.until(async () => (await chrome.k.currentBases()).join() === "de"); + assert.deepEqual(plain(chrome.store.baseLangs), ["de"]); + + const firefox = loadBackground(); + await firefox.k.ready(); + const own = (await firefox.k.currentBases()).join(); + await firefox.plant({ ui: { uiLang: "auto", baseLangs: ["de"] } }, "sync"); + // The listener's own call, awaited: it takes nothing. + await firefox.k.adoptSync({ ui: { newValue: { uiLang: "auto", baseLangs: ["de"] } } }); + assert.equal((await firefox.k.currentBases()).join(), own); + }); +}); + +describe("the lookup settings", () => { + let srv; + before(async () => { + srv = await startFixtureServer(); + }); + after(() => srv.close()); + + test("a planted model, data policy, lookup kind or words home is never used", async () => { + const rec = recorder((u, i) => fetch(u, i)); + const bg = loadBackground({ local: { baseLangs: ["en"] }, fetch: rec.fetch }); + await bg.k.ready(); + await bg.send({ type: "backend.set", lookup: { kind: "provider", provider: "openrouter", baseUrl: srv.llmUrl, model: "fake/model-a:free", dataCollection: "deny" } }); + await bg.send({ type: "secrets.set", id: "provider:openrouter", value: KEY }); + const trusted = plain(bg.store.lookup); + await bg.plant({ lookup: { ...trusted, model: "expensive/model", dataCollection: "allow" }, wordsHome: "server" }); + await putBack(bg, "lookup", trusted); + await putBack(bg, "wordsHome", "local"); + rec.requests.length = 0; + const res = await bg.send({ type: "add", text: "shukran" }, POPUP); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === res.job.id && ["done", "failed"].includes(j.state)), 20_000); + const chats = rec.requests.filter((r) => r.url.endsWith("/chat/completions")); + assert.ok(chats.length >= 1); + assert.ok(chats.every((r) => r.body.model !== "expensive/model" && r.body.provider?.data_collection === "deny"), JSON.stringify(chats.map((r) => [r.body.model, r.body.provider]))); + assert.equal((await (await bg.k.getStore()).list()).length >= 1, true, "saved in this browser, not sent to a server"); + }); +}); + +describe("Connect OpenRouter (slice 11 §4)", () => { + test("the key's arrival chooses OpenRouter in the trusted copy, so pages see it and a rewrite is put back", async () => { + const DOCS = { id: EXT_ID, url: "https://kotiko.org/connect/?code=c0de", tab: { id: 4, url: "https://kotiko.org/connect/?code=c0de" } }; + const rec = recorder(async (url) => (String(url) === "https://openrouter.ai/api/v1/auth/keys" ? new Response(JSON.stringify({ key: KEY }), { status: 200, headers: { "content-type": "application/json" } }) : Promise.reject(new TypeError("offline")))); + const bg = loadBackground({ fetch: rec.fetch }); + bg.fake.chrome.tabs.create = async () => {}; + await bg.k.ready(); + assert.equal((await bg.send({ type: "oauth.start" })).ok, true); + assert.deepEqual(await bg.send({ type: "oauth.code", code: "c0de" }, DOCS), { ok: true }); + await bg.fake.idle(); + const chosen = { kind: "provider", provider: "openrouter", baseUrl: null }; + assert.deepEqual(plain((await bg.k.area.get("lookup")).lookup), { ...plain(bg.store.lookup), ...chosen }); + assert.equal(bg.store.keys.providers.openrouter, true, "the pages' copy says a key is saved"); + await bg.plant({ lookup: { ...plain(bg.store.lookup), provider: "custom", baseUrl: "https://evil.example/v1" }, keys: { server: false, providers: {} } }); + await bg.until(() => bg.store.lookup.provider === "openrouter" && bg.store.keys.providers.openrouter === true); + }); +}); + +describe("Kotiko's pages change settings through the background", () => { + test("set, merge, add and remove, each checked; the mirror follows", async () => { + const bg = loadBackground(); + await bg.k.ready(); + assert.deepEqual(await bg.send({ type: "settings.set", set: { enabled: false }, add: { pausedHosts: ["a.example", "b.example"], hiddenLangs: ["ja"] } }, POPUP), { ok: true }); + assert.deepEqual(await bg.send({ type: "settings.set", remove: { pausedHosts: ["a.example"] }, merge: { prefs: { theme: "dark" }, speech: { allowOnline: true } } }), { ok: true }); + assert.deepEqual(await bg.send({ type: "settings.set", add: { "prefs.sensitiveAllowed": ["bank.example"] } }), { ok: true }); + await bg.fake.idle(); + assert.deepEqual(plain(await bg.k.area.get(["enabled", "pausedHosts", "hiddenLangs", "prefs", "speech"])), { enabled: false, pausedHosts: ["b.example"], hiddenLangs: ["ja"], prefs: { theme: "dark", sensitiveAllowed: ["bank.example"] }, speech: { allowOnline: true } }); + for (const k of ["enabled", "pausedHosts", "hiddenLangs", "prefs", "speech"]) assert.deepEqual(plain(bg.store[k]), plain((await bg.k.area.get(k))[k]), k); + }); + + test("bases and the interface language: the trusted copy, the pages' copy and storage.sync", async () => { + const bg = loadBackground(); + await bg.k.ready(); + assert.deepEqual(await bg.send({ type: "settings.set", merge: { ui: { baseLangs: ["es", "en"], uiLang: "es", baseLangsConfirmed: true } } }), { ok: true }); + await bg.fake.idle(); + assert.deepEqual(plain(bg.store.baseLangs), ["es", "en"]); + assert.deepEqual(plain(bg.fake.store.sync.ui), plain(bg.store.ui)); + assert.equal(bg.store.ui.uiLang, "es"); + }); + + test("what they may not change, or not in that shape, is refused and nothing is written", async () => { + const bg = loadBackground(); + await bg.k.ready(); + const refused = async (change) => { + const r = await bg.send({ type: "settings.set", ...change }); + assert.equal(r.code, "invalid_message", JSON.stringify(change)); + }; + for (const k of ["lookup", "server", "wordsHome", "keys", "words", "addJobs", "seedSalt", "celebrations", "recentlyDeleted", "token"]) await refused({ set: { [k]: null } }); + await refused({ set: { enabled: "no" } }); + await refused({ set: { baseLangs: [] } }); + await refused({ set: { baseLangs: ["en", "es", "fr", "de", "it"] } }); + await refused({ add: { hiddenLangs: ["not a tag"] } }); + await refused({ merge: { enabled: { a: 1 } } }); + await refused({ add: { enabled: ["x"] } }); + await refused({ add: { "prefs.theme": ["x"] } }); + await refused({ merge: { ui: { token: "x" } } }); + await refused({ set: { prefs: { blob: "x".repeat(70_000) } } }); + assert.deepEqual(await bg.send({ type: "settings.set" }), { error: { code: "invalid_message", message: "nothing to change" } }); + assert.deepEqual(await bg.send({ type: "settings.set", set: { enabled: false } }, CONTENT), { error: { code: "forbidden" } }); + assert.equal("enabled" in bg.store, false); + }); + + test("the word card's \"Don't swap this word\" is the one change a content script may ask for", async () => { + const bg = loadBackground(); + await bg.k.ready(); + await bg.send({ type: "settings.set", merge: { prefs: { theme: "dark" } } }); + assert.deepEqual(await bg.send({ type: "neverSwap", key: "кот", on: true }, CONTENT), { ok: true }); + await bg.fake.idle(); + assert.deepEqual(plain(bg.store.prefs), { theme: "dark", neverSwap: ["кот"] }); + assert.deepEqual(await bg.send({ type: "neverSwap", key: "кот", on: false }, CONTENT), { ok: true }); + await bg.fake.idle(); + assert.deepEqual(plain(bg.store.prefs.neverSwap), []); + assert.equal((await bg.send({ type: "neverSwap", key: "", on: true }, CONTENT)).error.code, "invalid_message"); + }); + + test("a backup's settings: the background applies what lib/backup.js takes from it", async () => { + const bg = loadBackground(); + await bg.k.ready(); + await bg.send({ type: "backend.set", lookup: { kind: "provider", provider: "openrouter", model: "a/b" } }); + const r = await bg.send({ type: "settings.restore", settings: { enabled: false, hiddenLangs: ["hy"], prefs: { theme: "dark" }, ui: { baseLangs: ["fr"], uiLang: "fr" }, lookup: { provider: "openai", baseUrl: "https://evil.example/v1", model: "x/y", dataCollection: "deny" }, server: { url: "https://evil.example" } } }); + assert.deepEqual(r, { ok: true }); + await bg.fake.idle(); + assert.equal(bg.store.enabled, false); + assert.deepEqual(plain(bg.store.hiddenLangs), ["hy"]); + assert.deepEqual(plain(bg.store.baseLangs), ["fr"]); + assert.equal(bg.store.ui.uiLang, "fr"); + assert.deepEqual([bg.store.lookup.provider, bg.store.lookup.baseUrl, bg.store.lookup.model, bg.store.lookup.dataCollection], ["openrouter", null, "a/b", "deny"], "never where requests go"); + assert.notEqual(bg.store.server?.url, "https://evil.example"); + assert.equal((await bg.send({ type: "settings.restore", settings: [] })).error.code, "invalid_message"); + }); +}); + +describe("installs and upgrades", () => { + // An install from before SCR-448: its store exists (schema 1), but nothing in it is the + // trusted copy yet. `routes: false` is one from before slice 28 too. + async function olderInstall({ local, routes = true }) { + const indexedDB = new IDBFactory(); + const first = loadBackground({ indexedDB }); + await first.k.ready(); + const store = await first.k.getStore(); + const rows = await store.meta.entries("area:"); + await store.meta.write([], rows.map((r) => r.key)); + await store.meta.remove("settingsTrusted"); + if (!routes) for (const k of ["routesBound", "route:server", "route:lookupProvider"]) await store.meta.remove(k); + store.close(); + return loadBackground({ indexedDB, local }); + } + + test("an install from before keeps every setting and its queue, once, then trusts only its own copy", async () => { + const jobs = [{ id: JOB, surface: "popup", text: "кот = cat", state: "done", createdAt: 1, results: [] }]; + const bg = await olderInstall({ local: { enabled: false, pausedHosts: ["a.example"], prefs: { theme: "dark" }, addJobs: jobs, wordsHome: "local", baseLangs: ["en"], somethingElse: 1 } }); + await bg.k.ready(); + assert.deepEqual(plain(await bg.k.area.get(["enabled", "pausedHosts", "prefs", "addJobs", "baseLangs"])), { enabled: false, pausedHosts: ["a.example"], prefs: { theme: "dark" }, addJobs: jobs, baseLangs: ["en"] }); + await putBack(bg, "somethingElse", undefined); + await bg.plant({ enabled: true }); + await putBack(bg, "enabled", false); + }); + + test("from before slice 28: an address no page could have set for a hosted service is dropped; a local service's is kept", async () => { + const hosted = await olderInstall({ routes: false, local: { wordsHome: "local", lookup: { kind: "provider", provider: "openai", baseUrl: "https://evil.example/v1", model: null, dataCollection: "allow" } } }); + await hosted.k.ready(); + assert.equal(hosted.store.lookup.baseUrl, null); + assert.equal(hosted.store.lookup.provider, "openai"); + const local = await olderInstall({ routes: false, local: { wordsHome: "local", lookup: { kind: "provider", provider: "ollama", baseUrl: "http://192.168.1.5:11434/v1", model: null, dataCollection: "allow" } } }); + await local.k.ready(); + assert.equal(local.store.lookup.baseUrl, "http://192.168.1.5:11434/v1"); + assert.equal(await (await local.k.getStore()).meta.get("route:lookup:ollama"), "http://192.168.1.5:11434/v1"); + }); + + test("a new install takes nothing a content script wrote first, not even with a planted 0.2 token", async () => { + const bg = loadBackground({ local: { token: "planted-token", serverUrl: "https://evil.example", enabled: false, pausedHosts: ["a.example"], addJobs: [{ id: JOB, text: "x", state: "queued" }], lookup: { kind: "provider", provider: "custom", baseUrl: "https://evil.example/v1" } } }); + await bg.fake.fireInstalled({ reason: "install" }); + await bg.until(async () => (await (await bg.k.getStore()).meta.get("migratedFrom")) === "new"); + await bg.until(() => bg.store.enabled === undefined && bg.store.addJobs === undefined && bg.store.token === undefined); + assert.deepEqual(plain(await bg.k.area.get({ enabled: true, pausedHosts: [], addJobs: [] })), { enabled: true, pausedHosts: [], addJobs: [] }); + assert.equal(bg.store.lookup.baseUrl, null); + assert.equal(await (await bg.k.getStore()).meta.get("route:lookup:custom"), null); + assert.deepEqual(plain(await (await bg.k.getStore()).secrets.ids()), []); + }); +}); diff --git a/test/bg/welcome.test.mjs b/test/bg/welcome.test.mjs index 9a2a8ab..076bcc0 100644 --- a/test/bg/welcome.test.mjs +++ b/test/bg/welcome.test.mjs @@ -130,14 +130,15 @@ describe("milestones and the end of the first run", () => { assert.deepEqual([a.claimed, b.claimed].sort(), [false, true]); assert.equal((a.claimed ? a : b).celebrate, true); assert.ok(bg.store.celebrations.done["vocab:first"]); - const off = loadBackground({ local: { prefs: { celebrations: false } } }); + const off = loadBackground(); + await off.k.seed({ prefs: { celebrations: false } }); assert.deepEqual(plain(await off.send({ type: "celebrations.claim", key: "vocab:first" })), { claimed: true, reason: null, celebrate: false }); assert.deepEqual(plain(await bg.send({ type: "celebrations.claim", key: "nope" })).error.code, "invalid_message"); }); test("the first word saved anywhere (here, the popup's add) finishes the first run", async () => { - const bg = loadBackground({ local: { onboarding: { completedAt: null, skipped: false, version: 2 }, baseLangs: ["en"] } }); - await bg.k.ready(); + const bg = loadBackground(); + await bg.k.seed({ onboarding: { completedAt: null, skipped: false, version: 2 }, baseLangs: ["en"] }); const r = await bg.send({ type: "add", text: "ありがとう = thanks" }, { id: EXT_ID, url: `chrome-extension://${EXT_ID}/popup.html` }); assert.equal(r.ok, true); await bg.until(() => bg.store.onboarding?.completedAt); diff --git a/test/dom/dashboard.test.mjs b/test/dom/dashboard.test.mjs index c81d5b9..0069e58 100644 --- a/test/dom/dashboard.test.mjs +++ b/test/dom/dashboard.test.mjs @@ -1009,7 +1009,8 @@ describe("Languages you read in (slice 50 §2)", () => { test("a change from the welcome tab or another device shows at once", async () => { const d = await open(); - await d.fake.chrome.storage.sync.set({ ui: { ...UI, baseLangs: ["es"] } }); + // The background's copy changes (SCR-448: pages read `ui` from storage.local). + await d.fake.chrome.storage.local.set({ ui: { ...UI, baseLangs: ["es"] } }); await d.settle(); assert.deepEqual(langs(d), ["es"]); }); diff --git a/test/dom/data.test.mjs b/test/dom/data.test.mjs index 53d0d3f..3beaace 100644 --- a/test/dom/data.test.mjs +++ b/test/dom/data.test.mjs @@ -243,7 +243,8 @@ describe("restoring a backup (§5)", () => { const sent = d.api.sent.find((m) => m.type === "backup.restore"); assert.equal(sent.words.length, 13); assert.equal(sent.restoreDeleted, true); - assert.equal(d.store.hiddenLangs?.[0], "hy", "settings restored"); + // The background applies them (SCR-448; test/bg/settings.test.mjs). + assert.equal(d.api.sent.find((m) => m.type === "settings.restore")?.settings.hiddenLangs?.[0], "hy", "settings restored"); assert.match(d.text("#toasts"), /Restored 13 words\./); d.$("#toasts .toast-undo").click(); await d.settle(); diff --git a/test/dom/pages.test.mjs b/test/dom/pages.test.mjs index d981035..132d489 100644 --- a/test/dom/pages.test.mjs +++ b/test/dom/pages.test.mjs @@ -25,8 +25,8 @@ async function until(fn, ms = 5000) { describe("privacy page (slice 28 §2)", () => { // The page as the browser opens it. `files` answers the extension's own files by path // (missing ones are a 404); `failing` paths make fetch reject, as a blocked request does. - function openPrivacy({ locale = "en", sync = {}, files = null, failing = [] } = {}) { - const fake = createFakeChrome({ sync }); + function openPrivacy({ locale = "en", local = {}, files = null, failing = [] } = {}) { + const fake = createFakeChrome({ local }); // The browser's language; a locale Kotiko has no translation for shows English text. const i18n = createI18n(locale === "es" ? "es" : "en"); fake.chrome.i18n = { ...i18n, getMessage: (key, subs) => (key === "@@ui_locale" || key === "ui_locale" ? locale.replace("-", "_") : i18n.getMessage(key, subs)) }; @@ -80,7 +80,7 @@ describe("privacy page (slice 28 §2)", () => { }); test("follows Kotiko's language setting, and falls back to English when the policy isn't translated", async () => { - const { root, asked, done, doc } = openPrivacy({ sync: { ui: { uiLang: "es" } }, files: { "privacy/es.md": null } }); + const { root, asked, done, doc } = openPrivacy({ local: { ui: { uiLang: "es" } }, files: { "privacy/es.md": null } }); await done(); assert.equal(doc.documentElement.lang, "es"); assert.equal(doc.title, createI18n("es").getMessage("privacy_title")); diff --git a/test/dom/welcome.test.mjs b/test/dom/welcome.test.mjs index de5b4b9..f00a7a6 100644 --- a/test/dom/welcome.test.mjs +++ b/test/dom/welcome.test.mjs @@ -62,6 +62,9 @@ async function openWelcome({ locale = "en", accept = null, bases = null, local = }; const bg = runInVm("background.js", { chrome: fake.chrome, fetch: bgFetch }); await bg.__kotiko.ready(); + // A new store takes nothing from storage.local (SCR-448): the settings go in as the + // background keeps them. + await bg.__kotiko.seed({ ...FRESH, baseLangs: b, ...local }); const tabs = { created: [], removed: [] }; const page = { diff --git a/test/e2e/a11y.spec.mjs b/test/e2e/a11y.spec.mjs index 8af044d..9d11589 100644 --- a/test/e2e/a11y.spec.mjs +++ b/test/e2e/a11y.spec.mjs @@ -104,8 +104,8 @@ async function openPopup(context, extensionId, { tabUrl = "https://en.wikipedia. async function popupConnected({ context, server, serviceWorker }, local = {}, words = POPUP_WORDS, bases = ["en"]) { await server.control({ words }); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); - await serviceWorker.evaluate((o) => chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: o.bases, baseLangsConfirmed: true } }), { bases }); - await serviceWorker.evaluate((o) => chrome.storage.local.set({ words: o.words, baseLangs: o.bases, enabled: true, pausedHosts: [], lastSync: Date.now() - 60_000, syncError: null, onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, ...o.local }), { words, local, bases }); + await serviceWorker.evaluate((o) => globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: o.bases, baseLangsConfirmed: true } }), { bases }); + await serviceWorker.evaluate((o) => globalThis.__kotiko.seed({ words: o.words, baseLangs: o.bases, enabled: true, pausedHosts: [], lastSync: Date.now() - 60_000, syncError: null, onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, ...o.local }), { words, local, bases }); } test.describe("popup", () => { @@ -211,8 +211,8 @@ test.describe("popup", () => { async function dashboard({ context, extensionId, server, serviceWorker }, { hash = "#words", size = { width: 1280, height: 900 }, v1Words, local = {} } = {}) { const words = v1Words ?? dashboardWords(Date.now()); await server.control({ v1Words: words, job: { state: "running", done: 40, total: 120 } }); - await serviceWorker.evaluate((l) => chrome.storage.local.set({ lastSync: Date.now() - 60_000, syncError: null, onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, ...l }), local); - await serviceWorker.evaluate(() => chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } })); + await serviceWorker.evaluate((l) => globalThis.__kotiko.seed({ lastSync: Date.now() - 60_000, syncError: null, onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, ...l }), local); + await serviceWorker.evaluate(() => globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } })); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); const page = await context.newPage(); await page.setViewportSize(size); @@ -355,7 +355,7 @@ test.describe("dashboard", () => { // ---------------------------------------------------------------- welcome and privacy async function welcome(context, extensionId, serviceWorker, { size = { width: 1100, height: 1000 } } = {}) { - await serviceWorker.evaluate(() => chrome.storage.local.set({ onboarding: { completedAt: null, skipped: false, version: 2 }, celebrations: null })); + await serviceWorker.evaluate(() => globalThis.__kotiko.seed({ onboarding: { completedAt: null, skipped: false, version: 2 }, celebrations: null })); const page = await context.newPage(); await page.setViewportSize(size); await page.goto(`chrome-extension://${extensionId}/welcome.html`); @@ -558,7 +558,7 @@ for (const how of ["system", "setting"]) { // The word card on a page. await popoverSetup({ server, serviceWorker, context }); - if (how === "setting") await serviceWorker.evaluate(() => chrome.storage.local.set({ prefs: { motion: "reduce" } })); + if (how === "setting") await serviceWorker.evaluate(() => globalThis.__kotiko.seed({ prefs: { motion: "reduce" } })); const page2 = await context.newPage(); await page2.emulateMedia({ reducedMotion: how === "system" ? "reduce" : "no-preference" }); await page2.goto(server.page("popover-light.html")); @@ -576,8 +576,8 @@ async function popoverSetup({ server, serviceWorker, context }) { await server.control({ words: POPOVER_WORDS }); await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("onboarding")).onboarding)).toBe(true); await serviceWorker.evaluate(async (words) => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); - await chrome.storage.local.set({ words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" } }); + await globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); + await globalThis.__kotiko.seed({ words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" } }); }, POPOVER_WORDS); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); await expect.poll(() => serviceWorker.evaluate(async () => Object.keys((await chrome.storage.local.get("baseRules")).baseRules ?? {}).join())).toBe("en,es"); diff --git a/test/e2e/casing.spec.mjs b/test/e2e/casing.spec.mjs index e9f7338..48368a9 100644 --- a/test/e2e/casing.spec.mjs +++ b/test/e2e/casing.spec.mjs @@ -17,8 +17,8 @@ async function setup({ server, serviceWorker, context }, words, bases) { await server.control({ words }); await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("onboarding")).onboarding)).toBe(true); await serviceWorker.evaluate(async (o) => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: o.bases, baseLangsConfirmed: true } }); - await chrome.storage.local.set({ words: o.words, enabled: true, lastSync: Date.now() }); + await globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: o.bases, baseLangsConfirmed: true } }); + await globalThis.__kotiko.seed({ words: o.words, enabled: true, lastSync: Date.now() }); }, { words, bases }); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); await expect.poll(() => serviceWorker.evaluate(async () => Object.keys((await chrome.storage.local.get("baseRules")).baseRules ?? {}).sort().join())).toBe([...bases].sort().join()); diff --git a/test/e2e/fixtures.mjs b/test/e2e/fixtures.mjs index 172f3c5..5626372 100644 --- a/test/e2e/fixtures.mjs +++ b/test/e2e/fixtures.mjs @@ -41,6 +41,11 @@ export async function connectServer(context, serviceWorker, url, token) { return res; } +// Writes settings as Kotiko's own code does (SCR-448): storage.local is only the +// background's mirror, and anything else written there is put back. Inside a +// `serviceWorker.evaluate`, call `globalThis.__kotiko.seed(items)` directly. +export const seed = (serviceWorker, items) => serviceWorker.evaluate((i) => globalThis.__kotiko.seed(i), items); + export const test = base.extend({ // The fixture server started in global-setup.mjs. server: async ({}, use) => { diff --git a/test/e2e/mixing.spec.mjs b/test/e2e/mixing.spec.mjs index 7c4853d..77f772e 100644 --- a/test/e2e/mixing.spec.mjs +++ b/test/e2e/mixing.spec.mjs @@ -43,7 +43,7 @@ test("Focus: only the focused language; a language added during Focus waits, and // A Turkish word arrives from elsewhere while focusing. await serviceWorker.evaluate(async () => { const { words } = await chrome.storage.local.get("words"); - await chrome.storage.local.set({ words: [...words, { id: 99, lang: "tr", native: "ev", english: "house", forms: ["house"], base_lang: "en", status: "active", created_at: new Date().toISOString() }] }); + await globalThis.__kotiko.seed({ words: [...words, { id: 99, lang: "tr", native: "ev", english: "house", forms: ["house"], base_lang: "en", status: "active", created_at: new Date().toISOString() }] }); }); await expect(p.locator("#focusStrip")).toContainText("Turkish is new. It's waiting until you leave Focus."); await expect(page.locator("#p1")).toHaveText("Many 谢谢 for the house. The 狗 likes the house."); diff --git a/test/e2e/popover.spec.mjs b/test/e2e/popover.spec.mjs index 5cd9f68..4fea56f 100644 --- a/test/e2e/popover.spec.mjs +++ b/test/e2e/popover.spec.mjs @@ -22,9 +22,9 @@ async function setup({ server, serviceWorker, context }, extra = {}) { // this test sets its own, or it can overwrite them. await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("onboarding")).onboarding)).toBe(true); await serviceWorker.evaluate(async (o) => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); + await globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); // "Mix within the page" (18), so the page's two "thanks" show 谢谢 and спасибо. - await chrome.storage.local.set({ words: o.words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" }, ...o.extra }); + await globalThis.__kotiko.seed({ words: o.words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" }, ...o.extra }); }, { words: POPOVER_WORDS, extra }); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); // The background writes the rules for both languages for content scripts to read. diff --git a/test/e2e/popup.spec.mjs b/test/e2e/popup.spec.mjs index de361db..d166cd3 100644 --- a/test/e2e/popup.spec.mjs +++ b/test/e2e/popup.spec.mjs @@ -26,11 +26,11 @@ test("the badge says off on a paused site and nothing elsewhere; off everywhere for (const url of [paused, other]) await (await context.newPage()).goto(url); await expect.poll(() => badgeOn(serviceWorker, paused)).toBe(""); - await p.evaluate(() => chrome.storage.local.set({ pausedHosts: ["127.0.0.1"] })); + await p.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { pausedHosts: ["127.0.0.1"] } })); await expect.poll(() => badgeOn(serviceWorker, paused)).toBe("off"); await expect.poll(() => badgeOn(serviceWorker, other)).toBe(""); - await p.evaluate(() => chrome.storage.local.set({ pausedHosts: [], enabled: false })); + await p.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { pausedHosts: [], enabled: false } })); await expect.poll(() => badgeOn(serviceWorker, other)).toBe("off"); await expect.poll(() => badgeOn(serviceWorker, paused)).toBe("off"); }); @@ -107,7 +107,7 @@ test.describe("with the browser in Spanish", () => { async function firstPaint({ server, popup, context }, rate) { await server.control({ words: WORDS }); const first = await popup.connect(server.kotikoUrl, server.token); - await first.evaluate(() => chrome.storage.local.set({ mixing: { focus: ["ru"], focusSince: new Date().toISOString() } })); + await first.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { mixing: { focus: ["ru"], focusSince: new Date().toISOString() } } })); const times = []; for (let i = 0; i < 10; i++) { const p = await context.newPage(); diff --git a/test/e2e/privacy.spec.mjs b/test/e2e/privacy.spec.mjs index b998a88..81ffb5b 100644 --- a/test/e2e/privacy.spec.mjs +++ b/test/e2e/privacy.spec.mjs @@ -125,6 +125,43 @@ test("settings rewritten from a content script send nothing to the new address, expect(settings.baseUrl, "the address the learner chose is back").toBe(server.llmUrl); }); +// SCR-448: content scripts can write storage.local, so it only mirrors the background's own +// copy. What one writes there is put back, a planted add job is never looked up, and in +// Chromium 140+ storage.sync refuses them outright. +test("settings, the add queue and the bases written from a content script are put back and never used", async ({ context, extensionId, server, popup, serviceWorker }) => { + await setUpLookups(context, extensionId, server.llmUrl); + const p = await popup.page(); + const before = await p.evaluate(() => chrome.storage.local.get(["enabled", "pausedHosts", "hiddenLangs", "baseLangs", "addJobs", "prefs"])); + const page = await context.newPage(); + await page.goto(server.page("basic.html")); + await expect(page.locator("#p1")).toBeVisible(); + const job = { id: "01900000-0000-7000-8000-00000000c0de", surface: "popup", text: "shukran", hintLang: null, baseLangs: ["en"], manual: null, state: "queued", createdAt: Date.now(), startedAt: null, attempts: 0, waits: 0, error: null, results: [], rejected: [], missingBases: [], retryAt: null, seen: false }; + const sync = await inContentScript(context, page, extensionId, async (planted) => { + await chrome.storage.local.set({ enabled: false, pausedHosts: ["127.0.0.1"], hiddenLangs: ["ru"], baseLangs: ["ja"], addJobs: [planted], prefs: { neverSwap: ["house"] }, planted: true }); + return chrome.storage.sync.set({ ui: { uiLang: "ru", baseLangs: ["ru"] } }).then(() => "written", (e) => String(e?.message ?? e)); + }, job); + expect(sync, "storage.sync is closed to content scripts").not.toBe("written"); + const keys = ["enabled", "pausedHosts", "hiddenLangs", "baseLangs", "addJobs", "prefs"]; + await expect.poll(() => inContentScript(context, page, extensionId, (k) => chrome.storage.local.get([...k, "planted"]), keys), { timeout: 10_000 }).toEqual(before); + // Put back once, and left alone: no loop of rewrites (Chrome hands objects back with its + // own key order). A loop would rewrite them many times a second. + const writes = await serviceWorker.evaluate((k) => new Promise((resolve) => { + let n = 0; + const count = (c, area) => void (area === "local" && Object.keys(c).some((key) => k.includes(key)) && n++); + chrome.storage.onChanged.addListener(count); + setTimeout(() => { + chrome.storage.onChanged.removeListener(count); + resolve(n); + }, 1000); + }), keys); + expect(writes).toBe(0); + await (await context.newPage()).goto(server.page("basic.html")); + expect((await server.state()).log.filter((r) => r.path === "/llm/v1/chat/completions"), "the planted job spent no lookup").toEqual([]); + // The popup's own change still works. + await p.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { enabled: false } })); + await expect.poll(() => p.evaluate(async () => (await chrome.storage.local.get("enabled")).enabled)).toBe(false); +}); + test("a hidden address planted for a hosted service: the key pasted afterwards never reaches it", async ({ context, extensionId, server, popup, blocked }) => { // The learner chooses OpenAI; the dashboard has no address field for it. const dash = await context.newPage(); @@ -172,7 +209,7 @@ for (const [name, base, words, file, originals] of [ ]) { test(`page scripts see the swaps but no original word and no Kotiko data: ${name} page`, async ({ context, extensionId, server, serviceWorker }) => { await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("onboarding")).onboarding)).toBe(true); - await serviceWorker.evaluate((b) => chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: [b], baseLangsConfirmed: true } }), base); + await serviceWorker.evaluate((b) => globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: [b], baseLangsConfirmed: true } }), base); const dash = await context.newPage(); await dash.goto(`chrome-extension://${extensionId}/dashboard.html`); expect((await dash.evaluate((w) => chrome.runtime.sendMessage({ type: "words.save", words: w }), words)).error).toBeUndefined(); diff --git a/test/e2e/screen-reader.spec.mjs b/test/e2e/screen-reader.spec.mjs index d11a674..131bb98 100644 --- a/test/e2e/screen-reader.spec.mjs +++ b/test/e2e/screen-reader.spec.mjs @@ -15,14 +15,14 @@ async function setup({ server, serviceWorker, context }, prefs = {}) { await server.control({ words: POPOVER_WORDS }); await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("onboarding")).onboarding)).toBe(true); await serviceWorker.evaluate(async (o) => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); - await chrome.storage.local.set({ words: o.words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" }, prefs: o.prefs }); + await globalThis.__kotiko.seed({ ui: { uiLang: "auto", baseLangs: ["en", "es"], baseLangsConfirmed: true } }); + await globalThis.__kotiko.seed({ words: o.words, enabled: true, lastSync: Date.now(), mixing: { mode: "mix" }, prefs: o.prefs }); }, { words: POPOVER_WORDS, prefs }); await connectServer(context, serviceWorker, server.kotikoUrl, server.token); await expect.poll(() => serviceWorker.evaluate(async () => Object.keys((await chrome.storage.local.get("baseRules")).baseRules ?? {}).join())).toBe("en,es"); } -const setPrefs = (serviceWorker, prefs) => serviceWorker.evaluate((p) => chrome.storage.local.set({ prefs: p }), prefs); +const setPrefs = (serviceWorker, prefs) => serviceWorker.evaluate((p) => globalThis.__kotiko.seed({ prefs: p }), prefs); // The page as it lays out, and the swap's markup, for one word. const look = (page, native) => diff --git a/test/e2e/welcome.spec.mjs b/test/e2e/welcome.spec.mjs index e74b73e..8280a8b 100644 --- a/test/e2e/welcome.spec.mjs +++ b/test/e2e/welcome.spec.mjs @@ -100,8 +100,8 @@ test("an update never opens the welcome tab, and an existing learner gets no fir // The install's own projection writes the (empty) word list; let it land before ours. await expect.poll(() => serviceWorker.evaluate(async () => !!(await chrome.storage.local.get("wordsVersion")).wordsVersion)).toBe(true); await serviceWorker.evaluate(async () => { - await chrome.storage.local.remove(["onboarding"]); - await chrome.storage.local.set({ words: [{ id: "1", lang: "ru", native: "дом", base_lang: "en", gloss: "house", forms: ["house"] }] }); + await globalThis.__kotiko.area.remove(["onboarding"]); + await globalThis.__kotiko.seed({ words: [{ id: "1", lang: "ru", native: "дом", base_lang: "en", gloss: "house", forms: ["house"] }] }); }); // An unpacked extension can't be updated in this harness (a reload disables it), so the // worker's own onInstalled listener runs with what Chrome sends on an update, against the diff --git a/test/helpers/fake-chrome.mjs b/test/helpers/fake-chrome.mjs index 150de23..dee3b9e 100644 --- a/test/helpers/fake-chrome.mjs +++ b/test/helpers/fake-chrome.mjs @@ -6,10 +6,22 @@ // Events fire asynchronously, as in browsers; `await fake.idle()` waits for them. // // const fake = createFakeChrome({ local: { token: "t" } }); +// createFakeChrome({ accessLevels: true }) storage areas have setAccessLevel (Chrome 140+; +// without it, as in Firefox, they don't) +// +// With `onSendMessage` (a page or content script tested without the real background), the +// fake also does the background's part for settings: it answers `settings.set` and +// `neverSwap` by changing storage.local (and storage.sync for `ui`) as lib/settings.js +// says, and copies storage.sync's `ui` to storage.local, as the background's copy is. // window.chrome = fake.chrome; // await fake.chrome.storage.local.set({ enabled: false }); // await fake.idle(); // onChanged listeners have run +import { requireExt } from "./load-script.mjs"; + +// lib/settings.js: how the background applies a page's `settings.set` (SCR-448). +const Settings = requireExt("lib/settings.js"); + // Values cross a JSON boundary in Chrome's storage, so copies come back, `undefined` // values are dropped, and objects from another realm (a vm context) become plain objects // of this one. @@ -62,6 +74,7 @@ export function createFakeChrome(options = {}) { tabs = [{ id: 1, active: true, url: "https://example.com/" }], clock = createClock(), onSendMessage = null, + accessLevels = false, } = options; const pending = new Set(); @@ -74,7 +87,7 @@ export function createFakeChrome(options = {}) { }; const onChanged = createEvent(); - const calls = { set: [], remove: [], sendMessage: [], alarms: [] }; + const calls = { set: [], remove: [], sendMessage: [], alarms: [], accessLevel: [] }; function area(name, initial) { const data = clone(initial); @@ -131,6 +144,13 @@ export function createFakeChrome(options = {}) { emit(changes); }, onChanged: areaChanged, + ...(accessLevels + ? { + async setAccessLevel({ accessLevel }) { + calls.accessLevel.push({ area: name, accessLevel }); + }, + } + : {}), }, }; } @@ -171,6 +191,20 @@ export function createFakeChrome(options = {}) { const alarms = new Map(); const onAlarm = createEvent(); + // The background's part for settings, when there is no background (see the top). + async function settingsMessage(msg) { + const change = msg.type === "neverSwap" ? { [msg.on ? "add" : "remove"]: { "prefs.neverSwap": [msg.key] } } : msg; + const r = Settings.edit(clone(areas.local.data), change); + if (r.error) return { error: r.error, code: "invalid_message" }; + if (r.patch.ui) { + if (r.patch.ui.baseLangs && !r.patch.baseLangs) r.patch.baseLangs = r.patch.ui.baseLangs; + await areas.sync.api.set({ ui: r.patch.ui }); + } + await areas.local.api.set(r.patch); + return { ok: true }; + } + if (onSendMessage && areas.sync.data.ui !== undefined && areas.local.data.ui === undefined) areas.local.data.ui = clone(areas.sync.data.ui); + const chrome = { storage: { local: areas.local.api, @@ -185,6 +219,7 @@ export function createFakeChrome(options = {}) { getManifest: () => ({ manifest_version: 3, version: "0.0.0-test" }), sendMessage(msg) { calls.sendMessage.push(clone(msg)); + if (onSendMessage && (msg?.type === "settings.set" || msg?.type === "neverSwap")) return Promise.resolve().then(() => settingsMessage(clone(msg))).then(clone); if (onSendMessage) return Promise.resolve().then(() => onSendMessage(clone(msg))).then(clone); return deliver(msg); }, diff --git a/test/perf/budgets.json b/test/perf/budgets.json index 3a04b96..81b2b04 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -85,6 +85,16 @@ "ci_factor": 3, "note": "Slice 11 §2 and its acceptance: building and writing it (into the fake storage area, which copies values like Chrome's) for 5,000 words." }, + "local.projection.write.trusted.5k": { + "budget_ms": 50, + "ci_factor": 3, + "note": "SCR-448: the same write through the background's trusted copy (IndexedDB meta, then the mirror), in fake-indexeddb. About 21 ms locally; slice 11's 50 ms write budget still holds." + }, + "local.projection.heal-check.5k": { + "budget_ms": 50, + "ci_factor": 3, + "note": "SCR-448: the change event of that write, compared with the trusted copy (two canonical JSON passes over 5,000 words); it matches, so nothing is written back. About 21 ms locally, in the background, after the pages have the words." + }, "precedence.pick.100k": { "budget_ms": 500, "ci_factor": 3, diff --git a/test/perf/run.mjs b/test/perf/run.mjs index aca19e9..bebc6b6 100644 --- a/test/perf/run.mjs +++ b/test/perf/run.mjs @@ -205,6 +205,34 @@ benchmarks["local.projection.write.5k"] = { return `${words.length}`; }, }; +// SCR-448: the projection goes to the background's trusted copy (IndexedDB `meta`, here +// fake-indexeddb) and then to the mirror; the change event of that write is checked against +// the trusted copy (a match, so nothing is written back). +const Settings = requireExt("lib/settings.js"); +const { IDBFactory, IDBKeyRange } = await import("fake-indexeddb"); +globalThis.IDBKeyRange ??= IDBKeyRange; +const trustedArea = async () => { + const store = await requireExt("lib/store.js").open({ indexedDB: new IDBFactory() }); + const mirror = fakeChrome().chrome.storage.local; + return { area: Settings.createArea({ meta: store.meta, mirror }), mirror }; +}; +benchmarks["local.projection.write.trusted.5k"] = { + setup: trustedArea, + run: async ({ area }) => { + const words = Projection.project(localRecords, ["en", "es"]); + await area.set({ words, baseLangs: ["en", "es"], wordsVersion: { n: 1, at: 0, by: null } }); + return `${words.length}`; + }, +}; +benchmarks["local.projection.heal-check.5k"] = { + setup: async () => { + const t = await trustedArea(); + const words = Projection.project(localRecords, ["en", "es"]); + await t.area.set({ words }); + return { ...t, change: { words: { newValue: JSON.parse(JSON.stringify(words)) } } }; + }, + run: async ({ area, change }) => `${(await area.heal(change)).restored.length}`, +}; const { JSDOM } = await import("jsdom"); const { createFakeChrome } = await import("../helpers/fake-chrome.mjs"); @@ -306,7 +334,7 @@ benchmarks["bulk.parse.5k"] = { async function measure(bench) { const { setup = () => undefined, run } = typeof bench === "function" ? { run: bench } : bench; const once = async () => { - const input = setup(); + const input = await setup(); const t = performance.now(); const result = await run(input); return { ms: performance.now() - t, result }; diff --git a/test/unit/i18n.test.mjs b/test/unit/i18n.test.mjs index 54d89f8..6ab2856 100644 --- a/test/unit/i18n.test.mjs +++ b/test/unit/i18n.test.mjs @@ -161,7 +161,7 @@ describe("KotikoI18n.t", () => { test("Kotiko's language (50 §8): a shipped locale overrides the browser's, missing keys fall back to English", async () => { const ctx = vm.createContext({ - chrome: { i18n: createI18n("en"), storage: { sync: { get: async () => ({ ui: { uiLang: "es" } }) } } }, + chrome: { i18n: createI18n("en"), storage: { local: { get: async () => ({ ui: { uiLang: "es" } }) } } }, Intl, console, }); diff --git a/test/unit/settings.test.mjs b/test/unit/settings.test.mjs new file mode 100644 index 0000000..2013577 --- /dev/null +++ b/test/unit/settings.test.mjs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// lib/settings.js (SCR-448): the trusted copy and its mirror, and the check of a page's +// `settings.set`. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { createFakeChrome } from "../helpers/fake-chrome.mjs"; +import { requireExt } from "../helpers/load-script.mjs"; + +const S = requireExt("lib/settings.js"); +const plain = (v) => JSON.parse(JSON.stringify(v)); + +// The store's `meta` rows, in memory. +function memoryMeta(rows = {}) { + const data = new Map(Object.entries(rows)); + return { + data, + entries: async (prefix) => [...data].filter(([k]) => k.startsWith(prefix)).map(([key, value]) => ({ key, value })), + write: async (puts, deletes) => { + for (const r of puts) data.set(r.key, r.value); + for (const k of deletes) data.delete(k); + }, + }; +} + +describe("same and canon", () => { + test("objects equal whatever their keys' order, as Chrome's storage gives them back", () => { + assert.equal(S.same({ a: 1, b: { c: [1, { d: 2, e: 3 }] } }, { b: { c: [1, { e: 3, d: 2 }] }, a: 1 }), true); + assert.equal(S.same({ a: 1 }, { a: 2 }), false); + assert.equal(S.same([1, 2], [2, 1]), false); + assert.equal(S.same({ a: undefined }, {}), true); + assert.equal(S.same(undefined, null), true); + assert.equal(S.canon([undefined]), "[null]"); + }); +}); + +describe("the trusted copy and its mirror", () => { + test("reads like storage.get, from the trusted copy; writes go to it, then the mirror", async () => { + const fake = createFakeChrome(); + const meta = memoryMeta({ "area:enabled": false, "other:key": 1 }); + const changed = []; + const area = S.createArea({ meta, mirror: fake.chrome.storage.local, onChange: (k) => changed.push(...k) }); + assert.deepEqual(plain(await area.get(null)), { enabled: false }); + assert.deepEqual(plain(await area.get("enabled")), { enabled: false }); + assert.deepEqual(plain(await area.get(["enabled", "nope"])), { enabled: false }); + assert.deepEqual(plain(await area.get({ enabled: true, pausedHosts: [] })), { enabled: false, pausedHosts: [] }); + await area.set({ pausedHosts: ["a.example"], skipped: undefined }); + await area.set({}); + assert.deepEqual(meta.data.get("area:pausedHosts"), ["a.example"]); + assert.deepEqual(plain(fake.store.local), { pausedHosts: ["a.example"] }); + await area.remove("pausedHosts"); + await area.remove([]); + assert.equal(meta.data.has("area:pausedHosts"), false); + assert.deepEqual(changed, ["pausedHosts", "pausedHosts"]); + assert.equal(await area.has("enabled"), true); + // A value handed out is a copy. + const got = await area.get({ prefs: { a: 1 } }); + got.prefs.a = 2; + assert.deepEqual(plain(await area.get({ prefs: { a: 1 } })), { prefs: { a: 1 } }); + }); + + test("heal: puts back changed values, removes unknown keys, restores removed ones; its own writes cost nothing", async () => { + const fake = createFakeChrome({ local: { enabled: true, junk: 1, prefs: { b: 2, a: 1 } } }); + const area = S.createArea({ meta: memoryMeta({ "area:enabled": false, "area:hiddenLangs": ["ja"], "area:prefs": { a: 1, b: 2 } }), mirror: fake.chrome.storage.local }); + assert.deepEqual(plain(await area.heal()), { restored: ["enabled", "hiddenLangs"], removed: ["junk"] }); + assert.deepEqual(plain(fake.store.local), { enabled: false, prefs: { b: 2, a: 1 }, hiddenLangs: ["ja"] }); + // An event that matches the trusted copy (in another key order) does nothing. + const before = fake.calls.set.length; + assert.deepEqual(plain(await area.heal({ enabled: { newValue: false }, prefs: { newValue: { b: 2, a: 1 } } })), { restored: [], removed: [] }); + assert.deepEqual(plain(await area.heal({ gone: { oldValue: 1 } })), { restored: [], removed: [] }); + assert.equal(fake.calls.set.length, before); + // A stale event: the mirror already holds the trusted value again. + assert.deepEqual(plain(await area.heal({ enabled: { newValue: true } })), { restored: [], removed: [] }); + await fake.chrome.storage.local.remove("hiddenLangs"); + assert.deepEqual(plain(await area.heal({ hiddenLangs: { oldValue: ["ja"] } })), { restored: ["hiddenLangs"], removed: [] }); + // After "delete everything" the next read loads again. + area.reset(); + assert.deepEqual(plain(await area.get("enabled")), { enabled: false }); + }); +}); + +describe("edit: what a page may change", () => { + const edit = (current, msg) => S.edit(current, msg); + + test("set, merge, add and remove, applied to the current values", () => { + assert.deepEqual(edit({ pausedHosts: ["a"], prefs: { theme: "dark", neverSwap: ["x"] } }, { + set: { enabled: false }, + merge: { prefs: { motion: "reduce" }, mixing: { focus: ["ja"] } }, + add: { pausedHosts: ["a", "b"], "prefs.neverSwap": ["y"], hiddenLangs: ["ja"] }, + remove: { pausedHosts: ["a"], "prefs.sensitiveAllowed": ["bank"] }, + }).patch, { + enabled: false, + prefs: { theme: "dark", neverSwap: ["x", "y"], motion: "reduce", sensitiveAllowed: [] }, + mixing: { focus: ["ja"] }, + pausedHosts: ["b"], + hiddenLangs: ["ja"], + }); + assert.deepEqual(edit({}, { set: { mixing: null, backupSnooze: 5, onboarding: null, speech: null, prefs: null } }).patch, { mixing: null, backupSnooze: 5, onboarding: null, speech: null, prefs: null }); + assert.deepEqual(edit({ ui: { uiLang: "auto" } }, { merge: { ui: { baseLangs: ["es"], baseLangsDetected: [], baseLangsConfirmed: true, uiLang: "pt_BR" } } }).patch.ui, { uiLang: "pt_BR", baseLangs: ["es"], baseLangsDetected: [], baseLangsConfirmed: true }); + assert.deepEqual(edit({ prefs: "broken" }, { add: { "prefs.neverSwap": ["x"] } }).patch, { prefs: { neverSwap: ["x"] } }); + assert.deepEqual(edit({ hiddenLangs: "broken" }, { add: { hiddenLangs: ["ja"] } }).patch, { hiddenLangs: ["ja"] }); + }); + + test("anything else is refused", () => { + const bad = [ + null, + {}, + { set: [] }, + { set: { lookup: {} } }, + { merge: { enabled: {} } }, + { merge: { prefs: [] } }, + { add: { "prefs.a.b": ["x"] } }, + { add: { nope: ["x"] } }, + { add: { pausedHosts: [] } }, + { add: { pausedHosts: "a" } }, + { add: { hiddenLangs: ["not a tag"] } }, + { set: { pausedHosts: ["has space"] } }, + { set: { baseLangs: ["en", "es", "fr", "de", "it"] } }, + { set: { backupSnooze: "soon" } }, + { set: { ui: { uiLang: "x".repeat(40) } } }, + { set: { ui: { baseLangsConfirmed: "yes" } } }, + { set: { ui: { baseLangsDetected: ["a b"] } } }, + { set: { ui: [] } }, + { set: { prefs: { neverSwap: "x" } } }, + ]; + for (const msg of bad) assert.ok(edit({}, msg).error, JSON.stringify(msg)); + }); + + test("language tags are checked by the caller's rule when it gives one", () => { + assert.ok(S.edit({}, { set: { baseLangs: ["xx-notreal"] } }, { isTag: () => false }).error); + assert.deepEqual(S.edit({}, { set: { baseLangs: ["en"] } }, { isTag: () => true }).patch, { baseLangs: ["en"] }); + assert.ok(S.PAGE_KEYS.includes("enabled") && !S.PAGE_KEYS.includes("lookup")); + }); +}); diff --git a/test/visual/dashboard-screenshots.mjs b/test/visual/dashboard-screenshots.mjs index 14a8220..dbddc0e 100644 --- a/test/visual/dashboard-screenshots.mjs +++ b/test/visual/dashboard-screenshots.mjs @@ -72,6 +72,15 @@ async function shoot(env, name, { scheme, size = { width: 1440, height: 900 }, h return file; } +// Settings go in through the background's own copy (`__kotiko`, in its service worker): +// storage.local is only its mirror, and anything else written there is put back (SCR-448). +const worker = (page) => page.context().serviceWorkers()[0]; +const seed = (page, items) => worker(page).evaluate((i) => globalThis.__kotiko.seed(i), items); +const clearSettings = (page) => worker(page).evaluate(async () => { + const { area } = globalThis.__kotiko; + await area.remove(Object.keys(await area.get(null))); +}); + async function run() { await fs.mkdir(OUT, { recursive: true }); const srv = await startFixtureServer(); @@ -83,12 +92,10 @@ async function run() { const connected = ({ v1Words = words, job = { state: "running", done: 40, total: 120 }, local = {} } = {}) => async (page) => { await control({ reset: true }); await control({ v1Words, job }); - await page.evaluate(async (o) => { - await chrome.storage.local.clear(); - await chrome.storage.local.set({ lastSync: Date.now() - 60_000, syncError: null, ...o.local }); - // As the settings connect (slice 28: an address written to storage.local is ignored). - await chrome.runtime.sendMessage({ type: "server.connect", url: o.url, token: o.token }); - }, { url: `${srv.url}/kotiko`, token: srv.token, local }); + await clearSettings(page); + await seed(page, { lastSync: Date.now() - 60_000, syncError: null, ...local }); + // As the settings connect (slice 28: an address written to storage.local is ignored). + await page.evaluate((o) => chrome.runtime.sendMessage({ type: "server.connect", url: o.url, token: o.token }), { url: `${srv.url}/kotiko`, token: srv.token }); }; const deleteIds = (ids) => async (page) => { await page.evaluate(async (list) => { @@ -146,14 +153,12 @@ async function run() { }, }], ["empty", { setup: connected({ v1Words: [], job: { state: "done", done: 0, total: 0 } }) }], - ["not-connected", { setup: async (p) => { await control({ reset: true }); await p.evaluate(() => chrome.storage.local.clear()); } }], + ["not-connected", { setup: async (p) => { await control({ reset: true }); await clearSettings(p); } }], ["unreachable", { setup: async (p) => { await control({ reset: true }); - await p.evaluate(async () => { - await chrome.storage.local.clear(); - await chrome.runtime.sendMessage({ type: "server.connect", url: "http://127.0.0.1:9", token: "t0ken" }); - }); + await clearSettings(p); + await p.evaluate(() => chrome.runtime.sendMessage({ type: "server.connect", url: "http://127.0.0.1:9", token: "t0ken" })); }, }], ["settings", { setup: connected(), hash: "#settings", full: true }], diff --git a/test/visual/local-screenshots.mjs b/test/visual/local-screenshots.mjs index d796ec4..53771f6 100644 --- a/test/visual/local-screenshots.mjs +++ b/test/visual/local-screenshots.mjs @@ -73,6 +73,11 @@ async function shoot(env, name, { scheme, page: kind, size, hash = "", setup, ac return out; } +// Settings go in through the background's own copy (`__kotiko`, in its service worker): +// storage.local is only its mirror, and anything else written there is put back (SCR-448). +const worker = (page) => page.context().serviceWorkers()[0]; +const seed = (page, items) => worker(page).evaluate((i) => globalThis.__kotiko.seed(i), items); + async function run() { await fs.mkdir(OUT, { recursive: true }); const srv = await startFixtureServer(); @@ -82,13 +87,14 @@ async function run() { // A fresh local profile: storage and the word store emptied, words in this browser. const fresh = async (page) => { await control({ reset: true }); + await seed(page, { wordsHome: "local", addJobs: [] }); await page.evaluate(async () => { - await chrome.storage.local.set({ wordsHome: "local", lookup: { kind: "none", provider: "openrouter", baseUrl: null, model: null, dataCollection: "allow" }, addJobs: [] }); + await chrome.runtime.sendMessage({ type: "backend.set", lookup: { kind: "none", provider: "openrouter", baseUrl: null, model: null, dataCollection: "allow" } }); for (const id of ["provider:openrouter", "provider:custom", "server"]) await chrome.runtime.sendMessage({ type: "secrets.remove", id }); const { words } = await chrome.runtime.sendMessage({ type: "words.list" }); if (words.length) await chrome.runtime.sendMessage({ type: "words.write", ops: words.map((w) => ({ op: "delete", id: w.id })) }); - await chrome.storage.local.set({ addJobs: [] }); }); + await seed(page, { addJobs: [] }); }; const withWords = async (page) => { await fresh(page); @@ -97,7 +103,7 @@ async function run() { const { addJobs = [] } = await chrome.storage.local.get("addJobs"); return addJobs.length >= 4 && addJobs.every((j) => j.state === "done"); }, null, { timeout: 10_000 }); - await page.evaluate(() => chrome.storage.local.get("addJobs").then(({ addJobs }) => chrome.storage.local.set({ addJobs: addJobs.map((j) => ({ ...j, seen: true })) }))); + await page.evaluate(() => chrome.storage.local.get("addJobs").then(({ addJobs }) => chrome.runtime.sendMessage({ type: "jobs.seen", ids: addJobs.map((j) => j.id) }))); }; const provider = (lookup, key) => async (page) => { await fresh(page); @@ -141,11 +147,11 @@ async function run() { page: "popup", setup: async (p) => { await withWords(p); - await p.evaluate(async () => { + await worker(p).evaluate(async () => { const now = Date.now(); const job = (id, text, error, retryAt) => ({ id, surface: "popup", text, hintLang: null, baseLangs: ["en"], manual: null, state: "waiting", createdAt: now - Number(id.slice(-1)), startedAt: now - 5000, attempts: 1, waits: 1, error, results: [], rejected: [], missingBases: [], retryAt, seen: false }); - const { addJobs = [] } = await chrome.storage.local.get("addJobs"); - await chrome.storage.local.set({ + const { addJobs = [] } = await globalThis.__kotiko.area.get({ addJobs: [] }); + await globalThis.__kotiko.seed({ addJobs: [ job("01900000-0000-7000-8000-000000000001", "shukran", { code: "lookup_not_set_up", details: {} }, null), job("01900000-0000-7000-8000-000000000002", "kniga", { code: "quota_exhausted", details: { retry_at: new Date(Date.UTC(2026, 9, 3, 0, 0)).toISOString() } }, Date.UTC(2026, 9, 3)), diff --git a/test/visual/popover-screenshots.mjs b/test/visual/popover-screenshots.mjs index 5a2f762..86cb96f 100644 --- a/test/visual/popover-screenshots.mjs +++ b/test/visual/popover-screenshots.mjs @@ -66,7 +66,8 @@ async function run() { try { for (const lang of ["en", "es"]) { const { context, sw, userDataDir } = await launch(lang); - await sw.evaluate((o) => chrome.storage.local.set({ words: o.words, enabled: true, lastSync: Date.now() }), { words: POPOVER_WORDS }); + // Through the background's own copy: storage.local is only its mirror (SCR-448). + await sw.evaluate((o) => globalThis.__kotiko.seed({ words: o.words, enabled: true, lastSync: Date.now() }), { words: POPOVER_WORDS }); // Connects as the settings do (slice 28: an address written to storage.local is ignored). const setupPage = await context.newPage(); await setupPage.goto(`chrome-extension://${new URL(sw.url()).host}/privacy.html`); diff --git a/test/visual/popup-screenshots.mjs b/test/visual/popup-screenshots.mjs index 64fa5e5..cfb7501 100644 --- a/test/visual/popup-screenshots.mjs +++ b/test/visual/popup-screenshots.mjs @@ -91,7 +91,7 @@ async function shoot({ context, url }, name, { scheme, setup, act, waitMs = 250, } }, { tabUrl, noPermission, slowStorage }); await page.goto(url); - await page.evaluate(() => chrome.storage.local.clear()); + await clear(page); if (setup) await setup(page); await page.reload(); if (act) await act(page); @@ -102,7 +102,14 @@ async function shoot({ context, url }, name, { scheme, setup, act, waitMs = 250, return file; } -const set = (page, items) => page.evaluate((i) => chrome.storage.local.set(i), items); +// Settings go in through the background's own copy (`__kotiko`, in its service worker): +// storage.local is only its mirror, and anything else written there is put back (SCR-448). +const worker = (page) => page.context().serviceWorkers()[0]; +const set = (page, items) => worker(page).evaluate((i) => globalThis.__kotiko.seed(i), items); +const clear = (page) => worker(page).evaluate(async () => { + const { area } = globalThis.__kotiko; + await area.remove(Object.keys(await area.get(null))); +}); // Connects as the settings do (slice 28: an address or token written to storage.local is ignored). const connect = (page, url, token) => page.evaluate((m) => chrome.runtime.sendMessage(m), { type: "server.connect", url, token }); diff --git a/test/visual/readme-demo.mjs b/test/visual/readme-demo.mjs index d424b9f..061dd9e 100644 --- a/test/visual/readme-demo.mjs +++ b/test/visual/readme-demo.mjs @@ -56,15 +56,19 @@ async function run() { const sw = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker")); const base = `chrome-extension://${new URL(sw.url()).host}/`; // Past the first run, with the words saved and Kotiko off for the first frame. + // Through the background's own copy (`__kotiko.seed`; storage.local is only its mirror). await sw.evaluate(async () => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: ["en"], baseLangsConfirmed: true }, seedSalt: "0123456789abcdef0123456789abcdef" }); - await chrome.storage.local.set({ onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, mixing: { mode: "mix" }, celebrations: null }); + for (let i = 0; i < 100 && !(await globalThis.__kotiko.area.get("onboarding")).onboarding; i++) await new Promise((r) => setTimeout(r, 50)); + const ui = { uiLang: "auto", baseLangs: ["en"], baseLangsConfirmed: true }; + const seedSalt = "0123456789abcdef0123456789abcdef"; + await chrome.storage.sync.set({ ui, seedSalt }); + await globalThis.__kotiko.seed({ ui, seedSalt, baseLangs: ["en"], onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, mixing: { mode: "mix" }, celebrations: null }); }); const setup = await context.newPage(); await setup.goto(`${base}dashboard.html`); const res = await setup.evaluate((words) => chrome.runtime.sendMessage({ type: "words.save", words }), WORDS.map((w) => ({ ...w, base_lang: "en", origin: "manual", pronunciation_source: "user" }))); if (res?.error) throw new Error(`words.save: ${JSON.stringify(res)}`); - await setup.evaluate(() => chrome.storage.local.set({ enabled: false })); + await setup.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { enabled: false } })); await new Promise((r) => setTimeout(r, 500)); const page = await context.newPage(); @@ -79,7 +83,7 @@ async function run() { const before = await shot("1-before"); // Kotiko on, from an extension page: the open tab swaps in place. - await setup.evaluate(() => chrome.storage.local.set({ enabled: true })); + await setup.evaluate(() => chrome.runtime.sendMessage({ type: "settings.set", set: { enabled: true } })); await page.locator("kotiko-w").first().waitFor(); await page.waitForTimeout(500); const after = await shot("2-after"); diff --git a/test/visual/store-screenshots.mjs b/test/visual/store-screenshots.mjs index fd67fb2..7f72423 100644 --- a/test/visual/store-screenshots.mjs +++ b/test/visual/store-screenshots.mjs @@ -99,10 +99,16 @@ async function launch() { // Past the first run, the learner's words saved, one language read, words mixed per page, // and lookups set up (the fixture server's fake model) through the settings' own messages. +// Settings go in through the background's own copy (`__kotiko.seed`): storage.local is only +// its mirror, and anything else written there is put back (SCR-448). The install's first +// run writes its own first, so this waits for it. async function seed(env, srv) { await env.sw.evaluate(async (base) => { - await chrome.storage.sync.set({ ui: { uiLang: "auto", baseLangs: [base], baseLangsConfirmed: true }, seedSalt: "0123456789abcdef0123456789abcdef" }); - await chrome.storage.local.set({ onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, mixing: { mode: "mix" }, celebrations: null }); + for (let i = 0; i < 100 && !(await globalThis.__kotiko.area.get("onboarding")).onboarding; i++) await new Promise((r) => setTimeout(r, 50)); + const ui = { uiLang: "auto", baseLangs: [base], baseLangsConfirmed: true }; + const seedSalt = "0123456789abcdef0123456789abcdef"; + await chrome.storage.sync.set({ ui, seedSalt }); + await globalThis.__kotiko.seed({ ui, seedSalt, baseLangs: [base], onboarding: { completedAt: Date.now(), skipped: false, version: 2 }, mixing: { mode: "mix" }, celebrations: null }); }, LOCALE); const page = await env.context.newPage(); await page.goto(`${env.base}dashboard.html`); diff --git a/test/visual/welcome-screenshots.mjs b/test/visual/welcome-screenshots.mjs index 0becba1..14a5a9b 100644 --- a/test/visual/welcome-screenshots.mjs +++ b/test/visual/welcome-screenshots.mjs @@ -56,25 +56,26 @@ const errors = []; const written = []; // Fresh state for each shot: no words, no AI, not onboarded, no celebrations yet. +// Settings go in through the background's own copy (`__kotiko.seed`; storage.local is only +// its mirror, SCR-448), and the lookup service and its key through the settings' own +// messages, which bind where requests go (slice 28 §7). privacy.html has no side effects. async function reset(env, { ai = false, words = false } = {}) { - await env.sw.evaluate(async ({ ai, key, llmUrl }) => { + await env.sw.evaluate(async () => { const g = globalThis.__kotiko; - const store = await g.getStore(); - await store.replaceAll([]); - await chrome.storage.local.set({ onboarding: { completedAt: null, skipped: false, version: 2 }, celebrations: null, prefs: {}, lookup: { kind: "none", provider: "openrouter", baseUrl: null, model: null, dataCollection: "allow" }, keys: { server: false, providers: {} }, addJobs: [] }); - await store.secrets.remove("provider:custom"); - if (ai) { - await store.secrets.set("provider:custom", key); - await chrome.storage.local.set({ lookup: { kind: "provider", provider: "custom", baseUrl: llmUrl, model: null, dataCollection: "allow" }, keys: { server: false, providers: { custom: true } } }); - } + await (await g.getStore()).replaceAll([]); + await g.seed({ onboarding: { completedAt: null, skipped: false, version: 2 }, celebrations: null, prefs: {}, addJobs: [] }); await g.projector.flush(); - }, { ai, key: KEY, llmUrl: env.llmUrl }); - if (words) { - const page = await env.context.newPage(); - await page.goto(`${env.base}welcome.html`); - await page.evaluate(() => chrome.runtime.sendMessage({ type: "words.save", words: [{ lang: "ru", native: "дом", base_lang: "en", gloss: "house", forms: ["house"], origin: "manual" }] })); - await page.close(); + }); + const page = await env.context.newPage(); + await page.goto(`${env.base}privacy.html`); + const lookup = ai ? { kind: "provider", provider: "custom", baseUrl: env.llmUrl, model: null } : { kind: "none", provider: "openrouter", baseUrl: null, model: null }; + const msgs = [{ type: "secrets.remove", id: "provider:custom" }, { type: "backend.set", lookup }, ...(ai ? [{ type: "secrets.set", id: "provider:custom", value: KEY }] : [])]; + if (words) msgs.push({ type: "words.save", words: [{ lang: "ru", native: "дом", base_lang: "en", gloss: "house", forms: ["house"], origin: "manual" }] }); + for (const m of msgs) { + const r = await page.evaluate((x) => chrome.runtime.sendMessage(x), m); + if (r?.error) throw new Error(`${m.type}: ${JSON.stringify(r)}`); } + await page.close(); } async function shoot(env, name, { scheme, size = { width: 1100, height: 1000 }, reduced = true, ai = false, words = false, act, full = true, url = "welcome.html", locator = null }) {