# Frontend apps
### The browser runtime
1. **`client.js` is mandatory and runs the whole sign-in.** Every app `index.html` loads `` before its own bundle; apps never reimplement it. It runs on any app-origin page that is not root's bootstrap page, whatever the app type; a backend app is an API server, and a backend serving browser pages is unsupported (`https://pam.pilely.app/specs/backend_apps` → `### What a backend app is for`). It runs the PKCE handshake (a random 32-byte verifier and a SHA-256 `code_challenge`), the state nonce (16 random bytes), the navigation to `auth.pilely.app/~/mint/authorize?target=&state=&code_challenge=`, the state check on return, the `POST https://pilely.app/~/mint/exchange` of `{code, code_verifier}`, token storage (sessionStorage key `pilely_app_token`), and stripping `pilely_code`/`pilely_state` from the URL with `history.replaceState` before anything else runs, even on a malformed callback. The re-mint, on an app origin only (off an app origin no re-mint happens): before every `window.pilely.fetch` send and on boot, a stored token past its `exp` is refreshed first. An expired anonymous token is replaced by a fresh anonymous mint before the call goes out; an expired user token starts the mint dance (a navigation that is invisible while the apex login is still valid), and the call goes out as it is. After a send, a `401` on a user token clears it and re-runs the mint dance, which shows the login screen only when the apex login is gone; a `401` with code `token_expired` on an anonymous token refreshes it, and any other `401` on a still-valid anonymous token is returned to the caller as is. Every refresh is bounded by one 30-second cooldown, and a refresh the cooldown refuses never holds the call: it goes out as it is. A `404` never triggers a refresh. Expiry is read on the server's clock: when a token is stored, `client.js` records the offset between the device clock and the token's `iat` (sessionStorage key `pilely_app_token_clock`), so a device whose clock is wrong never enters a mint or sign-in loop and its calls are never blocked by the expiry check. It is served from the apex only — on an app host `/~/client.js` is the app's own path — with `Content-Type: application/javascript; charset=utf-8`, `Access-Control-Allow-Origin: *` and `Cache-Control: public, max-age=300`, baked into the binary with `include_str!`. Root answers it in the serving-split layer before the routers, so the credentialed CORS layer never overwrites the wildcard.
2. **The `window.pilely` API.** `ready` is a promise that resolves once the mint callback has been consumed (and the anonymous boot, or the refresh of an expired stored token, attempted); call it before the first render. `user()` returns `{ id, handle, app }` from the token claims (`sub`, `handle`, `pile_id`), or `null` with no token or a `sub`-less anonymous token. `signIn(target?)` starts the mint dance; the target defaults to this origin's host on an app origin, else the `pilely-app` meta tag. `signOut()` clears the token and navigates to `auth.pilely.app/~/logout`. `fetch(input, init)` attaches `Authorization: Bearer `, sets `accept: application/json` when absent, refreshes an expired token before the send, and re-mints on a `401` as in item 1; the `401` it returns is readable by the page. `isAppOrigin()` is true on an app host or a bound custom domain, false on the apex and on `localhost`/`127.0.0.1`/`[::1]`. `apexOrigin()` is the origin `client.js` was loaded from. `authOrigin()` is `auth.`, or on loopback the `` value, else `http://auth.localhost:8080`. `claims()` is the raw decoded token payload (not verified; the server verifies). `token()` is the current token string or `null`. `appId()` returns, in order: the `pilely-app` meta tag when present; else the token's `pile_id` claim, only once `ready` has settled; else `null`. `takeReturnPath()` returns the path the page was on before the dance, consumed once.
3. **The `` tag is optional.** On an app origin (a registered subdomain or a bound custom domain) `signIn()`, the anonymous boot and the silent re-mint all target `location.host`, and the mint resolves the host through the registry; the tag is never read there. Off an app origin (local dev on `localhost`) the tag is the only mint target, and with neither a host nor a tag `signIn()` rejects with "no mint target". The tag also feeds `appId()` before `ready` has resolved, and on any origin it wins over the token's `pile_id` claim. A stale or placeholder value therefore breaks sign-in only on `localhost`, where it is the mint target; on any origin it makes `appId()` return the wrong id. The value must be the real `pile_id` that register returned. (corrected from /skill)
4. **Identity comes from the token's claims.** `user()` and `claims()` decode the token locally; there is no identity round trip. A `/~/me` call does not work off the apex: an app host owns every path, `/~` included, so `/~/me` there is the app's own path, and the session cookie (`__Host-pilely_session`, no `Domain`) exists only on the apex. Root sets exactly one cookie of its own on an app subdomain, the asset cookie: `__Host-pilely_asset=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200` (no `Domain`, so host-only; 12 hours; named `pilely_asset`, without `Secure`, on a local http deployment). It is set on a successful `POST /.well-known/pilely/spa_ticket` for a protected or private frontend app, a fresh one on every mint, and it releases that app's files, never its shell (`### Serving frontend apps`, item 1). A refused mint, a public app's host, a backend app's host and a bound custom domain get no cookie from root. Its name carries the reserved `pilely` prefix, so no backend can set it or receive it; any other cookie on an app host is the app backend's own (`https://pam.pilely.app/specs/backend_apps` → `### Forwarding to backend apps`, item 5). A bare `fetch` carries no token and goes out anonymous; only `window.pilely.fetch` attaches the Bearer.
5. **Public apps get an anonymous credential at boot.** On every app-origin page except the bootstrap page, holding no token, `client.js` POSTs `{target}` to `https://pilely.app/~/mint/anon` before app code runs (`https://pam.pilely.app/specs/accounts_and_tokens` → `### /~/mint/exchange and /~/mint/anon`). It returns a token only when the target is an existing, mint-eligible app with `access_mode = public`, frontend or backend alike; every other case (bad target, unknown app, unbound custom host, protected or private app) gets one byte-identical `404`, which the runtime negative-caches for 5 minutes per app. The anonymous token is `sub`-less, lives 15 minutes, and lets `window.pilely.fetch` read data opened with `anon_read`. When it has expired, the boot and every `window.pilely.fetch` send replace it with a fresh anonymous mint first (item 1), so a page left open keeps working with no action by the visitor. Because the mint never checks the app type, the boot also runs on an HTML page a public backend app answers; that is page serving on a backend, which is unsupported (`https://pam.pilely.app/specs/backend_apps` → `### What a backend app is for`). `user()` stays `null` on it. A user-only call made with it answers the uniform `404` (simple_db refuses an anonymous-via-app caller with `NotFound`), not `401`, so on a public app the sign-in affordance is gated on `window.pilely.user() === null` and calls `window.pilely.signIn()`; the "auto-sign-in on the first 401" pattern applies only to protected and private apps, where no anonymous credential exists.
6. **The `simple-*` hosts answer CORS preflights from app pages.** Root, not the services, grants it: on a request whose Host is a `simple-*` label or the exact `pilely` label (`pilely.pilely.app`, the `@pilely` pile), and whose `Origin` is a `