|
| 1 | +--- |
| 2 | +status: proposed |
| 3 | +date: 2026-10-20 |
| 4 | +scope: |
| 5 | + - packages/secure-origin/** |
| 6 | + - packages/realm-server/secure-origin/** |
| 7 | + - packages/realm-server/lib/secure-origin.ts |
| 8 | + - packages/realm-server/realm-server.Dockerfile |
| 9 | + - packages/realm-server/tests/helpers/index.ts |
| 10 | + - packages/matrix/support/isolated-realm-server.ts |
| 11 | + - mise-tasks/services/realm-server |
| 12 | + - mise-tasks/test-services/** |
| 13 | + - .github/workflows/*secure-origin* |
| 14 | + - packages/host/app/components/matrix/auth-*.gts |
| 15 | + - packages/host/app/components/matrix/login.gts |
| 16 | + - packages/host/app/components/matrix/register-user.gts |
| 17 | + - packages/host/app/components/matrix/forgot-password.gts |
| 18 | + - packages/boxel-ui/src/styles/variables.css |
| 19 | + - packages/boxel-ui/src/styles/theme.css |
| 20 | +--- |
| 21 | + |
| 22 | +# 008. Build the secure origin's pages as their own Ember app |
| 23 | + |
| 24 | +Every other screen in Boxel is a component in the host app. The secure |
| 25 | +origin's pages (the secure window, and the credential fields the app frames) |
| 26 | +are the deliberate exception. One script served anywhere on the secure origin |
| 27 | +can read every password typed on it, so all the code the origin serves is |
| 28 | +trusted code. It has to be small enough to review, and nothing else may change |
| 29 | +it. Plain JavaScript keeps it small, but it doesn't scale to the screens the |
| 30 | +secure window is gaining, such as key management. So the pages get a package |
| 31 | +of their own: an Ember app with its own components, an import allowlist |
| 32 | +enforced by the build, and a reviewed record of everything it ships. This ADR |
| 33 | +records that boundary and why the pages don't follow the rest of the UI |
| 34 | +architecture. |
| 35 | + |
| 36 | +## 1. Decision |
| 37 | + |
| 38 | +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", |
| 39 | +"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this |
| 40 | +document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, |
| 41 | +and only when, they appear in all capitals, as shown here. |
| 42 | + |
| 43 | +### 1.1. A package of their own |
| 44 | + |
| 45 | +The secure origin's pages MUST be built from their own package, |
| 46 | +`packages/secure-origin`. The realm server MUST serve on the secure origin only |
| 47 | +that package's build output, and the files allowed by §1.4. The build MUST run |
| 48 | +before any process that serves the secure origin starts, including the |
| 49 | +realm-server image, the development and test stacks, and test helpers that |
| 50 | +start the secure origin in-process. The realm server MUST NOT build anything |
| 51 | +at request time. |
| 52 | + |
| 53 | +### 1.2. Ember, with templates compiled at build time |
| 54 | + |
| 55 | +The pages SHOULD be an Ember app, written as Glimmer components. Templates |
| 56 | +MUST be compiled at build time. The build MUST NOT include Ember's runtime |
| 57 | +template compiler. |
| 58 | + |
| 59 | +### 1.3. What the package may import |
| 60 | + |
| 61 | +The package MUST NOT import from `packages/host`, `@cardstack/runtime-common`, |
| 62 | +the base realm, any realm or card module, or any script from |
| 63 | +`@cardstack/boxel-ui`. Every module in the build's output MUST come from the |
| 64 | +package itself or from a package named in its allowlist. A plugin in the |
| 65 | +package's build MUST fail the build when the output holds any other module, |
| 66 | +or any module of Ember's runtime template compiler (§1.2). Adding a package to |
| 67 | +the allowlist is a change to the secure origin's trusted code, and MUST be |
| 68 | +reviewed as one. |
| 69 | + |
| 70 | +### 1.4. Styles |
| 71 | + |
| 72 | +The pages MAY use boxel-ui's `variables.css`, `theme.css` and typeface, |
| 73 | +served as files, so their tokens are the app's. They MUST NOT load any other |
| 74 | +boxel-ui file. |
| 75 | + |
| 76 | +### 1.5. The policy stays as it is |
| 77 | + |
| 78 | +The build output MUST run under the secure origin's Content Security Policy |
| 79 | +unchanged, with no `unsafe-inline` and no `unsafe-eval`. Templates MUST NOT |
| 80 | +bind or set a `style` attribute. The package's template lint MUST enable |
| 81 | +`no-inline-styles` with `allowDynamicStyles: false`. Code that styles an |
| 82 | +element at run time MUST use the CSSOM (`element.style.setProperty`). Scoped |
| 83 | +CSS MUST be emitted as files. Entry documents MUST hold no inline script or |
| 84 | +style. A test MUST load each built page under the policy and fail on any |
| 85 | +`securitypolicyviolation` event. The secure origin MUST NOT be served by a |
| 86 | +development server that injects styles inline. |
| 87 | + |
| 88 | +### 1.6. Security logic stays plain modules |
| 89 | + |
| 90 | +The credential frame's message protocol and fixed forms, the Matrix requests, |
| 91 | +the session handling, origin matching and response redaction MUST live in |
| 92 | +`packages/secure-origin/src/security/`. Modules there MUST import nothing from |
| 93 | +Ember, Glimmer or the package's components, and a lint rule |
| 94 | +(`no-restricted-imports`) on that directory MUST enforce it. Components call |
| 95 | +these modules. They MUST be testable without rendering. |
| 96 | + |
| 97 | +### 1.7. Serving |
| 98 | + |
| 99 | +The build MUST emit one entry document for the shell and one for the |
| 100 | +credential fields, and a manifest that maps each entry to the files it loads. |
| 101 | +The realm server MUST read the manifest and the files once, at startup. The |
| 102 | +shell's path is configured per environment (`/` deployed, `/secure` in |
| 103 | +development). So the build MUST write asset URLs under a fixed placeholder |
| 104 | +base, and the realm server MUST replace that base with the configured path |
| 105 | +when it reads the files. The credential fields SHOULD have their own entry and |
| 106 | +bundle. Framing and headers MUST stay per path, as `secureOriginHeaders()` |
| 107 | +sets them. |
| 108 | + |
| 109 | +### 1.8. Reviewing what ships |
| 110 | + |
| 111 | +The package MUST commit a record of its build output: each module's package |
| 112 | +name and version, and its size. CI MUST rebuild the package and fail when the |
| 113 | +output differs from the record. So a bump to Ember or any allowed package |
| 114 | +shows, in the pull request that makes it, exactly what changes on the origin. |
| 115 | + |
| 116 | +### 1.9. One implementation of the sign-in screens |
| 117 | + |
| 118 | +The host SHOULD render its own sign-in, registration and password-reset |
| 119 | +screens from the package's presentational components, so that one |
| 120 | +implementation serves both origins. The host MAY import from the package. The |
| 121 | +package MUST NOT import from the host (§1.3). |
| 122 | + |
| 123 | +## 2. Rationale |
| 124 | + |
| 125 | +The rest of the UI is the host app, and new screens are expected to follow |
| 126 | +it. The secure origin can't. It exists so that card code in the app page |
| 127 | +can't read a credential, and the browser's same-origin policy does the |
| 128 | +isolating. That protects the origin, not a page on it, so every script the |
| 129 | +origin serves can read every window open on it [1][2]. All that code is |
| 130 | +trusted. What decides the architecture is how much trusted code there is, who |
| 131 | +writes it, and how a change to it gets reviewed. The host bundle is ruled out |
| 132 | +outright: it evaluates card code. |
| 133 | + |
| 134 | +Plain JavaScript keeps the trusted code small: about 100 KB of commented |
| 135 | +source, all of it written in this repository. It also costs a copy of every |
| 136 | +screen. `shell.css`, `credentials.css` and `dom.js` copy the host's auth |
| 137 | +screens, and tests compare the copies pixel by pixel to keep them in step [3]. |
| 138 | +Hand-built DOM is manageable for a few forms. It isn't for the screens the |
| 139 | +secure window is gaining, such as key management, which need the state, |
| 140 | +composition and templates the rest of the team writes in Ember. |
| 141 | + |
| 142 | +An Ember app of the pages' own has neither cost. A minimal Ember application |
| 143 | +(router, resolver and one component) is 293 KB of minified script, 91 KB |
| 144 | +gzipped, in a production build, and runs under the policy with no violation. |
| 145 | +That runtime is a fixed body of code from a widely used, pinned release, and |
| 146 | +it is the stack the team already writes and reviews. It needs no |
| 147 | +`unsafe-eval` once templates are compiled at build time. Components written |
| 148 | +for the package can follow the policy from the start. The import allowlist |
| 149 | +(§1.3) and the build record (§1.8) keep the trusted code to what the package |
| 150 | +names, and make each change to it visible. |
| 151 | + |
| 152 | +The security logic stays as plain modules in one directory (§1.6), because |
| 153 | +that code is what a reviewer of the origin has to read closely. Plain modules |
| 154 | +keep it readable without knowing the framework, and testable on its own. |
| 155 | + |
| 156 | +## 3. Considered options |
| 157 | + |
| 158 | +- **The package's own Ember app (chosen).** |
| 159 | +- **The host app bundle.** It evaluates card code. |
| 160 | +- **An Ember app on boxel-ui's components.** A sign-in form built from them |
| 161 | + shipped 321 KB minified, and 1,265 KB through boxel-ui's index modules, |
| 162 | + since boxel-ui declares no `sideEffects`. LoadingIndicator and the |
| 163 | + BoxelIcon logo set `style` attributes the policy refuses. Every later |
| 164 | + boxel-ui change would also ship onto the origin. |
| 165 | +- **Plain JavaScript, in the realm server or in its own package.** It is the |
| 166 | + smallest trusted code, but it means a hand-copied second implementation of |
| 167 | + every screen, which grows with each screen the window gains. |
| 168 | +- **A small view library, such as Preact or Lit.** It is smaller than Ember, |
| 169 | + but it is a second UI stack for the team to learn and review. |
| 170 | +- **Relaxing the policy with `style-src-attr 'unsafe-inline'`.** It would |
| 171 | + also let any style attribute that injected markup carries take effect. |
| 172 | + |
| 173 | +## 4. Consequences |
| 174 | + |
| 175 | +Until the package exists, a new secure-origin screen waits for it, since |
| 176 | +adding one to the plain pages would add a gap. That orders new secure-window |
| 177 | +screens, such as key management, after the package scaffold. Until the host |
| 178 | +renders its screens from the package (§1.9), a change to the host's sign-in |
| 179 | +screens still needs the same change in the package. |
| 180 | + |
| 181 | +## 5. Sources |
| 182 | + |
| 183 | +1. `.claude/skills/secure-origin/SKILL.md`, "The governing fact". |
| 184 | +2. [ADR 005](005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md) |
| 185 | + §1.4, the secure origin's pages and their policy. |
| 186 | +3. #6861, which adds the registration and password-reset pages, the copied |
| 187 | + styles and icons, and the pixel comparison. |
| 188 | + |
| 189 | +## 6. Remediation |
| 190 | + |
| 191 | +- `packages/realm-server/secure-origin/index.html`, `shell.js`, `shell.css`, |
| 192 | + `sign-in.js`, `sign-in-copy.js`, `register.js`, `password-reset.js`, |
| 193 | + `password-fields.js` and `dom.js` break §1.1. They are served as written, |
| 194 | + not built from `packages/secure-origin`. |
| 195 | +- `packages/realm-server/secure-origin/credentials.html` and |
| 196 | + `credentials.css` break §1.1, for the same reason. |
| 197 | +- `packages/realm-server/secure-origin/credentials.js`, `matrix.js`, |
| 198 | + `session.js`, `redact.js` and `app-origins.js` break §1.1 and §1.6. They |
| 199 | + are served as written, and they are not in `packages/secure-origin/src/security/`. |
| 200 | +- `packages/realm-server/lib/secure-origin.ts` (`SHELL_ASSETS`, |
| 201 | + `CREDENTIALS_ASSETS`) breaks §1.1 and §1.7. It serves fixed source files, |
| 202 | + not the package's build output read through a manifest. |
0 commit comments