Web server guide

The self-hosted solver web front end: a TLS edge, an SRP authentication service, and one per-collaborator application instance, wired together over unix sockets behind a kernel egress firewall.

Its purpose shapes every decision in it. The front end exists to give an invited collaborator a real solver shell in a browser — remote code execution is the product, not an accident. Nothing here pretends the shell is not a shell. The design spends its effort instead on two things: making identity unforgeable, and making a compromise's blast radius one collaborator's own sandbox.

The organising principle is one uid per collaborator. Each has their own system user, home, repo clone, git branch, and encrypted vault of secrets. A secret that rests on a user's uid is exposed only to that user's own code, which is not a leak — they own it. Kernel identity replaces claim-passing between services, and git works natively because each user pushes as themselves.

1 · Shape

Browser → Caddy → one service per collaborator, beside the public site and the auth, message and mail services, all leaving through one allowlisted way out

In detail:

                         :443
 browser ───TLS──▶  ┌───────────┐  edge: TLS, security headers
                    │   Caddy    │  · strips client X-User / X-Profile / X-User-Slug
                    │  (edge)    │  · forward_auth ──────────────┐
                    └─────┬──────┘  · routes by X-User-Slug      ▼
           unix sockets /run/euler                        ┌─────────────┐
                          │                               │    auth      │ SRP login,
                          │                               │   service    │ sessions,
                          ▼                               │  (aiohttp)   │ invites,
        ┌──────────────────────────────────┐              └──────┬──────┘ shell tickets
        │  euler-user@<slug>               │◀────────────────────┘
        │  User=<slug>                     │   200 + X-User + X-Profile + X-User-Slug
        │  serves this collaborator's      │   or 401 → /login
        │  content routes AND /ws          │
        │                                  │       ┌─────────┐  allowlist egress only
        │  ~/euler   their clone, branch   │──────▶│  Squid   │─▶ api.anthropic.com,
        │            user/<slug>           │       │ (egress) │   projecteuler.net,
        │  ~/.euler  their vault (0700)    │       └─────────┘   github, claude.com
        └───────┬──────────────────────────┘
                │         ┌─────────────┐  message spool: threads + read-state;
                └────────▶│  euler-msg  │  SO_PEERCRED identity, AF_UNIX only,
                          └─────────────┘  never routed by Caddy
                          ▲
        policy: /etc/euler/authorizations.json — read by auth, msg and the operator

Only Caddy is network-bound. Every application service listens on a unix domain socket under /run/euler/, which makes filesystem permissions the access control: each socket is 0660, and its group decides who can connect(). Two groups, because the per-user uids run their owner's code and must reach some sockets but never others:

  • euler-web — auth.sock and msg.sock: the services, and the per-user uids (a web shell redeems its ticket at auth and talks to the spool).
  • euler-edge — site.sock and every user-<slug>.sock: Caddy, euler-auth, euler-msg, euler-site. No per-user uid, so one collaborator's code cannot dial another's instance. /run/euler itself is root:euler-edge 1771: the services create their sockets there, the sticky bit keeps each from unlinking another's, and everyone else may only traverse it.

Each per-user instance also checks its caller with SO_PEERCRED (Caddy for pages and /ws, euler-auth and euler-msg for their pushes, root for the status sweep), so the identity headers it trusts can only have come through the edge. There is no port registry to maintain and no 0.0.0.0 mis-bind one typo away. Isolation is only real across distinct uids, so each service gets a dedicated nologin system user, and none runs as root.

Service User Socket / port Role
edge euler-caddy :443 (public) TLS, header stripping, forward_auth, routing
auth euler-auth /run/euler/auth.sock SRP login, sessions, invites, shell tickets
per-user app <slug> (e.g. u3c9a0f1e2b7d4a68) /run/euler/user-<slug>.sock one collaborator's content routes + /ws
admin plane euler-auth /run/euler-adm/auth-admin.sock (0600) account mutations; never routed by Caddy
message spool euler-msg /run/euler/msg.sock user↔staff message threads (§13)
msg admin plane euler-msg /run/euler-msg/admin.sock (0600) staff verbs from the operator's terminal
egress proxy euler-proxy 127.0.0.1:3128 Squid domain allowlist
mail relay euler-smtp 127.0.0.1:8025 sole holder of the Gmail credentials
certificates euler-acme — acme.sh DNS-01, on a daily timer
dynamic DNS euler-ddns — keeps the A record on the current public IP

Application code runs from a root-owned system venv at /opt/euler, never the repo checkout — the service users cannot traverse the repo owner's 0750 home, and keeping code out of a writable tree means a repo write cannot change what executes. The framework is aiohttp + Jinja2 with autoescape on.

Pages are composed server-side and updated by swapping rendered HTML fragments (htmx). There is no SPA, no client-side router, no JSON-assembly in the browser, and no build toolchain. This is not asceticism: it means all escaping is Jinja's, server-side, so the DOM-XSS class does not exist here, and one template renders both a full page and the fragment that updates it.

2 · The edge

Caddy terminates TLS on :443 as the unprivileged euler-caddy (AmbientCapabilities=CAP_NET_BIND_SERVICE rather than root), loading a certificate by absolute path from /etc/euler/tls/. It performs no ACME of its own.

Certificates come from acme.sh over DNS-01, run as euler-acme on a daily timer. DNS-01 needs no inbound port, so nothing listens on :80. Caddy's own ACME is not used because the caddy-dns/namedotcom plugin is unmaintained and no longer builds against current Caddy; delegating issuance to acme.sh keeps the DNS-01 benefit with stock Caddy. Renewal re-applies the key mode and reloads through Caddy's admin API rather than systemctl, so the non-root euler-acme can trigger it. The admin API is a unix socket, /run/euler-caddy/admin.sock (0660 euler-caddy:euler-caddy), never TCP: Caddy's primary group is its own, and euler-acme is its only other member. The reload is --forced, since a renewal changes the files the Caddyfile names and not the Caddyfile. The cert directory is euler-acme:euler-tls setgid 2750, so acme.sh's writes inherit the group and euler-caddy — the only member of euler-tls — reads the key at 0640: no chown, no root. frontend.sh rekey renews with a fresh private key, and acme.sh keeps rotating the key on every renewal after that.

The same name.com token drives DNS-01 and the euler-ddns timer, which PUTs the A record only when the public IP changes.

