Asten is local-first. The manuscript, the canon and every diagnostic live in one SQLite file on the author's machine. There is no account, no sync and no cloud model. This document describes what the API protects, how, and what it does not try to protect.
Asten assumes:
- The machine belongs to the author. Code that already runs as the author's
user (another program, a browser extension with native access) is out of
scope: it could read
asten.dbdirectly. - Web pages the author visits are not trusted. The API listens on
loopback, and any page in any browser on the machine could otherwise call
http://127.0.0.1:4317and read or change the manuscript. This is the main threat the authentication layer exists for. - Other machines on the same network are not trusted. By default the API binds to loopback only.
- Verne is not a party. Nothing is sent to Verne. The public site sets no cookies and runs no analytics.
Asten does not defend against a hostile operating-system user, a compromised
Office or Google host, or a browser that leaks localStorage to other origins.
ASTEN_AUTH selects the mode. The desktop app always runs the embedded API in
pairing mode.
| Mode | Who gets in | Use |
|---|---|---|
pairing |
Clients that exchanged a one-time six-digit code | Default. Desktop, dev, LAN for yourself |
owner |
Paired clients, plus a browser that knows a passphrase | Hosting the API for yourself behind TLS |
off |
Everyone | Loopback only. Refused on other binds |
The API refuses to start with ASTEN_AUTH=off on a non-loopback bind.
- Six decimal digits from a CSPRNG, shown once in the desk's Guests panel, or printed to the API's stderr at start-up when no client is paired yet.
- Stored as a salted SHA-256 hash with a ten-minute expiry.
- Single use. Five wrong attempts lock the code.
- A code grants a token of a stated kind. Codes minted under Guests grant
guesttokens; the bootstrap code printed at first start, and codes minted with "another desk", grantdesktokens. - Exchanging a code yields a bearer token. When the request is same-origin
(the desk served by the API itself, or by a proxy in front of both) the
response also sets an
asten_sessioncookie (HttpOnly,SameSite=Strict,SecurewhenASTEN_COOKIE_SECURE=true). A cross-origin desk gets no cookie, because browsers would send it to every other port on the host. - The cookie is also honoured only on same-origin requests: when an
Originheader is present it must name the host the request reached (theHostheader, or the HTTP/2 authority), or the request is answered401as if it carried nothing.SameSite=Strictkeeps other sites out, but not other ports of the same host or sibling subdomains, and a form posted from one of those would otherwise ride the cookie into routes that take no body, such as a snapshot restore. A bearer header is never attached by a browser on its own, so it works from any origin the CORS allowlist admits. - A client label (the name shown in the Guests panel) is at most eighty characters; a longer one is refused before the code is even checked.
- Single use is enforced by the store: two racing redemptions cannot both succeed. Wrong guesses are counted atomically.
- Format
ast_+ base64url of 32 random bytes. Shown to the client exactly once. - Only the SHA-256 hash is stored. Verification is constant-time.
- Each token has a kind (
desk,guest,owner), a label, a creation time and a last-used time. The Guests panel lists and revokes them. Revocation is immediate for every request that follows, including the redemption of a stream ticket minted before it; a change stream that was already open ends when its connection does. - The desktop shell revokes its previous desk tokens at start-up and issues a fresh one for the webview.
- Tokens travel in the
Authorization: Bearerheader or the session cookie. A bearer value must be theast_prefix and base64url characters and nothing else, or it is not consulted at all. The scheme is case-insensitive. A cookie jar split over severalCookiefields, as HTTP/2 allows, is read whole. Tokens never appear in a URL. - The Server-Sent Events stream cannot send headers from a browser, so an
authenticated client first asks
POST …/stream-ticketfor a ticket: random, hashed at rest, bound to one project, valid for sixty seconds, spent on first use, and refused if the token that asked for it has been revoked since. Only that ticket goes in the query string, so a reverse proxy's access log holds nothing worth stealing. - Request tracing spans record the method and path only, never the query string.
GET /desktells a paired client the API version and host. The database path is included only fordeskand ownersessiontokens.- The browser desk registers a service worker for installability on phones. It
caches only the shell's static files and never a response under
/api/, so the browser cache holds no manuscript, canon or token. - Token kinds carry authority.
deskand ownersessiontokens may mint pairing codes, list and revoke tokens and set or change the passphrase.guesttokens (Word, Docs, a paired browser pane) may read and write the story and revoke only themselves. A guest asking for more gets403.
- Optional. At least twelve characters. Hashed with argon2id.
- Setting it for the first time needs a
desktoken, except inownermode with no passphrase yet, where the first caller sets it. "Once" is enforced by the store with a plain insert, so two racing callers cannot both win. Changing it asks for the current passphrase and adeskorsessiontoken.POST /auth/loginis only answered inownermode. - A successful login issues an ordinary token of kind
owner.
/auth/pair, /auth/login, /auth/owner/setup and /auth/owner/passphrase
pass two token buckets: one per client (ten tries, refilling ten a minute) and
one per route shared by everyone (sixty a minute), so a crowd of fresh
addresses cannot multiply the allowance. IPv6 clients are keyed by their /64.
The bucket table is bounded; when full, the least recently seen client is
forgotten first.
Behind a reverse proxy every client arrives from the proxy's address. Name the
proxy in ASTEN_TRUSTED_PROXIES (addresses or CIDR blocks, comma-separated)
and the API keys clients by X-Forwarded-For instead: the rightmost hop that
is not itself a trusted proxy, which is the one the proxy appended. The
leftmost hop is whatever the client chose to send and is ignored, so a client
cannot give itself a fresh bucket per request. IPv4 clients of a dual-stack
listener arrive as ::ffff:a.b.c.d and are keyed, and matched against the
proxy list, as the IPv4 addresses they are rather than as one shared /64.
Never trust a proxy the client can bypass.
- CORS allowlist. Only the dev desk (
localhost:1420), the API's own origin, the Tauri webview origins,https://localhost:3000for the Word task pane andhttps://*.googleusercontent.comfor Apps Script sidebars are allowed by default.ASTEN_ALLOWED_ORIGINSadds more. Everything else gets noAccess-Control-Allow-Originand no private-network approval. Wildcards match one or more labels before the dot and nothing else, soevil-googleusercontent.comis not*.googleusercontent.com. - Private network access.
Access-Control-Allow-Private-Network: trueis sent only to allowed origins, so Chrome lets the Google sidebar reach loopback and nothing else can. - Headers on every response.
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, a restrictivePermissions-Policyand aContent-Security-Policy. Each response carries anx-request-id. WithASTEN_COOKIE_SECURE=trueevery response also carriesStrict-Transport-Security: max-age=31536000. - API responses (
/api/…,/health) areCache-Control: no-store, so manuscripts never land in a disk cache, and sit underdefault-src 'none'; frame-ancestors 'none': they are data, never a page. - The served desk (
ASTEN_SERVE_DESK) gets a policy fitted to it. Scripts come from its own origin plus the one inline bootstrap inindex.htmlthat applies the saved theme before paint, which is allowed by its SHA-256 hash, computed from the served file at start-up, never by'unsafe-inline'. Styles come from its origin and inline, because the editor injects a<style>element at start-up. Fonts come from its origin anddata:; images from its origin,data:andblob:; connections go to its origin only; there are no frames, objects, base URIs or foreign form targets, andframe-ancestors 'none'. The hashed files under/assets/are content-addressed, so they arepublic, max-age=31536000, immutable; a missing asset is a404, never the shell page, so nothing wrong is ever cached under an asset's name. The shell itself (index.html,sw.js, the manifest, the icons) isno-cache: kept, but revalidated on every load, so a new build reaches the browser at once. - Desktop CSP. The Tauri webview allows connections to
127.0.0.1andlocalhostonly, and no objects, base URIs or foreign form targets.
- 30-second request timeout, 10 MiB body limit, panic containment, response compression, request-id propagation and structured tracing.
- Optimistic concurrency on document commits (
expected_head_seq), so two guests cannot silently overwrite each other. - Every document keeps a hash-chained event log;
GET …/verifyrecomputes the chain and the Merkle root of the current block set.
Only asten.db:
- Projects, documents, block events and snapshots, facts, entities, events, settings, suppressions, drafts, baselines.
- Token hashes, pairing-code hashes and the argon2id owner hash. No plaintext secrets.
Nothing is written outside the database directory except the API log on stderr, which never contains a token.
If you bind beyond loopback (ASTEN_BIND=0.0.0.0:4317, or the Docker compose
file):
- Put TLS in front of it and set
ASTEN_COOKIE_SECURE=true. That also turns on HSTS for a year, so do it on a host name that will keep its TLS. - Use
ASTEN_AUTH=owneronce a passphrase is set, or stay inpairingand mint codes from a paired desk. - Add your desk's origin to
ASTEN_ALLOWED_ORIGINSif it is not served by the API itself (ASTEN_SERVE_DESK). - Keep the host port bound to an address only you can reach.
- Name the reverse proxy in
ASTEN_TRUSTED_PROXIESso rate limits apply per client rather than to the proxy as a whole.
The public pre-alpha at https://asten.103.191.50.32.sslip.io is not a mode of
the API; it is a host for it, services/asten-prealpha, built the way the
desktop shell hosts the API in-process (D6, D29). Nothing in the API's rules
changes.
- One API per visitor. The first request without a live
asten_visitorcookie creates a visitor. Each visitor gets a separate SQLite file and a separateasten_api::routerover it, inpairingmode. No two visitors share a database, a token table or a change stream. - The gateway is the paired client. When it opens a sandbox it revokes
that sandbox's earlier
desktokens and issues a fresh one, exactly as the desktop shell does at launch, and presents it as a bearer header on the visitor's requests. The token never reaches the browser; the visitor holds only the cookie. If the desk signs out (which revokes that token), the gateway issues a fresh one and retries once. - The visitor cookie is 32 random bytes,
HttpOnly,Secure,SameSite=Strict,Path=/,Max-Age24 hours. Only its SHA-256 is stored. It is removed from the request before the API sees it. - Origin rule. As for the session cookie (D24), the visitor cookie counts
only on same-origin requests: a request whose
Originis not the host it reached, or that the browser labelsSec-Fetch-Site: same-siteorcross-site, is answered403before any sandbox is touched. Siblingsslip.ionames are "same-site" to a browser, soSameSitealone is not enough. - Expiry and quotas. A visitor and everything they made are deleted 24 hours after the first visit; there are no backups. New visitors are capped per client address per hour and in total; a sandbox stops accepting writes at 48 MB; the state store caps value size, total size and key count.
- What the gateway stores beside the sandboxes (
/svc/v1/state): the desk's own preferences, assistant threads, console history and layout. Provider keys are never sent to it. The hosted pre-alpha and the browser desk call no provider and keep a typed key in the page's memory only (apps/web/src/workbench/secrets.ts); the desktop app keeps keys in the shell (see "The assistant"). - No egress. The gateway container sits on a Docker network with no route out; only a forwarding nginx container is reachable, on loopback, behind the host's TLS nginx with request-rate and body-size limits. Compile, import and every check run inside the sandbox's API; nothing a visitor types makes the server fetch anything.
- Not for manuscripts. The hosted copy is for trying the workbench. The site and the About page say so: real work belongs in the desktop app, where the file stays on the writer's machine.
The gateway's own tests (services/asten-prealpha/tests/gateway.rs) cover
isolation between visitors, the origin rule, the quotas, the state limits,
sign-out recovery and expiry.
Release 0.0.1b (D30). Everything below happens in the desktop shell's Rust
process (crates/asten-assistant, the ai_* commands); the webview has no
permission to reach a provider and its content-security policy still allows
only loopback.
- Keys. Typed once into
ai_set_keyand never returned. Stored in the operating system's credential store (Windows Credential Manager, the Secret Service, the Keychain) or, where none exists, in a file sealed with Argon2id and ChaCha20-Poly1305 that the writer unlocks with a passphrase. Held in zeroizing types with noSerialize; they never appear in logs, events, errors, exports, the ledger or the exact-request preview (which shows a placeholder). Tests plantZEUS-CANARY-style markers and search every output. - Where requests go. Only to the endpoint of a provider the writer enabled and consented to: HTTPS everywhere, plain HTTP only to a loopback address; userinfo, query strings and fragments in an endpoint are refused; redirects are not followed, so a key cannot follow one; size caps and idle timeouts apply to bodies and streams.
- Consent. Per provider, tied to the host and a notice version; a different host or a new notice asks again. The master switch is off by default; offline mode allows only loopback providers; a cap of zero means no call.
- What is sent. The writer's question and the thread, the fixed instructions, the tool definitions, and what the tools return. Manuscript wording is excluded unless the writer attaches a passage; wording quoted in a mark is withheld unless that mark is attached. The preview is produced by the same builder as the sender and carries the SHA-256 of the body it shows.
- What it cannot do. The tool table is closed: ten tools read or prepare (explain a mark, search, summarise a source, propose a fact for the writer to confirm) and five are closed by rule (anything that would edit the manuscript, confirm canon, change settings, run a command or decide an approval). An invented or closed tool name is refused and shown. A request for prose is refused before any provider is contacted (a classifier), the system prompt says it again, the tool gate says it a third time, and an output guard withholds a reply that reads like new narrative. Tests drive mock providers that try to comply.
- Not covered. What a provider does with text it receives is governed by that provider's terms. The adapters are tested against mock servers, not live services.
Dependabot (.github/dependabot.yml) opens one grouped pull request a week
per ecosystem (Cargo, npm, GitHub Actions) for minor and patch updates. Major
versions of axum, Tauri, sqlx, React, Vite and Tiptap are held back there and
taken deliberately, migration notes first. cargo audit and
pnpm audit --prod were run for this pass; run both again before a release.
What cargo audit reports that cannot be fixed from this repository, and why
each is accepted:
- RUSTSEC-2023-0071
rsa0.9 (Marvin timing side channel; no fixed version exists). Present inCargo.lockonly throughsqlx-mysql, an optional driver that sqlx's macro crate lists and nothing here enables; it is never compiled into an Asten binary. Asten speaks SQLite only. - RUSTSEC-2025-0075, -0080, -0081, -0098, -0100
unic-*0.9 (unmaintained). Pulled in byurlpatternthroughtauri-utils, at build time only, for the desktop shell's configuration; it goes when Tauri drops it. - RUSTSEC-2024-0370
proc-macro-errorand RUSTSEC-2024-0429glib0.18 (unmaintained; an unsound iterator). Linux-only GTK bindings beneath Tauri's window runtime; not compiled on Windows or macOS, and not reachable from the API. yoke-derive0.8.3 is yanked.yoke, beneath the ICU crates thaturluses, pins it, and no replacement release exists yet; it is a procedural macro that runs at build time only.
zip moved from 2.4 to 9.0 for RUSTSEC-2026-0367, -0368 and -0369. Asten
never extracts an archive to disk (it reads word/document.xml out of a
.docx in memory and writes one back), so the advisories did not apply, but
the fixed line is the one that receives updates. pnpm audit --prod reports
nothing.
Phone installation requires an HTTPS origin trusted by the phone. Use the
same reverse proxy for the desk and /api/; see Phone setup.
The localhost secure-context exception does not apply to another computer's
plain-HTTP LAN address. Do not disable browser security checks to install.
Report a vulnerability by opening a private security advisory on the GitHub
repository, or by writing to the address in the public site's security.txt.
Do not include a manuscript in the report.