Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

The web app2 of 12

Signing in

About 5 minutes to read

On this page

Kotoba ships with one optional password. Set it and the whole app sits behind a login screen; leave it unset and there is no login screen at all.

Screenshot placeholder: /login — the bobbing chibi with a speech bubble above a cream card titled "Kotoba — private", one password field with a reveal control, and a "Let me in" button.

When the login appears

Only when a password is configured. The gate reads two environment variables in this order and treats a non-empty value as "on" (lib/gate.ts:48-55):

KOTOBA_WEB_PASSWORD        # preferred
KOTOBA_GATE_PASSWORD       # accepted alias

With neither set, every request passes through untouched (proxy.ts:17). That is the local-development default, and it is deliberate: there is no lockout to lock yourself out of.

The order matters and is copied from the backend on purpose. The same value gates both sides, so a login cannot succeed with one variable while every /api/* call refuses the other (lib/gate.ts:47-50).

Three routes are protected (proxy.ts:46):

/app/:path*     the companion
/setup/:path*   first run — it writes keys and her name
/login          so somebody already signed in is not shown the gate again

/ is deliberately not matched. It renders nothing; it only redirects to /app, where the gate runs (app/page.tsx:11-13, proxy.ts:40-44).

What happens when you sign in

You type the password and the browser posts it to /gate. The password itself never ships in the page bundle — only the attempt travels, and it is compared server-side.

The comparison HMACs both the attempt and the real password and compares the digests in constant time (lib/gate.ts:61-67, 36-41). A wrong password waits 600 ms before answering 401 (app/gate/route.node.ts:36-39). That delay is constant friction against guessing, not a lockout — there is no attempt counter.

A correct password sets one cookie:

PropertyValue
Namekotoba_gate
Contentsan HMAC-SHA256 signature over an issue timestamp
httpOnlyyes
sameSitelax
path/
Lifetime12 hours

(lib/gate.ts:12-13, 88-98, 100-113.)

The secure flag follows the connection, not the build mode (lib/gate.ts:79-86). Any proxy hop claiming https turns it on; only a URL that positively says http: turns it off. This matters for self-hosting: npm start on http://<lan-ip>:3000 is a production build, so keying secure off the build mode would mark the cookie secure, the browser would silently drop it on an insecure origin, and the login would loop with no error to read.

The signing key is KOTOBA_GATE_SECRET if you set one, otherwise the password itself (lib/gate.ts:43-45). Changing the password therefore invalidates every existing session.

After a success the page does a full navigation rather than a client-side one, because the App Router has cached the earlier redirect to /login and a soft navigation would replay it (components/LoginGate.tsx:104-107).

Where it sends you afterwards

/login?next=… is attacker-controlled, so the value is resolved against the origin it must not leave and kept only if it stayed there; anything else falls back to /app (lib/safe-next.ts:14-23). The obvious test — "starts with a slash but not two" — does not hold, because the URL parser folds /\ into // and strips raw tab, newline and carriage return before parsing. Only the parser can see that, so the parser is what decides.

When a password is set, the backend requires it on /api/* as well. The browser gets it from GET /gate/token, which returns the token only to a request carrying a valid signed cookie and marks the response no-store, private (app/gate/token/route.node.ts). The value is kept in sessionStorage and sent as a Bearer header (lib/api.ts:10-42).

Where a header cannot be set — an EventSource, an <img>, a download, a window.open — a ?token= query parameter is used instead (lib/api.ts:45-51). For file viewing the backend swaps that token for a short-lived cookie and redirects to the same URL without it, so the address bar and browser history never keep it (api/src/kotoba/server.py:970-978).

With no password configured, /gate/token returns an empty token, gateIsOn() is false, and no Authorization header is sent — because the backend gate is open too.

Signing out

Settings → Security → Sign out. The row appears only when a password is configured, so there is never a control that would end nothing (components/panels/SettingsPanel.tsx:682-707).

Signing out drops the local token, deletes the sessionStorage entry, clears the login-diagnosis mark, asks /gate to expire the cookie with DELETE, and then does a full navigation to /login (lib/api.ts:56-67, SettingsPanel.tsx:690-694). The full navigation is the point: the in-memory app state — session id, token, open panels, a live call — must not survive into the next person's session.

The panel states what a sign-out costs: it ends the 12-hour session on this device, and her memory and files are untouched (SettingsPanel.tsx:699-701).

When the password is right and you land back on the login screen

That is a browser that did not keep the cookie: a secure cookie on a plain-HTTP origin, cookies blocked for the site, or a proxy that ate Set-Cookie. With nothing else to go on it looks exactly like a wrong password.

So the login screen records the instant a password was accepted and checks that mark when it mounts. Landing back within 60 seconds of a success raises a yellow notice reading "The word was right", pointing at cookies and at the self-hosting notes on plain HTTP (lib/gate-diagnostics.ts:14-51, components/LoginGate.tsx:79, 106, 322, 342-362). The mark is read once and cleared, so an ordinary later visit says nothing, and signing out clears it too — otherwise a sign-out would read as a browser that eats cookies.

This is a shared-password gate for a self-hosted instance. It is not a user-account system: there are no accounts, no roles, and no per-user data. Everyone who knows the password is the same person as far as Kotoba is concerned.