Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ Conventional Commits by release-please.

## Unreleased

- 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
you back to kotiko.org/connect/, Kotiko gets your key, and the page you started from
says you're connected. Pasting a key still works ("Paste a key instead").

- Self-hosted server: other accounts on the same computer can no longer read your words.
The database (with the `-wal` and `-shm` files next to it), the model list cache and a
new data folder are now readable only by you, like the API token and the backups
already were. At start, the server makes these files private again if an older version
left them readable, and logs what it changed. It leaves a data folder that also holds
other files as it is and warns with the command to fix it. `run.sh` and the service
from `install-service.sh` now start the server with umask `077`; run
`install-service.sh` again to update the service.

- Self-hosted server: your keys can now live in their own files instead of `.env`. Set
`LLM_API_KEY_FILE=/path/to/file` in place of `LLM_API_KEY=...`, and the same for
`API_TOKEN`, `TELEGRAM_BOT_TOKEN` and `TRANSCRIBE_API_KEY`; this is how Docker secrets
Expand All @@ -31,7 +46,7 @@ Conventional Commits by release-please.
policy, published from the same file the extension shows so the two can't differ. The
site sets no cookies, has no analytics and loads nothing from any other site. It also
has the page OpenRouter returns to after "Connect OpenRouter"; Kotiko reads the sign-in
code there and nowhere else (the button itself stays hidden until the site is live).
code there and nowhere else.
The README is now a short introduction that points to the site.

