+
-
-
+
+
+
+ ·
·
diff --git a/extension/welcome.js b/extension/welcome.js
index 0d7755b..fa64060 100644
--- a/extension/welcome.js
+++ b/extension/welcome.js
@@ -6,8 +6,9 @@
//
// 1. The languages they read: detected from the browser (slice 50), confirmed by doing
// nothing; ticking and unticking writes them at once.
-// 2. Their own AI (slice 11): a pasted OpenRouter key, another service or a local model, or
-// their Kotiko server; each checked once. Or no AI at all.
+// 2. Their own AI (slice 11): Connect OpenRouter (its sign-in in a new tab), a pasted
+// OpenRouter key, another service or a local model, or their Kotiko server; each checked
+// once. Or no AI at all.
// 3. The first word: anything the add box takes. "native = meaning" is parsed here, with
// no model and no network. Everything else is looked up with `words.preview`, which
// saves nothing; only "Make it my first word" saves (`words.save`).
@@ -49,7 +50,7 @@
detected: [],
ui: {},
backend: null,
- check: null, // { status: "busy" | "ok" | "bad", panel, text }
+ check: null, // { status: "busy" | "ok" | "bad", panel, text }; panel "connect" is the sign-in
panel: null, // "paste" | "other" | "server"
changing: false,
aiSkipped: false,
@@ -316,7 +317,7 @@
$(btn).setAttribute("aria-expanded", String(state.panel === panel));
}
renderOther();
- for (const panel of ["paste", "other", "server"]) renderStatus(panel);
+ for (const panel of ["connect", "paste", "other", "server"]) renderStatus(panel);
renderAskExtras();
}
@@ -367,6 +368,28 @@
return false;
}
+ // Connect OpenRouter (11 §4): the background keeps the PKCE verifier and opens OpenRouter's
+ // sign-in in a new tab. OpenRouter returns to kotiko.org/connect/, whose content script
+ // hands the code to the background; this page learns the key arrived when `lookup` and
+ // `keys` change (onStorage). Until then it says it's waiting, and every other way to
+ // connect still works. Selecting it again starts a new sign-in.
+ async function connectOpenRouter() {
+ state.panel = null;
+ const res = await call({ type: "oauth.start" });
+ state.check = failed(res) ? { status: "bad", panel: "connect", text: problemText(codeOf(res), detailsOf(res)) } : { status: "busy", panel: "connect", text: t("welcome_ai_waiting") };
+ renderAi();
+ announce(state.check.text);
+ }
+
+ // The sign-in finished on the other tab: OpenRouter now looks words up.
+ function connectArrived() {
+ if (state.check?.panel !== "connect" || state.check.status !== "busy") return;
+ if (!ready() || state.backend?.lookup?.provider !== "openrouter") return;
+ state.check = { status: "ok", panel: "connect", text: connectedText() };
+ state.changing = false;
+ announce(connectedText());
+ }
+
// Saves a pasted key for a provider (11 §3: the background keeps it; this page never sees
// it again), makes that provider the one that looks words up, and checks it.
async function saveKey(providerId, value, panel) {
@@ -1013,6 +1036,7 @@
if (changes.prefs) state.prefs = changes.prefs.newValue ?? {};
if (changes.lookup || changes.keys || changes.wordsHome || changes.server) {
refreshBackend().then(() => {
+ connectArrived();
renderAi();
retryPending();
});
@@ -1088,6 +1112,7 @@
});
$("skip").addEventListener("click", skip);
$("allow").addEventListener("click", allow);
+ $("aiConnect").addEventListener("click", connectOpenRouter);
$("aiPaste").addEventListener("click", () => openPanel("paste"));
$("aiOther").addEventListener("click", () => openPanel("other"));
$("aiServer").addEventListener("click", () => openPanel("server"));
@@ -1103,7 +1128,7 @@
state.aiSkipped = false;
state.check = null;
renderAi();
- $("aiPaste").focus();
+ $("aiConnect").focus();
});
$("pasteShow").addEventListener("click", () => {
const input = $("pasteKey");
diff --git a/server/install-service.sh b/server/install-service.sh
index 4ee7bbc..60f3290 100755
--- a/server/install-service.sh
+++ b/server/install-service.sh
@@ -265,6 +265,8 @@ StartLimitBurst=5
WorkingDirectory=${dir//%/%%}
ExecStart="$(exec_escape "$dir")/run.sh"
$environment
+# Files the server makes are readable by you only (run.sh sets the same umask).
+UMask=0077
Restart=on-failure
RestartSec=5
# 78 is a mistake in .env: retrying won't fix it. Fix .env, then restart by hand.
diff --git a/server/lib/kotiko/application.ex b/server/lib/kotiko/application.ex
index 2b74372..ccc0ab6 100644
--- a/server/lib/kotiko/application.ex
+++ b/server/lib/kotiko/application.ex
@@ -10,7 +10,7 @@ defmodule Kotiko.Application do
1. `Kotiko.Config.load!/0` parse and check the settings; exit 78 on a mistake
2. `Kotiko.DataDir.resolve_and_migrate!/0`
the data folder; copies the words from before the
- rename to Kotiko, once
+ rename to Kotiko, once; makes the files private
3. API token from API_TOKEN, the saved file, or a new one
4. `Kotiko.Migrations.run!/0` back the database up if a migration is pending, then
migrate on one connection, before the pool opens
diff --git a/server/lib/kotiko/config.ex b/server/lib/kotiko/config.ex
index 39510f2..bcbdaba 100644
--- a/server/lib/kotiko/config.ex
+++ b/server/lib/kotiko/config.ex
@@ -550,10 +550,12 @@ defmodule Kotiko.Config do
end
@doc false
+ # A folder this makes is private (0700); Kotiko.DataDir.make_private/1 sees to one that
+ # was already there.
# Sobelow: the folder is KOTIKO_DATA_DIR or the default, never from a request.
# sobelow_skip ["Traversal.FileModule"]
def ensure_dir(dir) do
- with :ok <- File.mkdir_p(dir),
+ with :ok <- Kotiko.Private.mkdir_p(dir),
{:ok, %File.Stat{type: :directory, access: :read_write}} <- File.stat(dir) do
:ok
else
diff --git a/server/lib/kotiko/data_dir.ex b/server/lib/kotiko/data_dir.ex
index 27115a0..890d849 100644
--- a/server/lib/kotiko/data_dir.ex
+++ b/server/lib/kotiko/data_dir.ex
@@ -26,15 +26,32 @@ defmodule Kotiko.DataDir do
which the next run deletes before starting over. Then `MOVED-TO-KOTIKO.txt` in the old
folder says where the words went, and the old API token is copied too, so the extension
stays connected.
+
+ Then, at every start, `make_private/1` keeps other users of the computer out: a folder
+ it creates is 0700, and every file Kotiko keeps in it 0600 (an old `slovo.db` in the
+ data folder too: its permissions, never its contents).
"""
require Logger
alias Exqlite.Sqlite3
- alias Kotiko.Token
+ alias Kotiko.{Private, Token}
@database "kotiko.db"
@legacy_database "slovo.db"
@note "MOVED-TO-KOTIKO.txt"
@partial_suffixes [".partial", ".partial-journal", ".partial-wal", ".partial-shm"]
+ @backups "backups"
+
+ # Every file Kotiko keeps in the data folder (install-service.sh --delete-data deletes
+ # the same list), made 0600 at each start.
+ @private_files ~w(kotiko.db kotiko.db-wal kotiko.db-shm kotiko.db-journal api-token
+ api-token.new models-cache.json models-cache.json.tmp) ++
+ Enum.map(@partial_suffixes, &(@database <> &1))
+
+ # The old name's database, still there in a folder that was moved in place.
+ @legacy_files Enum.map(["", "-wal", "-shm", "-journal"], &(@legacy_database <> &1))
+
+ # A data folder holding only these is Kotiko's, so make_private/1 makes it 0700.
+ @own_names @private_files ++ @legacy_files ++ [@backups, @note]
@type result :: :existing | :fresh | {:migrated, non_neg_integer()}
@@ -79,8 +96,122 @@ defmodule Kotiko.DataDir do
source = Application.get_env(:kotiko, :data_dir_source)
case migrate(target, legacy_dirs(target, source, System.user_home())) do
- {:ok, _result} -> target
- {:error, message} -> Kotiko.Config.halt!(message, 1)
+ {:ok, _result} ->
+ make_private(target)
+ target
+
+ {:error, message} ->
+ Kotiko.Config.halt!(message, 1)
+ end
+ end
+
+ @doc """
+ Makes sure other users of the computer can't read the words, at every start, before
+ the database opens:
+
+ * `kotiko.db` is created empty and 0600 if it isn't there yet, so the `-wal` and
+ `-shm` files SQLite makes next to it are 0600 too.
+ * Kotiko's own files that others can open (an install from before this check, or a
+ file copied in) are made 0600, and the `backups` folder and its copies 0700 and
+ 0600.
+ * The folder is made 0700 when it holds nothing but Kotiko's files: then it is
+ Kotiko's folder, whoever chose it. A folder with anything else in it may be shared
+ on purpose (`KOTIKO_DATA_DIR=~`, say), so it is left as it is, with a warning that
+ says how to make it private.
+
+ What was changed is logged as one warning (others could read it until now), and
+ anything that couldn't be changed as a warning with the command that fixes it. Never
+ stops the start. Returns `:ok`.
+ """
+ def make_private(dir) do
+ database = database(dir)
+ # If it can't be made, opening the database fails next, with its own message.
+ _ = Private.create(database)
+ backups = Path.join(dir, @backups)
+
+ files =
+ Enum.map(@private_files ++ @legacy_files, &Path.join(dir, &1)) ++
+ for name <- ls(backups),
+ String.starts_with?(name, "kotiko-"),
+ do: Path.join(backups, name)
+
+ results = Enum.map(files ++ [backups], &{&1, Private.restrict(&1)}) ++ folder(dir)
+
+ case for({path, :changed} <- results, do: path) do
+ [] ->
+ :ok
+
+ changed ->
+ Logger.warning(
+ "Other users of this computer could open Kotiko's files in #{dir} " <>
+ "(#{Enum.map_join(changed, ", ", &relative(&1, dir))}). Made them private: " <>
+ "only your account can open them now."
+ )
+ end
+
+ {ignored, failed} =
+ results
+ |> Enum.filter(&match?({_path, {:error, _why}}, &1))
+ |> Enum.split_with(&match?({_path, {:error, :ignored}}, &1))
+
+ # A disk that keeps no permissions ignores them for every file: say so once.
+ if ignored != [], do: Logger.warning(not_private(dir, :ignored))
+ Enum.each(failed, fn {path, {:error, why}} -> Logger.warning(not_private(path, why)) end)
+ end
+
+ # The folder itself: made private when it holds nothing but Kotiko's files.
+ # Sobelow: the data folder comes from the server's settings, never from a request.
+ # sobelow_skip ["Traversal.FileModule"]
+ defp folder(dir) do
+ own? =
+ match?({:ok, %File.Stat{type: :directory}}, File.lstat(dir)) and
+ Enum.all?(ls(dir), &(&1 in @own_names))
+
+ case File.stat(dir) do
+ {:ok, %File.Stat{mode: mode}} ->
+ cond do
+ not Private.shared?(mode) -> []
+ own? -> [{dir, Private.restrict(dir)}]
+ true -> [{dir, {:error, :not_only_kotiko}}]
+ end
+
+ {:error, _} ->
+ []
+ end
+ end
+
+ defp not_private(dir, :not_only_kotiko) do
+ "Other users of this computer can open the data folder #{dir}. Kotiko's own files in " <>
+ "it are private, but the folder holds other files too (or is a link), so Kotiko " <>
+ "leaves it as it is. To make it private: chmod 700 #{dir}, or give Kotiko a folder " <>
+ "of its own with KOTIKO_DATA_DIR."
+ end
+
+ defp not_private(dir, :ignored) do
+ "Couldn't make Kotiko's files in #{dir} private: this disk doesn't keep file " <>
+ "permissions (a Windows drive in WSL, say), so other users of this computer may be " <>
+ "able to read your words. Keep the data folder on a disk that does (set " <>
+ "KOTIKO_DATA_DIR)."
+ end
+
+ # Sobelow: as make_private/1.
+ # sobelow_skip ["Traversal.FileModule"]
+ defp not_private(path, reason) do
+ mode = if File.dir?(path), do: "700", else: "600"
+
+ "Couldn't make #{path} private (#{format(reason)}), so other users of this computer " <>
+ "may be able to read it. To fix it: chmod #{mode} #{path}"
+ end
+
+ defp relative(dir, dir), do: "the folder itself"
+ defp relative(path, dir), do: Path.relative_to(path, dir)
+
+ # Sobelow: folders under the configured data folder, never from a request.
+ # sobelow_skip ["Traversal.FileModule"]
+ defp ls(dir) do
+ case File.ls(dir) do
+ {:ok, names} -> names
+ {:error, _} -> []
end
end
@@ -129,7 +260,7 @@ defmodule Kotiko.DataDir do
{:ok, nil}
else
with {:ok, contents} <- File.read(from),
- :ok <- File.mkdir_p(to_dir),
+ :ok <- Private.mkdir_p(to_dir),
:ok <- Token.save(to, contents) do
{:ok, from}
else
@@ -153,7 +284,7 @@ defmodule Kotiko.DataDir do
# Sobelow: the data folder comes from the server's settings, never from a request.
# sobelow_skip ["Traversal.FileModule"]
defp fresh(target) do
- case File.mkdir_p(target) do
+ case Private.mkdir_p(target) do
:ok -> {:ok, :fresh}
{:error, reason} -> {:error, message({:io, "create the data folder", reason}, nil, target)}
end
@@ -167,7 +298,7 @@ defmodule Kotiko.DataDir do
database = database(target)
result =
- with :ok <- File.mkdir_p(target) |> io("create the data folder"),
+ with :ok <- Private.mkdir_p(target) |> io("create the data folder"),
{:ok, conn} <- Sqlite3.open(legacy, mode: :readwrite) |> io("open the old database") do
try do
copy_locked(conn, legacy, target)
@@ -203,13 +334,14 @@ defmodule Kotiko.DataDir do
with :ok <- lock(conn),
:ok <- integrity(conn, :source_corrupt),
{:ok, counts} <- counts(conn, :source_unreadable),
+ # Empty and 0600 before the words go in: VACUUM INTO keeps an empty file's mode.
+ :ok <- Private.create(partial) |> io("create the copy"),
:ok <- Sqlite3.execute(conn, "VACUUM INTO " <> sql_string(partial)) |> io("copy it"),
:ok <- verify(partial, counts),
:ok <- fsync(partial) |> io("save the copy to disk"),
:ok <- File.chmod(partial, 0o600) |> io("make the copy private"),
:ok <- copy_token(Path.dirname(legacy), target),
:ok <- File.rename(partial, database) |> io("rename the copy to #{database}") do
- private_dir(target)
{:ok, counts.words}
end
end
@@ -310,17 +442,6 @@ defmodule Kotiko.DataDir do
end
end
- # Best effort: the database is already private, and a shared folder someone chose may
- # not be theirs to change.
- # Sobelow: the data folder comes from the server's settings, never from a request.
- # sobelow_skip ["Traversal.FileModule"]
- defp private_dir(dir) do
- case File.chmod(dir, 0o700) do
- :ok -> :ok
- {:error, reason} -> Logger.warning("Couldn't make #{dir} private: #{format(reason)}")
- end
- end
-
# Sobelow: the database path comes from the server's settings, never from a request.
# sobelow_skip ["Traversal.FileModule"]
defp remove_partial(database) do
diff --git a/server/lib/kotiko/llm/catalog.ex b/server/lib/kotiko/llm/catalog.ex
index 345d1d2..2d6a40e 100644
--- a/server/lib/kotiko/llm/catalog.ex
+++ b/server/lib/kotiko/llm/catalog.ex
@@ -380,8 +380,7 @@ defmodule Kotiko.LLM.Catalog do
_ -> state
end
- # Sobelow: a fixed file name in the configured data folder, never from a request.
- # sobelow_skip ["Traversal.FileModule"]
+ # 0600, like every file in the data folder (Kotiko.Private).
defp write_cache(state) do
json =
Jason.encode!(%{
@@ -391,11 +390,11 @@ defmodule Kotiko.LLM.Catalog do
})
path = cache_path()
- tmp = path <> ".tmp"
- with :ok <- File.write(tmp, json), :ok <- File.rename(tmp, path) do
- :ok
- else
+ case Kotiko.Private.write(path, json, ".tmp") do
+ :ok ->
+ :ok
+
{:error, reason} ->
Logger.warning("Couldn't save the model list to #{path}: #{:file.format_error(reason)}")
end
diff --git a/server/lib/kotiko/migrations.ex b/server/lib/kotiko/migrations.ex
index cf730e4..b85c68b 100644
--- a/server/lib/kotiko/migrations.ex
+++ b/server/lib/kotiko/migrations.ex
@@ -70,6 +70,13 @@ defmodule Kotiko.Migrations do
path = Path.join(dir, "kotiko-pre-#{Kotiko.Health.version()}-#{stamp}.db")
path = if File.exists?(path), do: String.replace_suffix(path, ".db", "-2.db"), else: path
+ # Empty and 0600 before the words go in: VACUUM INTO fills an empty file and keeps its
+ # mode. The chmod after covers an empty file that was already there.
+ case Kotiko.Private.create(path) do
+ :ok -> :ok
+ {:error, reason} -> raise File.Error, reason: reason, action: "create", path: path
+ end
+
repo.query!("VACUUM INTO " <> sql_string(path))
File.chmod!(path, 0o600)
diff --git a/server/lib/kotiko/private.ex b/server/lib/kotiko/private.ex
new file mode 100644
index 0000000..6547581
--- /dev/null
+++ b/server/lib/kotiko/private.ex
@@ -0,0 +1,117 @@
+# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule Kotiko.Private do
+ @moduledoc """
+ Folders and files only the server's own account can open: folders 0700, files 0600.
+
+ The BEAM can't set the umask or give a mode when it creates a file, so a new file
+ starts with the process's umask (usually `022`: everyone can read it). These helpers
+ create a file empty, make it private, and only then put anything in it. `run.sh` and
+ the systemd unit also start the server with umask `077`, so with them nothing is ever
+ readable by others, not even for the moment between creating a file and its `chmod`.
+
+ SQLite gives the `-wal`, `-shm` and `-journal` files it makes the same mode as their
+ database (and `VACUUM INTO` an empty file keeps that file's mode), so creating
+ `kotiko.db` or a backup empty and 0600 before SQLite opens it keeps those private too.
+
+ These are POSIX permissions (Linux, macOS, WSL). On a file system that ignores them,
+ like a Windows drive mounted in WSL, `restrict/1` returns `{:error, :ignored}`.
+ """
+ import Bitwise
+
+ @doc "Whether `mode` lets anyone but the owner in (any group or other bit is set)."
+ def shared?(mode), do: band(mode, 0o077) != 0
+
+ @doc """
+ Creates the folder `dir`, and any missing parents. When this creates `dir` itself, it
+ is made 0700 before anything is put in it. A folder that already exists is left as it
+ is: `Kotiko.DataDir.make_private/1` decides about those.
+ """
+ # Sobelow: callers pass the data folder (or one inside it) from the server's settings or
+ # an operator's command line, never a path from a request.
+ # sobelow_skip ["Traversal.FileModule"]
+ def mkdir_p(dir) do
+ if File.dir?(dir) do
+ :ok
+ else
+ with :ok <- File.mkdir_p(dir), do: File.chmod(dir, 0o700)
+ end
+ end
+
+ @doc """
+ Creates `path` as an empty file only the owner can read, for SQLite to fill. Does
+ nothing when `path` already exists.
+ """
+ # Sobelow: the database or backup path under the configured data folder, never a request's.
+ # sobelow_skip ["Traversal.FileModule"]
+ def create(path) do
+ case File.open(path, [:write, :exclusive]) do
+ {:ok, io} ->
+ :ok = File.close(io)
+ File.chmod(path, 0o600)
+
+ {:error, :eexist} ->
+ :ok
+
+ {:error, _reason} = error ->
+ error
+ end
+ end
+
+ @doc """
+ Writes `contents` to `path` (mode 0600) through `path <> suffix`: the new file is made
+ private before anything goes in, then renamed over the old one, so readers never see a
+ half-written file and the contents are never in a file other users can read.
+ """
+ # Sobelow: a fixed file name in the configured data folder, never a path from a request.
+ # sobelow_skip ["Traversal.FileModule"]
+ def write(path, contents, suffix \\ ".new") do
+ tmp = path <> suffix
+ _ = File.rm(tmp)
+
+ with :ok <- mkdir_p(Path.dirname(path)),
+ :ok <- create(tmp),
+ :ok <- File.write(tmp, contents) do
+ File.rename(tmp, path)
+ end
+ end
+
+ @doc """
+ Makes the file (0600) or folder (0700) at `path` private when others can open it.
+ Returns `:ok` when it is already private or isn't there, `:changed`, or
+ `{:error, reason}`: a file error, or `:ignored` when the file system kept its own modes.
+ A symbolic link is left alone: what it points to may not be Kotiko's.
+ """
+ # Sobelow: paths of Kotiko's own files in the configured data folder, never a request's.
+ # sobelow_skip ["Traversal.FileModule"]
+ def restrict(path) do
+ case File.lstat(path) do
+ {:ok, %File.Stat{type: type, mode: current}} when type in [:regular, :directory] ->
+ if shared?(current), do: tighten(path, private_mode(type)), else: :ok
+
+ {:ok, %File.Stat{}} ->
+ :ok
+
+ {:error, :enoent} ->
+ :ok
+
+ {:error, _reason} = error ->
+ error
+ end
+ end
+
+ @doc "The private mode for a `:regular` file (0600) or a `:directory` (0700)."
+ def private_mode(:directory), do: 0o700
+ def private_mode(_regular), do: 0o600
+
+ # Checks again afterwards: some file systems accept chmod and keep their own modes.
+ # Sobelow: as restrict/1.
+ # sobelow_skip ["Traversal.FileModule"]
+ defp tighten(path, mode) do
+ with :ok <- File.chmod(path, mode),
+ {:ok, %File.Stat{mode: now}} <- File.lstat(path) do
+ if shared?(now), do: {:error, :ignored}, else: :changed
+ end
+ end
+end
diff --git a/server/lib/kotiko/token.ex b/server/lib/kotiko/token.ex
index adfddc7..26fefac 100644
--- a/server/lib/kotiko/token.ex
+++ b/server/lib/kotiko/token.ex
@@ -69,20 +69,9 @@ defmodule Kotiko.Token do
@doc """
Writes `contents` to `path` (mode 0600). The file is made private before the token goes
in, and swapped in with a rename, so the token is never in a file other users can read.
+ A data folder this creates is private too (`Kotiko.Private`).
"""
- # Sobelow: callers pass the token file's path from the data folder, never a request's.
- # sobelow_skip ["Traversal.FileModule"]
- def save(path, contents) do
- tmp = path <> ".new"
- _ = File.rm(tmp)
-
- with :ok <- File.mkdir_p(Path.dirname(path)),
- :ok <- File.touch(tmp),
- :ok <- File.chmod(tmp, 0o600),
- :ok <- File.write(tmp, contents) do
- File.rename(tmp, path)
- end
- end
+ def save(path, contents), do: Kotiko.Private.write(path, contents, ".new")
@doc """
One pasteable value carrying both the server address and the token, for the
diff --git a/server/run.sh b/server/run.sh
index 057818c..a488409 100755
--- a/server/run.sh
+++ b/server/run.sh
@@ -9,6 +9,10 @@
# Runs with MIX_ENV=prod unless MIX_ENV is set (in your shell or in .env). Prod builds
# into _build/prod, so the first start after switching compiles everything once.
set -euo pipefail
+# Everything the server writes (your words, their backups, the token) is readable by you
+# only. The server makes its own files private too; this covers the moment each one is
+# made, and anything else it writes.
+umask 077
cd "$(dirname "$0")"
if [ ! -f .env ]; then
diff --git a/server/test/kotiko/boot_test.exs b/server/test/kotiko/boot_test.exs
index 6614010..f71d4f4 100644
--- a/server/test/kotiko/boot_test.exs
+++ b/server/test/kotiko/boot_test.exs
@@ -23,6 +23,8 @@ defmodule Kotiko.BootTest do
end
# Runs `script` after putting `vars` in the app env. Returns {output, exit status}.
+ # The VM gets umask 022, the usual default, so files that end up private were made
+ # private by the server itself.
defp boot(vars, script, %{tmp: tmp, inetrc: inetrc, home: home}) do
paths =
Mix.Project.build_path()
@@ -39,7 +41,7 @@ defmodule Kotiko.BootTest do
{elixir, path} = elixir_without_shims()
- System.cmd(elixir, paths ++ ["-e", code],
+ System.cmd("sh", ["-c", ~s(umask 022 && exec "$0" "$@"), elixir | paths ++ ["-e", code]],
cd: tmp,
stderr_to_stdout: true,
env: [
@@ -131,6 +133,14 @@ defmodule Kotiko.BootTest do
:ok = :gen_tcp.send(socket, "GET /health HTTP/1.1\\r\\nhost: localhost\\r\\nconnection: close\\r\\n\\r\\n")
{:ok, response} = :gen_tcp.recv(socket, 0, 5_000)
IO.puts("HEALTH " <> response)
+ mode = fn path ->
+ case File.stat(path) do
+ {:ok, stat} -> stat.mode |> Bitwise.band(0o777) |> Integer.to_string(8)
+ {:error, _} -> "missing"
+ end
+ end
+ files = ["", "kotiko.db", "kotiko.db-wal", "kotiko.db-shm", "api-token"]
+ IO.puts("MODES " <> Enum.map_join(files, " ", &mode.(Path.join(#{inspect(data)}, &1))))
:ok = Application.stop(:kotiko)
"""
@@ -151,6 +161,10 @@ defmodule Kotiko.BootTest do
assert output =~ "Telegram: off"
assert output =~ "HEALTH HTTP/1.1 200"
assert output =~ ~s("db":"ok")
+ # Only this account can open the folder, the database and the files SQLite makes
+ # next to it (SCR-450), though the VM ran with umask 022.
+ assert output =~ "MODES 700 600 600 600 600\n"
+ refute output =~ "Other users of this computer"
# The key is in the settings, so it's never in the log.
refute output =~ "sk-or-v1-0123456789abcdef"
end
diff --git a/server/test/kotiko/config_test.exs b/server/test/kotiko/config_test.exs
index 666f4f4..4828f38 100644
--- a/server/test/kotiko/config_test.exs
+++ b/server/test/kotiko/config_test.exs
@@ -473,7 +473,7 @@ defmodule Kotiko.ConfigTest do
%{root: root}
end
- test "a missing folder is created", %{root: root} do
+ test "a missing folder is created, private", %{root: root} do
dir = Path.join(root, "a/b")
assert {:ok, config, _} =
@@ -481,6 +481,17 @@ defmodule Kotiko.ConfigTest do
assert config[:data_dir] == dir
assert File.dir?(dir)
+ assert Bitwise.band(File.stat!(dir).mode, 0o777) == 0o700
+ end
+
+ test "a folder that's already there keeps its permissions here", %{root: root} do
+ File.mkdir_p!(root)
+ File.chmod!(root, 0o755)
+
+ assert {:ok, _config, _} =
+ Config.parse(%{"KOTIKO_DATA_DIR" => root}, resolver: &resolver/1)
+
+ assert Bitwise.band(File.stat!(root).mode, 0o777) == 0o755
end
test "a file in the way is an error", %{root: root} do
diff --git a/server/test/kotiko/data_dir_test.exs b/server/test/kotiko/data_dir_test.exs
index 46765a9..d00b74f 100644
--- a/server/test/kotiko/data_dir_test.exs
+++ b/server/test/kotiko/data_dir_test.exs
@@ -136,6 +136,7 @@ defmodule Kotiko.DataDirTest do
assert File.dir?(ctx.target)
assert File.ls!(ctx.target) == []
+ assert mode(ctx.target) == 0o700
refute File.exists?(ctx.legacy_dir)
assert log == ""
end
@@ -326,6 +327,7 @@ defmodule Kotiko.DataDirTest do
new = Path.join(ctx.target, "api-token")
assert File.read!(new) == "the-old-token\n"
assert mode(new) == 0o600
+ assert mode(ctx.target) == 0o700
File.write!(new, "a-newer-token\n")
assert {:ok, nil} = DataDir.copy_legacy_token(ctx.legacy_dir, ctx.target)
@@ -341,4 +343,136 @@ defmodule Kotiko.DataDirTest do
assert {:ok, nil} = DataDir.copy_legacy_token(ctx.legacy_dir, ctx.legacy_dir)
end
end
+
+ # ── make_private/1 (SCR-450) ────────────────────────────────────────
+ # The test process keeps the shell's umask (often 022), so what ends up 0600 or 0700
+ # was made private by the server.
+
+ describe "make_private/1" do
+ defp file!(path, mode, contents \\ "") do
+ File.mkdir_p!(Path.dirname(path))
+ File.write!(path, contents)
+ File.chmod!(path, mode)
+ path
+ end
+
+ test "a new folder: kotiko.db is made empty and 0600, so -wal and -shm are too", ctx do
+ {:ok, :fresh} = migrate(ctx)
+ log = capture_log(fn -> assert :ok = DataDir.make_private(ctx.target) end)
+ assert log == ""
+
+ database = DataDir.database(ctx.target)
+ assert mode(database) == 0o600
+ assert mode(ctx.target) == 0o700
+
+ {:ok, conn} = Sqlite3.open(database)
+
+ try do
+ create_tables(conn)
+ insert(conn, 1, 3)
+ assert mode(database <> "-wal") == 0o600
+ assert mode(database <> "-shm") == 0o600
+ after
+ Sqlite3.close(conn)
+ end
+ end
+
+ test "an install from before: Kotiko's files are made private, with one warning", ctx do
+ dir = ctx.target
+ File.mkdir_p!(dir)
+ File.chmod!(dir, 0o755)
+ database = file!(DataDir.database(dir), 0o644, "words")
+ wal = file!(database <> "-wal", 0o644)
+ shm = file!(database <> "-shm", 0o664)
+ token = file!(Path.join(dir, "api-token"), 0o600, "token")
+ cache = file!(Path.join(dir, "models-cache.json"), 0o644, "{}")
+ # A folder moved in place keeps the old name's database next to the new one.
+ legacy = file!(Path.join(dir, "slovo.db"), 0o644, "words")
+ note = file!(Path.join(dir, "MOVED-TO-KOTIKO.txt"), 0o644, "moved")
+ backup = file!(Path.join(dir, "backups/kotiko-pre-0.4.0-20261001T000000Z.db"), 0o644)
+ File.chmod!(Path.join(dir, "backups"), 0o755)
+
+ log = capture_log(fn -> assert :ok = DataDir.make_private(dir) end)
+
+ assert log =~
+ "Other users of this computer could open Kotiko's files in #{dir} " <>
+ "(kotiko.db, kotiko.db-wal, kotiko.db-shm, models-cache.json, slovo.db, " <>
+ "backups/kotiko-pre-0.4.0-20261001T000000Z.db, backups, the folder " <>
+ "itself). Made them private: only your account can open them now."
+
+ for path <- [database, wal, shm, token, cache, legacy, backup],
+ do: assert(mode(path) == 0o600, path)
+
+ assert mode(Path.join(dir, "backups")) == 0o700
+ assert mode(dir) == 0o700
+ # The note says where the words went; nothing in it is private.
+ assert mode(note) == 0o644
+ assert File.read!(database) == "words"
+
+ # Once private, nothing more to say.
+ assert capture_log(fn -> DataDir.make_private(dir) end) == ""
+ end
+
+ test "a folder holding other files too is left as it is, with how to fix it", ctx do
+ dir = Path.join(ctx.root, "shared")
+ File.mkdir_p!(dir)
+ File.chmod!(dir, 0o755)
+ database = file!(DataDir.database(dir), 0o644)
+ theirs = file!(Path.join(dir, "notes.txt"), 0o644)
+
+ log = capture_log(fn -> DataDir.make_private(dir) end)
+
+ assert mode(database) == 0o600
+ assert mode(dir) == 0o755
+ assert mode(theirs) == 0o644
+ assert log =~ "could open Kotiko's files in #{dir} (kotiko.db)."
+
+ assert log =~
+ "Other users of this computer can open the data folder #{dir}. Kotiko's own " <>
+ "files in it are private, but the folder holds other files too (or is a " <>
+ "link), so Kotiko leaves it as it is. To make it private: chmod 700 #{dir}"
+ end
+
+ test "a data folder that is a link is left as it is, with a warning", ctx do
+ real = Path.join(ctx.root, "real")
+ File.mkdir_p!(real)
+ File.chmod!(real, 0o755)
+ link = Path.join(ctx.root, "link")
+ File.ln_s!(real, link)
+
+ log = capture_log(fn -> DataDir.make_private(link) end)
+
+ assert mode(DataDir.database(real)) == 0o600
+ assert mode(real) == 0o755
+ assert log =~ "can open the data folder #{link}."
+ end
+
+ test "a private folder that isn't only Kotiko's is fine as it is", ctx do
+ dir = Path.join(ctx.root, "mine")
+ File.mkdir_p!(dir)
+ File.chmod!(dir, 0o700)
+ file!(Path.join(dir, "notes.txt"), 0o644)
+
+ assert capture_log(fn -> DataDir.make_private(dir) end) == ""
+ assert mode(DataDir.database(dir)) == 0o600
+ end
+
+ test "a file it can't change gets a warning with the command that fixes it", ctx do
+ # A folder without the x bit: its files can't be reached until it's fixed.
+ dir = Path.join(ctx.root, "odd")
+ file!(DataDir.database(dir), 0o644)
+ File.chmod!(dir, 0o644)
+ on_exit(fn -> File.chmod(dir, 0o700) end)
+
+ log = capture_log(fn -> DataDir.make_private(dir) end)
+
+ assert log =~
+ "Couldn't make #{dir}/kotiko.db private (permission denied), so other " <>
+ "users of this computer may be able to read it. To fix it: chmod 600 " <>
+ "#{dir}/kotiko.db"
+
+ # The folder holds only Kotiko's files, so it was fixed.
+ assert mode(dir) == 0o700
+ end
+ end
end
diff --git a/server/test/kotiko/llm/catalog_test.exs b/server/test/kotiko/llm/catalog_test.exs
index 751643f..5f4077c 100644
--- a/server/test/kotiko/llm/catalog_test.exs
+++ b/server/test/kotiko/llm/catalog_test.exs
@@ -128,6 +128,7 @@ defmodule Kotiko.LLM.CatalogTest do
LLMStub.stub(fn _, conn -> LLMStub.answer(conn, %{}) end, models: @models)
:ok = Catalog.refresh()
assert File.exists?(cache_file())
+ assert Bitwise.band(File.stat!(cache_file()).mode, 0o777) == 0o600
restart()
assert %{source: "cache"} = Catalog.status()
diff --git a/server/test/kotiko/private_test.exs b/server/test/kotiko/private_test.exs
new file mode 100644
index 0000000..13b2b59
--- /dev/null
+++ b/server/test/kotiko/private_test.exs
@@ -0,0 +1,157 @@
+# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors
+# SPDX-License-Identifier: Apache-2.0
+
+defmodule Kotiko.PrivateTest do
+ # Private folders and files (SCR-450). The test process has whatever umask the shell
+ # gave it (often 022), so a file that comes out 0600 was made private by the code.
+ use ExUnit.Case, async: true
+ alias Exqlite.Sqlite3
+ alias Kotiko.Private
+
+ setup do
+ root = Path.join(System.tmp_dir!(), "kotiko-private-#{System.unique_integer([:positive])}")
+ File.mkdir_p!(root)
+ on_exit(fn -> File.rm_rf(root) end)
+ %{root: root}
+ end
+
+ defp mode(path), do: File.lstat!(path).mode |> Bitwise.band(0o777)
+
+ test "shared?/1 is any group or other bit" do
+ refute Private.shared?(0o100600)
+ refute Private.shared?(0o40700)
+ assert Private.shared?(0o100640)
+ assert Private.shared?(0o100604)
+ assert Private.shared?(0o40701)
+ end
+
+ describe "mkdir_p/1" do
+ test "a folder it makes is 0700", %{root: root} do
+ dir = Path.join(root, "a/b")
+ assert :ok = Private.mkdir_p(dir)
+ assert mode(dir) == 0o700
+ end
+
+ test "a folder already there is left as it is", %{root: root} do
+ dir = Path.join(root, "shared")
+ File.mkdir_p!(dir)
+ File.chmod!(dir, 0o755)
+ assert :ok = Private.mkdir_p(dir)
+ assert mode(dir) == 0o755
+ end
+
+ test "a file in the way is an error", %{root: root} do
+ path = Path.join(root, "file")
+ File.write!(path, "")
+ assert {:error, _} = Private.mkdir_p(path)
+ end
+ end
+
+ describe "create/1" do
+ test "makes an empty 0600 file, and leaves one that's there alone", %{root: root} do
+ path = Path.join(root, "kotiko.db")
+ assert :ok = Private.create(path)
+ assert mode(path) == 0o600
+ assert File.read!(path) == ""
+
+ File.write!(path, "words")
+ File.chmod!(path, 0o644)
+ assert :ok = Private.create(path)
+ assert mode(path) == 0o644
+ assert File.read!(path) == "words"
+ end
+
+ test "returns the error when the folder isn't there", %{root: root} do
+ assert {:error, :enoent} = Private.create(Path.join(root, "missing/kotiko.db"))
+ end
+ end
+
+ test "write/3 saves through a private file and creates a private folder", %{root: root} do
+ path = Path.join(root, "new-folder/models-cache.json")
+ assert :ok = Private.write(path, "{}", ".tmp")
+ assert File.read!(path) == "{}"
+ assert mode(path) == 0o600
+ assert mode(Path.dirname(path)) == 0o700
+ refute File.exists?(path <> ".tmp")
+
+ File.chmod!(path, 0o644)
+ assert :ok = Private.write(path, "[]", ".tmp")
+ assert File.read!(path) == "[]"
+ assert mode(path) == 0o600
+ end
+
+ describe "restrict/1" do
+ test "tightens a file or folder others can open, and says so", %{root: root} do
+ file = Path.join(root, "kotiko.db")
+ File.write!(file, "")
+ File.chmod!(file, 0o644)
+ assert :changed = Private.restrict(file)
+ assert mode(file) == 0o600
+ assert :ok = Private.restrict(file)
+
+ dir = Path.join(root, "backups")
+ File.mkdir_p!(dir)
+ File.chmod!(dir, 0o755)
+ assert :changed = Private.restrict(dir)
+ assert mode(dir) == 0o700
+ end
+
+ test "leaves a private file, a missing one and a link alone", %{root: root} do
+ file = Path.join(root, "api-token")
+ File.write!(file, "")
+ File.chmod!(file, 0o400)
+ assert :ok = Private.restrict(file)
+ assert mode(file) == 0o400
+
+ assert :ok = Private.restrict(Path.join(root, "missing"))
+
+ elsewhere = Path.join(root, "not-kotikos")
+ File.write!(elsewhere, "")
+ File.chmod!(elsewhere, 0o644)
+ link = Path.join(root, "models-cache.json")
+ File.ln_s!(elsewhere, link)
+ assert :ok = Private.restrict(link)
+ assert mode(elsewhere) == 0o644
+ end
+ end
+
+ # What make_private/1 relies on: SQLite gives the files it makes next to a database
+ # that database's mode, and VACUUM INTO an empty file keeps that file's mode.
+ describe "SQLite and a database made private before it opens" do
+ test "the -wal and -shm files are 0600 too", %{root: root} do
+ db = Path.join(root, "kotiko.db")
+ :ok = Private.create(db)
+ {:ok, conn} = Sqlite3.open(db)
+
+ try do
+ :ok = Sqlite3.execute(conn, "PRAGMA journal_mode = WAL")
+ :ok = Sqlite3.execute(conn, "CREATE TABLE words (native TEXT)")
+ :ok = Sqlite3.execute(conn, "INSERT INTO words VALUES ('кот')")
+
+ assert mode(db) == 0o600
+ assert mode(db <> "-wal") == 0o600
+ assert mode(db <> "-shm") == 0o600
+ after
+ Sqlite3.close(conn)
+ end
+ end
+
+ test "VACUUM INTO an empty private file keeps it private", %{root: root} do
+ db = Path.join(root, "kotiko.db")
+ copy = Path.join(root, "copy.db")
+ {:ok, conn} = Sqlite3.open(db)
+
+ try do
+ :ok = Sqlite3.execute(conn, "CREATE TABLE words (native TEXT)")
+ :ok = Sqlite3.execute(conn, "INSERT INTO words VALUES ('кот')")
+ :ok = Private.create(copy)
+ :ok = Sqlite3.execute(conn, "VACUUM INTO '#{copy}'")
+ after
+ Sqlite3.close(conn)
+ end
+
+ assert mode(copy) == 0o600
+ assert File.stat!(copy).size > 0
+ end
+ end
+end
diff --git a/server/test/kotiko/scripts_test.exs b/server/test/kotiko/scripts_test.exs
index 091506c..03802f3 100644
--- a/server/test/kotiko/scripts_test.exs
+++ b/server/test/kotiko/scripts_test.exs
@@ -90,6 +90,20 @@ defmodule Kotiko.ScriptsTest do
assert calls(ctx) == ["mix prod deps.loadpaths --no-compile", "mix prod run --no-halt"]
end
+ test "starts the server with umask 077, so what it writes is private", ctx do
+ stub(ctx, "mix", ~s[echo "umask $(umask)" >> "#{ctx.calls}"])
+ write_env(ctx, "", 0o600)
+
+ # A loose umask in the shell it's started from doesn't matter.
+ {_, 0} =
+ System.cmd("bash", ["-c", "umask 022 && exec bash run.sh"],
+ cd: ctx.dir,
+ env: [{"PATH", "#{ctx.bin}:/usr/bin:/bin"}, {"HOME", ctx.home}, {"MIX_ENV", nil}]
+ )
+
+ assert "umask 0077" in calls(ctx)
+ end
+
test "fetches dependencies when they are missing or out of date", ctx do
stub(ctx, "mix", ~s(if [ "$1" = deps.loadpaths ]; then exit 1; fi))
write_env(ctx, "", 0o600)
@@ -162,6 +176,8 @@ defmodule Kotiko.ScriptsTest do
assert text =~ "\nRestartPreventExitStatus=78\n"
assert text =~ "\nStartLimitBurst=5\n"
+ # Files the service makes are private from the moment they exist.
+ assert text =~ "\nUMask=0077\n"
calls = calls(ctx)
assert "mix prod compile" in calls
diff --git a/server/test/kotiko/token_test.exs b/server/test/kotiko/token_test.exs
index aa4ec02..b314ce9 100644
--- a/server/test/kotiko/token_test.exs
+++ b/server/test/kotiko/token_test.exs
@@ -57,6 +57,13 @@ defmodule Kotiko.TokenTest do
assert {:error, "The token in " <> _} = Token.resolve(nil, dir)
end
+ test "a data folder it has to make is private too", %{tmp_dir: dir} do
+ data = Path.join(dir, "new-data")
+ assert {:ok, _token, {:generated, _}} = Token.resolve(nil, data)
+ assert Bitwise.band(File.stat!(data).mode, 0o777) == 0o700
+ assert Bitwise.band(File.stat!(Token.path(data)).mode, 0o777) == 0o600
+ end
+
test "generate replaces the saved token", %{tmp_dir: dir} do
{:ok, old, _} = Token.resolve(nil, dir)
assert {:ok, new, {:generated, _}} = Token.generate(Token.path(dir))
diff --git a/site/src/content/docs/providers/openrouter.md b/site/src/content/docs/providers/openrouter.md
index e75ef24..0ea0a7c 100644
--- a/site/src/content/docs/providers/openrouter.md
+++ b/site/src/content/docs/providers/openrouter.md
@@ -6,8 +6,28 @@ description: Get a free OpenRouter key and use it to look words up in Kotiko.
OpenRouter gives one key for many AI models, several of them free. It's Kotiko's default
and the easiest way to start: Kotiko uses only free models unless you choose another.
+## Connect OpenRouter
+
+The quickest way: Kotiko asks OpenRouter for a key for you, with nothing to copy.
+
+1. On Kotiko's welcome page, select **Connect OpenRouter (free)**. Or open Kotiko's
+ **Settings**, then **Word lookups**, choose **OpenRouter** and select **Connect
+ OpenRouter**.
+2. OpenRouter opens in a new tab. Sign in or create an account, and allow Kotiko's
+ request.
+3. OpenRouter sends you back to [kotiko.org/connect/](/connect/), which says "Connected"
+ once Kotiko has the key. You can close that tab.
+
+The page on kotiko.org has no scripts. Kotiko reads the one-time code in its address and
+trades it with OpenRouter for your key; the code works only for the Kotiko that started
+the sign-in, for 10 minutes. If the page says the sign-in expired, select **Connect
+OpenRouter** again.
+
## Get a key
+Or make a key yourself and paste it:
+
+
1. Open [OpenRouter's keys page](https://openrouter.ai/settings/keys) and sign in or create an
account.
2. Select **Create API Key**. Give it a name you'll recognise, like "Kotiko".
@@ -45,6 +65,3 @@ are run by companies that may keep or train on it. **Only use services that don'
text** in **Word lookups** asks OpenRouter for those that don't (fewer free models may be
available). See [OpenRouter's privacy policy](https://openrouter.ai/privacy) and
[Kotiko's privacy policy](/privacy/).
-
-A **Connect OpenRouter** button that signs you in without copying a key is coming; it
-returns to [kotiko.org/connect/](/connect/).
diff --git a/site/src/content/docs/server/index.md b/site/src/content/docs/server/index.md
index f05c7ef..7ab4446 100644
--- a/site/src/content/docs/server/index.md
+++ b/site/src/content/docs/server/index.md
@@ -48,6 +48,11 @@ prints it. `mix kotiko.token` (in `server/`) prints it again, with a pairing str
your own, set `API_TOKEN` in `.env` (at least 24 characters, for example from
`openssl rand -hex 24`).
+Only your account can read what the server keeps in the data folder: your words, their
+backups and the access key. If something there is readable by others, the server makes it
+private at start, or tells you the command that does. See
+[files in the data folder](/server/configuration/#files-in-the-data-folder).
+
Your keys don't have to be in `.env`: each one can live in its own file, with
`LLM_API_KEY_FILE=/path/to/file` instead of `LLM_API_KEY=...` (and the same for the other
keys). Replacing a key is then replacing its file and restarting. See
diff --git a/site/src/content/docs/start.md b/site/src/content/docs/start.md
index e55d999..02fbc8d 100644
--- a/site/src/content/docs/start.md
+++ b/site/src/content/docs/start.md
@@ -31,9 +31,13 @@ even with a key set up.
An AI looks words up for you, so you can ask in your own words: "how do you say cat in
Japanese", "hola", "dog in Arabic". OpenRouter gives free access to several AI models.
-1. Select **Get a free key**. It opens OpenRouter's keys page.
-2. Sign in to OpenRouter and create a key. Copy it.
-3. Back in Kotiko, select **Paste an OpenRouter key (free)** and paste it.
+1. Select **Connect OpenRouter (free)**. OpenRouter opens in a new tab.
+2. Sign in to OpenRouter, or create an account, and allow Kotiko's request.
+3. OpenRouter sends you back to a Kotiko page that says "Connected". Close it; the welcome
+ page now says "Connected to OpenRouter, free models."
+
+Already have a key? Select **Paste a key instead** and paste it. **Get a free key** opens
+OpenRouter's keys page if you'd rather make one yourself.
[Step by step, with what the free tier allows](/providers/openrouter/).
diff --git a/slices/11-local-first-mode/SPEC.md b/slices/11-local-first-mode/SPEC.md
index 7e2be5d..7631f27 100644
--- a/slices/11-local-first-mode/SPEC.md
+++ b/slices/11-local-first-mode/SPEC.md
@@ -600,9 +600,8 @@ server's fake model.*
language from the hint, else a recent language whose script fits, else the script's usual
language (Han is `zh`); Latin-script words with neither go to the lookup. The picker is
24's. Words saved without a pronunciation nudge the refresh.
-- **Connect OpenRouter** stays a hook: the docs site's callback page (44) doesn't exist, so
- the button isn't shown; `oauth.start`, `oauth.code` and `lib/pkce.js` are built and
- tested with stubs.
+- **Connect OpenRouter** was a hook until the docs site's callback page (44) went live; it
+ is shown since 2026-10-06 (below).
- **Anthropic** uses the compatibility layer with `jsonMode: "none"` and a manual model
(`claude-haiku-4-5`), marked beta (open question 2). Model preference lists for OpenAI,
Gemini and Groq are a first guess; the learner can type any model id.
@@ -618,7 +617,7 @@ server's fake model.*
moves (12); the welcome tab's setup (22); `needs_choice`, `addManual`, relang, the hint
chip, drafts and server adds as jobs (24); `storage_full` and `vocabulary_full` wording
review (25); the Firefox permission check for provider origins and the CSP for LAN `http://`
-servers (28); the docs-site callback (44); the Firefox "delete site data on close" release
+servers (28); the Firefox "delete site data on close" release
check (§2 contingency, manual).
**Tests.** `test/unit/llm-client.test.mjs` (every preset's URL, headers and JSON mode; 401,
@@ -635,6 +634,40 @@ 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, 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`
+and `http://` redirect to it. The button had no flag to turn on: it was absent, with an
+HTML comment where it would go in `welcome.html` and `dashboard.html`. Now:
+
+- **Welcome tab** (22 §4): "Connect OpenRouter (free)" is the primary button and "Paste a
+ key instead" joins the row of links, as 22's sketch has it (`welcome_ai_paste_primary`
+ is gone). It sends `oauth.start` and says "Waiting for OpenRouter… Finish signing in on
+ the other tab." under the button, with every other way still open. When `lookup` and
+ `keys` change and OpenRouter is ready, the step collapses to "Connected to OpenRouter,
+ free models." with no Test lookup: OpenRouter has just issued the key, and a Test would
+ spend one of the 50 free lookups a day. Selecting it again starts a new sign-in (a new
+ verifier replaces the pending one).
+- **Dashboard, Word lookups**: with OpenRouter chosen and no key saved, or while replacing
+ it, "Connect OpenRouter" and one line of help sit above the key field. The section's
+ status line says it's waiting; when `keys` changes the page reads the masked secrets
+ again, shows "Saved key: sk-or-…" and a "Connected to OpenRouter." notice. Choosing
+ another service or saving a pasted key stops the wait.
+- OpenRouter's PKCE page, read again on 2026-10-06, matches `lib/pkce.js`: `/auth` with
+ `callback_url`, `code_challenge`, `code_challenge_method=S256` and `key_label`; `POST
+ /api/v1/auth/keys` with `code`, `code_verifier` and `code_challenge_method`, answering
+ `key`; codes are single-use and last 10 minutes. The optional `state` isn't sent: the
+ code is useless without the verifier, which never leaves the background.
+- Tests: the welcome and dashboard DOM tests cover the visible buttons, waiting, the key
+ arriving, a failed exchange and a sign-in that can't start; `test/e2e/connect.spec.mjs`
+ now starts from the real buttons on both pages and checks the sign-in tab's address
+ (the callback, an S256 challenge, `key_label`), the return page under the live page's
+ CSP, the exchange's body and both pages updating.
+- Still to confirm with a real OpenRouter account (maintainer, before launch): that
+ OpenRouter accepts the `https://kotiko.org/connect/` callback (its docs promise
+ localhost and https callbacks; this one hasn't been tried) and returns there with a
+ code that the exchange turns into a working key.
+
## Open questions
1. **Train-on-prompts default.** Many free OpenRouter models route to providers that may
diff --git a/slices/22-first-run-onboarding/SPEC.md b/slices/22-first-run-onboarding/SPEC.md
index baffd72..d406b33 100644
--- a/slices/22-first-run-onboarding/SPEC.md
+++ b/slices/22-first-run-onboarding/SPEC.md
@@ -837,9 +837,10 @@ lookup in the tests goes to the fixture server's fake model.*
`toLocal` (11) now keeps `ui.baseLangs` in step with the local list. Not built: the
dashboard's "Languages you read in", 50's upgrade rule beyond 11's, and per-base data
other than `en` and `es`.
-- **Connect your AI (§4).** "Connect OpenRouter (free)" is absent, as 11 decided, until the
- docs site serves the PKCE callback (44); **Paste an OpenRouter key (free)** is the primary
- button. The key field is `type="password"`, `autocomplete="off"`, `spellcheck="false"`,
+- **Connect your AI (§4).** "Connect OpenRouter (free)" was absent, as 11 decided, until the
+ docs site served the PKCE callback (44); since 2026-10-06 it is the primary button and
+ "Paste a key instead" is the first link (11's Implementation notes). Before that,
+ **Paste an OpenRouter key (free)** was the primary button. The key field is `type="password"`, `autocomplete="off"`, `spellcheck="false"`,
with Show; pasting (or Enter, or leaving the field) saves through `secrets.set`, makes
OpenRouter the provider (`backend.set`) and runs 11's one-request check (`backend.test`);
the field is cleared and shows "Saved key: sk-or-…a1b2". "Another service or my own
@@ -949,9 +950,9 @@ lookup in the tests goes to the fixture server's fake model.*
| Try “hello”, then ask (connected) | 4 | +1 |
| Bases detected wrong | +1 per chip changed | |
| Several results | +1 when choosing one other than the first | |
-| Connect OpenRouter (one click) | waits for 44's callback page | |
+| Connect OpenRouter (one click) | 4 (Connect OpenRouter, type, Enter, Make it), plus OpenRouter's own sign-in | +1 |
-**Waiting for other slices.** The one-click OpenRouter connect (44); 24's `preview: true`
+**Waiting for other slices.** 24's `preview: true`
add job and `needs_choice` (the page uses the stateless preview above); 13's `parse.js`
and bulk add; 14's segmenter matcher, without which a Japanese or Chinese base has no
working preview (the legacy matcher needs word boundaries); about 1,000 sentences per Full
diff --git a/slices/29-server-ops-hardening/SPEC.md b/slices/29-server-ops-hardening/SPEC.md
index ed9a820..5389280 100644
--- a/slices/29-server-ops-hardening/SPEC.md
+++ b/slices/29-server-ops-hardening/SPEC.md
@@ -150,6 +150,7 @@ is table-tested without touching the environment.
```bash
#!/usr/bin/env bash
set -euo pipefail
+umask 077 # what the server writes is private (SCR-450)
cd "$(dirname "$0")"
[ -f .env ] || { echo "No .env yet. Run: cp .env.example .env && chmod 600 .env, then fill it in." >&2; exit 78; }
if [ -n "$(find .env -perm /077)" ]; then
@@ -188,13 +189,18 @@ CONTRIBUTING and `.env.example` say to single-quote values containing `$`, space
WorkingDirectory=
ExecStart="/run.sh"
Environment="PATH="
+ UMask=0077
Restart=on-failure
RestartSec=5
RestartPreventExitStatus=78
```
(`WorkingDirectory=` takes the rest of the line as one path, so spaces need no quotes
- there, only `%` escaping.)
+ there, only `%` escaping. `UMask=0077`, like `umask 077` in `run.sh`, keeps every file
+ the server makes private from the moment it exists; the server also makes its own files
+ 0600 itself, see `Kotiko.DataDir.make_private/1` and the
+ [configuration reference](../../docs/reference/configuration.md#files-in-the-data-folder).
+ Added for Linear SCR-450.)
- After installing, wait up to 20 s for `GET /health` to return 200 and print the result;
on failure print the last 20 journal lines and the exit status.
- Slice 04 adds the old-unit cleanup; slice 40 adds release support.
diff --git a/slices/44-docs-site/SPEC.md b/slices/44-docs-site/SPEC.md
index 7067614..7252dfd 100644
--- a/slices/44-docs-site/SPEC.md
+++ b/slices/44-docs-site/SPEC.md
@@ -363,8 +363,8 @@ slice 11 §4 says Kotiko's content script reads the code, and none did. This sli
`https://kotiko.org/connect/*` (no new permission; a justification row in
`store/chrome-web-store.md`), with four strings (`connect_*`). It sends `{type:
"oauth.code", code}`, writes the outcome into `#kotiko-connect-status`, and then removes the
-code from the address. The "Connect OpenRouter" button stays hidden: showing it is slice
-11's call once the site is live.
+code from the address. The "Connect OpenRouter" button stayed hidden until the site was
+live; slice 11 shows it since 2026-10-06.
**Privacy of the site.** No cookies, analytics, web fonts or embeds. Every Starlight page
carries a CSP ` ` allowing only the site itself (`'unsafe-inline'` for Starlight's
diff --git a/store/assets.md b/store/assets.md
index 80fa7f3..62ae2b0 100644
--- a/store/assets.md
+++ b/store/assets.md
@@ -10,7 +10,7 @@ The full artwork list (Edge, social previews, favicons) is in
| Asset | Chrome Web Store | Firefox Add-ons | Exists now | Final from |
|---|---|---|---|---|
| Icon | 128x128 PNG: 96x96 artwork with 16 px of transparent padding per side | From the package (`icons` in the manifest); shown at 32 and 64 px | `brand/logo/store-icon-128.png` (placeholder kitten, correct padding) and `extension/icon128.png` | The artist (slice 05) |
-| Screenshots | 1280x800 (or 640x400), PNG, square corners, full bleed; at least 1, up to 5 | Same files (1280x800, 1.6:1); no fixed limit | Scripted, below | This script, after the artwork lands |
+| Screenshots | 1280x800 (or 640x400), PNG, square corners, full bleed; at least 1, up to 5 | Same files (1280x800, 1.6:1); no fixed limit | **Done (2026-10-06):** `brand/store/screenshots/en/01-page.png` … `05-dark.png`, 1280x800 24-bit PNGs from the script below, each looked at | This script again after the artwork lands |
| Small promo tile | 440x280, **required** | n/a | No | **Done (2026-10-06):** `brand/store/promo-small.png`, exported from `brand/illustrations/store-promo-tile.png` (the kitten on the moon with "Kotiko"; checked legible at half size) |
| Marquee promo tile | 1400x560, optional | n/a | No | **Done (2026-10-06):** `brand/store/promo-marquee.png`, exported from `brand/illustrations/marquee.png` (every word checked for spelling) |
@@ -27,7 +27,11 @@ files to `//` (bare captures in `raw/`). The captions are the `s
entries of `store/listing/.json`, set in the design system's display face on the
canvas color; the script stops if the caption's contrast is under 4.5:1 (it is 15.7:1 with
today's tokens). The page is `test/fixtures/pages/store-article.html`, a text written for
-it, so no third-party content or logo appears and no attribution is needed.
+it, so no third-party content or logo appears and no attribution is needed; the script
+serves it at `https://www.example.com/articles/…` (a reserved example address, answered by
+the browser's route, never the internet) so the popup's page line names a website. The run
+takes about a minute: the dashboard shot waits for the background to finish the
+pronunciation of the word the popup shot adds.
| # | id | Shows | Caption (en) |
|---|---|---|---|
@@ -37,9 +41,11 @@ it, so no third-party content or logo appears and no attribution is needed.
| 4 | `welcome` | The welcome page, asking for the next word | Start with the word you want most |
| 5 | `dark` | The same article in dark mode with a card open | Easy on the eyes, day and night |
-Before uploading, look at each file: the popup shot is taken in a tab, so its page line
-reads "Kotiko can't run on browser pages like this one", and the article shots use
-placeholder words chosen for an English reader. A listing in another language gets its own
+Before uploading, look at each file. The popup is opened in a tab of its own, so the script
+first opens the article and answers the popup's "which tab is active" question with that
+real tab: "This page" then describes the article (it stops if the popup says it's on a
+browser page). The welcome shot shows OpenRouter connected, as most learners will see it.
+The article shots use placeholder words chosen for an English reader. A listing in another language gets its own
words and article in the script when its translation lands, and the browser in that
locale.
diff --git a/test/dom/dashboard.test.mjs b/test/dom/dashboard.test.mjs
index 2985685..c81d5b9 100644
--- a/test/dom/dashboard.test.mjs
+++ b/test/dom/dashboard.test.mjs
@@ -1115,6 +1115,9 @@ describe("Word lookups and the words' home (slice 11)", () => {
case "backend.test":
b.sent.push(clone(msg));
return { ok: true, model: "fake/model-a:free", ms: 1234, quota: { remaining: 41, limit: 50 } };
+ case "oauth.start":
+ b.sent.push(clone(msg));
+ return st.oauthError ? { error: "Couldn't reach OpenRouter.", code: "offline" } : { ok: true, url: "https://openrouter.ai/auth?callback_url=https%3A%2F%2Fkotiko.org%2Fconnect%2F" };
case "migrate.preview":
b.sent.push(clone(msg));
return { to: msg.to, count: 23, server: st.server.url };
@@ -1149,6 +1152,9 @@ describe("Word lookups and the words' home (slice 11)", () => {
assert.equal(d.text("#providerNote"), "Free models by default. A key with a credit limit is the safest choice.");
assert.equal(d.text("#lookupState"), "Paste a key to start looking words up.");
assert.equal(d.$("#dataCollectionRow").hidden, false);
+ assert.equal(d.$("#oauthRow").hidden, false, "Connect OpenRouter is shown now that kotiko.org/connect/ is live");
+ assert.equal(d.text("#connectOpenRouter"), "Connect OpenRouter");
+ assert.equal(d.text("#oauthHelp"), "Sign in to OpenRouter in a new tab and Kotiko gets a key, with nothing to copy. Or paste a key below.");
d.$("#lookupKey").value = "sk-or-v1-abcdefghijklmnopqrstuvwxyz-a1b2";
d.$("#saveKey").click();
@@ -1157,6 +1163,7 @@ describe("Word lookups and the words' home (slice 11)", () => {
assert.equal(d.$("#lookupKey").value, "", "the page keeps nothing");
assert.equal(d.text("#lookupKeyMasked"), "Saved key: sk-or-…a1b2");
assert.equal(d.$("#lookupKeyEntry").hidden, true);
+ assert.equal(d.$("#oauthRow").hidden, true, "a saved key hides Connect OpenRouter");
assert.equal(d.text("#lookupState"), "Ready to look words up.");
assert.equal(d.$("#banners").children.length, 0, "the banner goes once lookups work");
assert.equal(JSON.stringify(d.store).includes("abcdefghijklmnop"), false, "never in storage");
@@ -1167,12 +1174,53 @@ describe("Word lookups and the words' home (slice 11)", () => {
d.$("#replaceKey").click();
assert.equal(d.$("#lookupKeyEntry").hidden, false);
+ assert.equal(d.$("#oauthRow").hidden, false, "replacing the key offers Connect OpenRouter again");
d.$("#removeKey").click();
await d.settle();
assert.deepEqual(backend.sent.find((m) => m.type === "secrets.remove"), { type: "secrets.remove", id: "provider:openrouter" });
assert.equal(d.text("#lookupState"), "Paste a key to start looking words up.");
});
+ test("Connect OpenRouter: the sign-in opens in a new tab, the section waits, and the key arriving from the return page shows masked", async () => {
+ const backend = withSettings(fakeBackend(), { ...LOCAL, lookup: { ...LOCAL.lookup, kind: "provider" } });
+ const d = await openDashboard({ local: LOCAL, hash: "#settings/lookups", backend });
+ assert.equal(d.$("#oauthRow").hidden, false);
+ d.$("#connectOpenRouter").click();
+ await d.settle();
+ assert.deepEqual(backend.sent.filter((m) => m.type === "oauth.start"), [{ type: "oauth.start" }]);
+ assert.equal(d.text("#lookupState"), "Waiting for OpenRouter… Finish signing in on the other tab.");
+ assert.equal(d.$("#lookupKeyEntry").hidden, false, "pasting a key stays available while waiting");
+
+ // The background traded the return page's code for a key: it saves it and says so in storage.
+ backend.st.masked["provider:openrouter"] = "sk-or-…c3d4";
+ backend.st.keys.providers.openrouter = true;
+ await d.fake.chrome.storage.local.set({ keys: clone(backend.st.keys), lookup: clone(backend.st.lookup) });
+ await d.settle(10);
+ assert.equal(d.text("#lookupKeyMasked"), "Saved key: sk-or-…c3d4");
+ assert.equal(d.$("#oauthRow").hidden, true);
+ assert.equal(d.text("#lookupState"), "Ready to look words up.");
+ assert.match(d.text("#toasts") ?? d.doc.body.textContent, /Connected to OpenRouter\./);
+ });
+
+ test("Connect OpenRouter: a sign-in that can't start says why; choosing another service stops waiting", async () => {
+ const backend = withSettings(fakeBackend(), { ...LOCAL, lookup: { ...LOCAL.lookup, kind: "provider" } });
+ backend.st.oauthError = true;
+ const d = await openDashboard({ local: LOCAL, hash: "#settings/lookups", backend });
+ d.$("#connectOpenRouter").click();
+ await d.settle();
+ assert.equal(d.$("#lookupState .conn-bad") !== null, true);
+ assert.doesNotMatch(d.text("#lookupState"), /Waiting for OpenRouter/);
+
+ backend.st.oauthError = false;
+ d.$("#connectOpenRouter").click();
+ await d.settle();
+ assert.equal(d.text("#lookupState"), "Waiting for OpenRouter… Finish signing in on the other tab.");
+ d.$$("#providerOptions [role=radio]")[1].click();
+ await d.settle();
+ assert.equal(d.$("#oauthRow").hidden, true, "only OpenRouter has the sign-in");
+ assert.doesNotMatch(d.text("#lookupState"), /Waiting for OpenRouter/);
+ });
+
test("Ollama needs no key, shows its one setting and an editable address; the model is optional", async () => {
const backend = withSettings(fakeBackend(), { ...LOCAL, lookup: { ...LOCAL.lookup, kind: "provider", provider: "ollama" } });
const d = await openDashboard({ local: LOCAL, hash: "#settings/lookups", backend });
diff --git a/test/dom/welcome.test.mjs b/test/dom/welcome.test.mjs
index 09a64f1..de5b4b9 100644
--- a/test/dom/welcome.test.mjs
+++ b/test/dom/welcome.test.mjs
@@ -42,7 +42,7 @@ after(async () => {
const plain = (v) => JSON.parse(JSON.stringify(v));
-async function openWelcome({ locale = "en", accept = null, bases = null, local = {}, reduced = false, network = false } = {}) {
+async function openWelcome({ locale = "en", accept = null, bases = null, local = {}, reduced = false, network = false, exchange = null } = {}) {
const b = bases ?? [locale];
const fake = createFakeChrome({
runtimeId: EXT_ID,
@@ -55,6 +55,8 @@ async function openWelcome({ locale = "en", accept = null, bases = null, local =
const requests = [];
const bgFetch = (url, init) => {
requests.push(String(url));
+ // OpenRouter's PKCE key exchange (Connect OpenRouter), answered here when a test asks.
+ if (exchange && String(url) === "https://openrouter.ai/api/v1/auth/keys") return Promise.resolve(exchange(init));
if (!network) return Promise.reject(new TypeError("Failed to fetch"));
return fetch(String(url).replace("https://openrouter.ai/api/v1", srv.llmUrl), init);
};
@@ -162,7 +164,11 @@ describe("arrival (22 §2)", () => {
assert.deepEqual([chip.getAttribute("role"), chip.getAttribute("aria-checked"), chip.textContent.trim()], ["checkbox", "true", "English"]);
assert.equal(p.text("#addBase"), "Another");
assert.ok(p.visible("#aiSetup"));
- assert.equal(p.text("#aiPaste"), "Paste an OpenRouter key (free)");
+ assert.ok(p.visible("#aiConnect"), "Connect OpenRouter is shown now that kotiko.org/connect/ is live");
+ assert.equal(p.text("#aiConnect"), "Connect OpenRouter (free)");
+ assert.ok(p.$("#aiConnect").classList.contains("btn-primary"));
+ assert.equal(p.text("#aiPaste"), "Paste a key instead");
+ assert.equal(p.text("#connectStatus"), "");
assert.equal(p.text("#askLine"), "What's the first word you'd love to learn?");
assert.equal(p.$("#askText").placeholder, "how do you say hello in Japanese");
assert.equal(p.$("#askText").value, "", "the examples are a placeholder only");
@@ -498,6 +504,64 @@ describe("with the learner's own AI (22 §4, §5)", () => {
});
});
+describe("Connect OpenRouter (11 §4, 22 §4)", () => {
+ const CODE = "code-0123456789";
+ const CALLBACK = `https://kotiko.org/connect/?code=${CODE}`;
+ // The return page's content script, as the background sees it.
+ const RETURN_PAGE = { id: EXT_ID, url: CALLBACK, tab: { id: 9, url: CALLBACK } };
+
+ test("opens OpenRouter's sign-in, waits, and collapses to Connected when the return page hands the code over", async () => {
+ const exchanged = [];
+ const p = await openWelcome({
+ exchange: (init) => {
+ exchanged.push(JSON.parse(init.body));
+ return new Response(JSON.stringify({ key: KEY }), { status: 200, headers: { "Content-Type": "application/json" } });
+ },
+ });
+ p.click("#aiConnect");
+ await p.until(() => p.$("#connectStatus.status-busy"));
+ assert.equal(p.text("#connectStatus"), "Waiting for OpenRouter… Finish signing in on the other tab.");
+ assert.equal(p.$("#aiConnect").getAttribute("aria-describedby"), "connectStatus");
+ assert.ok(p.visible("#aiSetup"), "the other ways to connect stay available while waiting");
+ assert.ok(p.visible("#aiPaste"));
+ const pending = JSON.parse(await (await p.bg.__kotiko.getStore()).secrets.get("pkce:pending"));
+ assert.match(pending.verifier, /^[A-Za-z0-9_-]{43}$/);
+ assert.deepEqual(p.requests, [], "starting the sign-in sends nothing; the tab goes to OpenRouter");
+
+ // OpenRouter sends the browser back to kotiko.org/connect/ with a code.
+ const res = await p.fake.deliver({ type: "oauth.code", code: CODE }, RETURN_PAGE);
+ assert.deepEqual(plain(res), { ok: true });
+ assert.deepEqual(exchanged, [{ code: CODE, code_verifier: pending.verifier, code_challenge_method: "S256" }]);
+ await p.until(() => p.visible("#aiConnected"));
+ assert.equal(p.text("#aiConnectedText"), "Connected to OpenRouter, free models.");
+ assert.ok(p.$$("#log p").some((n) => n.textContent === "Connected to OpenRouter, free models."));
+ assert.equal(p.store.lookup.provider, "openrouter");
+ assert.ok(!JSON.stringify([p.store, p.sync]).includes(KEY), "the key is in no storage area");
+ });
+
+ test("a sign-in that fails leaves the step open with every other way to connect", async () => {
+ const p = await openWelcome({ exchange: () => new Response("{}", { status: 400 }) });
+ p.click("#aiConnect");
+ await p.until(() => p.$("#connectStatus.status-busy"));
+ const res = await p.fake.deliver({ type: "oauth.code", code: CODE }, RETURN_PAGE);
+ assert.equal(res.code, "key_rejected");
+ await p.fake.idle();
+ assert.ok(p.visible("#aiSetup"));
+ assert.ok(!p.visible("#aiConnected"));
+ // Selecting it again starts a new sign-in.
+ p.click("#aiConnect");
+ await p.until(async () => (await (await p.bg.__kotiko.getStore()).secrets.get("pkce:pending")) !== null);
+ });
+
+ test("Change goes back to the step with Connect OpenRouter focused", async () => {
+ const q = await openWelcome({ network: true, local: { lookup: { kind: "provider", provider: "custom", baseUrl: srv.llmUrl }, keys: { server: false, providers: { custom: true } }, words: [{ id: "1", lang: "ru", native: "дом", base_lang: "en", gloss: "house", forms: ["house"] }] } });
+ assert.ok(q.visible("#aiConnected"));
+ q.click("#aiChange");
+ assert.ok(q.visible("#aiConnect"));
+ assert.equal(q.doc.activeElement, q.$("#aiConnect"));
+ });
+});
+
describe("skip, reopen, edit (22 §8 to §10)", () => {
test("Skip for now finishes the first run and closes the tab", async () => {
const p = await openWelcome();
diff --git a/test/e2e/connect.spec.mjs b/test/e2e/connect.spec.mjs
index 0bf942f..3d75b21 100644
--- a/test/e2e/connect.spec.mjs
+++ b/test/e2e/connect.spec.mjs
@@ -3,21 +3,28 @@
// "Connect OpenRouter" (slice 11 §4) with the docs site's return page (slice 44): Kotiko's
// content script for https://kotiko.org/connect/ hands `?code=` to the background, which
-// trades it for a key, and the page's status line says how it went. kotiko.org is served
-// here by Playwright (a stand-in with the real page's status line); OpenRouter's key
-// exchange is stubbed inside the service worker, so nothing leaves the machine.
+// trades it for a key, and the page's status line says how it went. The sign-in starts from
+// the buttons learners see: the dashboard's Word lookups and the welcome tab. kotiko.org and
+// OpenRouter's sign-in page are served here by Playwright (stand-ins; the return page keeps
+// the real one's status line and CSP); OpenRouter's key exchange is stubbed inside the
+// service worker, so nothing leaves the machine.
import { test, expect } from "./fixtures.mjs";
const CALLBACK = "https://kotiko.org/connect/";
-const PAGE = `Connecting
+// The live page's own CSP (no script at all) is kept: Kotiko's content script runs in its
+// own world and isn't bound by it.
+const PAGE = `Connecting
Connecting Kotiko to OpenRouter
If Kotiko is installed in this browser, it is finishing your sign-in now.
`;
-// Serves the stand-in for kotiko.org and example.org; those requests are expected, so
-// they're taken out of the "left localhost" list at the end.
+const SIGN_IN = `OpenRouter Sign in `;
+
+// Serves the stand-in for kotiko.org and example.org, and for OpenRouter's sign-in page;
+// those requests are expected, so they're taken out of the "left localhost" list at the end.
async function serveSite(context, blocked) {
await context.route(/^https:\/\/(kotiko|example)\.org\//, (route) => route.fulfill({ status: 200, contentType: "text/html", body: PAGE }));
+ await context.route(/^https:\/\/openrouter\.ai\/auth\?/, (route) => route.fulfill({ status: 200, contentType: "text/html", body: SIGN_IN }));
return () => {
for (let i = blocked.length - 1; i >= 0; i--) if (/^https:\/\/(kotiko\.org|example\.org|openrouter\.ai\/auth)/.test(blocked[i])) blocked.splice(i, 1);
};
@@ -25,10 +32,9 @@ async function serveSite(context, blocked) {
const status = (page) => page.locator("#kotiko-connect-status");
-test("the return page hands the code to Kotiko, which saves the key and says so", async ({ context, serviceWorker, extensionId, blocked }) => {
- const done = await serveSite(context, blocked);
- // OpenRouter's key exchange, answered inside the service worker; it records what it got.
- // The worker's own requests can't be routed by Playwright, so its fetch is replaced.
+// OpenRouter's key exchange, answered inside the service worker; it records what it got.
+// The worker's own requests can't be routed by Playwright, so its fetch is replaced.
+async function stubExchange(serviceWorker) {
await serviceWorker.evaluate(() => {
const real = globalThis.fetch;
globalThis.__exchanges = [];
@@ -45,16 +51,40 @@ test("the return page hands the code to Kotiko, which saves the key and says so"
return real(url, init);
};
});
+}
+
+// Selects a "Connect OpenRouter" button and returns OpenRouter's sign-in tab it opened.
+async function signInTab(context, button) {
+ const isSignIn = (p) => p.url().startsWith("https://openrouter.ai/auth?");
+ await button.click();
+ // The tab opens on about:blank and then loads OpenRouter's address.
+ await expect.poll(() => context.pages().some(isSignIn)).toBe(true);
+ const tab = context.pages().find(isSignIn);
+ const auth = new URL(tab.url());
+ expect(auth.searchParams.get("callback_url")).toBe(CALLBACK);
+ expect(auth.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(auth.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(auth.searchParams.get("key_label")).toBe("Kotiko");
+ return tab;
+}
+
+test("the return page hands the code to Kotiko, which saves the key and says so", async ({ context, serviceWorker, extensionId, blocked }) => {
+ const done = await serveSite(context, blocked);
+ await stubExchange(serviceWorker);
- // "Connect OpenRouter" from an extension page: the background keeps a verifier and opens
- // OpenRouter's sign-in, whose callback is the docs site's page.
+ // The settings' "Connect OpenRouter" button: the background keeps a verifier and opens
+ // OpenRouter's sign-in in a new tab, whose callback is the docs site's page.
const ext = await context.newPage();
- await ext.goto(`chrome-extension://${extensionId}/privacy.html`);
- const start = await ext.evaluate(() => chrome.runtime.sendMessage({ type: "oauth.start" }));
- expect(new URL(start.url).searchParams.get("callback_url")).toBe(CALLBACK);
+ await ext.goto(`chrome-extension://${extensionId}/dashboard.html#settings/lookups`);
+ const connect = ext.locator("#connectOpenRouter");
+ if (!(await connect.isVisible())) await ext.locator("#providerOptions [role=radio]").first().click();
+ await expect(connect).toBeVisible();
+ await expect(connect).toHaveText("Connect OpenRouter");
+ const tab = await signInTab(context, connect);
+ await expect(ext.locator("#lookupState")).toHaveText("Waiting for OpenRouter… Finish signing in on the other tab.");
- // OpenRouter sends the browser back with a code.
- const page = await context.newPage();
+ // OpenRouter sends the browser back with a code, in the same tab.
+ const page = tab;
await page.goto(`${CALLBACK}?code=test-code-0123456789`);
await expect(status(page)).toHaveText("Connected. Kotiko now looks words up with OpenRouter's free models. You can close this tab.");
// The code is gone from the address, so a reload doesn't send it again.
@@ -70,6 +100,25 @@ test("the return page hands the code to Kotiko, which saves the key and says so"
expect(Object.keys(secrets)).toContain("provider:openrouter");
const { lookup } = await serviceWorker.evaluate(() => chrome.storage.local.get("lookup"));
expect(lookup).toMatchObject({ kind: "provider", provider: "openrouter" });
+ // The settings tab, still open, shows the key that arrived.
+ await expect(ext.locator("#lookupKeyMasked")).toHaveText(/^Saved key: sk-or-…/);
+ await expect(ext.locator("#oauthRow")).toBeHidden();
+ done();
+});
+
+test("the welcome tab's Connect OpenRouter (free) waits for the sign-in, then says Connected", async ({ context, serviceWorker, extensionId, blocked }) => {
+ const done = await serveSite(context, blocked);
+ await stubExchange(serviceWorker);
+ const welcome = await context.newPage();
+ await welcome.goto(`chrome-extension://${extensionId}/welcome.html`);
+ const connect = welcome.locator("#aiConnect");
+ await expect(connect).toBeVisible();
+ await expect(connect).toHaveText("Connect OpenRouter (free)");
+ const tab = await signInTab(context, connect);
+ await expect(welcome.locator("#connectStatus")).toHaveText("Waiting for OpenRouter… Finish signing in on the other tab.");
+ await tab.goto(`${CALLBACK}?code=welcome-code-0123456789`);
+ await expect(status(tab)).toHaveText(/^Connected\./);
+ await expect(welcome.locator("#aiConnectedText")).toHaveText("Connected to OpenRouter, free models.");
done();
});
diff --git a/test/visual/store-screenshots.mjs b/test/visual/store-screenshots.mjs
index 0c67d43..fd67fb2 100644
--- a/test/visual/store-screenshots.mjs
+++ b/test/visual/store-screenshots.mjs
@@ -11,7 +11,7 @@
// //-.png (captioned, the files to upload). Default outDir:
// ./store-shots; default locale: en. Chromium with the unpacked extension, the browser in
// that locale, and a page written for the purpose (test/fixtures/pages/store-article.html,
-// so no third-party content or logos appear). The caption's colors come from the design
+// so no third-party content or logos appear), served at www.example.com by a route. The caption's colors come from the design
// tokens, and the script stops if their contrast is under 4.5:1. KOTIKO_E2E_CHROMIUM picks
// another Chromium build.
//
@@ -30,6 +30,10 @@ const EXT_DIR = path.join(ROOT, "extension");
const OUT = path.resolve(process.argv[2] ?? "store-shots");
const LOCALE = process.argv[3] ?? "en";
const SIZE = { width: 1280, height: 800 };
+// The article is served at an ordinary-looking address (example.com is reserved for
+// examples, RFC 2606) so the popup's "This page" line names a website, not 127.0.0.1. The
+// browser never reaches the internet: the route below answers with the fixture file.
+const ARTICLE_URL = "https://www.example.com/articles/store-article.html";
const WORDS = {
en: [
@@ -87,6 +91,8 @@ async function launch() {
if (["http:", "https:"].includes(u.protocol) && !["127.0.0.1", "localhost"].includes(u.hostname)) return route.abort();
return route.continue();
});
+ const articleHtml = await fs.readFile(path.join(ROOT, "test/fixtures/pages/store-article.html"), "utf8");
+ await context.route(ARTICLE_URL, (route) => route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: articleHtml }));
const sw = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
return { context, sw, base: `chrome-extension://${new URL(sw.url()).host}/`, userDataDir };
}
@@ -111,10 +117,10 @@ async function seed(env, srv) {
await new Promise((r) => setTimeout(r, 500));
}
-async function article(env, srv, scheme, hover) {
+async function article(env, scheme, hover) {
const page = await env.context.newPage();
await page.emulateMedia({ colorScheme: scheme, reducedMotion: "reduce" });
- await page.goto(srv.pageUrl("store-article.html"));
+ await page.goto(ARTICLE_URL);
await page.locator("kotiko-w").first().waitFor();
const shown = await page.locator("kotiko-w").allTextContents();
const pick = hover.find((w) => shown.includes(w));
@@ -127,20 +133,36 @@ async function article(env, srv, scheme, hover) {
}
const shots = [
- { id: "page", take: (env, srv) => article(env, srv, "light", HOVER[LOCALE].light) },
+ { id: "page", take: (env) => article(env, "light", HOVER[LOCALE].light) },
{
id: "popup",
+ // A real popup describes the tab it was opened over. Here it is a tab of its own, so the
+ // article is opened first and the popup's "active tab" question answered with that real
+ // tab (its id and address): "This page" then describes the article, not a browser page.
async take(env) {
+ const article = await env.context.newPage();
+ await article.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
+ await article.goto(ARTICLE_URL);
+ await article.locator("kotiko-w").first().waitFor();
+ const tab = await env.sw.evaluate(async (url) => (await chrome.tabs.query({ url })).map(({ id, url: u, title, windowId }) => ({ id, url: u, title, active: true, windowId }))[0] ?? null, article.url());
+ if (!tab) throw new Error("The article's tab wasn't found");
const page = await env.context.newPage();
await page.setViewportSize({ width: 380, height: 600 });
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
+ await page.addInitScript((t) => {
+ if (!globalThis.chrome?.tabs?.query) return;
+ const query = chrome.tabs.query.bind(chrome.tabs);
+ chrome.tabs.query = (info = {}, ...rest) => (info.active ? Promise.resolve([t]) : query(info, ...rest));
+ }, tab);
await page.goto(`${env.base}popup.html`);
await page.locator('#main[data-ready="true"]').waitFor();
+ if (await page.locator("#unsupported").isVisible()) throw new Error("The popup describes a browser page, not the article");
+ await page.locator("#pageSection").waitFor();
await page.locator("#addText").fill(ADD[LOCALE]);
await page.locator("#addText").press("Enter");
await page.locator('#jobs [data-kind="word"]').first().waitFor();
await page.locator("#addText").blur();
- return { page, region: page.locator("#main") };
+ return { page, region: page.locator("#main"), also: [article] };
},
},
{
@@ -149,6 +171,11 @@ const shots = [
const page = await env.context.newPage();
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
await page.goto(`${env.base}dashboard.html`);
+ // The word added in the popup gets its pronunciation in the background (slice 07 §8),
+ // which shows "Adding pronunciations…: 0 of 1" until its next run (about a minute);
+ // the shot waits for that line to go rather than show it.
+ await page.waitForTimeout(1500);
+ await page.locator("#refreshLine").waitFor({ state: "hidden", timeout: 120_000 });
await page.waitForTimeout(800);
return page;
},
@@ -160,11 +187,19 @@ const shots = [
await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" });
await page.goto(`${env.base}welcome.html`);
await page.locator('#main[data-ready="true"]').waitFor();
+ // Shown as most learners will see it: connected to OpenRouter, the default, rather than
+ // the fixture's "Another service". Only the label changes; nothing is looked up.
+ for (const msg of [{ type: "secrets.set", id: "provider:openrouter", value: "sk-or-v1-0123456789abcdef0123456789abcdef" }, { type: "backend.set", lookup: { kind: "provider", provider: "openrouter", baseUrl: null, model: null } }]) {
+ const r = await page.evaluate((m) => chrome.runtime.sendMessage(m), msg);
+ if (r?.error) throw new Error(`${msg.type}: ${JSON.stringify(r)}`);
+ }
+ await page.reload();
+ await page.locator('#main[data-ready="true"]').waitFor();
await page.waitForTimeout(300);
return page;
},
},
- { id: "dark", take: (env, srv) => article(env, srv, "dark", HOVER[LOCALE].dark) },
+ { id: "dark", take: (env) => article(env, "dark", HOVER[LOCALE].dark) },
];
// The captioned 1280x800 frame: the caption on the canvas color, the capture below it.
@@ -202,6 +237,7 @@ async function run() {
if (got.region) await got.region.screenshot({ path: raw });
else await page.screenshot({ path: raw });
await page.close();
+ for (const p of got.also ?? []) await p.close();
const frame = await env.context.newPage();
await frame.setViewportSize(SIZE);