Loopback Server & Auth
One loopback HTTP+WS server (core/server/static-file-server.ts, with its
admission policy in core/server/loopback-gate.ts) serves the
web app at /, the media files mounted by plugins, and the framed-protocol
WebSocket socket. On device it binds 127.0.0.1 on an ephemeral port, and
every gated request is authorized by a per-session credential:
- The host starts the worklet, which writes
{ origin, port, bootstrap }tohandoff.json(never the session token). The host injects only the one-timebootstrapnonce into the page (window.__ekrooh = { bootstrap }), never the raw token. - The page exchanges the bootstrap nonce for a
bare_session_<port>cookie viaPOST /login(HttpOnly; SameSite=Strict; Path=/). The nonce is single-use: after the page redeems it, it is spent. An unredeemed nonce also expires (see below), so a late/loginwith it is a 401; an existing session cookie keeps working. The raw session token is never accepted as a login body. - The cookie then authorizes
<img>/<video>/fetchand the WebSocket upgrade. Non-browser clients use theX-Bare-Tokenheader. There is no?token=query-param path — a token must never appear in a URL. - The host loads
http://127.0.0.1:<port>/index.htmland deleteshandoff.json. A host that needs the session token for its ownX-Bare-Tokencalls gets it fromruntime.start()inside the worklet, not from disk.
The nonce lives for BOOTSTRAP_TTL_MS (120 s) of running time, counted
from the first public page request (normally the host loading the page; a POST /login
that arrives before any page load is accepted only inside the 600 s unloaded
window). The clock pauses while the Bare worklet is
suspended and restarts on resume, so an app backgrounded between page load and
login still logs in. A nonce whose page never loads expires after 600 s of
running time from bind. bootstrapTtlMs on LoopbackServerOptions changes the
120 s; a non-positive or non-finite value falls back to the default, and it is
capped at 600 s. An expired nonce never comes back.
Cookies are scoped to the host, not the port (RFC 6265 §8.5). The port in the
cookie name (bare_session_<port>) only stops two ekrooh servers on one
machine from overwriting each other’s session; it does not stop the
browser from sending the cookie to another listener on 127.0.0.1. A page
served from another loopback port is also same-site with this server, so
SameSite=Strict still attaches the cookie to its <img>/<video> loads and
navigations. With auth on, the server therefore refuses (403) any gated
request or WebSocket upgrade whose Sec-Fetch-Site is same-site or
cross-site; only same-origin and none (a navigation the user started)
are admitted. A request whose Origin is exactly an allowedOrigins entry
is exempt (you listed it because it is cross-site); a request without Origin
never is. A client that sends no Sec-Fetch-Site (a non-browser client, an
older WebView) is judged by the origin rule alone. The public app mount and
dev mode are not affected.
Every request, in every mode, must also pass the origin rule: its Host
must name the bound server (127.0.0.1, localhost or [::1] on the bound
port), which defeats DNS rebinding, and an Origin, when sent, must be that
server or one listed in allowedOrigins. WebSocket upgrades are not subject to
CORS, so this is what stops another site open in the same browser from driving
the backend. With auth on, an upgrade without an Origin is refused.
The dev backend (mode=dev) runs the same bundle with auth off on a fixed port
(ws://localhost:8080), so browser dev needs no credential, but the origin
rule still applies: the Vite dev server’s origin is passed as
origin=http://localhost:5173.
HTML documents carry a Content-Security-Policy whose connect-src names the
one protocol socket the page may open (ws://<host>:<port>), not any ws:
host. A directory mount never serves a file whose real path (symlinks
resolved) lies outside the mount root, and a path segment that decodes to
., .., a separator or NUL is a 400.
Public vs gated (the threat model)
Section titled “Public vs gated (the threat model)”On one loopback origin, only the bootstrap page is public, and only because
a fresh WebView must load it before it can POST /login:
- Public (pre-auth): the explicit bootstrap directory mount at
/— the bundled web app — markedmountDir('/', dir, { public: true }). - Gated: every file mount (media), the WS upgrade, custom routes, and any
directory mount not marked
{ public: true }. A non-public directory mount 401s unauthenticated and 404s unknown paths (no SPA fallback to the bootstrapindex.html).
Publicness is an explicit opt-in marker, never a property of the mount kind.
import { createLoopbackServer } from '@ekrooh/bare/runtime';
const server = createLoopbackServer(); // synchronous; binds on first useserver.mount('/media/photo-1', '/path/to/photo.jpg'); // gatedserver.mountDir('/photos', '/path/to/photos', { public: false }); // gatedconst url = await server.url('/media/photo-1');A gated directory mount must not be inside (or equal to) the public one:
mountDir throws when the gated root is inside or equal to the public root,
in either mount order (compared by real path,
case- and normalization-insensitively on macOS, iOS and Windows, whose
filesystems ignore case; foldPathCase overrides). A gated root strictly
around the public root is allowed: it only re-serves public files, sandboxed.
Otherwise the gated files would be served before login, as app code, under the
public mount’s URL. The public mount also refuses any file whose real path is
inside a gated directory mount. A gated file mount (mount) inside the
public root is not caught: keep files you gate out of the web app directory.
Security notes
Section titled “Security notes”- Tokens are never placed in URLs or exposed to page JS — only the one-time
bootstrap nonce, and the session cookie is
HttpOnly. Referrer-Policy: no-referrerandX-Content-Type-Options: nosniffon framework responses, custom routes included. Gated responses also carryCross-Origin-Resource-Policy: same-originwhenever auth is on; with auth off (dev) it is left off so the Vite dev page can embed backend media.- Gated requests and upgrades with
Sec-Fetch-Site: same-site|cross-siteare refused (403) when auth is on. - The bootstrap nonce is single-use and expires after 120 s of running time from the page load (600 s if the page never loads).
- Credentials are compared in constant time.
- Files from file mounts and gated directory mounts carry a
sandboxContent-Security-Policy: opened as a document, they run in an opaque origin. - A non-loopback
hostis refused unless auth is on; the Host/Origin rule only stops browsers. - WS upgrade origin check, single-client policy, idle timeout. A replaced
protocol socket is closed with WebSocket code 4001 (
"replaced"), and any frame it still sends is ignored. POST /loginbodies are capped at 4 KiB: an oversized body is a 413 and the connection is closed.- Media mounts are per-session URLs — the mount id is unguessable.
The full decision, including the tradeoff vs per-resource capability tokens
(deferred), is an ADR: docs/adr/0003-loopback-auth-surface.md.
createLoopbackServerresolves through the@ekrooh/bare/runtimeexport; see the runtime reference for the exact surface.