- The Telegram bot no longer assumes you read English. It gives meanings in the languages
Expand Down
Binary file added brand/store/screenshots/en/01-page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/store/screenshots/en/02-popup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/store/screenshots/en/03-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/store/screenshots/en/04-welcome.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added brand/store/screenshots/en/05-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ code at runtime; every script is a classic script that attaches one namespace to
([`lib/llm/client.js`](../extension/lib/llm/client.js), with `policy.js` and
`catalog.js`) calls any OpenAI-compatible API through the presets in
[`spec/providers.json`](../spec/providers.json). [`lib/pkce.js`](../extension/lib/pkce.js)
holds the "Connect OpenRouter" sign-in; its button stays hidden until the docs site
(slice [44](../slices/44-docs-site/SPEC.md)) serves the callback page, so pasting a key
is the way in today.
holds the "Connect OpenRouter" sign-in (the welcome tab's first choice and a button in
the dashboard's Word lookups), which returns to the docs site's `/connect/` page (slice
[44](../slices/44-docs-site/SPEC.md)); pasting a key works too.
- **Server mode**: [`lib/sync-controller.js`](../extension/lib/sync-controller.js) keeps
one sync running at a time, and [`lib/validate-words.js`](../extension/lib/validate-words.js)
checks the server's answer before it is stored. [`lib/words-v1.js`](../extension/lib/words-v1.js)
Expand Down
14 changes: 13 additions & 1 deletion docs/privacy/en.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kotiko privacy policy

Version 1 · 5 October 2026
Version 2 · 6 October 2026

Kotiko is a browser extension that swaps words on the web pages you read for words you're
learning in other languages. This page says exactly what it keeps, what it sends, and to
Expand Down Expand Up @@ -69,6 +69,16 @@ Kotiko also asks the service for its list of models (with OpenRouter, its free m
and OpenRouter for how many free lookups you have left today. "Test" in the settings
sends the word "hello".

**Connecting OpenRouter.** If you choose "Connect OpenRouter", Kotiko opens OpenRouter's
sign-in page, where you sign in to OpenRouter directly; Kotiko never sees your OpenRouter
password. OpenRouter then sends your browser to <https://kotiko.org/connect/> with a
one-time code in the page address. That page is part of Kotiko's website: it sets no
cookies, loads nothing from other sites, and only shows the sign-in's progress. Kotiko
reads the code there and trades it with OpenRouter for your key, which it keeps as
described below. The code works only once, expires after ten minutes, and is useless
without a second secret that never leaves your browser. Like any website, kotiko.org's
hosts (GitHub Pages and Cloudflare) see each visit, including that address.

**Pronunciations from Wiktionary.** For a word in a language with word stress (such as
Russian, Ukrainian or Spanish), Kotiko asks English Wiktionary, run by the Wikimedia
Foundation, for that word's page, to get its pronunciation right. Only the word is sent,
Expand Down Expand Up @@ -158,4 +168,6 @@ everything in Kotiko's Settings.

## Changelog

- **Version 2, 6 October 2026.** Added "Connecting OpenRouter": the one-time sign-in code
passes through kotiko.org.
- **Version 1, 5 October 2026.** First version.
2 changes: 1 addition & 1 deletion docs/privacy/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ at run time.

| Host | Contacted by Kotiko? | Why it's in the code |
|---|---|---|
| `openrouter.ai` | Yes, when OpenRouter is your provider | Lookups, the free-model list and remaining free lookups; the key page link; the "Connect OpenRouter" sign-in (built, but its button stays hidden until the docs site serves the callback page) |
| `openrouter.ai` | Yes, when OpenRouter is your provider | Lookups, the free-model list and remaining free lookups; the key page link; the "Connect OpenRouter" sign-in (the sign-in page `openrouter.ai/auth` in a new tab, then the code traded for a key at `/api/v1/auth/keys`) |
| `api.openai.com` | Yes, when OpenAI is your provider | Lookups |
| `api.anthropic.com` | Yes, when Anthropic is your provider | Lookups |
| `generativelanguage.googleapis.com` | Yes, when Google Gemini is your provider | Lookups |
Expand Down
24 changes: 21 additions & 3 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ The folder that holds your words and the server's files (see

- Default: `$XDG_DATA_HOME/kotiko`, or `~/.local/share/kotiko` when `XDG_DATA_HOME` isn't
set (or isn't an absolute path, which the XDG Base Directory spec says to ignore). The
server creates it if it's missing.
server creates it if it's missing, readable only by you (`0700`).
- Words already in `~/.local/share/kotiko` stay there: while `$XDG_DATA_HOME/kotiko` has no
`kotiko.db` and `~/.local/share/kotiko` has one, the server keeps using
`~/.local/share/kotiko`. To move them, stop the server and move the folder.
Expand Down Expand Up @@ -291,11 +291,29 @@ check.

| File | What it is | Permissions |
|---|---|---|
| `kotiko.db` (with `kotiko.db-wal` and `kotiko.db-shm` while running) | Your words, kept responses for repeated adds (24 hours), cached lookups (30 days), and the background jobs' state. SQLite | Created with your umask: usually readable by other users of the computer unless your umask is `077`. The default folder is inside your home folder. To keep it private: `chmod 700` the data folder (usually `~/.local/share/kotiko`) |
| `kotiko.db` (with `kotiko.db-wal` and `kotiko.db-shm` while running) | Your words, kept responses for repeated adds (24 hours), cached lookups (30 days), and the background jobs' state. SQLite | `0600`. The server creates it empty and private before SQLite opens it, and SQLite gives `-wal` and `-shm` the database's permissions |
| `api-token` | The generated API token, when `API_TOKEN` isn't set | `0600`, written so no other user can read it at any moment |
| `models-cache.json` | OpenRouter's model list as last read, used when the server starts offline | Your umask |
| `models-cache.json` | OpenRouter's model list as last read, used when the server starts offline | `0600` |
| `backups/kotiko-pre-<version>-<time>.db` | A copy of the database made before an update changes it; the newest five are kept | Folder `0700`, files `0600` |

Only your account can open these files. At every start the server checks them before it
opens the database:

- Any of the files above that other users can open (say, from an install before this
check) is made `0600` again, and `backups/` `0700`. The server logs one warning that
names what it changed.
- The data folder is made `0700` when it holds nothing but Kotiko's files. A folder with
anything else in it may be shared on purpose (`KOTIKO_DATA_DIR=~`, say), so the server
leaves it as it is and warns at every start, with the command that makes it private
(`chmod 700 <folder>`). Giving Kotiko a folder of its own stops the warning too.
- A file it can't change (owned by another account, say) gets a warning with the
`chmod` that fixes it. The server starts either way.

`run.sh` and the systemd service from `install-service.sh` also start the server with
umask `077`, so a file it makes is private from the moment it exists. These are POSIX
permissions (Linux, macOS, WSL). On Windows, keep the data folder on WSL's own disk, not
on a Windows drive such as `/mnt/c`, which doesn't keep them; the server warns if it can't.

To go back to a backup, see the README, "Updating". To start over, stop the server and
delete `kotiko.db`.

Expand Down
18 changes: 9 additions & 9 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ its argument and evidence.
| Only `/health` answers without the token | One plug runs before routing and allows exactly `GET` and `HEAD` `/health`; everything else needs `Bearer <token>`, compared in constant time. It matches on the raw path, so encoded spellings can't slip past | [`router.ex`](../../server/lib/kotiko/router.ex) `authorize/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "every method and path spelling is 401", "only GET and HEAD /health are open"; [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) "encoded and dotted paths need the token" (through a real socket) |
| `/health` says nothing else | It returns name, version, API versions and database state only | [`health.ex`](../../server/lib/kotiko/health.ex) `report/1` |
| A strong token, kept private | 32 bytes from `:crypto.strong_rand_bytes`, written to a file made 0600 before the token goes in, then renamed into place; a chosen `API_TOKEN` under 24 characters stops the boot | [`token.ex`](../../server/lib/kotiko/token.ex) `generate/1`, `save/2`, `check/3`; [`token_test.exs`](../../server/test/kotiko/token_test.exs) "first boot makes a 43-character token in a 0600 file", "short or whitespace-only tokens are refused"; [`config.ex`](../../server/lib/kotiko/config.ex) `api_token/1` |
| Other accounts on the computer can't read the words | The BEAM can't set a mode on create, so files are created empty, made 0600, then filled: the database before SQLite opens it (SQLite gives `-wal`, `-shm` and `-journal` the database's mode, checked by a test), each backup before `VACUUM INTO`, the token and model cache through a private temporary file. A data folder the server makes is 0700. At every boot, before the Repo opens, `make_private/1` tightens any of Kotiko's files others can open and the folder when it holds only Kotiko's files, and warns otherwise. `run.sh` sets umask 077 and the unit `UMask=0077` | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`private_test.exs`](../../server/test/kotiko/private_test.exs); [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) (boots with umask 022 and checks the modes); [`scripts_test.exs`](../../server/test/kotiko/scripts_test.exs) |
| Listens on this computer unless told otherwise; warns when it doesn't | `BIND` defaults to `127.0.0.1`; at boot the address is classified (loopback, Tailscale, LAN, all interfaces, public) and the last three log a warning; a public address repeats it daily | [`config.ex`](../../server/lib/kotiko/config.ex) `@default_bind`; [`exposure.ex`](../../server/lib/kotiko/exposure.ex) `classify/2`; [`application.ex`](../../server/lib/kotiko/application.ex) `repeat_public_warning/2`; [`exposure_test.exs`](../../server/test/kotiko/exposure_test.exs) |
| Answers only to names it knows | A plug before routing allows `localhost`, IP literals, this machine's own name and `ALLOWED_HOSTS`; others get 421, with or without a token | [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex); [`host_check_test.exs`](../../server/test/kotiko/plug/host_check_test.exs); [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "an unknown name gets 421 with or without a token, on every route" |
| Strangers can't make it do work | Body parsing is a plug after `authorize`; 64 KB cap, 1 MB for the batch route | [`router.ex`](../../server/lib/kotiko/router.ex) `parse_body/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "are parsed only after auth", "over 64 KB are refused with 413"; [`router_v1_test.exs`](../../server/test/kotiko/router_v1_test.exs) "takes at most 500 words, and bodies up to 1 MB" |
Expand Down Expand Up @@ -61,7 +62,7 @@ its argument and evidence.
| Someone on the same network | Plain-HTTP traffic when the server listens beyond loopback | Loopback by default; startup warnings; Tailscale or HTTPS advice |
| A malicious or broken model answer | The word pipeline on the server or in the extension | Schema and rule checks, length caps, script checks, text-only rendering |
| A Telegram user who isn't allowed | The bot's chat | Sender allowlist |
| Another user on the server machine | Files in the data folder, depending on permissions | `api-token` and backups are 0600; the database follows the umask (residual risk, section 7) |
| Another user on the server machine | Files in the data folder, depending on permissions | Every file the server keeps there is 0600 (the database is created private before SQLite opens it, so `-wal` and `-shm` are too) and a folder it makes 0700; each start re-checks them; `run.sh` and the systemd unit use umask 077 |
| A compromised dependency or GitHub Action | The build, CI, and later the release | Lockfiles, actions pinned by commit SHA, Dependabot, OSV-Scanner over every lockfile in CI and before each release, `mix hex.audit` and `mix deps.audit` in CI, no runtime npm code in the extension |
| A malicious pull request | The code base | Review by a maintainer; CI with read-only token; branch protection on going public ([PUBLIC_CHECKLIST.md](../PUBLIC_CHECKLIST.md)) |

Expand All @@ -84,7 +85,7 @@ its argument and evidence.
│ ▼ HTTPS ▼ HTTPS ▼ file system
│ model provider, Wiktionary Telegram (allowlisted data folder
│ (answers untrusted) sender IDs) (kotiko.db,
│ api-token 0600)
│ api-token: 0600)
CI (GitHub Actions, read-only token) ──▶ stores, after a maintainer approves (slice 30)
```

Expand All @@ -96,7 +97,7 @@ its argument and evidence.
| 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) |
| Server to Telegram | Updates in, messages out | HTTPS; sender allowlist | [`bot.ex`](../../server/lib/kotiko/bot.ex); [`telegram.ex`](../../server/lib/kotiko/telegram.ex) |
| Server to file system | The database, token, backups | `api-token` 0600; `backups/` 0700 with 0600 files; database follows the umask | [`token.ex`](../../server/lib/kotiko/token.ex); [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2` |
| Server to file system | The database, token, backups | `kotiko.db` and its `-wal`/`-shm`, `api-token`, `models-cache.json` 0600; `backups/` 0700 with 0600 files; a data folder the server makes 0700, one that holds only Kotiko's files made 0700 at start (otherwise a warning); umask 077 in `run.sh` and the unit (`UMask=0077`) | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2`; [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) |
| CI to stores | Release packages | The store jobs run in the `release` environment: a maintainer approves each run, and only `v*` tags (signed, verified against `.github/allowed_signers`) may deploy. The maintainer creates the environment before the first release | [slice 30](../../slices/30-release-pipeline/SPEC.md) |

## 4. Secure design principles
Expand Down Expand Up @@ -162,12 +163,11 @@ These match "What you can't expect" in the [requirements](requirements.md).

- **Data at rest is not encrypted.** Anyone who can read the browser profile or the data
folder gets the words, keys and token. Assumption: the user's account and disk are theirs.
- **The database file follows the umask.** The server makes `api-token` 0600 and the
`backups/` folder 0700, but creates `kotiko.db` (and the data folder, when it makes it)
with the process's default permissions, and the systemd unit written by
`install-service.sh` sets no `UMask`. On a computer shared with other accounts, they may
be able to read the word list. Not yet assigned to a slice; the natural homes are 29
(server ops) or 40 (packaging).
- **A shared data folder stays shared.** When `KOTIKO_DATA_DIR` points at a folder that
also holds other files, the server makes its own files private but leaves the folder's
permissions alone and warns at every start; other accounts can then see the files' names
and sizes, not their contents. On a disk that keeps no POSIX permissions (a Windows drive
in WSL) nothing can be made private; the server warns.
- **Plain HTTP beyond loopback** exposes the token and words to the network. The server
warns; it does not refuse.
- **The model provider sees every lookup**, under its own terms.
Expand Down
Loading