The generated /etc/euler/Caddyfile (0644, non-secret) does four things:

  1. Strips X-User, X-Profile, and X-User-Slug from every inbound request, before any routing. These are identity, and identity may only ever reach the app tier as a copy of the forward_auth response.
  2. Serves /healthz natively and /assets/* + /vendor/* statically from /etc/euler/web-content, so pages reference 'self' assets and the CSP holds. The templates name every asset by a hash of its bytes (/assets/site.css?v=<hash>), and a request carrying ?v= is served public, max-age=31536000, immutable: a redeploy that changes the file changes the URL, so the old copy is never asked for again. A bare path — what a script imports by itself, the maintenance page's stylesheet — is served no-cache, "keep it, but ask before using it": it names different bytes after a redeploy, and with only Caddy's ETag / Last-Modified browsers fall back to heuristic freshness, silently leaving new HTML driven by old JS. That is not a theoretical failure — it is what made a reworked user menu's terminal toggle inert against the previous site.js. § 11.10 has the whole policy.
  3. Proxies the public auth surface — /login, /register*, /reset*, /forgot, /password, /terms, /auth/* — straight to auth.sock. The shell-ticket and admin endpoints are deliberately absent from this list; they are socket-peer only.
  4. Gates everything else through forward_auth to /auth/check, then routes the request to its user's own socket:
@user header_regexp X-User-Slug ^[a-z][a-z0-9-]{1,30}$
reverse_proxy unix//run/euler/user-{http.request.header.X-User-Slug}.sock

A request-header placeholder in the dial address is enough — no dynamic-upstream module. The header_regexp matcher admits only a well-formed system slug; anything missing or malformed falls through to the 503 holding page, which is also what a deprovisioned user's dead socket produces via handle_errors. The WebSocket upgrade survives the placeholder upstream, so content and /ws share the one route.

Caddy adds the transport headers that need no per-response state — HSTS, X-Content-Type-Options, Referrer-Policy — plus a fallback CSP using the set-if-absent (?) operator, so it applies only to Caddy-native responses that carry no application CSP of their own.

3 · Egress

"Egress only via Squid" is enforced at the kernel, not by an environment variable a compromised process can simply ignore. Two layers.

Squid runs on loopback 127.0.0.1:3128 as euler-proxy with a default-deny domain allowlist (api.anthropic.com, .projecteuler.net, .github.com, .githubusercontent.com, .claude.ai, .claude.com, .anthropic.com, www.ecb.europa.eu for update-usd-rate). Client-side HTTPS_PROXY/HTTP_PROXY is written to /etc/euler/egress.env and loaded by the service units, so AI features, the problem scraper, and gh egress only through it. The allowlist lives in /etc/euler-proxy/squid.allowlist — edit and reload.

nftables (scripts/setup/firewall.sh → euler-firewall.service) makes that structural. A table inet euler with an egress-only (hook output) chain permits only the specific (uid, port) each service needs — on loopback as much as off-host — then drops the rest:

uid Permitted egress
euler-proxy tcp dport {80,443} — the only service with real internet
euler-acme tcp dport 443 (ACME + DNS provider)
euler-ddns tcp dport 443 (name.com, ipify)
euler-smtp tcp dport 587 (Gmail submission)
infra uids udp/tcp dport 53
every euler-* loopback :3128 (Squid) and :53 (the resolver)
euler-auth loopback :8025 (the mail relay — its only client)
maintainers' per-user uids (euler-maint) loopback :22 — the local sshd (loopback- and key-only), so an operator signed in over the web can ssh <operator>@localhost to run a redeploy; users reloads the ruleset when a rung changes
every euler-* everything else dropped, loopback included

Loopback is an allowlist, not the service fabric: the services talk over unix sockets, so nothing else listening on 127.0.0.1 — on a shared host, an IDE or sshd — is in reach of a per-user web shell.

Two details matter and are easy to get wrong:

  • The chain is policy accept and drops only the enumerated euler-* uids via a final skuid { … } drop. SSH, root, and your own shell are untouched — there is no lock-out risk.
  • Loopback is matched by destination address (ip daddr 127.0.0.0/8 / ip6 daddr ::1), never by oif "lo". The interface-index match does not hit loopback output on the WSL2 kernel: euler uids' loopback SYNs fell through to the final drop while replies still passed on ct state established, so only new service-to-service connections broke. The address space is also the actual security intent.

Per-user uids are created dynamically at provision time and are named for the collaborator's slug alone, so the generator enumerates them by the euler-user parent group every provisioned uid joins — there is no name prefix left to match on — and folds them into the drop. Because the chain is policy-accept, an un-enumerated uid would reach the internet directly, bypassing Squid — which is why provision reloads the firewall.

Mail is the founding case of the principle that a secret lives only with the service that performs its operation. Rather than granting the app tier a direct-internet exception for SMTP, euler-smtp listens on loopback :8025, is the sole holder of the Gmail credentials and the sole uid permitted :587, forces the envelope sender, and never logs bodies (they carry one-time codes). A firewall relay guard bars every other euler-* uid from :8025 — including a hostile web shell. euler-auth submits mail over loopback holding no credentials at all.

4 · Authentication

Login is browser-side SRP-6a: the password never crosses the wire and never reaches the server or its disk. The server stores only {salt, verifier}; each side proves knowledge through a challenge/response, and the client verifies the server back.

This is not merely good hygiene here — it is load-bearing for the vault. Because the server never sees the password, it cannot derive the key that unwraps a user's secrets, which is exactly what makes those secrets opaque to the operator at rest.

4.1 Registration and reset

Registration is invite-only and two-channel: an admin mints an invite, and the invitee proves live mailbox control before any password is set. Every secret is stored hashed, and every step is single-use and time-boxed. State lives in /var/lib/euler-auth/pending.json, keyed by hash(link-token), walking invited → otp_sent → verified → (consumed):

  1. Invite — users add <email> [profile] provisions the account (§7) and mints a pending record, emailing https://<FQDN>/register?token=<32-byte token>. No user record exists yet.
  2. Open link — a valid, unexpired invited token renders the page: the email read-only, the Terms in a scroll box whose accept checkbox unlocks only at the end of the text, and "email me a code". Anything else renders a generic "link not valid" page.
  3. Accept Terms, request OTP — records the acceptance, mints a 6-digit OTP (hash(otp), +10 min, attempts=0), and emails it. Resends are capped.
  4. Verify OTP — a match under 5 attempts moves to verified; 5 failures invalidate the OTP and a fresh one must be requested.
  5. Set password — the browser derives {salt, verifier} locally from a policy-compliant password and posts them with the account's display name. This creates the users.json entry, consumes the pending record, and lands on /login (no auto-login).

The two secrets do different jobs: the link token (32 bytes, 7 days) proves possession of the invite; the OTP (6 digits, 10 minutes, 5 tries) proves live mailbox control at completion time. Both are stored only as hashes and never logged.

Reset is self-service — admins never reset passwords. /forgot runs the identical link → OTP → set-password pipeline with Terms skipped, and its responses are generic regardless of whether the email exists. Completing a reset revokes every live session and remember-me token — and destroys the vault, deliberately (§8.2).

4.2 The login handshake

  1. POST /auth/challenge {email} → the service loads {salt, v}, computes B = k·v + gᵇ, and replies {salt, B, id, kdf} — the handshake is held under that random id, so another one started for the same address cannot cancel it, and kdf names the Argon2id parameters the account's x was derived with (docs/srp-guide.md § 3). Unknown or disabled accounts get a stable decoy salt/B (never stored), so the response reveals nothing.
  2. POST /auth/verify {email, A, M1, id, remember} → the server checks M1; on success it replies {M2, email, profile} and sets the session cookie (plus the remember-me cookie if asked). On failure, a generic 401.
  3. The browser verifies M2 — mutual authentication — and is logged in.

Signing in and out keep your place. On the public site there is no trip to make: the right pane is the sign-in form — the /login page's own (_loginform.html), marked data-next-here — and once the handshake succeeds login.js reloads the page the left pane shows at that moment, which, now carrying a session, is served by the reader's own instance. That pane starts minimized to the footer, so the header's Login link there shows it (the same act as the footer's hidden window) and focuses the form. Elsewhere sign-in and sign-out are full-page trips through this service, so the page the left pane is on travels with them as next: the header's Login link carries /login?next=<path>, the Logout form a next field, and Caddy's redirect of an expired session ?next= its path. site.js stamps the live path on the click (the header is not re-rendered as the pane moves). Signed in, login.js lands on it; signed out, the 303 goes back to it — served now by the public site — unless it is a page only a session can see (/account), which lands on /.

next is attacker-controlled, so it is an allowlist rather than a sanitiser (packages/site/euler_site/auth/returnto.py): / or a path under one of the left pane's routes (/solutions, /docs, /blogs, /about, /shell, and /account after signing in). Another host — //evil.example, or /\evil.example, which browsers read as one — a scheme, an auth page, the terminal or an API route is /.

The browser client (packages/site/euler_site/content/assets/srp.js) interoperates byte-for-byte with srp.py: RFC 5054 2048-bit group, g=2, SHA-256, PAD-to-|N| on A/B/S. tests/test_srp_interop.py cross-tests them (needs make install-nodejs).

4.3 Sessions and remember-me

  • Session solver_session: an opaque token → in-memory (email, profile, expiry), 12 h TTL, Secure; HttpOnly; SameSite=Lax; Path=/. Sessions are per-process; a service restart drops them all.
  • Remember-me solver_remember, only when requested: a selector:validator token whose server side (selector → (email, HMAC(validator), expiry)) lives in remember.json and survives restarts, 30-day TTL. With a valid remember cookie and no session, POST /auth/resume verifies it in constant time, rotates the validator (one-time use), and opens a fresh session. A wrong validator revokes the selector — that is theft detection, not just hygiene. Logout clears both cookies and deletes the server-side record. The login page is where it is used: the edge sends a browser holding the cookie there when its session has lapsed (§ 11.0), the page marks its form data-resume (the cookie is HttpOnly, so only the server can tell), and login.js redeems it before asking for a password, then lands on next. A refused resume clears both cookies, so a dead token sends the browser to the public site from then on rather than to the login page every time. A resume derives no vault key — there is no password to derive it from — so the vault is as a sign-in whose key derivation failed leaves it.

4.4 The forward_auth endpoint

GET /auth/check is what Caddy calls on every gated request: a live session returns 200 with X-User (email), X-Profile, and X-User-Slug; no session returns 401, which Caddy turns into a 302 /login?next=<path> for browser navigations — or, for a read of public content with no remember-me cookie, into the public page itself (§ 11.0). Caddy copies those headers onto the proxied request after deleting any client-supplied copies, and the app sockets are unreachable except through Caddy — which is why downstream services can trust them. That rests on two things, not one: the per-user and site sockets are group euler-edge, whose members are Caddy, auth, msg and site and never a collaborator's uid (§ 1), and each per-user instance also checks its caller's uid on every request (SO_PEERCRED: Caddy for the routes, auth for logout and vault reset, msg for delivery, root for status) — so a tenant who reached a socket anyway still could not speak for someone else.

4.5 Policy constants

packages/site/euler_site/auth/policy.py:

Constant Value Meaning
SESSION_TTL_SECONDS 12 h session lifetime (in-memory)
REMEMBER_TTL_SECONDS 30 d remember-me lifetime (rotated on use)
CHALLENGE_TTL_SECONDS 120 s pending SRP challenge hold
MAX_PENDING_CHALLENGES 4096 handshakes held at once (oldest dropped first)
MIN_PASSWORD_LENGTH / classes 16 / lower+upper+digit+special password policy (client-enforced)
INVITE_TTL_SECONDS / LINK_TOKEN_BYTES 7 d / 32 emailed link validity / entropy
OTP_DIGITS / OTP_TTL_SECONDS / OTP_MAX_ATTEMPTS / OTP_MAX_SENDS 6 / 10 min / 5 / 5 one-time code
TICKET_TTL_SECONDS 60 s one-time shell ticket
AUTH_RATE_MAX / AUTH_RATE_WINDOW_SECONDS 30 / 60 s per-client rate limit on unauthenticated endpoints
RESET_COOLDOWN_SECONDS / RESET_DAILY_MAX 15 min / 4 per-account reset mails; a new /forgot never replaces a link already sent
MAIL_PER_RECIPIENT_MAX / MAIL_PER_RECIPIENT_WINDOW_SECONDS / MAIL_DAILY_MAX 6 / 1 h / 150 budget for mail an unauthenticated request can cause
POW_BITS / POW_TTL_SECONDS 18 / 10 min proof of work on /forgot and /request-invite (auth/pow.py)
PROFILES reader, contributor, maintainer web-assignable profiles for the admin API

4.6 State

/var/lib/euler-auth, euler-auth-only 0600, shared with no other uid — not even the repo owner. The admin commands never touch these files; they call the service (§6.5).

File Purpose
users.json SRP verifier DB: {salt, verifier, kdf, terms_version, terms_accepted_at, created, disabled} per email. kdf names the Argon2id parameters the verifier's x was derived with (docs/srp-guide.md § 3). No profile — that lives in authorizations.json, resolved fresh at each login. Not to be confused with /etc/euler/roster/users.json (§6.3): this one holds the login secrets and is euler-auth's alone; that one holds public keys, is world-readable, and carries no address at all.
pending.json in-flight invites and resets, keyed by hash(link-token).
remember.json remember-me selector → (email, HMAC(validator), expiry), rotated on use.
session-secret 32-byte HMAC key for remember-me; created on first start.

5 · Identity

A single subject is resolved once per process, at startup, with no anonymous fallback (packages/solver/solver/shell/login.py; docs/package-guide.md § 2):

Subject(user, slug, channel, auth_method, profile)

user is the raw identity (email on the web, OS login in a terminal); slug is its filesystem-safe form; channel is terminal or web; auth_method records how identity was proven, for audit. Nothing else in the codebase re-derives identity.

Two front ends, each with an explicit voucher:

Front end Voucher Mechanism
Web shell (python -m euler_webshell.shell) auth service SOLVER_TICKET — a one-time ticket redeemed over auth.sock (packages/webshell/euler_webshell/shell/identity.py)
Solver shell (solver) the OS the real uid's account name (pwd, never $USER), its role from the root-owned authorizations.json (packages/solver/solver/auth/identity.py)

The shell ticket. Nothing carried in the environment can be a credential: /proc/<pid>/environ is same-uid-readable, so anything there is readable by any sibling process of that user. Instead, on WebSocket attach the service forwards the caller's session cookie to POST /shell-ticket, which mints a single-use, 60-second ticket bound to (email, profile, display name); the forked child redeems it at startup, which consumes it and returns the authoritative identity. Missing, expired, or reused → the shell aborts. Minting requires a live session cookie the shell user does not hold, and the /shell-ticket* endpoints are not routed by Caddy.

The slug pin. A redeemed ticket whose system_slug(email) differs from the forking instance's EULER_USER_SLUG aborts. That instance is that user's uid — with their home, keys, and clone — so a ticket for anyone else means misrouting or a bypass, and the process must not run.

The solver shell's role. When a root-owned authorizations.json is deployed — the file and every directory above it root-owned, since a root-owned file in a user-owned directory can be renamed away — it decides: the login's own entry; else the e-mail whose system_slug is this login (a per-user uid, so a solver run inside a web shell — claude-solve's headless Claude, a nested solver "…", an unset SOLVER_TICKET; solver — is that same collaborator at that same rung, never more); else reader. An EULER_AUTHZ_FILE override is honoured only under the same ownership rule. With no policy deployed (a collaborator's own machine) the checkout owner is admin and anyone else contributor. The role is a guardrail inside one uid, not a boundary (package-guide § 2.2); the boundaries are the uids, file permissions and the separate-uid services. There is no assume-an-identity path: SOLVER_USER is display-only.

The slug scheme is worth one note. slugify emits a .-bearing form (mercanther_gmail.com-3f9e97) that fails useradd's NAME_REGEX, so the system slug used for uids, homes, sockets, and X-User-Slug is a stricter derivation: u + sixteen hex digits of SHA-256 of the normalised e-mail (uff8d9819fc0e12bf), matching ^u[0-9a-f]{16}$. It is the unix account name — the uid, /home/<slug>, the socket, the unit instance, and the user/<slug> branch all read the same, with no euler-user- prefix in front of it. Two consequences are deliberate: the name is short enough to stay well inside the system limit whatever the address, and it publishes nothing about who the collaborator is (/home, ps, and the branch list are all readable by the other collaborators). The e-mail remains the login identity; the slug is the derived system identity, and the only way back from one to the other is to recompute system_slug over the policy's identities — which is what make status-web does to show each unix name with its e-mail alias.

6 · Authorization

Authorization is a plain profile ladder. A command or route declares the minimum profile it needs, and enforcement is a rank comparison:

reader  <  contributor  <  maintainer  <  admin

The ladder is structural, not configuration — code, templates, and the provisioning kits all branch on these names — so it lives in code (packages/solver/solver/auth/subject.py::LADDER). authorizations.json's optional ladder field documents it and is validated on load; a mismatch fails loudly rather than silently reordering trust.

Declaring that floor is mandatory — requires is a required Literal['reader', 'contributor', 'maintainer', 'admin'] on the decorator, so a command that omits it is a type error rather than a silently-exposed command. There is no default to get wrong.

6.1 The policy file

/etc/euler/authorizations.json (root:euler-authz 0640) is the system of record, and it carries exactly one decision — who has which profile:

{
  "ladder": ["reader", "contributor", "maintainer", "admin"],
  "users": {
    "vikas": "admin",
    "vikas.munshi@gmail.com": "maintainer"
  }
}

The users map is keyed by web email or OS-login name, one map for both channels. It names every collaborator, so it is readable only by euler-authz — euler-auth, euler-msg and the operator — and root-write only because every mutation goes through the sudo-gated users path (which keeps the file's group and mode). A per-user uid reads its own row instead — /etc/euler/profiles/<account> (root:<account> 0640), which every writer of the map re-publishes — because a solver it runs (claude-solve's headless Claude, a nested solver "…") takes its rung from the policy, not from the web shell's ticket. A process that may read neither loads the policy as one with no names in it, never as no policy at all. It lives outside the repo so that a repo write cannot change policy. There is no policy file in the repo: absent a deployed one the built-in default maps nobody, so the checkout owner floors to admin by uid and everyone else is contributor. The installer seeds the real file.

6.2 The roster is not the policy

/etc/euler/roster/users.json (§6.3) carries a profile field, and it is a mirror. Every rank comparison — in the shell, in every service, on every route — reads the file above and only that file. The rule the roster is built to is facts that verify themselves, or nothing — a public key seals or it does not — and no decisions. users list reports where the two disagree; the host is what applies.

The euler-maint group that may write the roster is a write-ACL, not a copy of the ladder: it grants "may record a public key and a date", never a profile capability. A member who has since been demoted can therefore mislead a menu, and nothing else.

6.3 The roster (/etc/euler/roster/users.json)

Who exists and what public key they hold. It is read by everyone and written by maintainers, which is exactly the shape its readers need: the registry it replaces was an admin-plane read behind sudo, which the shell that most needs it — a maintainer's, over the web — can never obtain, so key-rekey refused to rotate without it, key-split had to be handed a public key by hand, and msg send's recipient menu shrank to two words.

It spent one revision as a tracked file, which fixed the reading and broke the writing: users is admin-gated and sudo'd, but user-authorize and key-split are maintainer commands, so a maintainer's grant wrote the file in their own clone on their own branch, where it reached nobody and collided at the next sync. /etc/euler/roster/ is root:euler-maint 2775 — setgid, so a file created there inherits the group — with the file at 0664. The directory carries the group because an atomic replace renames a temp file over the target and so needs write permission on the directory; a group-writable file in a root-owned directory would force truncate-and-rewrite, and a crash mid-write leaves an empty roster, which reads as "nobody holds a key".

The floor is euler-maint and never euler-user or euler-web (which hold every collaborator) for one specific reason: public_key is the only field here that is used rather than displayed — a grant seals half the master key to it — so whoever can write it can redirect a future grant to a key they hold. With host read access to the share that is the whole master key, which is why the group must be people who already have it.

{
  "version": 1,
  "users": {
    "uff8d9819fc0e12bf": {
      "public_key": "…", "scope": "web", "profile": "contributor",
      "invited": "…", "provisioned": "…", "key_issued": "…", "removed": null
    }
  }
}

Four properties, each load-bearing:

  • Keyed by slug, and no e-mail address anywhere in it. system_slug already keeps addresses out of /home, the process table and branch names. The spool routes by box key and a box key is the slug, so a recipient can be named without publishing who they are.
  • Acts, not states. invited / provisioned / key_issued / removed are what the operator did, dated when they did it, so nothing else can make them stale. Registration state and disabled stay on the host and are joined in at display time: a mirror of disabled reading active for a locked-out account is exactly the stale copy that gets trusted.
  • Two floors, one file, no sweep. users add / change / remove write it as the operator; user-authorize / key-split write it as any maintainer, recording the key they just sealed to. Each verb writes both places it needs to — the host's SoR through the sudo'd admin CLI, and this file for what every rung must read without sudo — so there is no registration step to remember and no repair sweep to run. users list reports drift if they get out of step anyway.
  • Total reader. Absent, mangled (conflict markers included) or from a future schema all read as empty, because every caller's fallback is to ask the host, and a roster that raised would break commands that used to work without it.

No key material. Half the master key lived here briefly, on the argument that a single share of a 2-of-2 split is a uniformly random point and reveals nothing. True, and beside the point: this repository is public, so a half anyone can clone is not a second factor. It now lives at /etc/euler/share.json on the host — see the Secret Sharing Guide.

This file used to carry profiles/grants/objects — an object:permission grant vocabulary whose real job was deriving per-path filesystem ACLs on a shared operator tree. Per-user uids retired that entire layer: each collaborator is alone in their own clone, so there is nothing to partition by path. A legacy-shaped file still loads; only its users map is read.

6.4 Enforcement

  • Shell, at decoration time. As each command module imports, @register checks the declared floor against the subject's profile. If it does not clear, the command is not registered — invisible to ?, help, and completion, and unknown command (exit 127) if invoked. One process serves one identity, so the set is fixed for the process's life.
  • Web routes. The app router gates each route against the requester's X-Profile — the same policy, the same ladder.

modules.csv is a pure loader manifest (module, registers_commands). Every module imports; commands the subject cannot hold simply do not register.

The channel (terminal vs web) is not an authorization axis. In the per-user model the isolation boundary is the uid, and a user's web shell is a shell in their own sandbox — no different from their terminal — so gating on channel earned nothing. subject.channel survives informationally: show/edit still branch browser-tab versus OSC-to-pane.

Dropping that axis had one consequence worth stating, because it removed a backstop rather than a redundancy: users list had been kept off the web by the channel gate alone, and its non-admin path printed the whole roster — every collaborator's email and profile. The fix belongs at the command, not the ladder: users list for a non-admin is scoped to the caller's own entry, and the full roster is an admin view.

Staleness is re-login. The subject resolves once at process start, so a policy edit takes effect at the next login or shell start. users change/disable/remove and password reset all revoke sessions, and the auth service additionally pushes a teardown to any live web shell (§12) — so revocation is immediate on every plane, not deferred until a running process happens to die.

6.5 Administration — the users command

users, like msg and the key-distribution commands, belongs to the web host: it loads in the web shell and in solver-host (the operator's terminal on the host — the solver shell plus these commands), never in a plain solver (docs/package-guide.md).

users splits by verb. users list registers at the reader floor and self-scopes for non-admins. The mutating verbs require admin and go through the wheel-gated admin plane: the CLI (euler_site.auth.admin) re-executed under sudo, writing the root-owned policy file and reaching the euler-auth admin socket.

Sudo rather than a bespoke admin group is deliberate. The operator is a sudoer anyway, and the operator's ordinary uid is the most exposed on the host — browsers, dev tooling, AI agents all run there. An operator-readable admin token would let any of them silently mint admin invites, gating the highest-privilege API below sudo. The token lives only in root-readable auth.env, and the admin socket (0600, euler-auth-private) is never routed by Caddy.

users list                                    # roster; non-admins see only their own entry
users add alice@example.com                   # WEB: provision + mint an emailed invite (default: reader)
users add bob@example.com contributor         # WEB: invite a contributor
users add vikas admin                         # LOCAL: a bare os-login → direct map entry, no invite
users change alice@example.com contributor    # promote / demote
users disable alice@example.com               # also kills live sessions + remember tokens
users enable  alice@example.com
users remove  alice@example.com               # delete the account/entry, pending invites, and deprovision

Every verb writes both places it needs to. The host's system of record — profile in authorizations.json, SRP state in the service's own store — through the sudo'd admin CLI, and the roster (§6.3) for what every rung must read without sudo. There is deliberately no key-registration verb: the public key key-rekey re-issues against is recorded by the grant that issues one (user-authorize), so the two files stay in step by construction rather than by a sweep somebody has to remember. users list reports it when they have drifted anyway.

It holds public material only, so losing it costs a re-authorization, never access. An account with no key in the roster loses access at the next rotation, which key-rekey names before you confirm.

The accounts that predate the roster were migrated once (August 2026): their acts dated from the registration record and the enc-key file's mtime, and the duplicate public_key column stripped from the auth service's own store. One store, one copy. The migration script was deleted with the commit that ran it — it was a migration, not a tool.

The roster itself moved out of the repository shortly after, for the write-path reason above; scripts/setup/auth.sh (deploy_roster) lays down the group, the directory and an empty file, and seeds the group from everyone the policy already ranks maintainer or admin.

A rotation publishes before it issues, and that order is load-bearing. key-rekey re-encrypts the tracked private tree under the new key, commits only solutions/private (by pathspec — git commit otherwise sweeps in whatever else is staged) and pushes it to origin/master, and only then sends anybody their payload. Publishing second would strand the first person to act on their message: a rotation makes every earlier blob unreadable, so the instant they take the new key their own HEAD stops decrypting, and every way out goes through HEAD — git stash materialises it, so git-sync cannot even reach its merge. They would be stuck being stale with no self-service repair. A push that fails is therefore fatal to the rotation: nothing is issued, and re-running mints a further key, which costs nothing because nobody ever received the last one.

The receiving half re-homes the clone. Even publishing first, a collaborator's own HEAD predates the rotation, so msg act checks whether it still decrypts and moves the clone onto the published tree when it does not — git reset --hard origin/master, the one worktree-wide command that never has to materialise the unreadable HEAD on the way. Uncommitted private edits are carried across as plaintext, collected before the new key is written, which is the only moment they can be told apart from the rotation's own churn; plaintext is key-agnostic, so they re-encrypt cleanly on the next commit. A clone whose HEAD already does not open — wedged by an earlier rotation it never re-homed from — is asked nothing, because it has no truthful answer: with the held key not matching HEAD every present private file cleans to a different blob and reads as an edit. Unpushed commits stop it: reset would orphan them, so the clone is left alone and the situation named. git-sync performs the same re-home for a clone that wedged before any of this existed, minus the edits it can no longer identify.

Which side is unreadable says what to do. A clone that has not yet been issued the new key is perfectly coherent — its own HEAD opens under the key it holds — and it is origin/master that it cannot read. Nothing local fixes that: the key is in the spool. So git-sync fetches first, asks both questions, and refuses before merging when the incoming blobs are the unreadable ones; merging anyway cost a live collaborator a filter traceback and a half-applied merge that left a private file deleted in their worktree. The login banner asks the same pair, so a stale clone is told how to take the key rather than the generic "run git-sync to see why" — which, after a rotation, points at the one command that cannot work. The instruction follows the channel: over the web it names the header's message chip, whose key row is a save-labelled button, so nobody has to find and type a message id; a terminal, having no header, gets msg list / msg act <id>.

add has two paths. An @-address is the web path: provision the collaborator (§7), write the map entry, and mint an emailed invite — provisioning happens before the invite, so there is never a dangling invite to a box with no shell. A bare os-login is the local path: just the map entry, no invite, because a local login authenticates by being that OS user.

admin is assignable only to a local os-login. The admin API validates a web identity against reader/contributor/maintainer. The identity resolver, by contrast, does not cap a redeemed ticket — so an email mapped to admin out-of-band (a root edit of the policy file) would carry admin over the web. That is the accepted posture in §14, not an oversight: containment for the highest-privilege operations is the profile grant plus SRP plus uid isolation, with no channel backstop.

There is no reset verb — reset is self-service.

6.6 Command floors

Every command's declared floor is generated from the live registry into authorizations.md by update-docs. The shape of it:

Floor Commands
reader ls, show, results, test-cases, problems, progress, search, git-status, git-sync, git-filter, user, vault, msg (own threads + send to staff), ?/echo/clear/pause, key-reconstruct
contributor new, edit, evaluate, benchmark, compile-c, lint, mark, !, claude-api, claude-solve, costs, git-commit, git-push, git-hooks, git-identity
maintainer summary (rewrites the shared progress state), gh-merge (merges a solutions-only pull request), msg send to anyone but staff, user-authorize and key-split (grant enc-key access — the staff floor, so whoever receives a key request can act on it), git-publish, update-usd-rate, web file-delete
admin users (the whole command, sudo'd), key-rekey, host-authorize / host-unlock, update-docs, update-models, pip-upgrade, sys-setup

Two floors deserve their reasoning. ! (raw bash) sits at contributor, not admin: in the per-user model a shell escape grants nothing that evaluate's arbitrary Python did not already, and it is the user's own uid sandbox either way. AI commands sit at contributor because the credentials are the user's own Anthropic key from their own vault — it is their spend, not the operator's.

7 · The per-user tier

One systemd template instance per collaborator — euler-user@<slug>.service, User=<slug> — serves both that user's content routes and their /ws shell, all operating on their own ~/euler. Caddy routes every request for a user to their one socket by X-User-Slug.

Each instance is born as the right uid: no process changes uid, nothing runs as root, no setuid anywhere. It is socket-activated (euler-user@<slug>.socket), so idle invitees cost nothing and systemd starts the instance on first attach.

Content and shell are one service because the isolation boundary is now the uid. Splitting them would separate two things that already share a sandbox — one service kind, one socket per user.

home  /home/<slug>/                                (0700, uid-private)
  ├─ euler/          their repo clone — content, branch user/<slug>
  │    └─ solutions/private/**   ciphertext until enc-key authorized (§9)
  └─ .euler/         their vault (§8)
       ├─ id         their X25519 private key, encrypted under the vault key
       ├─ env        their ANTHROPIC_API_KEY etc., encrypted
       └─ vault      {salt, iterations, wrapped_vk}

Nothing in that directory is a password. A password at rest beside the ciphertext it unlocks hands it to exactly the reader the encryption exists to stop, leaving the 0700 home as the only real protection — which is the thing the vault is meant to improve on.

The application is shared and read-only; the content is per-user. One /opt/euler venv everyone runs, pointed at each user's own tree with EULER_REPO_ROOT=~/euler.

The ~/euler + ~/.euler layout is not cosmetic: solver.config already derives the secrets directory as root_dir.parent/.{root_dir.name}, so a repo root of ~/euler yields a secrets dir of ~/.euler with no new path logic — the private key, env file, and .state all fall into place per-user for free.

Defence in depth on routing. The instance carries an identity middleware that refuses any request whose X-User maps to a different system_slug. In production Caddy already routes by the slug forward_auth returned, so this only ever agrees — it is the code-side backstop if the edge is ever misconfigured.

7.1 Provisioning

Account creation is an admin act, from a terminal, under sudo, and it handles no secrets at all. users add <email> <profile> drives scripts/setup/user.sh:

  1. create the system user <slug> (in the euler-user parent group) and its 0700 home at /home/<slug>. The slug is u + 16 hex of SHA-256 of the e-mail — 64 bits, because it is both the uid and the edge's routing key, so a second address sharing it must be infeasible to find. It is recorded as that address's in /etc/euler/owners.json (root 0600, slug → SHA-256 of the e-mail), and provisioning refuses a slug recorded for another address, or an existing account with no record — never adopted;
  2. clone ~/euler directly from the public GitHub repo — anonymous read, no credentials — with the crypt filter disabled, checked out on a fresh branch user/<slug>. Origin stays the GitHub URL, so the user pushes as themselves later;
  3. lay down ~/.euler (0700) and the socket-activated instance;
  4. mint the web invite; the user sets their password, and their vault initialises on first login;
  5. no keys, no Anthropic secret — those are the user's own later, self-service steps.

The clone lands ciphertext for free. The crypt filter is registered in a clone's local git config, which is never cloned, plus a tracked .gitattributes rule. A fresh clone inherits the attribute but not the filter definition, so encrypted blobs pass through untouched. GitHub only ever holds the filter's ciphertext anyway. So a user who is not yet key-authorized cannot read private plaintext even though the files sit in their own home — the git filter enforces the enc-key layer with no extra machinery.

Repair — sudo bash scripts/ops/reset-user.sh <slug> fetches and hard-resets one collaborator's clone to origin/master, as that user. It exists because a clone can reach a state its owner cannot leave with the verbs their profile has. git-reset is at reader now, so a branch merely ahead of origin/master is self-service — but it is --soft by design, which is exactly why it is not the answer here: a conflicted merge or a half-checked-out worktree needs --hard, and no rung is given that. It discards their local commits and uncommitted changes — that is the point, and it prints what it is about to destroy and asks first. It runs git as them, never as root, because root-owned objects in their home turn one wedged clone into a permanently wedged one. It also passes /etc/euler/egress.env through explicitly: the host is default-deny outbound and Squid is the only route to github.com, which the services get via EnvironmentFile= and a sudo -u shell inherits not at all — without it the fetch fails at DNS rather than at the network. If the reset fails decrypting solutions/private — their vault is unlocked only inside their own session, and a sudo -u shell has none — --no-filter resets with the filter not required, leaving ciphertext for their own git-filter install to decrypt in place.

Teardown (users disable / remove) stops and disables the instance and removes the home. Dropping the account's master-key access is a separate admin act (key-rekey, §9) — it needs the master key, which the root provisioning plane deliberately never touches.

8 · The vault

A user's secrets — ~/.euler/id (their X25519 private key) and ~/.euler/env (their Anthropic key and anything else) — are stored encrypted, so they are opaque to the operator at rest rather than protected by file permissions alone.

Envelope encryption, not direct password encryption:

  • A random per-user vault key VK (32 bytes) encrypts the secrets with AES-256-GCM, a fresh nonce per blob. A magic header marks a file as vault ciphertext, so plaintext and encrypted forms both load transparently.
  • VK is stored wrapped under PK = Argon2id(password, "euler-vault-pk:" ‖ salt) (64 MiB, 3 passes), reusing the SRP salt, in ~/.euler/vault — which also records the salt and the KDF, so the terminal path is self-contained.
  • Use: derive PK → unwrap VK → decrypt. A password change re-keys the vault: a fresh VK, the few secret files re-encrypted under it (crash-safe, docs/vault-guide.md).

The browser derives the identical PK from the password it already holds and the salt already in the SRP challenge, with the same vendored Argon2id it uses for the SRP key — no extra round-trip, and the label keeps the two derivations apart. The cost is paid once per session.

8.1 Delivering the key

The code that needs VK — load_private_key and the git clean/smudge filter — runs in subprocesses (git spawns the filter once per file), so VK must reach the user's children, not just their interactive shell.

VK lives in a uid-private tmpfs file (0600), written at session start and removed at session end. Only its path is exported, as EULER_VAULT_KEY_FILE, and inherited by subprocesses so the filter finds it. The key itself is never in any process's environment — not in ps e, not in core dumps, not in every child's /proc/environ — only in the tmpfs file and, transiently, in the memory of the code decrypting with it.

Where VK comes from:

  • Web — the browser derives PK at login and passes it to the user's own service at shell attach; the service unwraps VK and writes the tmpfs file. PK and VK never touch server disk. The terminal page unlocks before the first /ws attach, so the forked shell inherits the key file. Note that the auth service cannot derive PK — it never sees the password — which is precisely what keeps the vault opaque.

  • Terminal — $EULER_VAULT_PASSWORD if the operator exported one (a script, CI, an unattended run), otherwise the shell asks, once, at startup (solver.crypto.keys.unlock_session) and writes the tmpfs file its children inherit. There is deliberately no password file: the earlier ~/.euler/user_pass made the terminal vault ceremony — anyone who could read the ciphertext could read the password next to it, so encrypting bought nothing over the 0700 dir that was already there.

    The asking has to happen in the shell, at startup, because the readers that need VK are subprocesses — the git clean/smudge filter above all — with no terminal and a stdout that belongs to git. solver.crypto.vault is on that path and therefore never prompts; every interactive part lives in solver.crypto.keys. A declined or wrong password is not fatal: the shell runs locked, and the private solutions and claude-api are what stay unavailable.

    Only the terminal is ever asked — main.py gates the startup unlock on subject.channel. On the web the vault is the browser's job by design (it derives PK and the service writes the key file before forking the shell), so a web shell that stopped to ask would be routing through the server the one secret that deliberately never goes there — and, on a locked vault, would stall every attach behind a prompt the user cannot see. The explicit vault unlock command still prompts on any channel: that one the user asked for.

    A session removes the key file it created, and only that one. write_session_key mkstemps one file per unlock, so without cleanup they accumulate for the life of the tmpfs and a VK outlives the shell that made it — "locked" then means nothing until logout, since anything running as that uid can lift a working key off disk without the password. keys.unlock_session therefore unlinks its own file at exit. The distinction is ownership, and it is the whole point: on the web path the per-user service writes the key file and shares it across every shell that user has open, so a shell that cleaned up an inherited file would lock the others — and the service's own session — out mid-flow. A file we merely found is never ours to remove. (Best-effort: SIGKILL runs no handler. This bounds the pile to live shells, not to every shell that ever ran.)

    One mkstemp per session is deliberate, not an oversight to "fix" with a single stable path: concurrent shells would then share one filename, and the first to exit would unlink it out from under the rest.

    The installers (scripts/setup/*.sh) read the same encrypted ~/.euler/env for the deployment's authoring config, through scripts/setup/authoring_env.sh → python -m solver.crypto.readenv, which returns the same dotenv lines whether the file rests as plaintext or ciphertext — so a kit works on both sides of vault init.

The routes are on the user's own service: GET /vault/status, POST /vault/unlock (unlock, or initialise on first login with the SRP salt; a mismatched PK returns 409 stale, never destructive), POST /vault/rekey, and the socket-peer POST /internal/vault-reset. The browser keeps PK in sessionStorage and auto-unlocks on page load. Logout locks the vault and removes the key file.

8.2 Change versus reset

These differ fundamentally, and the difference is a feature.

  • Change (current password known): the secrets survive, under a new key. PK is derivable from the old password, so the old VK opens the secrets, which are re-encrypted under a fresh VK wrapped by the new PK. Every session still holding the old VK ends: the auth service drops the other sessions and the web shell, and the changing page signs itself out.
  • Reset (password forgotten): the vault does not survive — by design. VK is recoverable only from the password. An SRP reset re-mints the login verifier and shares nothing with the vault, so the new password's PK cannot unwrap the old VK. The secrets are unrecoverable and must be re-provisioned: user --regen (new keypair → admin re-authorizes) plus re-uploading the Anthropic key. The reset flow re-initialises the vault rather than leaving a stale blob wrapped under a dead PK.

That destruction is the point: a malicious operator cannot use reset as a covert vault backdoor, because a reset visibly destroys the vault — the user's keys stop working. A recovery-code escape hatch is deliberately not offered; it would add a second VK holder and weaken exactly this property.

8.3 What the vault does and does not promise

State it precisely, because the honest boundary is narrower than "zero-knowledge" and over-claiming here would be worse than not having the feature.

The secrets are used server-side — the Anthropic key by claude-api, the private key by the git filter, both in the user's own shell process — so at time of use VK and the plaintext are in server memory. A malicious active root can read that memory (ptrace) or modify the shared solver code to exfiltrate. No design protects a secret from the entity that controls the CPU it runs on.

The guarantee is therefore exactly: encrypted at rest and opaque to a passive or honest operator; not protected against a malicious active root. This is the same boundary the master key has always lived with — root could always scrape it from a smudging process — so the vault extends at-rest opacity to per-user secrets rather than claiming something new.

8.4 The vault command

vault status | init | change-password. init migrates the operator's own plaintext ~/.euler/id and env in place. user --regen persists the new key vault-encrypted when a vault is unlocked, and refuses when locked.

9 · Private solutions: the enc-key layer

Trust is two independent dials, and every combination is valid:

Layer Grants Mechanism Set by
Ladder which commands you may run authorizations.json admin, at users add / users change
enc-key whether you can see private-solution plaintext your X25519 public key wrapped into keys/enc-key.json admin, later, on request

A contributor without a key edits public problems and sees ciphertext for private ones; a contributor with a key can decrypt and edit them; a reader with a key can read private plaintext but not edit. The ladder is a capability gate; enc-key is a content-visibility gate. They do not interact.

The crypto master key is wrapped, per authorized public key, in the tracked file keys/enc-key.json — proof of possession: hold the matching private key, unwrap the master key, smudge and clean. (See gitfilter-guide.md.)

  • Each user has their own X25519 keypair, and their own enc-key file: ~/.euler/enc-key.json, two records — verify, and the master key wrapped to their public key. Machine-local, 0600, beside the private key that opens it. Nothing about key material is in the checkout, committed, or pulled.

    It was tracked until v1.6.0: keys/enc-key.json, every authorised key, distributed by git. That put per-machine key material into shared state and everything painful followed — a rotation dirtied a tracked file, sync.sh stashed and popped it around the merge, the pop conflicted with the authorised copy coming the other way, and the markers left the JSON unparseable, which every reader takes for "not authorised". A reader could not even repair it (git-reset was contributor-floored then; it sits at reader now). It also needed an attribution map, a purge verb, a machine-local overlay and a repair path in git-sync — all to manage a sharing nobody wanted.

  • Issuing is a deliberate staff act, never self-service — it grants decryption of the whole private corpus, so user-authorize sits at maintainer: the floor that receives a key request is the floor that can act on it. The flow: the user runs user, which files a request through the message spool (§13) carrying their public key; a maintainer runs user-authorize <message-id>, which wraps the master key to that public key and sends the payload back as its own message; the user runs msg act <id>, which writes their enc-key file and wires the filter. The distribution channel is the spool, and the payload travels through it for the same reason the old file could sit in a public repo: without their private key it is inert.

  • The message id, not the key. user-authorize takes either — they are told apart by shape (64 hex is a key, 16 is a thread id) — but the message form is the one to reach for: the key comes out of the request and the requester comes from the thread's author, which the spool resolved from SO_PEERCRED when it was filed and which no sender can dress up as somebody else. It refuses rather than guesses (the subject must be the key-request one, the body must hold exactly one hex token) and asks before granting. The worked request is dismissed once the grant is delivered.

  • Taking a key proves before it writes. The subject must be the key-issue one, the payload must unwrap with this machine's private key, and its verify must decrypt to the known text. Only then does it replace the file — which may be the only thing between this machine and the whole private tree, so a mistargeted or corrupted payload has to fail before the write, not be discovered after it.

  • A rotation needs nobody. user --regen mints a key pair and re-wraps the master key to it in place, because the file is this machine's alone. A request is filed only when the master key cannot be loaded at all — a first run, or a rotation whose carry failed. A locked vault never reaches that path: the private-key persist refuses first, so nobody is asked to re-issue to somebody who already holds the key.

  • Revocation is key-rekey (admin), and it is the only thing that revokes. It rotates the master key, writes the operator's own file first (a rotation that fails half way must still leave the operator able to decrypt the tree it just re-encrypted), re-issues by message to every public_key on the roster (§6.3), and re-encrypts the private tree. An account with no registered public key cannot be re-issued to and is named before you confirm — that person loses access at this rotation, which is sometimes the intent and must never be a surprise.

  • The operator's own terminal cannot reach the spool, by design: /run/euler/msg.sock is euler-web-only and the operator's uid deliberately is not. Both directions therefore go through the sudo plane (euler_webshell.msg.client), which is why a rotation or a grant may prompt for a password and a collaborator's own commands never do.

user-authorize and key-rekey never need a user's private key, so the vault never interferes with enrollment or rotation. A user can have a working vault and no master-key access at all — their pubkey simply is not in enc-key.json.

The account page's public-key row states exactly that, because "am I authorized?" is the only question the row exists to answer: it reports can / cannot decrypt from an actual read_master_key() — the unwrap and the verify, not the presence of a file — and shows the hand-it-to-an-admin instruction only when the answer is cannot. An authorized user has already done it, and being told again reads as though it had not worked. Every failure mode collapses to one answer here (no keypair, a locked vault, no entry in enc-key.json, a key that no longer unwraps after a key-rekey): not yet.

10 · Git

Git is native in the user's own clone — there is no broker, and nothing proxies it. git-status/git-sync/git-reset sit at reader; git-commit/git-push/ git-hooks/git-identity at contributor; gh-merge at maintainer; git-publish at admin.

git-reset is the one ref-moving verb at reader, and it earns it by what --soft does not do: it keeps the working tree and makes no commit, so it can neither destroy work nor put anything on master. The blast radius that floors every other write verb is empty here, while the contributor floor stranded the rung least able to help itself — a reader cannot commit their way out of a clone that has drifted ahead of origin/master.

git-commit is the one commit verb: what it stages is named by its targets (solution, solutions, docs, blogs, update) rather than by a command per body of work, and --amend folds into HEAD instead of a second verb. gh-merge is the one merge verb, and it reads from the same table: its allowlist is the union of those targets, so a pull request may carry whatever git-commit could have staged — several commits, spanning several targets — and one file outside it refuses the whole request. There is no one-tree rule and no per-body-of-work gate.

  • git-identity (scripts/git/configure-identity.sh) is the user's one-time self-service setup: gh auth login (device flow, which works in a web shell), gh auth setup-git, and user.name/user.email from their GitHub profile. Their commits are authored and pushed as them.
  • git-sync carries the enc-key pull flow. After the pull, if the filter is unwired and read_master_key() now verifies — meaning an admin authorized this user's key and pushed — it wires the clean/smudge filter and re-checks out solutions/private. Ciphertext becomes plaintext in place. Silently a no-op otherwise.
  • git-push pushes the current branch as the user (-u origin <branch>); --force-with-lease only after a rebase, and never on master. Pushing master needs admin. It then opens the branch's pull request onto master (gh pr create, the same shape as scripts/git/publish.sh): landing on master needs gh-merge merge, so the PR is how a collaborator actually asks for their work to land, and a branch sitting on origin that nobody has been asked to review is not delivered work. Idempotent — a branch already under review has its URL reported rather than a second PR opened, so re-pushing as the branch grows keeps working and the open PR picks up the new commits. Skipped on master, on a branch level with origin/master (nothing to review), and with --no-pr.
  • gh-merge is the one gate to master: list shows what is waiting, merge <number> squash-merges it. A pull request must sit wholly inside solutions/ or wholly inside blogs/ — anything outside is refused, and a branch spanning both is asked to become two pull requests (one review is of one kind of work), and that gate is what puts the command at maintainer rather than admin: merging a branch of solutions or blog articles is reviewing content, but a branch that also edits the framework, the scripts, or the keys is asking for something else, and belongs to an admin reading it on GitHub. The file list is read from the pull request (gh pr view --json files); a list that cannot be read refuses the merge rather than passing for an empty one.

So a collaborator's work lands on master only through a maintainer's review, and only when it is solutions. That is the review gate the web must not bypass, and it is enforced by the floor plus the scope check rather than by convention.

The merge is a squash, which rewrites the branch's commits into one on master. The collaborator's next git-sync absorbs that: the rebase drops the now-duplicate commits (git matches them by patch-id) and the prune drops the merged branch, provided the repo deletes head branches on merge. Both halves are needed — without the prune, the deleted branch lingers as a stale origin/user/<slug>; without auto-delete, there is nothing to prune and the old branch shadows the next push.

11 · The site

Importing this package must stay free. packages/site/euler_site/site/__init__.py re-exports nothing: the content service's app, its gitstate reader and, through that, solver.crypto.wire are reached by naming the submodule, never by importing the package. The auth service reads exactly one module from here (content, for the start page's README), and it is the untrusted-input surface that holds no key material — so it must not acquire the crypt filter's configuration as a side effect of an import.

That was not hypothetical. The package used to re-export build_app for convenience, which made from euler_site.site import content execute the whole content app. It stayed invisible while the crypto package invented a repo root whenever it could not find one; the moment that started refusing (§ Git), the auth service — which has no EULER_REPO_ROOT, because it needs no working tree — raised at import, never signalled readiness under Type=notify, and sat in activating. Caddy authenticates every request through it, so the site went down with it. Nothing had ever imported those names from the package.

11.0 Two content roots: the public site and your clone

The content is served from one of two copies, by the same app (packages/site/euler_site/site/app.py) with the same templates (docs/package-guide.md § 4):

Visitor Served by Content Surface
Not signed in euler-site (python -m euler_site.site, EULER_SITE_PUBLIC=1), /run/euler/site.sock the packaged copy: the euler-content wheel in /opt/euler, built from tracked public files only — README.md, docs/, blogs/, solutions/public/, solutions/problems.json, and the code the code viewer shows (packages/, scripts/, the root config files) read routes only (build_public_app): no terminal, no git chip, no account, no writes; identity headers are never read
Signed in their own euler-user@<slug> instance their own ~/euler clone everything, by profile

The docs, blogs and public solutions are published unencrypted on GitHub anyway, so asking for a login to read them protected nothing. What the public site cannot show is structural, not a check: the wheel holds no solutions/private (scripts/content/stage.py refuses one), and the unit runs ProtectHome=true on a uid of its own, AF_UNIX only — no clone is even visible to it. A private problem's page shows its title and solved state from problems.json (public on projecteuler.net), and nothing else.

Caddy picks the copy by the session cookie: a GET/HEAD of a content path (/, /solutions/…, /docs/…, /blogs/…, /about/…, /shell, /story/…) from a browser with no solver_session / solver_remember cookie goes to site.sock; everything else goes through forward_auth as before (§ The edge). That is not a trust decision — either copy is public content to whoever asks — so a forged or missing cookie gains nothing. The code viewer (/code/…, § The code viewer) is the exception that ignores the cookie: it exists on the public site alone, so every /code read goes to site.sock, signed in or not. A stale session cookie (expired, or dropped when the auth service restarted) makes forward_auth answer 401, and on a read of those same paths the edge then serves the public page too — the reader is signed out, so they see what a signed-out reader sees, not a login form in its place — and expires the dead cookie, so the next request goes straight to the public site. The exception is a browser that also holds a remember-me cookie: that may still sign it back in, so it goes to the login page, which tries it first (§ 4.3). Anything other than a public read with a stale cookie — /account, a POST — goes to the login page as before.

In the templates, browsable (navigation is live: a subject, or the public site) is separate from subject (what is yours: Actions, the terminal, the chips, the account). On the public site the right pane, where the terminal would be, is the way in: the sign-in form itself (_signin.html, the /login page's form from _loginform.html), so signing in costs no extra page. It sits in the same window a signed-in reader's terminal does — the titlebar, its connect dot shown disabled (nothing to connect until you sign in) and its hide button live, the header's terminal chip with the same two rows, the footer's hidden window — so the pane looks and behaves as it will after sign-in. Unlike the terminal it starts hidden (rendered ws-hidden, so a no-JS page is never covered by it), on its own per-tab flag (sessionStorage), and the header's Login brings it back. The auth tier's own pages pass auth_tier, which keeps the chip and the footer window inert there: their right pane is the dialogue itself. The public copy is as current as the last make redeploy-web — it is versioned with the code (the release that installed it), where a signed-in visitor sees their clone as it is.

11.1 The app shell

GET / serves the whole shell; every other path renders into a region of it. Four fixed regions occupy exactly the viewport — the page itself never scrolls; each middle pane scrolls its own overflow.

┌──────────────────────────────────────────────────────────────────────────────┐
│ HEADER  eiπ+1=0 │ Solutions · Docs · Blogs · Terminal │ Actions │ ⌂ crumbs… │ ❯● │ ⑂ main │ 🯅 │
├──────────────────────────────────────┬───────────────────────────────────────┤
│  LEFT PANE  (#content)                │  ┌ solver terminal ─── ● Connect ─ ─┐ │
│  navigable content, htmx-swapped;     │  │ the solver PTY terminal over /ws │ │  equal
│  deep-linkable; scrolls ↕ and ↔       │  └ persists across left-pane swaps ─┘ │  width
├──────────────────────────────────────┴───────────────────────────────────────┤
│ FOOTER                       © · license · terms · acknowledgements │▭ terminal│  fixed
└──────────────────────────────────────────────────────────────────────────────┘
  • Header — one control surface, identical on every page: the brand (→ /), primary nav, the Actions menu (page-specific verbs, always present even when empty), the three navigation marks — back, refresh, bookmarks — breadcrumbs, the terminal chip, the git chip (§11.9), and the user glyph (Account, Logout).

    The back arrow is not redundant with the browser's. The browser's back navigates the document and would take the terminal with it, so the pane keeps a history of its own and performs a swap, not a navigation. Refresh is the same move for the current page, and for the same reason: the address bar's refresh would drop the shell.

    Scroll memory. Every page the left pane shows reopens at the scroll offset it was last left at, for the rest of the browser session — however you return: back, refresh, a nav or crumb link, the terminal's show, the browser's back/forward, or a reload. The browser cannot provide this because the window never scrolls; #content is its own scroll box. site.js files the offset per path (pathname + search) in sessionStorage (euler:scroll) just before the pane is swapped and when the document is hidden, and applies it after a GET lands in #content — again once MathJax has typeset the page, which changes its height. A #fragment wins over the remembered offset; swaps from writes (save, delete) leave the scroll alone; the terminal's scrollback is xterm's own and is never touched.

    Bookmarks (_bookmarks.html) is the third of those marks and the one that leaves: everything in it opens in a tab of its own. Almost all of it is the places the work refers to that this app does not host — Project Euler (site, archive, your progress), the repository and its pull requests, and the five references a solver actually opens mid-problem (OEIS, MathWorld, Wolfram|Alpha to check a result, and the standard library docs of both solution languages — Python's and cppreference's C). The two same-origin entries are The Euler story, the presentation deck at /story/ (§ 11.5, The story deck), and The source code, the code viewer at /code/ (§ 11.5, The code viewer): this host serves both, but as pages of their own rather than pane views, so they belong with the links that leave. They are a menu rather than nav items because the nav names pages this app renders into the left pane. The list is static chrome, so it is markup in the partial, not context — which is also what lets the auth tier render it without a clone or a config. Every entry wears the .ext ↗ marker and opens in a new tab — site.js's externalize() does that for the off-site ones, the deck's link carries target=_blank itself — so following one never navigates the pane the terminal lives in. It stays live signed out — with the brand and the user pill, the third control that is not dimmed there, because an off-site link needs no session (the deck does: signed out, it lands on the login).

    The terminal chip and git sit inside .app-who, before the user glyph: margin-left: auto pushes that group right, and both belong on the identity side of the gap — they are state about your session and your clone, not about the page in the pane.

    Connect/disconnect lives on the terminal window's own titlebar (§ Right pane), next to hide: the session is a property of that window, and a control on the thing it acts on needs no explaining. It is one entry, not two, and it is just the dot — state and act in one mark, sized to the hide button beside it. No verb: a word in a 1.6rem titlebar competes with the terminal's own name, for a control whose two states a colour already tells apart. What it will do is its title, which is also its accessible name and which site.js flips with the state. The user menu carries no terminal item at all; it is for getting places.

    The header's terminal chip reports both of the terminal's states, because they are independent and either one explains a shell that is not answering: the session (connected/disconnected) as the dot's colour, and layout (visible/hidden) as a slash struck through the glyph. The tooltip says both in words (terminal — connected, hidden). It exists because the titlebar can be hidden away (§ Footer), and the one thing that must never become unreadable is where the session stands.

    And it offers the act for each. The chip used to report only, with every control on the terminal window's own titlebar — which is exactly where they are unreachable in the state a person most needs them: hide the window and the titlebar goes with it, leaving the header saying "hidden" beside no way to bring it back but the nav's Shell item. It is now a <details class="menu"> on the same chassis as messages, git and the user pill, with a row per state: connect/disconnect and hide/show. The titlebar's controls are unchanged and stay the closest ones to hand while the window is there.

    Every details.menu closes on a click outside it. A click in the terminal lands in the iframe's document, which the page's listener cannot see, so terminal.js reports each press as {euler: 'pane-click'} and site.js closes the open menus.

    Both rows carry the attributes site.js already dispatches on, so the panel added no wiring: [data-term-toggle] for the session, [data-term-hide]/[data-term-show] for the layout. The session row is one control that flips (the act on offer is always the opposite of the state, and site.js writes its label, title and accessible name from the same string), while hide and show stay two single-purpose controls — site.js's invariant, so neither can lie about a state it did not read. They read as one flipping row because CSS shows whichever applies (body.ws-hidden), which keeps both the invariant and the single row a person expects.

    The chip is deliberately not .term-menu. That class marks a menu whose verbs type into the shell, which site.js dims when the socket is down — and Connect is precisely what someone looking at a disconnected chip came for.

    A terminal control is any [data-term-toggle] carrying a [data-term-dot] (its title and accessible name are painted, so a control needs no text of its own); a readout is any [data-term-status]. The iframe reports {euler: 'term-state', connected} on every open and close and site.js paints all of them from that single state, so the titlebar's toggle, the header's chip and the git panel's offline note cannot disagree — and a session that drops on its own never leaves a control offering to disconnect something already gone. They render disconnected and are painted on load: markup is served before the socket has said anything, and a control that claims a live session it has not been told about is a claim nothing can correct.

  • Left pane #content — the navigable region. Links hx-get a route and swap it here; hx-push-url updates the URL, so every view is deep-linkable.

  • Right pane #ws — a same-origin iframe onto /terminal, its own document. This is what makes terminal persistence structural rather than a matter of discipline: htmx swaps, content-page JS, and history restores cannot reach another browsing context's document.

  • Footer — right-aligned: © · license · terms · acknowledgements, all swapping into the left pane (auth-tier pages — terms, change password — return a bare fragment on HX-Request so they render in #content without nesting a page), and — only while the terminal is hidden — its window at the very end.

    Nothing is reserved for it: it is display: none while the terminal is visible, so the documents hold the right edge on their own and hiding it pushes them left by exactly the window's width. What appears there is a window collapsed to its titlebar — window figure · name · show glyph — so what it is and what clicking it does are the same picture.

body is a viewport-high grid (auto 1fr auto); the panes are equal (1fr 1fr) independent scroll containers (min-height: 0). Controls never move between pages; only pane content changes.

One layout, signed out and signed in. The auth service's pages are standalone public documents on their own routes, but they are not a separate place: they render this same base.html (its own base.html is gone, and its ChoiceLoader takes web/templates first, then site/templates). The split is the two panes:

  • the left pane is the start page — _home.html, the one partial both tiers render;
  • the right pane holds the sign-in dialogue, standing exactly where the terminal will.

subject is the only switch. Signed out the header is present and inert (55% opacity): the visitor sees the shape of the place before being let into it. The brand and the user pill stay live — they are the two controls that still do something (home, and the way in), and the pill reads as empty, a dashed ring, never as disabled. The section strip's tiles keep their boxes and their copy, as <span>s with a padlock where the arrow goes, because a visitor should see exactly what is behind the login. No count is shown signed out — neither the solved count in the lede nor the strip's data lines: the auth service has no clone and no problems.json, so it cannot know them, and the tiles fall back to prose.

The auth tier builds a subject from its session cookie (shell_context in auth/app.py) purely so the header renders correctly on the two pages a signed-in visitor can still land on — /terms and /password, both deep-link fallbacks. It is presentational only: that service's routes gate on session_identity, never on it.

Signed out, base.html's shell_assets block is overridden away: no htmx and no MathJax. Nothing swaps and nothing carries TeX, and a login box has no business pulling a megabyte of typesetter. /about/license and /about/acknowledgements sit behind forward_auth, so the footer dims them signed out rather than offering links that bounce to /login; /terms is an auth route and public, so it stays live.

A README summary is rendered below the strip on the start page, in both tiers — an extract, not the whole README, closed by a "read the full README" link (in-app to /docs/readme signed in, out to GitHub signed out). It comes from the packaged copy packages/site/euler_site/content/home-summary.md: a tracked, generated file that the update-docs command rebuilds from the slice of the root README.md between its <!-- HOME:START --> / <!-- HOME:END --> markers (root stays the source of truth), shipped as package data via pyproject.toml. Both tiers read that copy, cached per process: euler-auth runs from /opt/euler with ProtectHome=true and has no clone to read, and the start page must say the same thing whatever branch a collaborator is on. /docs/readme and /about/readme still read the clone's full README — that is the docs viewer's contract (a contributor editing docs must see their own edit), not an inconsistency.

11.2 Visual identity

The brand is Euler's identity, e^iπ + 1 = 0. The glyph and favicon are its geometric reading on the unit circle: the upper semicircle from 1 to −1 (the rotation by π) plus the unit segment from −1 back to 0, a two-stroke SVG in accent orange. The wordmark is the formula itself, not the word "euler".

Two themes, dark by design. Dark is the default and the palette of record; light is the same set of tokens re-chosen for a pale ground, under :root[data-theme="light"]. The header's toggle (beside the user pill, live in both tiers) switches between them. site.js loads from <head> without defer and sets data-theme on <html> as its first act, so the page never paints the wrong ground first: the reader's stored choice, or with none made, the system's prefers-color-scheme, followed live. The choice is kept in localStorage under euler-theme, the key the code viewer (/code/) shares, so one choice holds on both. Without JS there is no data-theme, the page is dark, and the toggle is not shown.

token dark light
--bg #0f1115 #f4f5f7
--surface #171a21 #ffffff
--surface-hi #1f2430 #eceef2
--border #2a2f3a #d6dae2
--text #e5e7eb #1a1d24
--muted #9ca3af #5d6472
--accent #f97316 #c2410c
--accent-ink #fed7aa #9a3412

The light accent is orange-700, not the brand orange: #f97316 on white is under 3:1, too faint to read as link text. The state colours deepen with it for the same reason.

Detail colours are tokens too: grid heat (--heat-1…5, mixed from the accent and the ground, so they follow the theme without light values of their own), file git status, and a semantic set kept separate from the accent — --ok / --danger for state pills and destructive verbs, --error-* / --notice-* for the auth flashes. So are the few values that used to be literals: --on-accent (ink on the primary button), --shadow / --shadow-deep, --chrome (the sign-in window's titlebar), and --plate.

Everything that is not CSS follows the theme by event. Each change is announced as euler:theme on window. The terminal's iframe is its own document, so it loads site.js too and themes itself the same way; a toggle in the page reaches it as a storage event (fired in every other same-origin document when the key changes), which also keeps other open tabs in step. terminal.js re-reads the tokens into xterm's options; the editor (editor.js, which also draws a solution file's read-only view, so a file reads the same viewed as edited) holds its theme in a CodeMirror Compartment and reconfigures every mounted view in place, buffer and history intact — oneDark's syntax colours on the dark ground, CodeMirror's default (light-page) highlight style on the light.

Statements follow it too. projecteuler.net's emphasis colours — class="red", inline style="color:…" and the LaTeX \color{…} — all map onto the --st-red/blue/green/orange tokens (content.STATEMENT_COLOURS): lifted and softened on the dark ground, deepened on the light. The LaTeX form becomes \color{var(--st-red)}: MathJax's color extension passes a name it does not know through verbatim as the element's CSS colour, and the replacement carries no # into the TeX (§ the \color race below). Transparent diagrams are classified by their ink (ink-dark, ink-light), and each theme plates the kind its ground would hide: dark line art on a light --plate in the dark theme, light line art on a dark one in the light theme.

site.css is the site's only stylesheet, loaded by both tiers, and Caddy serves it from /assets ahead of forward_auth (frontend.sh) — so a signed-out page needs no session to be styled. There is no app service in the path for static assets. (There were two stylesheets, site.css and auth.css, duplicating the same tokens; collapsing them is what makes one layout possible. The two form styles collapsed with them: .auth-form is the one, and .pane-form is gone.)

Everything is self-contained and same-origin: no external fonts or CDNs, a system font stack, vendored htmx and MathJax from /vendor. Statements and notes carry math as TeX text ($…$); MathJax typesets on load and after every swap.

That vendoring has to include what MathJax fetches at runtime: its woff fonts, and the TeX extension for every macro outside the bundle's default packages — <bundle root>/input/tex/extensions/<pkg>.js for \color, \pu, \style, \boldsymbol, \enclose and \unicode. Same-origin, so script-src 'self' admits it; missing, it 404s and the noundefined package renders the macro as its own name in red — the "\color in red" a cached statement showed before the extensions were vendored. The set that ships is the set the cached statements actually use (packages/site/euler_site/content/vendor/README.md lists it, and how to re-scan).

Those six are preloaded, and the swap re-typeset is chained. Both halves are about the same failure, and it took two goes to see it whole. MathJax's autoload fetches a package on first use — in the middle of a typeset — and site.js used to fire MathJax.typesetPromise() behind a bare if (window.MathJax && MathJax.typesetPromise) guard. That guard loses on either side of its race with the deferred bundle: too early MathJax is still the plain config object and the swap is never typeset at all; a moment later typesetPromise exists but startup has not added the packages, so the typeset runs against a half-configured TeX input and every non-default macro renders as its own source. Reaching a problem by the terminal's show did exactly that, printing \color[RGB]124, 192, 255 mid-sentence, while a refresh of the same URL looked perfect — which is what made it read like a content bug rather than a wiring one.

So loader.load fetches the six at startup, and the swap re-typeset waits on mathReady, resolved inside MathJax's own pageReady (after the loader and the initial typeset). Later swaps queue behind their predecessor, which MathJax requires — concurrent typesets on one document are unsupported, and it is the rapid sequence (show 238, show 91, show 238) that made this reproducible at all: a single swap always recovered. Reproduced in headless Chrome against the real vendored bundle, failing before the change and clean after, and pinned by tests/test_web_verbs.py::MathJaxWiringTests.

The terminal follows the theme, with a contrast floor. Its ground, text and cursor are the tokens. The shell's own output is not: it paints with colours chosen for a dark terminal (solver.config.theme — near-white #e5e7eb body text, amber command names), and it knows nothing about the page. On the light ground that would be near-white on white, so the light theme sets xterm's minimumContrastRatio to 4.5: xterm darkens any foreground short of that against its cell's background, keeping the shell's hues and making them legible. The dark theme sets it to 1, leaving the shell's colours exactly as chosen.

The narrow layout (≤ 820px). A phone, or a narrowed window. One pane at a time, filling the viewport (100dvh, which a phone's collapsing toolbars do not overflow): split, neither half of a phone is usable, a terminal a dozen rows tall least of all. So the terminal, when shown, covers the content, and hiding it (the titlebar's handle, sized for a thumb at this width, or the header's chip) is the way back. It starts hidden on a narrow screen, waiting in the footer, so a phone opens on the content. A pane that moves to another page hides it again — the header's nav, the back arrow, the shell's show each asked to see a page — but a re-render of the same page (the refresh arrow, a nudge from the shell after a command) does not, or the terminal would vanish mid-typing, and neither does a control that asks for the terminal too ([data-term-show]: the nav's Shell item). The narrow state is the document's own: never written to storage, so showing the terminal on a phone does not change what a wide screen remembers, and the wide flag never opens a terminal under a phone's reader. Crossing the breakpoint repaints from whichever flag now rules. The CSS and site.js share the one breakpoint (tests/test_site_layout.py holds them together).

Form fields are 16px at this width. iOS zooms the page into any focused field set smaller and does not zoom back out, and the site's 13px root put every field there — the sign-in form, the filters, the editor's buffer and xterm's hidden input alike.

Signed out, the body's class says which pane leads (base.html § body_class): the public site's pages arrive ws-hidden, so the content leads and the sign-in window waits in the footer exactly as a phone's terminal does (Login shows it, covering the content); is-dialogue on the auth pages (auth_base.html) shows the dialogue alone, because there it is the page; and the terms page, a document, takes is-signin, which shows the content alone. The header keeps one control surface but sheds what a phone cannot spare — the separators, the breadcrumbs, the formula beside the glyph, the controls that are locked anyway — and breaks onto two rows at a fixed place: where to go (brand, nav, Actions) above, where you are and who you are (back, refresh, bookmarks; the chips, the theme toggle, the pill) below. The section strip (_sections.html) is hidden there too: on a phone it is four stacked tiles repeating the header nav's four links.

11.3 Writing style

The voice of pages the service authors — index copy, blurbs, status lines, not the rendered guides themselves:

  • Never state the obvious. No "click a link to navigate"; no restating what the reader is looking at.
  • Humour, lightly. The register: "Twelve guides stand between you and the next unsolved problem. Read one, ignore the rest, feel briefly invincible."
  • Intuitive and useful. Every sentence either orients or enables; cut the rest.
  • Block boxes for items. Enumerable things render as cards, two or three per row — not bullet lists.

11.4 Path ownership

Owner Paths
Caddy-native / static /healthz, /assets/*, /vendor/*, /favicon.ico
Auth service /login, /register*, /reset*, /forgot, /password, /terms, /auth/*
Per-user service everything else, including /ws

The static row is why one stylesheet is enough: /assets/* and /vendor/* are handled by Caddy above the forward_auth block, so the signed-out pages load the same site.css as the shell with no session and no app service in the path.

The auth service's pages render the shell (base.html, _nav.html, _home.html, the crumbs and actions partials) from site/templates and override none of it — the chrome has one source of truth, not a copy that drifts. Its own templates/ dir holds only what is genuinely auth's: auth_base.html and the dialogues.

11.5 Routes

A fragment is what an hx-get/hx-post renders into #content; a direct hit on the same path returns the whole shell with that pane pre-populated, so links are shareable and reload-safe. Writes always return a fragment, never the shell.

Method Path Renders Floor
GET / the app shell; left pane = landing (_home.html + README), right pane = the /terminal iframe reader
GET /terminal the right pane's standalone document: xterm.js + the /ws client reader
GET /shell the Shell item's target page: the four start tiles + docs/user-guide.md reader
GET /solutions/ problems.json as 10×10 century grids + summary reader
GET /solutions/{n}/ statement, then test cases · results · files · notes reader
GET /solutions/{n}/{filename} one problem file reader
GET /topics… · /edit/topics… the old name of /blogs: 301 to the same path there, so existing links keep working —
GET /docs/ · /docs/{name} docs index (a card grid per group of content.DOC_GROUPS) · a rendered doc reader
GET /docs/file/{path} a doc-referenced repo file, from the readable roots only reader
GET /blogs/ · /blogs/{name} blogs index · a blog page ({name} may be a nested folder/page path) reader
GET /about/{name} footer pages: readme · license · acknowledgements reader
GET /story/ · /story/{path} the presentation deck's own files, raw: docs/presentation/ (§ The story deck below) reader
GET /code/ · /code/{path} the code viewer, public site only: a folder's contents or one file, highlighted; ?raw the file itself (§ The code viewer below) none
GET /account identity + the profile ladder, the icon, the credential panel, the password form reader
GET /account/icon this user's uploaded icon reader
POST /account/icon choose a standard icon or upload an image; answers with the panel reader
GET /git the header git chip's contents — the refresh the shell, the menu-open and the poll ask for (§11.9) reader
GET /messages the header's message chip alone — count, rows, verbs (§13) reader
GET/POST /edit/solutions/ progress upload (empty buffer) → save the page, run summary in the terminal maintainer
GET/POST /edit/solutions/{n}/{filename} file editor → save contributor
DELETE /edit/solutions/{n}/{filename} delete a bare .py/.c → the problem-page fragment maintainer
GET /ws the PTY WebSocket attach reader

Canonical trailing slash. Every GET path is canonical with its slash; a slashless GET returns a 301, so each view has exactly one URL.

The /docs/file/ view may serve only the service's readable roots — docs/, blogs/, packages/solver/solver/templates/, solutions/, README.md, LICENSE, and the vendor README.

The story deck. /story/ serves the project presentation (docs/presentation/, "Eleven Years of Puzzles") from the collaborator's own clone as the static files it is — not rendered into the shell, since it is a self-contained fullscreen page with its own scripts, styles and fonts. The bookmarks menu opens it in a tab of its own. /story/ is its index.html; only the deck's file types (.html, .css, .js, .woff2, .txt) are served, and nothing outside the folder (content.resolve_file refuses traversal and symlink escape), so anything else in the folder is not. Every response is a FileResponse, so it gets no-cache and the baseline CSP like any other file. The deck is written to fit that CSP — every script is a file, no inline <script>, and its fonts are bundled (docs/presentation/fonts/, SIL OFL) because default-src 'self' refuses a Google Fonts stylesheet; test_story_deck_fits_the_csp holds it there. It honours a system that asks for reduced motion, and only there offers a Motion button (key M) to override that for the deck alone; motion.js makes that one decision, and test_story_deck_motion_has_one_owner holds every script and stylesheet rule to it. The git middleware skips /story/ like the static trees: a deck load is a dozen files, none of them wearing the chip. The data it shows (data/history.js) is what update-presentation-data last generated and committed — public metadata only, never an answer.

The code viewer. /code/ shows the project's own source — packages/, scripts/, and the root files that configure them (content.CODE_ROOTS: .gitattributes, .gitignore, .shellcheckrc, install.sh, LICENSE, Makefile, mypy.ini, pyproject.toml, tox.ini) — as one standalone page: the file on the left, the tree on the right, where the app shell keeps the terminal. It is the public site's alone (add_content_routes(public=True), handler code_page, reads in euler_site/site/code.py): scripts/content/stage.py ships the scope in the content wheel, and the edge sends every /code read to site.sock whatever the cookie, so every reader — signed in or not — sees the code of the last deploy, one copy, no clone involved. The repository is public on GitHub, so the page shows nothing that was not already public; tests/test_public_site.py holds the wheel's roots and CODE_ROOTS together.

  • Paths resolve through the viewer's index of the scope, never the filesystem, so a traversal or a name outside the scope is a 404. A folder is canonical with its slash (/code/scripts → 301 /code/scripts/); /code/ is the top.
  • The headline is the file's summary: the first sentence of what it says about itself — a module docstring, a Markdown heading, a pyproject.toml description, or the leading comment block after any shebang, encoding line or template {% extends %} — cut at the first sentence end (or a : that opens a list), without the (docs/….md § n) references it cites. A folder speaks through its __init__.py, pyproject.toml or README.md. With nothing to say, the headline is the name. The tree offers each summary on hover, and the filter matches it.
  • The code is highlighted server-side by Pygments into numbered lines, each its own #L<n> anchor; the :target line is marked, so a line link needs no script. A file over 160 KB or with a line over 1000 characters (the minified vendor bundles) is shown uncoloured; one over 512 KB, or not text, is offered ?raw only. ?raw answers text/plain with nosniff, so a template or script is read, never run.
  • The page is the story deck's family: its bundled fonts (from /story/fonts/, which the CSP allows as same-origin), its paper-and-ink palette in light and its developer palette in dark — the system's choice, or the header toggle's, kept in localStorage. code.css and code.js are versioned assets like the site's own. Below 820 px the tree is a drawer behind a Files button. Without script the page still works: the tree follows the file, and only the filter, the toggle and the drawer are missing.
  • Links in. The bookmarks menu's The source code, and every guide or blog link into the scope that /docs/file/ cannot serve (content._repo_link): those become /code/… with target="_blank" and the .ext marker instead of a pane swap. What neither viewer shows still goes to GitHub.

Page chrome via out-of-band swaps. Breadcrumbs, Actions and the git chip live in the fixed header, which htmx never re-renders — so every fragment response carries them as hx-swap-oob alongside the pane content, and a full-page render places the same partials directly. One source of truth per page: the handler supplies crumbs and actions, and the git middleware supplies git. Actions is filtered by the subject's profile, but hiding is UX; the route's floor is the boundary.

Page-specific verbs go in the Actions menu, never on the page. A page's controls — Edit, Save, Delete, a "show drafts" toggle, a blog's Write / Rewrite — belong in the header Actions menu, built as the handler's actions list, not rendered as links or buttons in the content pane. One place to look for what a page can do, one rendering path, and the pane stays the content. An Action is {label, kind, …}: kind is get (an hx pane-swap), post / delete (an hx mutation), submit (submit the pane's editor form), or term (type its command into the web shell via data-term-cmd, the same one-execution-path move the git chip's verbs make — so the command's own requires() is the real boundary). The back-step is the breadcrumb trail in the header (and the ← control beside it); a page carries no "← docs" / "← blogs" link of its own.

11.6 Content pages

  • The five index pages — home · solutions · docs · blogs · terminal — are one layout. From a distance they are the same page: a hero, the section strip, the divider, and then the page's own content. What changes below the divider is all that changes.

    KICKER · H1 · lede
    
    ┏━━━━━━━━━━━┓┌───────────┐┌───────────┐┌───────────┐   the section strip:
    ┃π Solutions┃│§ Docs     ││λ Blogs   ││❯ Terminal │   the current one lit,
    ┃311 of 1007┃│16 guides  ││859 of 1007││● connected│   each carrying live state
    ┗▔▔▔━━━━━━━━┛└───────────┘└▔▔▔▔▔━━━━━━┛└───────────┘
    
    GUIDES ────────────────────────  filter…          16   the divider
    ┌───────────┐┌───────────┐┌───────────┐┌───────────┐
    │ the page's own tiles, grids or prose              │
    

    The hero's three slots say three different things, in the same order on all five pages. The kicker is the page's one witty line — an aphorism, lowercase, no full stop; it is not the section name, which the header nav and the strip already say twice. The h1 is the plain title (Solutions, Docs, Blogs, The solver shell; on home, the site's own name). The lede is the fact worth knowing, told with the same voice as the kicker — a count and what is still ahead of it, never a restatement of what the content below already shows.

    The section strip (_sections.html, one definition for all five pages) carries live counts, not prose: it repeats the header's nav on every page, so it has to say something the header cannot — how much is solved, how much is written, whether the shell is up. The Shell tile's line is the socket itself, painted by site.js from the iframe's report ([data-term-dot], [data-term-state]), never asserted by the server. The counts come from content.section_state — problems.json, a docs/ scan, and the union (not the sum) of the problems the finished blog articles reach.

    It is the same tile object as the grids below it, in a quieter register: flat on the page ground, no lift on hover, because chrome does not move and content does. Two grids of the identical object would read as one field of eight. The page you are on is the one filled tile on the strip, wearing an inset accent edge and, in the corner slot, a you-are-here mark instead of the → — there is no journey to offer to the page already in the pane. The strip is exempt from the filter for the same reason.

    The divider (_divider.html) is the .subrule device — a mono label trailed by a hairline — doing three jobs: it names the region below, it narrows it, and the count at the end of the rule is the count of what survived. Typing hides the cards that miss and folds away a folder heading whose whole grid went (on /blogs/ each folder is itself a <details>, arriving open, whose foldings are remembered for the session like the scroll and the query — and which a live query opens wherever it matched; the divider carries expand all / collapse all beside the filter, on the page rather than in the Actions menu since the public site has none; its cards run widest reach first, content.list_blog_groups); on the century grids it dims instead, because a cell's position is what says which problem it is and hiding the misses would renumber the grid under the reader. The query is remembered per section for the browser session (sessionStorage), so returning to the blogs resumes the narrowed view without arriving pre-typed over the guides. The start page has prose below its rule and nothing to narrow, so it states that instead of offering a dead box.

    /shell is that layout with the docs index cut to four (content.SHELL_DOCS, the guides worth having at a prompt) and then the command catalogue itself — the generated docs/commands-catalogue.md, rendered in place, which is what a reader standing at a prompt is actually looking for. It closes the page instead of a link out to the docs index: a page about the shell should end with what to type into it. The one filter narrows the shelf and the catalogue's rows together, so git leaves the git commands standing. /shell is what the header's Shell item and the Shell tile swap into the pane while they show and focus the shell: the page about the thing the click just took you to. Not to be confused with /terminal, which is the shell (the right pane's own document).

    The tile is one object across every page — the strip, docs, blogs, and the head of every century grid on solutions — and it is two lines, because that is what it has to say: what it is, and one fact about it.

    line what it holds
    first the section's mono glyph (π · § · λ · ❯), the title beside it, and the corner slot — arrow / padlock / final pill
    second the supporting line

    The glyph sits with the name it marks rather than on a line of its own, so a 16-tile grid reads as 16 short blocks and not as a column of floating glyphs. A title too long for its track takes a second line (then ellipsis), with the glyph and the corner mark staying on the first.

    The supporting line is the rule that makes the pages one system: mono when it is a number, body face when it is a sentence. The strip, blogs and centuries carry a count (95 of 240 solved, from blogs/articles.json and problems.json); a guide carries prose (its # title). Nothing carries both — a tile says one thing about itself. Signed out the strip has no clone to count, so its tiles fall back to prose rather than to a number that would be invented.

    Where the count is a fraction of work done, the tile also carries a 2px progress hairline on its bottom edge, filled to that fraction. It appears on blog tiles and century tiles and nowhere else: its absence on a guide says "not a progress thing" rather than "zero". The width is the app's one inline style — it is the datum, and there is no class-per-percent worth having (style-src 'unsafe-inline' is already a recorded exception, below).

    The corner slot holds affordance or state, never decoration. The → fades in on hover/focus — sixteen resting grey arrows in a grid this dense is noise, and a bordered box on a card grid already reads as clickable — while the padlock (signed out), the final pill and the strip's you-are-here mark are state and stay put.

    Four per row is set for the pane the terminal leaves (half the viewport); below a 1200px viewport the grid drops to two and below 700px to one, because under ~150px a tile truncates its own title.

    The Shell tile, and the header's Shell item with it, points at two things at once: the shell already here in the right pane, and /shell, the page about it. So each is an ordinary hx-get link that also carries [data-term-show] — the pane swaps to /shell, and the terminal is shown when it has been hidden to the footer and focused either way. Show means "take me to the terminal", and a control that reveals the shell but leaves the next keystroke going nowhere has done half its job; site.js does not preventDefault, so both halves run on the one click. Focus is the iframe's to give ({euler: 'focus'} — the parent cannot reach xterm's hidden textarea across the boundary). The socket is untouched: connecting is the titlebar's act (§ Header).

  • Account — identity (email, and the slug that names the system user, home and clone that are theirs alone), then the profile ladder: the four rungs with theirs lit and the ones below it filled, because the ladder is cumulative and a reader who saw only their own rung lit would misread it. LADDER is the kernel's own tuple, so the rendered rungs cannot drift from the model the checks use. Then the icon (site/icons.py): one of a standard set or an uploaded image, worn here and on the header's pill. It is kept in the user's .state/<slug>/; site.js scales an upload to a small square before sending it, and the server keeps only PNG, JPEG or WebP, by their leading bytes, never SVG, because the bytes come back under this origin. Then the credential panel (the per-user service's /account/vault fragment) and the password form. Changing a password lives here rather than in the user menu: it is an account thing, and the menu is for getting places.

  • Solutions — the 10×10 century grids: square cells (and so square grids, via aspect-ratio), packed as many per row as fit (auto-fill), shaded by difficulty with the heat tokens, title and pct on hover. Each grid is headed by the tile: the range where the glyph goes, 36 of 100 solved on the data line, and the hairline along the bottom edge pointing straight into the grid it measures — a century is a blog you have or have not worked through, so it says so the same way. It is an <h2>, not a link (there is no per-century page), so it keeps the chassis and drops the hover.

  • Progress upload (maintainer, the summary floor) — an empty paste buffer, because this is a replace, not an edit: the previous .progress.html is superseded wholesale, parse-or-reject before anything lands. The upload writes only that page; the import is summary's, which the response runs in the user's terminal (HX-Trigger: {"euler-term-run": "summary && …"} → the same run message the git menu's verbs post), chained with && to the regenerating verbs in data-flow order — update-tags, update-presentation-data — less any above the uploader's rung (_PROGRESS_CHAIN). update-models, update-docs and update-usd-rate are left out: there is no admin over the web shell, and they rewrite source and settings (packages/solver/solver/ai/models.py, docs/, packages/solver/solver/config/values.conf) that an upload has no business moving. So the upload gets everything summary does and this tier must not: the solved-preserving merge into problems.json, the staff notice for unregistered answers, fetching new problems' statements, and the commit. When the shell has re-written problems.json it emits OSC 5379 progress;, and a listener inside solutions.html (euler:progress-changed from:body) re-reads the grid — scoped by presence, like the account panel. A disconnected terminal cannot take the command; the status line says to run summary there.

  • Problem — statement first, then test cases · results · files · notes. Two off-site links on the meta line (projecteuler.net and the GitHub solution directory). Test cases render as a table, not raw JSON. Files flow horizontally, plain-text links, zero-size files hidden, each name coloured by its git status with the status spelled out in the hover title.

    The statement is foreign content, and it assumes a light page. It is projecteuler.net's own markup, cached verbatim, and two things in it are written for their white ground. content.prepare_statement meets both on the way out — the dark ground stays the design, because a bright panel in the middle of the page would be the more visible bug.

    Emphasis colours. A statement asks for colour three ways, and all three land on content.STATEMENT_COLOURS: the LaTeX \color{…} MathJax renders (the case that started this — no stylesheet can reach it, since by the time CSS applies MathJax has turned it into a fill), an inline style="color:…" (which CSS could only beat by shouting !important), and their class="red" spans (the stylesheet's .statement .red rules, scoped so a generic .red cannot collide). Each maps to the same hue lifted for a dark ground: #3333ff on #0f1115 was the reported case. A colour outside the table is left exactly as written — the author's own value beats a guess.

    The TeX form emits \color[RGB]{r,g,b}, never a # literal, and that is not a style preference. # is TeX's macro-parameter character, and \color arrives from an autoloaded extension (§ the vendored TeX extensions above): on the first statement to use it, MathJax parses the argument as ordinary math while the extension is still in flight, and a # there is You can't use 'macro parameter character #' in math mode — a rendered error, not a retry. A first cut that emitted \color{#7cc0ff} shipped this and it hid well, because the race only loses one way round: reaching a problem by htmx swap hit the window and broke, while a full refresh had the extension cached and rendered perfectly. \color{blue} never showed it — {blue} is valid math, four italic letters — so every colour projecteuler.net actually writes was immune. [RGB]{r,g,b} is the LaTeX-standard model form (the vendored color.js implements rgb, RGB, gray, named — there is no HTML model) and contains nothing math mode objects to; it is verified in a headless render both with the extension loaded and with it deleted outright.

    Transparent diagrams. A see-through image takes the page's ground. Of the cached statement images 43 are dark line art on transparency — a black diagram on a near-black field, on problems as ordinary as 15, 86 and 91 — while 29 are light ink that our ground suits exactly, and 183 are opaque and carry their own background. content.plate_statement_images reads the pixels (_image_ink, Pillow, cached per file by mtime and size) and marks only the dark-ink ones ink-dark; the stylesheet gives those a light plate. The pixels are the only honest source — projecteuler's class="dark_img" sits on 37 of the dark-ink images and 20 of the light-ink ones, so it does not tell them apart in our cache. p0091 carries one of each in a single statement, which is the whole reason no single background could have worked. Pillow's absence degrades to "no plate", which is exactly the old rendering.

Every off-site link opens in a new tab. site.js stamps target="_blank" rel="noopener noreferrer" on any link whose host is not ours, on load and after every swap. It is a document-wide rule rather than per-link markup precisely because the riskiest links are the ones we do not author — cached projecteuler.net statements, notes' reference links. Following one in place would tear down the shell, and with it the terminal.

Rendered-doc links are rewired for the shell: a foo.md cross-link becomes the /docs/ route; a repo-relative ../<path> becomes the /docs/file/<path> viewer (so one authored link resolves both on GitHub and in-app); every internal link gets hx-* so it swaps the pane in place.

11.7 The save gate

The checks every write passes, in packages/site/euler_site/site/validate.py: .py (auto-fix, then flake8 over stdin), .c (scratch-dir compile against the runner header), .json re-indent, and .html through nh3.

notes.html is served back and rendered, so writing it verbatim would be stored XSS. nh3 runs a tailored allowlist on save — article/h3/h4/p/ul/li/table/code/a, keeping a's class/target/rel/href, MathJax $…$ surviving as text — and the sanitised output is stored, with the editor showing the diff. Store-clean rather than reject-and-restore because nh3 always normalises (it adds <tbody>, rewrites rel), so rejecting would bounce essentially every save.

nh3 gates what is stored; the CSP blocks what would execute. Independent layers.

Notes stay raw HTML rather than Markdown: they are semantic HTML5, as the AI convention and the entire existing corpus already are, with math as MathJax TeX text. nh3 closes the stored-XSS hole; there is no hand-authored-raw-HTML hazard left for Markdown to remove.

11.8 Content-Security-Policy

Every served page carries one, emitted by an app middleware (packages/site/euler_site/csp.py, shared by every rendering service) because a strict policy needs a per-response nonce — the app that renders the page mints it and stamps it into both header and template.

default-src 'self'; script-src 'self' 'nonce-<per-response>' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';
frame-ancestors 'self'; base-uri 'none'; object-src 'none'

Scripts allow no unsafe-inline and no unsafe-eval, ever.

Three entries are recorded exceptions rather than oversights:

  • script-src 'wasm-unsafe-eval' — the password key x is Argon2id (srp.js, §4), from the vendored hash-wasm, whose WebAssembly a page may compile only with this keyword. It admits WebAssembly.compile/instantiate and nothing else; JavaScript eval and new Function stay blocked, and the module itself is a pinned, SRI-checked 'self' file. It is on every page because the sign-in form is in the public site's right pane, and the password change swaps into the signed-in shell, whose policy is fixed when it loads.

  • style-src 'unsafe-inline' — MathJax v3, CodeMirror 6, and htmx's indicator rules all inject stylesheets at runtime with no nonce hook (verified empirically: headless Chrome logged the violations, and the math rendered garbled under bare 'self'). The residual risk is CSS injection only, and the markup paths that could carry it are closed upstream — Jinja autoescape, and nh3 strips style tags and attributes from stored HTML. Script execution remains nonce-gated.

  • frame-ancestors 'self' — the shell frames its own /terminal document. Cross-origin embedding stays blocked.

connect-src 'self' needs no wss: exception: the terminal's WebSocket is same-origin, and 'self' covers the upgrade to the page's own host. Verified — headless Chrome logs zero violations across the shell and the framed terminal with the socket live. (CSP 3 says so; it was worth confirming because CSP 1 did not, and older engines required listing the wss: origin.)

Since htmx uses hx-* attributes rather than inline script, it coexists with a strict policy — but avoid hx-on: handlers, which do not.

11.9 The git chip

The header's chip (_git.html) reports this collaborator's clone — branch, divergence from origin/master, worktree counts, and whether the private solutions are readable — and offers the verbs that move it. Same <details class="menu"> chassis as Actions and the user pill: one menu idiom in the header, not three.

Quiet when clean. An in-sync branch with nothing uncommitted is a glyph and a name in --muted — no counts, no tick. Every mark that appears is something to act on: ahead in --git-staged (yours, ready to go), behind in the accent ink (the pull you owe), and a --git-modified ring for a dirty worktree — a ring, not a disc, so it cannot be read as the terminal's connect dot two controls to its right.

One read per navigation. packages/site/euler_site/site/gitstate.py reads the chip on each navigation; a middleware in install_content stashes it on the request (render is sync and cannot await), and render._context picks it up. Three commands, staged by what each one needs, so a partial failure costs only the part that failed:

  1. git rev-parse --abbrev-ref HEAD — the branch. A pure ref read: no worktree scan, no filter, works with the vault locked. The spine; if even this fails the clone is genuinely unreadable (unknown, "state not read").
  2. git rev-list --left-right --count origin/master...HEAD — the divergence. Also refs only, no filter.
  3. git status --porcelain=v2 — the worktree file counts. The one read that scans files.

Always origin/master. Not the branch's tracking branch, and so not git status's own # branch.ab — that field counts against origin/user/<slug> and answers "have I pushed?". The question this workspace turns on is "how far am I from master?", because master is where work lands and git-sync closes the gap. The chip measures what scripts/git/status.sh measures, against the same ref; the two must not drift.

The worktree read needs the vault; branch and divergence do not. With the filter wired, git status hashes solutions/private/** through the clean filter, which needs the master key, which needs the vault unlocked. Right after login the vault is still locked — the browser posts /vault/unlock only after the first render — so this read fails on the first paint. Because it is staged last and on its own, the chip is then worktree_unknown, not blanked: branch and divergence show, the panel says "worktree state pending — unlock the vault", and a hollow dot (not the amber dirty ring) marks it, because three zero counts we could not read must never render as "clean". site.js's auto-unlock fires euler:git-changed the moment it lands, so the worktree fills in a beat later without a navigation. (Before this staging the whole chip read "state not read" on every first load — and its cause was invisible, because the read discarded git's stderr; it now logs it, since 128 alone names none of a locked vault, a dead filter, or a missing ref.)

Freshness against the remote — fetch where it is worth blocking, not on every swap. The divergence answers "how far am I from origin/master?", and the honest answer is against the remote as it is now: a clone that never fetches reads "level" while the remote moved ahead (the bug that surfaced this). So gitstate.read takes fetch, and runs a git fetch origin master — like status.sh — before the count when it is set. A fetch is a network round trip that blocks the render it runs on, so the caller spends it only where the user expects freshness and not on every pane swap:

  • full page loads (login, reload — not HX-Request) and the chip's own /git endpoint (its git-changed refresh and the 10-minute poll) → fetch=True;
  • content navigations → the local ref, never blocking on the network.

git-sync fetches on its own, so after one the ref is already current. Every fetch is throttled on .git/FETCH_HEAD's mtime (which any fetch resets, git-sync included), so rapid reloads — and a load right after a sync — do not re-hit the remote; a slow or offline remote falls back to the local ref within a short timeout rather than hanging the load.

The chip refreshes itself, and swaps its CONTENTS to do it. _git.html is the element — the <details>, its triggers, and the .term-offline class site.js paints on it; _git_menu.html is what /git returns and what lands inside it (hx-swap="innerHTML", and hx-swap-oob="innerHTML" for the per-navigation swap). The element has to survive its own refresh, because it holds the open state — this is the shape the message chip settled on first (§13), and the git chip joined it when the second trigger below arrived. Consequence worth knowing when editing: every state class the chip wears rides on the <summary>, not on the <details>, since an inner swap cannot reach the outer element's attributes.

Three triggers, no overlap:

  • euler:git-changed from:body — the shell moved the clone. Git commands run in the terminal, which is not a navigation, so the web shell says so over OSC 5379 and site.js turns it into the event (the vault's auto-unlock fires it too, to fill in a worktree the first paint could not scan).
  • toggle[this.open] throttle:1s — the user opened the menu. The push above can be missed: it rides the terminal's WebSocket, so a disconnected or hidden terminal drops it silently, and an external push moves origin/master with nothing local to notice. Opening the menu is the one moment a person is asking where their clone stands, and it is exactly when the answer must not be whatever the last navigation happened to read. The filter keeps it to opening — a bare toggle fires on close too, spending a read on a panel nobody is looking at any more.
  • every 600s — the slow poll, so an external push shows without the user reloading or running a git command. /git reads with fetch=True, so this is the one that goes to the remote on its own. It used to be a separate <span class="git-poll"> in the header, because an oob outerHTML swap on every navigation restarted an every timer placed on the chip; with the contents swapping instead, the element survives and the timer lives where it belongs — and no longer fires on a tier that has no clone and no /git to answer it.

Two inert states, and they are not the same. No clone at all (git is None — the auth tier, or a uid without access to .git) renders a plain span: no id, no triggers, nothing targets it, and nothing ever will within that document. A clone that is there but would not answer (unknown) renders the real chip, wearing an absent branch name and saying "branch state could not be read" — it keeps the verbs, because git-status --details in the terminal is exactly the diagnosis the chip cannot make for itself, and it keeps the triggers, because an unlocked vault a moment later resolves it. The "private solutions" row is dropped there: that state never reached the read that sets filter_wired, and reporting "locked" would be a guess dressed as a fact.

--no-optional-locks. git status normally takes .git/index.lock to write back its refreshed stat cache. The chip is a reader on a page render, and the user is typing real git commands into their terminal one pane away: a status read that grabs the index lock can make their git-commit fail. This is git's own answer for status displays — a little repeated hashing, no lock.

The verbs type into the terminal. Each is a [data-term-cmd] button (site.js → the /terminal iframe), exactly like the account page's tool rows: the web shell is the front door, so there is one execution path and one audit trail. Hence every verb names the command it types — the menu is a way into the shell, not around it. The floors come free: the command lands in a shell that already resolved this subject, and requires() there is the boundary whatever the menu shows. git-commit needs no problem argument, because the shell supplies the one it is on, and no message either — the command asks for one at the prompt, so the row starts the commit rather than completing it.

The rows name the work, not the commands. "Save my work", not "Commit"; "Land a pull request", not "Pull requests"; "Un-commit, keep my changes", not "Reset to master". The command is on every row regardless — the macro renders it into .cmd beside the label — so the menu still teaches exactly what it types, which frees the label to say which of the day's jobs this is. A panel whose labels are the command names is a transcript of the shell rather than a way into it, and git-commit solution docs blogs is a fine thing to run and a poor thing to call a button. They are ordered as the job runs: see what changed, take what others landed, save it, hand it over, land someone else's.

One commit row, three targets. git-commit solution docs blogs is a single button for "save what I did", whichever of those the afternoon went into. Naming three targets costs nothing when only one has changed: a clean target contributes no path and the commit says so, which is exactly what makes one row viable where two previously asked the reader to decide which half of their own work they were saving. solutions and update are deliberately not on it — staging the whole tree, or regenerated package source, is not a one-click act.

One merge row, because there is one queue. The panel carries a single gh-merge merge (maintainer), which offers the open requests as a menu — so the row needs no number and a maintainer sees everything that is waiting. It used to carry a second gh-merge-docs button, because the two gates were disjoint and half the queue had no button otherwise; with one allowlist there is one queue and one row.

The message chip's merge row names its own request. A PR_REVIEW_SUBJECT notice is about one branch, so msg act on it resolves that branch to its request number (solver.core.git.merge_pr_for_branch) and lands it. Offering the whole queue as a menu there would ask the reader to pick out a request they had just been told the answer to — and let them pick a different one. The branch in the subject is therefore load-bearing in both directions: it names the request to merge, and it dismisses the notice afterwards.

A disconnected terminal is a designed state. terminal.js's send() drops the frame on a closed socket, so a click would silently do nothing — the one outcome a control must never have. site.js paints .git-offline from the same single termConnected every other [data-term-*] control reads; the panel then says so and offers Connect.

Locked private solutions point at Account, not at a self-service unlock. The private row reports decrypted/locked (gitstate.filter_wired), but when locked it offers no git-filter install verb: the common locked cause is a reader not yet granted enc-key access, for whom install just errors ("gain key access before installing") — they need an admin to authorise their key first, and the chip cannot cheaply tell that apart from a key that merely has not been wired. So the row links to /account, where the state is known (can_decrypt, an X25519 unwrap too costly per-navigation) and the right action is offered: the public-key panel gives Create identity (user) when there is no keypair, and **Copy

  • Sync** when there is a key that cannot decrypt yet — user has already filed the request with staff (§13), so this is the follow-up: git-sync pulls the grant once a maintainer runs user-authorize, and auto-wires the filter. git-sync is the one command that advances every case and never errors, so it is what every locked path (the account tool row included) runs; the manual git-filter install stays a shell command for the rare has-access-but-unwired case, unsurfaced where it would mislead.

The refresh: the shell says when it moved. A git command runs in the terminal, which is not a navigation — so without a nudge the chip would be most wrong exactly after the user acted. Once a git command returns, the web shell emits OSC 5379 git;<token>: the commands are named in overlays.toml (docs/package-guide.md § 3.2), and euler_webshell/shell/osc.py is the one definition of that wire. terminal.js posts {euler: 'git-changed'}; site.js dispatches euler:git-changed on <body>; the chip's own hx-get="/git" (hx-trigger="… from:body", hx-swap="outerHTML") replaces itself. One read, at the one moment the state changed. It fires for a hand-typed git-sync exactly as for the menu's item — the menu types the same command, so there is one path — and for the sync a web shell runs at startup (§12.1), which dispatches that same command: the nudge rides sys.stdout directly, so the quiet that hides the sync never hides it. A lost or replayed sequence costs a stale chip until the next navigation, never a wrong one.

Three states that are not "clean". With no readable .git — the shared content tier runs as a uid with no access to it — read() returns None and the chip is inert ("no clone"); that is also what the auth tier's pages render, since they build their own contexts and never set git. When even the branch will not read — the clone is there but git answers nothing — the chip is unknown ("state not read"). And when the branch and divergence read but the worktree scan is blocked (the locked-vault case above), it is worktree_unknown: most of the answer, honestly labelled pending. None of the three ever paints "clean" over data it could not read.

11.10 Caching

The failure this policy exists to prevent is a response that says nothing. With no Cache-Control, a browser may apply heuristic freshness — reuse a response for a fraction of its age since Last-Modified — so a file last touched months ago can be served from cache for days without a single request reaching us. It is invisible from the server, it only bites the users who happen to hold the old copy, and its symptom is the worst kind of bug report: "works for me, they need a hard refresh".

Every response therefore carries a directive, stamped by an app middleware (packages/site/euler_site/cache.py, shared by every service — the same shape as the CSP one above) and by the edge for what Caddy serves itself. Two classes, split on what the response is, not on what it contains:

class directive why
rendered pages, htmx fragments, JSON no-store per-user, authenticated, and stale the moment the file behind it changes; there is no version worth keeping, and it stays off disk on shared machines
FileResponse — served files, bare /assets and /vendor paths no-cache identical bytes for every user, already carrying ETag/Last-Modified: keep it, but revalidate before every use — an unchanged file costs one 304, a changed one is picked up at once
/assets and /vendor with ?v=<hash> (the edge) public, max-age=31536000, immutable the URL names its bytes, so there is nothing to ask: kept a year, never revalidated — see Versioned asset URLs below

no-cache is not "do not cache" (that is no-store); it is "ask first". That distinction is the whole design: the file classes stay cheap without ever being able to go stale.

Two paths need naming because they are easy to miss:

  • Error responses. A raised HTTPException is a response, and an unstamped 404 is heuristically cacheable like any other — a browser that cached "404" for a problem you have since written would keep serving it. The middleware stamps the exception path too.
  • htmx's own history. htmx snapshots the swapped pane into localStorage and restores it on Back without asking the server — a cache no HTTP header reaches. Since this app's content changes underneath the browser (you edit in the terminal, or another tab), base.html sets <meta name="htmx-config" content='{"historyCacheSize": 0}'>: Back is a history miss, and htmx re-requests the pane (refreshOnHistoryMiss stays false, so it is an ajax fetch, not a full reload).

The public site's pages are the exception to no-store. Signed out, a page is the same for everyone and changes only when a deploy replaces the content package and the code, so euler-site gives every successful page validators (revalidated_middleware): an ETag from a fingerprint of the installed content tree and the site package — path, size and mtime of every file, taken once at startup, so every deploy changes it — combined with the path, the query and the headers the body depends on (HX-Request, Sec-Fetch-Dest, which Vary names: a fragment and a full page share a URL). The directive is public, no-cache: kept, by a browser or a shared cache, and revalidated before every use. A revalidation with a matching If-None-Match is a 304 answered before the handler runs, so nothing is rendered. Errors stay no-store, files keep their own validators, and a page whose body carried its response's CSP nonce would be left untagged (a 304 brings a fresh nonce in its header; no template uses one today). Signed-in pages stay no-store: they are per-user and follow the clone.

Versioned asset URLs — the only positive max-age. A bare /assets/site.css is the same path before and after a redeploy, so it can only be revalidated. The templates therefore never write one: they write {{ asset('/assets/site.css') }}, which packages/site/euler_site/assets.py turns into /assets/site.css?v=<12 hex digits of the file's SHA-256>, and the edge serves any request carrying ?v= as public, max-age=31536000, immutable (the asset_cache snippet in the generated Caddyfile). A page load that used to cost one conditional request per script and stylesheet now costs none; a redeploy that changes site.js changes its URL, and the new page fetches it at once. tests/test_assets.py fails on a template that writes a bare /assets or /vendor URL.

What makes the hash honest is where it is read: from the installed euler-site package, which ships its content/assets and content/vendor trees for exactly this — built from the same checkout that frontend.sh redeploy copies to the edge. And redeploy-web refreshes the edge first, before the services restart (§ 15.2): the bytes are in place before any page names them. The other order would let a restarted service hand out a new hash while the edge still served the old file, and a browser would keep that old file under the new URL for a year. (The order that remains is harmless: for a moment an old page asks for its old hash and gets the new file, under a URL no new page will name.) A file the package lacks gets its bare path, and with it the revalidated rule.

Two things stay revalidated, because their URLs are written inside the files rather than the templates: what a script imports by itself (site.js's import('/assets/editor.js') and the CodeMirror modules behind it), and the fonts and extensions MathJax fetches relative to its own script. The maintenance page is static HTML and names its stylesheet bare too.

12 · The web shell

The terminal is the front door: every rung gets one. What varies by rung is what the shell inside it will run, not whether it exists. It is the full solver shell — lexer, parser, interpreter, prompt-toolkit — not a curated command protocol, because reusing the hardest proven code with authorization already enforced at registration beats inventing a bespoke command surface.

Attach is gated on the reader floor plus a live session plus a one-time ticket. Inside, the rungs diverge by the same decorator floors as everywhere else (§6.6): a reader shell registers only the read commands and the shell's own safe AST expression evaluator (no calls, no attribute access), so it runs no user code at all; contributor adds edit, eval/benchmark, raw bash, the AI commands, and the git write verbs.

12.1 Lifecycle

One persistent shell per user (the manager keys by email), tmux-style: a second tab attaches to the same shell. A single background drainer reads the PTY continuously into a bounded replay buffer and fans output out to every attached socket, so the shell survives disconnects and a reconnect redraws.

Its lifetime is decoupled from any socket and bounded by exactly five teardown paths:

  1. In-shell exit (exit / Ctrl-D) — the drainer sees EOF and reaps.
  2. Logout — the auth service, the only party that sees the event, POSTs a best-effort /internal/logout {email} to the user's socket. It can: euler-auth is in euler-web and the socket is 0660. The endpoint is socket-peer only — Caddy routes /ws and nothing else to these services, so no browser can reach it.
  3. Revocation — users change/disable/remove and password reset push the identical teardown. This closes the one real gap in "staleness is re-login": a running shell resolved its permissions at startup, and without the push a demoted or disabled account would keep its old authority until the process happened to die.
  4. Shutdown notice — logout-all warns first, then closes the shell (below).
  5. Service stop — on_cleanup closes every shell, and systemd's cgroup kill collects stragglers.

Plus hygiene, not security: a detached-TTL reaper closes a shell with zero attached sockets for EULER_WS_DETACHED_TTL (default 24 h — generous on purpose, so a long benchmark survives a closed laptop). Session expiry is deliberately not a teardown: the owner remains an authorized user, re-attach demands a fresh login and a fresh ticket anyway, and killing work on a timer would break the persistence contract.

The reasoning: attach is freshly vouched every time — a live session at the edge, a one-time ticket at the fork — so the only residual risk a persistent shell carries is stale authority inside an already-running process. The revocation push removes exactly that, at its source, rather than having the shell tier poll for something it cannot observe.

A web shell syncs its clone as it starts. run_interactive dispatches git-sync before the banner, on the web channel only (the web shell's Extension.on_start, sync_at_start; solver and solver-host have none) — a terminal user drives their own clone and did not ask for a fetch. Before the banner, not after it, because the banner's status line (git describe, the master-key state) is read once the sync is done: a sync that pulls a release or a key grant is reported, not contradicted, by the screen above the first prompt. It stays in the shell's own process — a separate solver git-sync ahead of the PTY's shell would have no identity, since the web shell's is a one-time ticket. Without it a collaborator opened onto whatever their clone held when they last used it, and the enc-key pull flow (§9) that wires their filter waited on them remembering to type the command. It runs with the shared console quiet, which also routes the sync script's own output to DEVNULL (run_cmdline), so the wait shows one muted syncing with origin… line (the banner's clear takes it) rather than a wall of git; a failed sync still prints one line, under the banner, because a stale clone with no hint is the one outcome silence must not cover. An interrupt during the fetch is absorbed by the register adapter, so a Ctrl-C at startup still lands at a prompt.

Note what "at startup" means against the persistence above: this is once per shell, not once per attach. Re-opening the tab re-attaches to the running shell and syncs nothing — the fresh fetch comes with a new shell, i.e. after a teardown or the 24 h detached reaper. The chip's own remote-fetch freshness (§11.9) is what keeps a long-running shell's view current in between.

12.2 Four client-side subtleties

The shell is the front door for interactive logins. The account page's tool rows (GitHub CLI, Claude Code) make the status the button: clicking posts {euler: 'run', command} to the iframe, which types the command and a return into the PTY — git-identity, and for Claude Code the step its state calls for (below). Nothing there is privileged: it is the same PTY the keyboard writes to, the command is echoed, and it is the user's to edit or interrupt. These logins are interactive device flows that belong in a terminal, and a web form that "did it for you" would have to handle a credential it has no business touching. With no session attached there is nothing to type into, so the iframe says so in the terminal rather than dropping the click silently.

Because those commands run in the terminal and not through a navigation, the panel they change would otherwise go stale — so they nudge it the way a git command nudges the chip. user (a new identity) and git-identity (a GitHub sign-in) emit OSC 5379 account;, which site.js turns into an euler:account-changed body event; #vault-panel carries hx-trigger="… euler:account-changed from:body, euler:git-changed from:body" and refetches /account/vault. The git event is on the list because git-sync / git-filter change decrypt access too — so those emit only git; and the panel picks it up, rather than any command emitting both. The refresh is scoped by presence: #vault-panel is in the DOM only while /account is the visible pane, so the body events reach a listener — and cost a fetch — only then, and are inert otherwise. The Claude Code row has one command per state, and the state's sign-in half is Claude Code's own claude auth status (loggedIn): not available (below contributor: disabled), not installed (tool-setup claude: the user's own install, into ~/.local/bin through Squid), not set up (no first interactive run — hasCompletedOnboarding unset in ~/.claude.json) and not signed in (both claude-login: claude /login, whose first run onboards first, then claude auth status), and signed in (claude auth status). Each is a shell command, not a ! passthrough, because tool-setup and claude-login emit account; (overlays.toml): the panel re-reads the moment they exit — a cancelled login included, since claude-login reports either way. The bare claude command no longer emits: a session changes nothing the row shows. The heading's Regen button types user --regen the same way; user nudges, so the new key shows as soon as the confirmation is answered.

Reporting on those tools means finding them the way the shell does: the service's PATH is systemd's, not a login PATH, so vault_api._which also looks in ~/.local/bin — where per-user installs land (claude does). A tool the user can run in their web shell must not read as not installed on their account page. The same lookup feeds the version beside each CLI's name (vault_api._tool_version: the first N.N[.N] of its --version).

The refresh guard. A full page load (F5, address-bar entry, tab close) is the only thing that can reach the terminal, and the iframe guards it with a beforeunload confirmation armed while its WebSocket is open, disarmed on disconnect and on deliberate exits (the shell posts {euler: 'disarm'} to the iframe before logout). The PTY itself survives server-side regardless — the dialog protects the scrollback and flow, not the process.

logout, from the shell. Only the browser holds the session cookies, so the command asks the page: it emits OSC 5379 logout;<token>;<seconds> and waits. terminal.js counts down and posts each tick up as {euler: 'logout-notice'}, and site.js draws it in #session-notice, a bar under the header across both panes: outside the terminal, so a hidden terminal still warns, and over the panes, so neither resizes. Then {euler: 'logout'} makes site.js submit the header's own logout form, as the bar's button does, so there is one sign-out whichever control started it, and path 2 above reaps the shell. The notice takes nothing from the terminal: every key still reaches the shell, and the button is how to skip the wait. A shell no page answers closes itself and says the browser session is still signed in.

logout-all, ahead of a shutdown. redeploy-web, upgrade-web and remove-web run it first (make logout-web), and an operator can run it alone. euler_webshell.logout_all, under sudo, posts to the root-only /internal/logout-notice of each instance systemctl reports running (dialling a socket would start a stopped one), and each instance keeps its own clock (user/logout.py), ticking to its terminals over text frames. The shell reports running / idle over OSC, TermModes follows it, and the notice uses it:

  • an idle shell closes when the 5s countdown ends, or at once with no terminal attached;
  • running work extends the wait on its own, up to a 5-minute limit, then is killed. Waiting counts as running, so an idle claude or an open dialogue holds the wait, and a & job does not;
  • logout-all extend=<seconds> adds to the countdown and the limit, and logout-all --now closes every shell at once, whether or not a notice stands, so no reason is shown.

The bar's button ends the wait at once, even while work runs: the sign-out closes the shell and kills that work, and the button says so. At the end the instance sends logout-end, which signs open tabs out through the header's form. The waiting command then calls /admin/revoke-all, which covers users with no tab open and deliberately pushes no teardown, since that would start every stopped instance. A tab whose terminal is down gets no frame, so while it is on screen site.js asks /auth/check when the terminal drops, on returning to the tab, and every 15s, and reloads the page on a 401. A page with an editor open stays put, to keep its text. A notice is only as old as the deployed venv: the deploy that first ships this runs the previous build's logout-web, which skips it.

The attach replay is drawn, never obeyed. A reattaching terminal is sent the shell's recent scrollback, which contains the control sequences of commands that already ran — show/edit's OSC 5379 among them. A client that acted on those again would hijack the pane on every page load: deep-link to one problem and land on whatever the shell last showed. So the server closes the replay with an explicit {"replay":"end"} text frame (the raw byte stream cannot carry an in-band marker), and the client honours OSC only after it — sequenced through xterm's write queue, since write() is asynchronous and the bytes are still being parsed when the marker arrives. A monotonic token remains the within-session guard.

Only the shell's own sequences are obeyed. Text that merely contains ESC ] 5379 — a file being printed, eval output, a merged commit message — would otherwise drive the pane just as show does. So every sequence starts with a per-shell key: ESC ] 5379 ; <key> ; <action> ; …. The per-user service draws it (PtySession.osc_key) when it forks the shell and hands it over as EULER_OSC_KEY, which osc.py pops at import so no command the shell runs inherits it; the page learns it from the replay-end frame, {"replay":"end","osc": "<key>"} — a text frame, which no PTY output can imitate — and swallows any sequence that does not lead with it.

A subshell shuts the channel. ! sh, ! py, claude and ! ssh hand the PTY to an interactive child (run_interactive), and a header verb clicked meanwhile would type git-sync at a bash prompt or into a Python REPL. So the shell brackets the child with busy;<token> and ready;<token> (the extension's handoff, osc.handoff). While busy, terminal.js obeys no other OSC action and refuses the parent's run, and reports busy with its term-state; site.js paints body.term-busy, which greys out every [data-term-cmd] control — the chips' panels say why — until ready. Unlike the acts, the pair is a state, rebuilt from the attach replay (the later one wins), so a reconnect mid-subshell comes back disabled. The child cannot forge ready: it never had the key.

State outlives the replay. The replay is only the newest 256 KiB, and a long claude session repaints far more than that — the busy that opened it scrolls out while it still runs, and a reconnect would come back offering to type git-sync into it. So the per-user service follows the states through the whole stream as it drains the PTY (ws/manager.py § TermModes: busy/ready under the shell's key, and the keyboard modes below), and the replay-end frame carries them — {"replay":"end","osc":…,"busy":…,"kitty": […],"otherKeys":…,"mouse":…,"sgrMouse":…,"focus":…,"paste":…}. terminal.js adopts them at the replay/live boundary, over whatever the replay rebuilt. The state lives with the shell rather than in the browser: one shell is shared by every tab and device a collaborator attaches from, and a browser-side copy (a variable, sessionStorage, a cookie) would be one tab's memory of a state that can change while it is away — a second tab exits the subshell, the browser restarts — where the server saw every byte.

Enter carries its modifiers — when the application asked for them. xterm.js sends Enter as a bare CR whatever else is held down (its keyboard knows only Alt, as ESC CR), so Shift-Enter used to reach the PTY indistinguishable from Enter, and the tools that read that chord as "newline, don't submit" — Claude Code — never saw it. Two encodings carry the modifier, kitty's CSI 13 ; <mod> u and xterm's modifyOtherKeys CSI 27 ; <mod> ; 13 ~, and neither may be sent unasked: a reader that knows only the legacy encoding takes the second for text and lands ;2;13~ in its line buffer, which is precisely what bash does. So terminal.js watches the enabling sequences go past on the way out (CSI > <flags> u / CSI < u, CSI > 4 ; <n> m / CSI > 4 m) and encodes to match whatever the foreground app turned on. With nothing on — the common case, since Claude Code requests those protocols only for terminals on its own allowlist, which this one is not — it falls back to what a terminal user would have bound by hand: ESC CR for Shift and Alt, which is Meta-Enter to any readline-ish reader and the newline Claude Code's /terminal-setup installs for this key in editors' terminals, and LF for Ctrl. The fallback costs the solver's own prompt nothing: prompt-toolkit accepts the line on all three, exactly as it did on a bare CR.

Modes, unlike the OSC actions above, are rebuilt from the replay. An action must not re-fire; a mode is the state of the shell being reattached to, still running whatever app set it. The replay alone can lose an enable that still holds (it drops its oldest bytes), so the replay-end frame's kitty / otherKeys — followed through the whole stream server-side, as above — replace what the replay rebuilt; replacing rather than adding also stops a reattach pushing the same kitty flags onto the stack a second time. The kitty query (CSI ? u) is deliberately left unanswered: this client encodes Enter and nothing else, so advertising a flag set would promise an app the disambiguated Esc and Tab it would then sit waiting for.

The replay types nothing. xterm.js answers some of what it parses: a Device Attributes query (CSI c) gets CSI ? 1 ; 2 c, and enabling focus reporting (CSI ? 1004 h) gets an immediate CSI O while the terminal is unfocused. The replay still carries the queries of programs that already ran — claude sends both as it starts — and the answers, sent on, land at whatever reads the PTY now: the solver's prompt, as stray [O[?1;2c text. So terminal.js forwards nothing xterm emits until the replay/live boundary (a keystroke in those milliseconds goes with it). The reporting modes — mouse tracking and its SGR encoding, focus reports, bracketed paste — are the ones that make a terminal write to the PTY unasked, so they are followed server-side like the keyboard modes (mouse / sgrMouse / focus / paste, with RIS resetting them) and written into xterm just ahead of the boundary, where the focus report that enabling 1004 provokes is dropped with the rest.

13 · Messaging

Every message stands on its own — there are no replies. The spool is not an email or chat replacement: it is how a command tells somebody something they must act on, and the act is always on one message. A request and the grant that answers it are two messages with two verbs, which is precisely what lets the header chip name the verb a row is for. Threading bought conversation nobody was having, and cost a second place for content to hide — an answer buried in a reply is invisible to anything reading the message it answers, which is exactly how taking a key came to look in the wrong place for one.

This is a mechanism for commands, not a chat feature. Its purpose is to let a command tell staff something they have to act on. The founding case is user: it mints a keypair, and the public key is inert until a maintainer runs user-authorize on it — so the command files that request itself, where before the account page said "copy your public key to the admin and wait" with nothing behind the waiting.

Everything below follows from that framing, and reading it the other way — as an inbox people write in — is what makes this tier look bigger than it is.

Three flows, and only three:

  • any user (or a command running as them) → staff (maintainer+);
  • staff → one or more named users;
  • staff → everyone (a broadcast notice).

There is no user↔user leg. Every message has staff at one end, so there is no presence, no peer graph, no per-pair ACLs and no cross-peer ordering to get right. Widening it to peer-to-peer later is a redesign, not a setting.

Delivery is asynchronous by construction: the spool is the system of record and the recipient reads when they next look. A message is never lost because someone was offline, and nothing in the path waits on a live connection.

Reading and writing happen in the shell. The web surface is one header chip and nothing else — no page, no thread view, no compose form. That is not an omission to fill in later: the browser holds no write route to the spool at all, so every message and every message arrives over msg.sock from a uid the kernel vouched for, and the command's own requires() is the only gate there is. A guarded write surface would be strictly more to get wrong than an absent one.

13.1 Why its own service

The spool holds attacker-authored free text — by design, that is what a message is. The auth service was the obvious host (it is the only service every collaborator can already reach), and it was rejected: it holds the SRP verifier database, the session table and the ticket mint, so a parsing or authorization slip in a message path would land in the one process whose compromise is total. That is the same reasoning that keeps the Gmail credentials in euler-smtp rather than in auth (§3) — a secret lives only with the service that performs its operation, and the converse holds too: an untrusted-input surface lives away from the secrets it has no business near.

Two cheaper designs were also rejected, and for reasons worth keeping:

  • A shared spool directory with drop-box permissions (0730, group-owned, senders O_EXCL-create, st_uid attributes the sender unforgeably). Elegant, and needs no new service at all — but restricting "only staff may write to a user's box" requires a unix group shadowing the profile ladder, reintroducing exactly the per-path filesystem-ACL layer the per-user model retired (§6.1). Worse, a demoted maintainer would keep broadcast rights until their group membership was fixed, breaking the immediate-revocation contract of §6.4.
  • Per-user mailboxes in each collaborator's own tree, pushed to their instance. Good at-rest story, but offline recipients, deprovisioned instances and retries all need a central spool anyway — and the operator is an os-login admin with no instance at all, so the staff inbox would have nowhere to live.

So: a dedicated euler-msg, and the isolation is real rather than nominal because of one observation — system_slug() is a pure hash (SHA-256) of the normalised e-mail (packages/solver/solver/auth/identity.py), and euler-msg is in euler-authz. The service can therefore build its own slug → e-mail → profile map from the policy file. It follows that euler-msg:

  • is never routed by Caddy — the per-user instance is its only web-facing consumer, and /messages already routes there through forward_auth like any other content path, so the edge needs no new rule;
  • never talks to the auth service — no cookie forwarding, no shared secret, no ordering dependency between the two units;
  • runs RestrictAddressFamilies=AF_UNIX — it cannot open a socket to anything, ever, which is strictly harder than auth (which needs AF_INET for the mail relay).

Its blast radius is message bodies and read-state. Nothing else is in the process.

The package is a sibling of the other service packages, and imports nothing from euler_site.auth — loading auth's modules into this process would recouple the two at review time and defeat the point:

Module Role
__main__.py Entry point: bind msg.sock (0660 euler-web) + msg-admin.sock (0600), serve until SIGTERM.
config.py MsgConfig from the environment — variable names and defaults in [msg] of packages/solver/solver/config/env.conf; every path overridable for a scratch-dir dev run.
app.py The public and admin apps: endpoints, the peer-uid middleware, profile floors, the delivery push.
store.py The thread store — single-writer JSON, TTL sweep, caps, read-state.
identity.py Peer uid → login → e-mail → profile, over authorizations.json; re-read on mtime change.
admin.py The sudo-gated proxy behind the operator's terminal path (§13.3).
commands.py The msg shell command (§13.7).
notify.py notify_staff() — how a command sends, and the reason the tier exists.

Three utilities move up out of the auth package so both services can use them without either importing the other:

Was Now Why it is shared
auth/client.py packages/site/euler_site/unixhttp.py the unix-socket JSON client, wanted by both admin CLIs
auth/storage.py + requests.py::sanitize packages/site/euler_site/store.py atomic 0600 JSON writes, and control-char hygiene for the two stores that hold user-authored text
auth/admin.py::_env_file_values packages/site/euler_site/envfile.py reading a scoped /etc/euler/*.env for a root-only admin token

auth/client.py and auth/storage.py remain as re-exports, so nothing downstream changes.

13.2 Identity and authorization

The kernel is the identity. Every request on msg.sock is authenticated by SO_PEERCRED — the same mechanism the per-user unit already calls authoritative (§7). The peer uid is the slug; the slug resolves to an e-mail through the system_slug reverse map; the e-mail resolves to a profile through authorizations.json. There is no from field on the wire, so there is nothing to forge, and no bearer token on the hot path to leak. The per-user service and its PTY children share a uid, which is correct — they are one principal.

Authorization is the same rank comparison as everywhere else, resolved per request against the policy file, so a demote lands within one request rather than at next login:

Verb Floor
read own inbox, mark read reader
send to staff reader
read the inbound queue, send notices maintainer
notice to named users, notice to everyone maintainer

The reader floor on sending is deliberate: a new invitee's first need is often to ask a question, and the terminal is the front door for every rung (§12).

13.3 The wire contract

GET  /messages?since=<iso>      this slug's threads + unread count      reader
POST /messages                  {subject, body} → the staff queue       reader
POST /messages/<id>/reply       {body}                                  reader (own thread)
POST /messages/<id>/read        mark read                               reader
GET  /staff/queue               the inbound queue                       maintainer
POST /staff/notice              {to: [email…] | '*', subject, body}     maintainer

The admin socket (/run/euler-msg/admin.sock, 0600, euler-msg-private) carries the same staff verbs for the operator's terminal, reached under sudo with a token from root-readable msg.env — exactly the wheel-gated shape of the auth admin plane (§6.5), and for the same reason: the operator's ordinary uid is the most exposed on the host and is deliberately not in euler-web, so it cannot dial msg.sock at all.

It lives in the service's own runtime directory (systemd RuntimeDirectory=euler-msg, 0700 euler-msg) rather than in /run/euler-adm, which is 0750 euler-auth and so not writable here. Each admin plane owning its own directory is the better shape anyway: root traverses either, and nothing else traverses both.

The CLI behind it (packages/webshell/euler_webshell/msg/admin.py) is a thin authenticated proxy, not a second command surface — it takes a method and an admin path, injects the invoking identity, and prints the reply as one JSON line, so a single renderer serves both channels and the terminal cannot drift from the web. Two details there are deliberate: the request body arrives on stdin, never in argv (a message body in the process table is readable by every uid on the host through /proc), and the identity is SUDO_USER rather than an argument — root could assert anything, but a typo cannot file a message under a stranger's name, and the profile check still runs against the policy file.

13.4 State

/var/lib/euler-msg, euler-msg-only 0700, single-writer (one process). One record per thread, not per recipient:

{ "id": "…", "kind": "inbound|notice", "author": "<slug>",
  "subject": "…", "body": "…",
  "recipients": {"<slug>": {"read": "iso|null"}},
  "replies": [{"author": "<slug>", "body": "…", "at": "iso"}],
  "created": "iso", "updated": "iso", "expiry": 0 }

A broadcast is therefore one record with N recipients, not N copies: "everyone" is cheap, read-state stays per-person, and a collaborator provisioned after a notice was sent is simply not in it — which is the correct behaviour, not an omission.

Bodies are plaintext only, sanitize()d on the way in (control characters stripped, length-capped) and rendered through Jinja autoescape. No markdown, no HTML, no rich rendering. That deletes the injection class rather than defending against it, which matters more here than anywhere else in the stack precisely because this store exists to hold text other people wrote.

Bounded like the invite queue it resembles (§4.6's neighbour, requests.json): a per-sender standing cap, a global cap, and a TTL swept on every access, so an unworked queue cannot grow without limit.

13.5 Delivery

Five ways the count reaches you, all reusing machinery that already exists:

  1. Live nudge. euler-msg POSTs /internal/message to the recipient's own user-<slug>.sock — the same socket-peer-only push the auth service uses for logout and vault-reset teardown (§12.1); euler-msg carries SupplementaryGroups=euler-web to reach it. The instance sends a {"euler":"message","unread":n} TEXT frame to any attached browser terminal, which relays it to the page as an euler:message body event. A recipient with no terminal open notifies nobody, and that is not a failure — the count is waiting for them at (2).

  2. On document load. The chip fetches /messages on load, so a full page load is always current whether or not a push ever reached the user. It is deliberately not sent out-of-band with every pane navigation the way the git chip is: the count moves when someone else acts, which no navigation can predict, so a spool read per navigation would buy nothing. The push is only a nudge — a lost one costs a stale count until the next document load, never a lost message.

  3. On opening the panel — hx-trigger="toggle[this.open] throttle:1s". (1) and (2) are both pushes, and a push can be missed: the live nudge rides the terminal's WebSocket, so a disconnected or hidden terminal silently drops it, and load has not fired since the last full document load. Opening the menu is the one moment a person is asking what is waiting, and it is exactly when the answer must not be twenty minutes old. The filter is what keeps it to opening — a bare toggle fires on close too, spending a fetch on a panel nobody is looking at any more; htmx calls a trigger filter with the element as this (eventFilter.call(elt, evt)), so this.open reads the state the event just produced. The throttle is because the trigger is a pointer away from being held down: it fires the first open and drops the rest of the second, which no reader can perceive as staleness.

  4. The shell's own nudge. The three above cover a message arriving. Reading one is the user's own act in their own shell, which no service sees — so the msg command emits OSC 5379 msg;<token> on every path that changes what the chip shows (read, send, reply, notice, dismiss), exactly as git-sync does for the git chip. terminal.js turns it into the same euler:message body event the delivery frame produces, so the chip has one listener and does not care which side moved. Read-only verbs (list, queue) emit nothing: a nudge that fires when nothing changed trains the reader to ignore it.

    Without this the chip is at its most wrong exactly after the user acted — they read the one unread thread and the badge still says 1 until the next full page load.

  5. In the terminal. msg list is a reader-floor command, so the count is always one command away regardless of what the browser chrome is showing.

The chip is a <details class="menu"> on the same chassis as Actions, the git chip and the user pill — one menu idiom in the header, not four. It carries the state in two counts (N unread, M threads, shown even at zero), then up to five threads newest-first with unread ahead of read, then the verbs. Every row and every verb is a data-term-cmd button: a row types msg act <id>, and the shell does what that message asks and prints the result there. So the body never reaches the browser at all, and the boundary is the command's own floor rather than anything this menu decides to show. When the terminal is disconnected the panel says so and offers to connect it, painted from the same termConnected every other [data-term-*] control reads — a verb that silently does nothing is the one outcome a control must never have.

Every row types the same command and says what it will do. One entry point, msg act <id>, because a message knows what it is for — the rows used to type four different commands, which meant msg_api.py decided what a row was for and the command decided again. Now verb_for (§13.7) decides once, and the row renders only its label:

the row is the label it carries what msg act <id> does
a key issued to you save writes your enc-key file, reading the payload from the thread over the socket, so no key material reaches the browser
half a key (key-split) reconstruct key-reconstruct <share> — the sealed half is read from the thread over the socket, unwrapped with your private key and completed with the half committed in your clone; same destination as save, one step longer
somebody else's key request, read by staff authorize user-authorize <id> — the public key is read over the socket, and nobody retypes 64 hex characters off a screen
a pull request, read by staff merge gh-merge merge — no id is needed, the verb walks the pull requests as GitHub knows them, and the merge closes this notice by the branch in its subject
anything else (none) prints it and marks it read

The label appears only when the act is not a plain read: a row that says "save" or "merge" is telling you something, where "read" on every line is noise. A rung below staff gets no label on a key request or a pull request, because for them acting on it is a read — verb_for applies the ladder once rather than the chip spelling it out again. This is also why a request and its answer being two messages matters: one message, one act.

That the ladder shows on a row by omission, not by a locked button, is a deliberate exception to this panel's shown-but-locked rule. A locked verb in the verb list teaches the ladder on a menu everyone sees; a locked one on a row would ride on every message of a kind most readers never receive, and on a reader's own key request it would offer them a grant they can never make.

The panel's two verbs are msg list and msg send, both reader. It used to carry msg queue (maintainer) as well — a second name for the part of msg list staff already see.

That mechanism is selected by the .term-menu marker every such menu carries, not by each menu's own class. It used to be .git-menu/.git-offline, which meant the second menu to need it could not join without editing site.js.

The two nudges use different transports on purpose. A service-originated push must not use OSC 5379: that channel rides the PTY byte stream and is replayed into a reattaching terminal (§12.2), so it would re-fire on every page load and would need the monotonic-token dance to be safe. A TEXT frame is out-of-band, is never replayed, and needs no token. A shell-originated nudge is the opposite case — it is already on the shell's stdout, the replay guard already covers it, and routing it out to the spool and back would invent a second mechanism for something the terminal already carries. So the rule stays exactly as it reads: OSC for what the shell did, TEXT frames for what the service did.

13.6 No mail

euler-msg sends no e-mail, which is what lets it stay AF_UNIX-only. The consequence is stated plainly: a user→staff message waits for staff to open a shell or a page. For an invited set of collaborators with a host the operator works on daily, that is an acceptable latency, and the spool never drops the message.

Adding a digest nudge later is a deliberate, separate decision with a real cost — it needs AF_INET in the unit, an entry in the mail relay's permitted-sender list in firewall.sh, and it gives up the "cannot open a socket" property above. Do not add it as a convenience.

13.7 The msg command

Verb-split like users (§6.5), registered from packages/webshell/euler_webshell/msg/commands.py. Whichever plane answers is client.py's problem: a web shell's uid is in euler-web, so the PTY child dials msg.sock directly; the operator's terminal uid deliberately is not, and falls back to sudo against the admin socket.

msg list                                       # everything waiting for you, newest first
msg read <id>                                  # one message, and mark it read
msg act <id>                                   # do what it asks (see below)
msg send subject="…" body="…"                  # → staff, the default audience
msg send to=uff8d9819fc0e12bf,u5ff860bf1190596c subject="…" body="…"   # STAFF-only recipients
msg send to=everyone subject="…" body="…"      # STAFF: broadcast
msg dismiss <id>                               # drop a message you are done with

Recipients are named by slug, and the menu offers them from the roster (§6.3) — a plain file read, so a maintainer over the web gets the same list the operator's terminal does. An e-mail address still resolves (box_of collapses either form to the same box key), but nothing has to publish one to address a message.

The subject and body are name=value tokens rather than bare positionals because the message id is the one argument that reads naturally in the leading position — msg read <id> and msg act <id> are the two forms typed most often.

Five verbs, one per thing a person does with a message. There were seven. queue was list with a staff-only filter over a mailbox that already shows staff every inbound thread, and notice was send with a different to — both marked STAFF in the menu, and both refusing after walking a contributor through subject, body and recipients. A menu that offers what it will then refuse teaches the ladder in the worst possible place. The ladder now lives in what a verb asks: to is only asked of staff, because below that floor there is one possible answer (staff), and it is the default.

act is the message's own verb. save used to be a verb for one message kind, so the reader had to know which of seven verbs their message wanted. A message knows what it is for: verb_for(subject, is_staff=…, is_own=…) in packages/webshell/euler_webshell/msg/__init__.py maps it to save (take the key it carries), reconstruct (complete the half key it carries with the half committed in the clone — key-split's message), authorize (grant the key it asks for — staff, and not your own request), merge (land the pull request it announces — staff), or read. That one function is also what labels the header chip's rows, so the button and the command cannot drift, and the two staff acts are unreachable below maintainer because the function never hands them out there. Each act cleans up after itself, so nothing is left to dismiss: taking a key drops the message that carried it, granting one drops the request, and a merge drops the notice (§13.7 above).

dismiss is not floored in the command. The service already answers that question better — staff may drop anything, anyone else a message they are a party to — and the old maintainer check meant the rung that most needs the tidying, a reader clearing a worked notice, was the one refused.

Commands send through notify_staff (packages/webshell/euler_webshell/msg/notify.py), not through the command layer:

from euler_webshell.msg.notify import notify_staff
notify_staff(subject, body)      # -> bool, best-effort, never raises

Two properties every caller depends on. It is best-effort: a wedged or undeployed spool must not fail the act that was trying to report itself — user still mints the keypair, and the operator learns of it the next time they look. And it prints nothing: these fire inside another command's flow, which owns what the user reads, so a failure is logged rather than dropped into the middle of someone's key output. It is stdlib-only, so a command can call it in a base install with no aiohttp.

user is the first caller, and only on the path that needs it — a key was just minted and it cannot decrypt, so somebody with the master key has to act. A key that already decrypts needs nothing, and a bare user status view is not a request.

Two more callers, on the same terms — the act is the system of record, the message is a nudge on top, and each subject is a constant in packages/webshell/euler_webshell/msg/__init__.py so the chip can offer the right verb:

  • git-push, when it opens a pull request (PR_REVIEW_SUBJECT): a pushed branch with a request nobody has been told about is the same dead end user had before this layer existed — the collaborator has done all they can, and the act that lands it belongs to someone with no reason to be looking. Only for a newly opened request: re-pushing a branch as it grows keeps the one open request, and a notice per push would turn the queue into a changelog of somebody's afternoon. The reviewer's verb is gh-merge merge.

    This one closes itself. gh-merge dismisses the notice for each branch it lands (_dismiss_pr_notice → notify.dismiss_by_subject), so a worked review does not leave its own instruction in the queue — the same tidying msg act does for a taken key. The handle is the subject, not a thread id: the chip's row types a bare gh-merge merge because the queue that verb walks is GitHub's, so the merge never sees the message. Hence the branch in PR_REVIEW_SUBJECT + branch, matched exactly (the prefix alone names the kind and would sweep every waiting review). It runs after the merge and before the sync — a sync that fails leaves work on the maintainer's clone, not a request still awaiting a reviewer — and is best-effort like every other spool call, with a chip nudge only when a row actually went.

  • summary, when the progress page contradicts what is recorded as solved (UNREGISTERED_SUBJECT): mark records a problem solved the moment its own results confirm the answer, which can be long before the answer is given to projecteuler.net. The import keeps the local record — it only ever adds solved problems — and reports the problems whose answers were never registered upstream. Prose, so the verb is a plain read.

13.8 The kit

scripts/setup/msg.sh, modelled on smtp.sh — the closest existing sibling, being a small single-purpose service with its own uid and no Caddy route. Actions deploy | remove | upgrade | redeploy | status | help; upgrade is an alias of deploy, like auth's and smtp's. It creates the euler-msg user and group, generates /etc/euler/msg.env (root:euler-msg 0640), and installs the root-owned unit. It does not own /opt/euler — auth.sh does — so msg.sh redeploy only restarts.

The unit is harder than auth's, because this service needs nothing off the socket layer:

Type=simple
User=euler-msg
SupplementaryGroups=euler-web          # push to the per-user sockets; chgrp its own
StateDirectory=euler-msg               # /var/lib/euler-msg, 0700
StateDirectoryMode=0700
RuntimeDirectory=euler-msg             # /run/euler-msg, 0700 — its own admin plane
RuntimeDirectoryMode=0700
RestrictAddressFamilies=AF_UNIX        # cannot reach the network at all
IPAddressDeny=any
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/euler               # the shared socket dir; tmpfiles owns it
MemoryDenyWriteExecute=true

There is no After=/Wants= on euler-auth: the spool never talks to it, so the two services have no ordering relationship to declare. require_venv refuses a deploy when euler_webshell.msg is not importable from /opt/euler, which is the one way this kit can fail confusingly — the venv belongs to auth.sh, and a checkout newer than the venv would otherwise install a unit that crash-loops on ModuleNotFoundError.

firewall.sh still folds euler-msg into the egress drop set. The unit hardening already makes off-host traffic impossible, so this is defence in depth: the nftables chain is policy-accept, and an un-enumerated uid would reach the internet directly if the unit were ever loosened.

14 · Security posture

The trust boundary is the invite list — named, trusted collaborators. These are the risks accepted by design; each is a deliberate choice with its containment stated.

A contributor+ login is host code execution. A contributor can edit a solution and evaluate it, which runs arbitrary Python on the host; claude-solve launches a headless agent with host tool access. Gating ! high would not contain this — the effective trust boundary is who receives which profile, not the command policy. Contained: reader (the default invite tier) is genuinely read-only, so a new invitee triggers no host execution of user code. Every web session runs as that collaborator's own uid, in their own 0700 home and clone, from the root-owned venv, loopback-only behind the kernel egress firewall. A compromised session reaches that user's home — never the operator's, never another user's. Standing controls: grant contributor+ deliberately, keep the invite list audited, keep reader free of every code-execution command, do not widen the unit's sandbox or the per-user egress lock.

A web session reads the private plaintext its user is authorized for. Each instance serves its own clone, and solutions/private/** is ciphertext at rest until the operator key-authorizes that person (§9). Private-plaintext visibility is per-person and revocable, orthogonal to the ladder. What remains accepted: compromising an authorized user's session reads the private plaintext in that user's worktree — the deliberate cost of a shared solver over an invited set. Per-user uids keep one compromise out of every other tree. The egress lock narrows, but does not close, the way off the host: the Squid allowlist includes general-purpose services (GitHub), so a compromised session can still send what it reads somewhere — and so can a contributor+ acting on their own, since pushing their branch is their workflow and a domain allowlist cannot tell a push to this repository from a gist. The control for that is who is key-authorized, not the proxy: narrowing GitHub per uid would break git-push, and pinning it to one repository would mean intercepting TLS.

admin is web-reachable. With the channel axis gone, an admin account signed in over the web could run the highest-privilege operations. In practice the admin API will not assign admin to a web identity (§6.5), so this requires an out-of-band root edit — but nothing in the resolver caps it. Why accepted: the containment that matters is the uid plus SRP plus the profile grant, not which keyboard the request came from. Standing controls: keep admin assigned to the operator alone; the admin plane stays wheel-gated under sudo; admin-floor commands never widen to lower rungs.

A key-authorized user's uid holds master-key access. From user-authorize onward their uid can unwrap the master key: decrypt the full private corpus (including straight off GitHub) and forge ciphertext. Why accepted: it is the product. A trusted collaborator working on private solutions needs exactly this power, and proof-of-possession is the cleanest form of it. The act is per-person, explicit, and revocable — never self-service, and key-rekey re-wraps only to still-authorized keys. Standing controls: authorize named, trusted people only; treat enc-key.json changes as audited events (they are commits); rekey on any doubt.

The vault is at-rest opacity only. Encrypted at rest and opaque to a passive or honest operator; not protected against a malicious active root (§8.3). Do not over-claim "zero-knowledge."

eval/benchmark run untrusted solution code as the user. A solution file is arbitrary code, executed in the user's own session — the same uid holding their unlocked vault. A malicious solution can read its own runner's secrets: the session VK, their Anthropic key, and, if authorized, the master key. Why accepted: the blast radius is the user themselves, which is the normal property of any shell that runs untrusted code as you; different uids keep it away from every other user and the operator, and egress is still Squid-gated. Sandboxing eval in a lower-privilege sub-uid or namespace is the standing future hardening.

Regression guards

Sound controls — invariants, not open work. Weakening any of these needs a matching decision recorded here.

  • SRP-6a (packages/site/euler_site/auth/srp.py): the password never reaches the server or disk; correct PAD, u≠0 / A,B mod N≠0 checks, secrets.compare_digest throughout; cross-tested against the browser client. x comes from Argon2id (64 MiB, 3 passes), so a stolen users.json is a slow offline attack, and a verifier at any other cost is refused.
  • Anti-enumeration: stable decoy salt/B for unknown emails at /auth/challenge (its secret persists, so decoys survive a restart as real salts do); generic responses — and response times: /forgot sends in the background — from /forgot and every invalid-token page.
  • Resets cannot be blocked, or used to spend the mail: a new /forgot never replaces a link already sent (cooldown + daily cap per account); a completed reset revokes the rest; mail an unauthenticated request triggers is budgeted per recipient and per day.
  • Mail-sending forms cost the sender first: /forgot and /request-invite carry a signed, ten-minute stamp, and assets/pow.js must find a nonce giving SHA-256(stamp:nonce) POW_BITS (18) leading zero bits before the post is accepted — about a second in a browser, once per request (auth/pow.py; spent stamps are refused). The budgets above still cap what a patient script can do; this makes it pay per request.
  • A login proves today's password: a pending SRP challenge is bound to the verifier it was built from, so one begun before a reset or change cannot finish after it; it is held under its own random id, so a handshake someone else starts for the address cannot cancel it.
  • Invite-only registration: single-use, hashed, time-boxed link tokens plus a live-mailbox OTP; the account written is the invite's bound email, never client-supplied.
  • Remember-me: selector:validator with rotation-on-use, theft detection, and HMAC(validator) at rest.
  • Identity is unforgeable: Caddy strips client X-User/X-Profile/X-User-Slug and routes on the slug forward_auth returns; the per-user sockets are euler-edge, which no per-user uid joins, and each instance admits only its expected peers by SO_PEERCRED (§ 1); it refuses a request whose X-User maps to another slug, and a /ws upgrade from another origin; web shells prove identity by a single-use ticket, not env; the local admin fallback requires the checkout-owner uid, and an unreadable policy file never counts as an absent one.
  • Slugs cannot be shared: system_slug is 64 bits of SHA-256, and /etc/euler/owners.json (root 0600) records which address owns each one; provisioning refuses a slug owned by another address or an existing account with no record, so an account is never adopted.
  • The editor stays in its problem: a file name reaching /edit or a delete names one file directly inside that problem's directory — no separator, no ./.. — resolved through the same helper the readers use.
  • Only the shell drives the pane: every OSC 5379 sequence carries the shell's own key, handed over by the service and popped from its environment, and given to the page on a text frame; printed text that contains the sequence is ignored.
  • Key messages come from staff: msg act saves or reconstructs a key only from a notice thread, which only staff can create; anything written to staff is prose.
  • Wheel-gated admin plane: the admin socket is 0600 euler-auth-private; the token lives only in root-readable auth.env; users mutations run under sudo.
  • Secret hygiene: auth state 0600, euler-auth-only, off the operator uid; a user's id/env vault-encrypted at rest in their 0700 home; the session VK lives only in a 0600 uid-private tmpfs file whose path is exported — the key itself is never in any process environment; logout and reset remove it; no secrets in logs (auth access logs are disabled, since tokens travel in query strings).
  • Ciphertext by default: a provisioned clone is born filter-unwired, so solutions/private/** rests as ciphertext until the deliberate user-authorize; gitfilter install verifies key access before wiring anything.
  • Master-key gate to master: git-push on master needs admin and force-pushing it is refused unconditionally; a collaborator's work lands only via gh-merge merge, which a maintainer may run but only for a pull request confined to solutions/ — never one touching keys/, the scripts or the framework. git-publish sits at maintainer so a maintainer can publish an enc-key grant they just made, and that does not widen the gate: scripts/git/publish.sh pushes master directly only when the authenticated GitHub user is the repo owner, and routes everyone else to a branch plus a pull request. The two legs are independent — a profile check on git-push, a GitHub-identity check in the publish script — so weakening either alone still leaves master closed.
  • Network posture: only Caddy is network-bound; the app tier is loopback-only (systemd IPAddressDeny + host nftables), and loopback itself is an allowlist (Squid, the resolver, the relay for euler-auth); Caddy's admin API is a unix socket; all egress — including every dynamic per-user uid, enumerated by the euler-user group — via the Squid allowlist.
  • Every unit is sandboxed: Caddy, acme, ddns, the firewall, Squid, smtp, auth, msg and site run with a read-only system, private /tmp and devices, the kernel, clock, hostname and cgroups out of reach, other processes hidden in /proc, no new namespaces, a @system-service syscall filter, and only the address families and capabilities each uses; msg and site, unix-socket-only, have no network at all (PrivateNetwork). The per-user unit takes all of that except the syscall filter, the namespace ban, MemoryDenyWriteExecute and ProcSubset, because it runs the collaborator's own tools (gcc, Node for Claude Code).
  • Secrets stay with their one reader: the TLS key is euler-tls (Caddy alone), the authorization map euler-authz (auth, msg, the operator).
  • Cookies: Secure; HttpOnly; SameSite=Lax, site-wide.
  • The message spool is off the auth process (§13.1): attacker-authored free text lives in euler-msg, which holds no verifiers, sessions or tickets, is unreachable from the edge, and is AF_UNIX-only. Message bodies stay plaintext-with-autoescape — no markdown or HTML renderer is added to that path — and its identity stays SO_PEERCRED, never a header or a body field.
  • The browser cannot write to the spool (§13): the per-user tier serves one read-only chip fragment and the delivery push, and holds no write route at all. Every message and reply arrives over msg.sock from a uid the kernel vouched for, so the command's floor is the only gate. Adding a compose or reply route would move that boundary into the web tier for the first time.

15 · Operating it

Every service ships the same kit — deploy / remove / status (plus upgrade and redeploy where they mean something), config generation, and a health probe that reports actually-serving rather than "process exists". Because the units live in root's systemd and run as locked-down euler-* users, lifecycle needs sudo.

The verbs are deliberate, and the Makefile target's verb is always the action its script takes. Local things the terminal solver needs — apt packages, the .venv, git hooks, completions, Chrome, Claude Code — are install / uninstall. System things the web stack needs are deploy / remove / redeploy, because they touch root's systemd, the euler-* identities, and /etc/euler rather than your checkout.

15.1 Deploy

echo 'EULER_TLS_DOMAIN=euler.example.com' >> ~/.euler/env   # if not already set
make deploy-web    # frontend → egress → ddns → firewall → smtp → auth → msg → site → user

Or per kit: make deploy-frontend | deploy-egress | deploy-ddns | deploy-firewall | deploy-smtp | deploy-auth | deploy-msg | deploy-site | deploy-user. Every kit's deploy is idempotent, so re-running it is the safe way to re-assert a drifted host.

~/.euler/env is the deploy-time authoring source of truth, read by the deploy path (repo owner + sudo), which deploys scoped runtime config into /etc/euler. That scoping is the point: euler-ddns sees only the DNS credentials, euler-smtp only the Gmail ones, and no runtime service ever reads the full file. It carries EULER_TLS_DOMAIN, the DNS provider credential pair (default NAMEDOTCOM_USERNAME / NAMEDOTCOM_TOKEN), SMTP_ADDRESS / SMTP_APP_PASSWORD, and the operator's own ANTHROPIC_API_KEY. The provider is selectable via $EULER_TLS_DNS_PROVIDER (default namecom; also cloudflare, route53, godaddy, digitalocean, gandi).

Register the first admin account with users add.

15.2 Redeploy versus upgrade

make redeploy-web is the everyday turnaround after a source change: it pushes new code, templates, and static assets without touching identities, units, certs, or the firewall. frontend.sh redeploy goes first: it refreshes /etc/euler/web-content plus the Caddyfile and reloads the edge, so the new static files are in place before any restarted service names them by their hash (§ 11.10). Then auth.sh redeploy reinstalls the repo into the shared /opt/euler venv (the content wheel with it), msg.sh redeploy and site.sh redeploy restart the spool and the public site against it, and user.sh redeploy bounces running instances so their sockets re-activate them against the rebuilt venv and re-lays each clone's per-clone setup from the operator's checkout. It is gated on make check-version (its first prerequisite): the version packages/solver/solver/version.py names — the number this build bakes into the venv — must have its vX.Y.Z tag on origin, or the redeploy refuses. That stops a release that was bumped and tagged locally but never pushed from running the deployed venv ahead of what any collaborator clone can git-sync to.

Per-clone setup travels with the redeploy. Three things inside a collaborator's clone are laid down from this checkout rather than by them: their git hooks, the repo-root config those hooks read, and their crypt filter's command. All are things they cannot receive any other way.

The config is .gitignore and .shellcheckrc — configuration in the same sense the hooks are, because each decides a verdict: .gitignore's ! negations are what the pre-commit "gitignored files" gate reads, and .shellcheckrc's external-sources is what stops a shell script from passing or failing on whether the file it sources happened to be in the same shellcheck command. Shipping the hook from here while leaving its config to the clone would run a gate this repo did not choose. Unlike the hooks, though, these two are tracked files, so they are restored only when the clone's own HEAD already holds those exact bytes — putting the file back when a collaborator has edited theirs, and doing nothing otherwise. A clone that is behind on one is skipped and named in the output, deliberately: git refuses to merge over a path whose worktree copy differs from HEAD — untracked, or tracked-and-modified, and even when the bytes are identical to what the merge is bringing in. Copying into a behind clone would trade "your shellcheck lane is unconfigured until you sync" for "you cannot sync at all", and the sync is precisely what fixes the first. A clone keeps whatever filter command was recorded the day it was wired, and only git-filter install rewrites it — which needs the master key, so it runs in their own session and nowhere else; writing the command, though, needs no key at all (only the re-checkout does), so the operator can push it. It has to carry -P: git runs a filter with the cwd at the top of the worktree, and a solver checkout has a packages/solver/solver/ package sitting right there, so without it the filter imports the clone's source instead of the venv's — a clone that falls behind then runs an old filter against a current key file and stops decrypting. An unwired clone is left alone; wiring it is theirs to do when they are issued a key.

Cutting a release. The version is one tracked file, packages/solver/solver/version.py, written only by scripts/version/release.sh (make release): it derives the SemVer bump from Conventional Commits, rewrites the file, commits, tags vX.Y.Z, and pushes the commit + tag to origin (ARGS=--dry-run previews, ARGS=--no-push stops before publishing). The deployed venv is only as current as the last release baked into it, so the full flow is make release → make redeploy-web — the push in the first step is exactly what lets check-version pass in the second. A new web-shell command additionally needs each per-user clone to git-sync and the terminal to reconnect before it appears (the persistent PTY builds its command registry at spawn; see § 12).

Reach for make upgrade-web only when identities, units, or the Caddy/acme packages themselves change. Note that systemd units are re-laid by user.sh upgrade, not by redeploy — a unit change needs the upgrade path.

upgrade is the one verb that is not uniform, because for most kits there is nothing for it to do that deploy does not already do:

kit upgrade
frontend.sh, egress.sh Genuinely distinct: upgrades the Caddy / acme.sh / Squid packages and regenerates config + unit, without issuing a cert.
auth.sh, smtp.sh, msg.sh, site.sh, user.sh An alias of deploy — the deploy path is already the re-assert path.
ddns.sh, firewall.sh No upgrade action at all; their deploy is idempotent and doubles as one, which is why upgrade-web calls deploy-ddns and ends on a firewall.sh reload.

make remove-web tears the stack down in reverse; the kits prompt before deleting state.

15.2.1 Status

make status-web is the read-only counterpart: it runs every kit's status in deploy order — edge, egress, DDNS, firewall, mail relay, auth service, message spool, per-user tier — and never changes anything. It needs sudo all the same, because what it reads is root-owned (nftables, /etc/euler), the clones sit under 0700 homes, and the shell report is a query on each running instance's own socket.

The last section is the collaborator roster, keyed on the unix name — the slug, which is equally the uid, the home, the socket and the unit instance — with the e-mail as an alias. The alias is looked up, not read off the name: the slug is a hash, so the report recomputes system_slug over every identity in /etc/euler/authorizations.json and indexes by the result. A slug with no alias is an account the policy no longer maps.

  u331cecba2f07a3da  vikas.munshi@gmail.com (maintainer)
             uid ✓ · clone ✓ · hooks ✓ · socket ✓ listening · service ✓ running
             web shell ✓ live · connected (1 terminal(s)) · pid 41207
  uff8d9819fc0e12bf  alice@example.com (contributor)
             uid ✓ · clone ✓ · hooks ✓ · socket ✓ listening · service … idle (socket-activated on demand)
             web shell … none (instance idle)

Read the two shell facts separately: live means that user's persistent PTY shell is running (it survives a closed laptop — see § 12), and connected means a browser terminal is attached to it right now. A live-but-NOT connected shell is normal and shows how long it has been detached; the reaper collects it after the TTL.

An idle service is likewise not a fault — instances are socket-activated, so a collaborator who is not browsing has no process. That is also why the shell query is only made for a running instance: dialing the socket is what starts one, and a status command must not fork a service for every idle invitee. The instance answers on GET /internal/status over its own socket — socket-peer only, and Caddy answers /internal/* with 404 rather than routing it.

15.3 Going public

Only needed for access beyond the LAN.

  • Router: port-forward TCP 443 to the host's LAN IP (no port 80 — DNS-01 needs none), and give the host a DHCP reservation so its LAN IP does not drift.

  • WSL2 mirrored mode → Windows Hyper-V Firewall, from an elevated PowerShell:

    New-NetFirewallHyperVRule -Name "WSL-Caddy-443" -DisplayName "WSL Caddy HTTPS" `
      -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
      -Protocol TCP -LocalPorts 443 -Action Allow
    

    Confirm the VMCreatorId with Get-NetFirewallHyperVVMCreator; requires [wsl2] firewall=true in .wslconfig (the default).

  • Dynamic DNS: euler-ddns.timer runs every 5 minutes, PUTting the A record only when the public IP changes, using the same token as DNS-01. Being infra egress, it does not pass through Squid.

15.4 Renewal

acme.sh runs as non-root euler-acme on a daily timer; its --reloadcmd fixes the key mode and force-reloads via Caddy's admin socket (§ 2). frontend.sh upgrade re-registers that command, which acme.sh stores per domain. Renewal needs no DNS credentials re-supplied — acme.sh saved the token in the cert .conf at issue time. Force one with frontend.sh renew. Each kit's status reports unit + health; frontend.sh status also shows cert expiry and /healthz.

15.5 Configuration summary

Layer Key config
Authoring source ~/.euler/env (repo owner reads; installer deploys scoped copies)
Edge /etc/euler/Caddyfile (0644), /etc/euler/tls/server.{crt,key} (euler-acme:euler-tls, setgid, key 0640), /etc/euler/web-content, /run/euler-caddy/admin.sock (0660)
Egress /etc/euler-proxy/{squid.conf,squid.allowlist}; client HTTPS_PROXY in /etc/euler/egress.env
Firewall + relay /etc/euler/nftables.conf; /etc/euler/smtp.env (root:euler-smtp 0640)
Auth /opt/euler/venv; /etc/euler/auth.env (root:euler-auth 0640); /var/lib/euler-auth (0600)
Messaging /etc/euler/msg.env (root:euler-msg 0640); /var/lib/euler-msg (0700)
Policy /etc/euler/authorizations.json (root:euler-authz 0640); /etc/euler/profiles/<account> (root:<account> 0640, that account's own row)
Per-user tier /etc/euler/user.env (no secrets); /home/<slug>/ (0700)
Sockets /run/euler (root:euler-edge 1771); auth.sock, msg.sock (0660 …:euler-web); site.sock, user-<slug>.sock (0660 …:euler-edge); /run/euler-adm/auth-admin.sock and /run/euler-msg/admin.sock (0600, one per admin plane)

15.6 Verify

  1. solver-host "users add you@example.com" emails a 7-day invite; opening it walks Terms (scroll-gated) → OTP → set-password and lands on /login?registered=1.
  2. Signing in never places the password in a request body. A tampered or expired token shows the generic page, and /register with no token is linked from nowhere.
  3. An unauthenticated request to any gated path 302s to /login; /healthz and /assets/* stay public.
  4. /forgot for a nonexistent email returns the same generic "check your mailbox" page as a real one.
  5. Masquerade: a forged X-User or X-User-Slug header is stripped at the edge, so the upstream sees only the forward_auth value and a user cannot reach another's socket; a bare solver run as a euler-* uid with no ticket aborts; the web-shell ticket is single-use, so a replay from a sibling's /proc/<pid>/environ is dead on arrival; and a ticket whose slug differs from the instance pin aborts.
  6. Egress: firewall.sh status probes an allowed and a dropped destination per uid.