# SEC · Before the first deployment (build plan step 3b)

**Status:** SEC-1 to SEC-13 decided under the Architect's autonomy grant, 8 November 2026. The mockup (`reji/ui/html/sec/confirm.html`)
was drawn and checked in light, dark and on a phone. **SEC1 to SEC4 built, 8 November 2026.** `id` is rescored 7.6 of 10
(`SECURITY_ASSESSMENT_ID.md`) and the platform 7.9 (`SECURITY_ASSESSMENT_ATRIUM.md`): ready for the first deployment on the
conditions in §4.

---

## 0 · The one-paragraph version

`id` is where every product's sign-in starts, so one script injected into any page there runs with a session that opens every
product. The `id` assessment (`reji/docs/SECURITY_ASSESSMENT_ID.md`) scores web hardening 4 of 10 and calls it the critical
absence. SEC closes its Priority 1 before anything is deployed: a strict Content Security Policy with nonces, uploaded files kept
away from the session's origin, and a fresh proof of identity before the changes that matter at `id` (web addresses, products,
people). It then has a fresh reviewer assess what nobody has yet: Atrium's own risks as a platform (app keys and scopes, the three
hosts, signed webhooks, OIDC, one breach reaching every product), and fixes what that finds.

**What it is.** Hardening, not a feature: headers, one middleware's reach, one dialog that gains a second way to confirm.
**Scope.** Every page Atrium serves (`id`, the console, the organisation pages), every file it hands out, and the routes named in
§3. Not in scope: Priority 2 (key rotation, refresh-token revocation) and Priority 3 (keys from the Architect, CI audits), which
stay open in the assessment.
**Failsafe.** The policy has a report-only mode (`CSP_MODE=report`), so the first deployment can watch what it would block before
it blocks anything; a blocked Turnstile script still leaves the proof-of-work puzzle; a person with no password confirms by a code
instead of meeting a dead end.

---

## 1 · What's there now

| Area | Now | Gap |
|---|---|---|
| Headers | `SecurityHeaders`: nosniff, `X-Frame-Options`, `Referrer-Policy`, HSTS in production | No CSP, no `Permissions-Policy`, no `Cross-Origin-Opener-Policy`; proxies not trusted, so behind a load balancer `isSecure()` and the client IP are wrong |
| Inline code | `app.blade.php` writes a theme script and two `<style>` blocks; Inertia's progress bar and dialog insert `<style>` | A nonce must reach all of them, Inertia's through its `nonce` default |
| Turnstile | `BotCheck.vue` loads Cloudflare's script on sign-in, join, invitations, `me/SignIn`, `me/Claim` | Its origin must be allowed there and nowhere else |
| Files | `/file/{file}` streams from the vault on whichever host asked; images and PDFs open inline | An uploaded file opened on `id`'s origin shares it with the session |
| Fresh check | `RequireFreshSignIn` (sign-in within 15 minutes or password confirmed) guards passkeys only, and answers JSON 423 | Web addresses, products and people ask for nothing; an Inertia page gets a JSON error |
| Step-up | `password.confirm`: a dialog over the page, or a page | An account with no password meets "it can't open this page" |

---

## 2 · Decisions

**Decided under the Architect's autonomy grant, 8 November 2026.**

| # | Decision | Chosen | Why, and the alternative |
|---|---|---|---|
| **SEC-1** | The policy's shape | A nonce per response; `script-src 'self' 'nonce-…'`; `style-src-elem 'self' 'nonce-…'` with `style-src-attr 'unsafe-inline'`; `img-src` and `font-src` the app's own hosts with `data:` and `blob:`; `connect-src 'self'`; `object-src 'none'`; `base-uri 'none'`; `frame-ancestors 'self'`; `frame-src 'self'`. Every page, not only `id` | Script is what a stored-XSS bug needs, and nonces stop it. Vue and reka set styles through the CSSOM, which CSP doesn't govern, but Vue's stringified static trees write `style` attributes, so attributes stay allowed: an attribute can't select or load anything. A hash list instead of nonces breaks with every build |
| **SEC-2** | `form-action` | Left out | Chrome applies it to the redirects after a form, and a sign-in ends on a product's own host, which changes with each instance. What it guards (an injected form) needs an injection the script rules already make inert |
| **SEC-3** | Rolling it out | `CSP_MODE` = `enforce` (the default, so development and tests break loudly) or `report`. The production example ships `report` for the first deployment; reports go to `/csp-report`, throttled and logged with the page's path and the blocked origin only; readiness warns while it's `report` | An environment value, not a setting: it's the deployment's state, not a choice a tenant makes. Enforcing on day one risks locking people out over a missed nonce |
| **SEC-4** | Turnstile's origin | Allowed only on the pages that draw the bot check, and only when a site key is set | A page reached by Inertia navigation keeps the policy of the page first loaded, so the check may find its script blocked; the puzzle behind it (CL) is the way in, as designed |
| **SEC-5** | The other headers | `Permissions-Policy` turning off camera, microphone, geolocation, payment and USB, and keeping passkeys to this origin; `Cross-Origin-Opener-Policy: same-origin`; proxies trusted from `TRUSTED_PROXIES` (none by default); readiness asks for `SESSION_ENCRYPT` in production | Checkout and provider sign-in are redirects, not pop-ups, so COOP breaks nothing |
| **SEC-6** | Files until their own host exists | Every file answer carries `Content-Security-Policy: sandbox` with nothing allowed but itself, and `Cross-Origin-Resource-Policy: same-origin`. Pictures still open; a PDF downloads, because Chrome refuses to draw a sandboxed PDF | A sandboxed document has an opaque origin, so even an HTML file served by mistake can't reach the session. Opening PDFs inline costs too much on the session's origin |
| **SEC-7** | Their own host | `FILE_HOST`: when set, `/file/{file}` still checks who's asking on the app's host, then sends the browser to a signed address on the files host, good for 5 minutes and for that file alone; the files host answers nothing else and reads no session. PDFs open inline there | The host needs the Architect's DNS, so it's built and tested now and switched on with an environment value. A cookie on the files host instead would need a second sign-in |
| **SEC-8** | Where the fresh check applies | Adding and removing a web address; starting a product; adding, removing, deactivating and inviting people (one or by import). Roles already ask for the password | The assessment's three, read as the changes that hand someone a way in or point a product at a new address |
| **SEC-9** | An account with no password | Confirms by a code sent to one of its own verified numbers or addresses, in the same dialog, counted by the same rules and limits as sign-in codes. An account with a password keeps the password | Only the holder's phone or inbox proves the holder, as at sign-in. Signing out and in again would end every product's session too |

