==> read the decisions
ADR-0023: Project UIs run in sandboxed frames
Status: Accepted (2026-10-15). Amends ADR-0004: a project's UI no longer runs in the app's page; the custom element, the shared theme (ADR-0017) and the release layout stand. Implemented 2026-10-15.
Context
Section titled “Context”A project's UI is a custom element mounted in the app's own page (ADR-0004), so it runs in the app's origin. Its code -- and every package in its build -- can act as whoever is viewing it: fetch a CSRF token, mint a personal access token and send it away, change the password, end sessions. While each user only opens their own projects that is a user's own code against their own account, but a compromised dependency is enough, and pushing will be opened to more people.
Decision
Section titled “Decision”- A project's UI runs in an iframe with
sandbox="allow-scripts allow-forms"-- deliberately withoutallow-same-origin. (allow-formsbecause a browser otherwise blocks a form before itssubmitevent, so a project's<form onSubmit>never runs; the frame's policy saysform-action 'none', so no form sends itself anywhere.) The frame loads a small shell and the project's bundle from the CDN (/t/<user>/…, as today) and runs in an opaque origin: no cookies, no storage, no access to the app, to the CDN's origin or to another project's frame. Top-level navigation and popups are refused. - Its backend calls are bearer calls. The app page hands the frame a short-lived token over
postMessage, valid for that project's routes only, and the frame callsapi.<domain>/p/<slug>/…with anAuthorizationheader. This frame token is the one narrowly scoped token the platform issues; personal access tokens stay unscoped (ADR-0020). A browser cannot put a header on a WebSocket, so a frame offers its token as a subprotocol (cs, cs.bearer.<token>) and the API answerscs: no stored ticket, and no token in a URL. - Theme, mode and language arrive by
postMessageon load and on every change; the frame reports its height so the page sizes it. - The CDN path sends a strict Content Security Policy and
frame-ancestorsnaming the app only, with the samesandboxas a policy directive, so the shell is sandboxed even if loaded on its own. - The change lives in the shared library and the app page:
@cs/api'sprojectApi()asks the parent for a token instead of using the cookie,@cs/uitakes theme and language from messages. User repos rebuild against the new shared library and change nothing else. - No per-user origins, probably ever. A separate user-content domain with an origin per user is out of scope for a personal cloud; the sandbox is the isolation.
Consequences
Section titled “Consequences”- A project's code, or a package in it, can no longer act as the viewer beyond its own project's API, for the few minutes its token lives.
- Projects have no browser storage (
localStorage, IndexedDB and service workers throw in an opaque origin). Their data lives behind their own API, which is where it belongs. - Requests from a frame carry
Origin: null; with bearer authentication and no cookies, nothing depends on it.
Alternatives rejected
Section titled “Alternatives rejected”- Keeping the UI in the app's page. The problem above.
- An origin per user on a separate domain. Real isolation with storage, at the cost of a second domain, a wildcard certificate and per-user hosts: more than a personal cloud needs.
- Frames from
cdn.<domain>without the sandbox. Every project would share one origin, and that origin is the same site as the app.