---

## 3 · Slices

| Slice | What | Done when |
|---|---|---|
| **SEC1** | The page policy: `ContentSecurityPolicy` middleware, nonces to Vite, the shell and Inertia; `CSP_MODE`; `/csp-report`; Turnstile scoped; `Permissions-Policy`, COOP; `TRUSTED_PROXIES`; readiness checks | Every page loads with no violation in the console in light and dark, as a guest, Ama and the Service; tests read the header |
| **SEC2** | Files away from the session: the sandbox and CORP on every file; PDFs download on the app's hosts; `FILE_HOST` with the signed hand-off | Tests: the headers, a PDF's disposition, the hand-off signed, expiring and refused elsewhere, no session read on the files host |
| **SEC3** | Confirm it's you before what matters: `RequireFreshSignIn` answering Inertia as `password.confirm` does; the code step-up (mockup `sec/confirm.html`); applied per SEC-8 | Tests per route; the dialog walked in the app by an account with and without a password |
| **SEC4** | Atrium's own risks: a reviewer who hasn't seen the build assesses the platform; the serious findings fixed with tests; `reji/docs/SECURITY_ASSESSMENT_ATRIUM.md` | The assessment written, each fix proven by a test, the `id` assessment rescored |

---

### 3.1 · What SEC4 found, and SEC-10 to SEC-13

A reviewer who hadn't seen the build found isolation sound and one product key too strong: four serious findings, ten lesser
(`SECURITY_ASSESSMENT_ATRIUM.md`). Decided under the grant, 8 November 2026:

| # | Decision | Chosen | Why, and the alternative |
|---|---|---|---|
| **SEC-10** | A product's company-paid code text (F1) | Kept, as NS-7 decided (Campus's codes go through Nuntius until Clavis takes them), but a server fills only digits for the code and minutes and one of its own hosts for that organisation; Atrium fills the name; the sign-in codes' hourly count per number | Barring `/v1` from the code templates breaks NS-7 and NS-9, which are the Architect's |
| **SEC-11** | A product linking an existing account (F2) | The same answer whether the number already signs in or not; nothing of that account readable by the product until the person takes the place (`linked_by_product_at`) | CL-9 stands (the product decides whom to add); its stated premise, "at worst add a name", is made true. A consent page at `id` waits for the invitations on Home |
| **SEC-12** | What a key may call, and cutting it off (F3, F4, F5) | Chosen by the Service at connecting and in the drawer, taken by live keys at once; revoke one key, switch a server off (its sign-in and tokens too), from the console or `server:revoke`; keys last a year (`ServerKeys::LIFE_MONTHS`), readiness warning a month before | A life in a setting: no workspace would differ, so it's a constant |
| **SEC-13** | A sign-out from another site (F7) | Silent only with that account's ID token for that client; otherwise asked first, posted | Refusing a sign-out without the hint would break products that never kept the token |

Also fixed: a retry key per organisation (F8), the recheck for strangers (F9), proxies never trusted for the host and readiness
warning on `*` (F10), bad keys held per address (F11), no codes from the console's host (F13), old Sailup reports (F14). The
keys card was added to the existing drawer in its own patterns, without a mockup of its own.

## 4 · Open items for the Architect

- The files host's name and DNS (with A8's DNS), and `FILE_HOST` set once it resolves.
- Switching `CSP_MODE` to `enforce` after the first deployment's reports are clean.
- From SEC4, not blocking: the invitations waiting on Home (so a linked person can take the place), the key and address on each
  usage event, a second channel told when a key is issued, webhook secret rotation.
