# Phase 1 Verification Memo — Closure Record for FND-01..06

**Phase:** 01-foundations-registry-oauth
**Plan:** 01-01 (Walking Skeleton)
**Authored:** 2026-05-21
**Re-discuss applied:** 2026-05-22 — FND-02 and FND-03 reopened against Arda V1 reality (see §FND-02 and §FND-03 below for current locks; original closures preserved as superseded for audit trail).
**v3 supersedence (2026-05-22 later):** FND-02 v2 host (`dev-website.bigscreencloud.com`) superseded by D-20 (`api.bigscreencloud.com` direct). FND-02 v2 flow shape intact; only host fields change. See §FND-02 v3 below + `01-CONTEXT.md` §D-20.
**Schema (per D-02):** Each gap heading contains `### Question`, `### Investigation`, `### Answer`, `### Confidence`, `### Evidence source`.

This memo is the durable closure record for the six externally-dependent unknowns blocking Phase 1+ work. Each gap was investigated Claude-led (D-01) before any human escalation. FND-04 is closed by the Shadow DOM prototype shipped in Task 4 of Plan 01-01; FND-02 and FND-03 were initially locked at the Task 5 human-verify checkpoint (2026-05-21) and then **re-locked on 2026-05-22** when Arda V1 client registration UI inspection surfaced four contradictions with the original closures (see `ARDA-V1-BLOCKER.md` and `01-CONTEXT.md` §Re-Discuss).

## Closure Status

| Gap     | Topic                              | Status                      | Confidence | Locked at |
| ------- | ---------------------------------- | --------------------------- | ---------- | ---------------- |
| FND-01  | CloudFront distribution config     | CLOSED                      | MEDIUM     | 2026-05-21       |
| FND-02  | Arda OAuth flow choice             | CLOSED (host-corrected v3)  | HIGH       | 2026-05-22 (v3)  |
| FND-03  | admin_api network reachability     | CLOSED (host-corrected v3)  | HIGH       | 2026-05-22 (v3)  |
| FND-04  | Shadow DOM render-layer prototype  | CLOSED                      | HIGH       | 2026-05-21       |
| FND-05  | Reserved-paths allowlist mechanism | CLOSED                      | HIGH       | 2026-05-21       |
| FND-06  | Registry SPOF mitigation strategy  | CLOSED                      | HIGH       | 2026-05-21       |

> FND-04 closed by Plan 01-01 Task 4 (Shadow DOM prototype shipped + four research questions resolved; runtime smoke is pending local dev-server run, documented in FND-04 §Investigation).
> FND-02 closed at Plan 01-01 Task 5 (2026-05-21) — PKCE-loopback locked; self-authorized by Brandon (project owner). **SUPERSEDED 2026-05-22 by D-13 (dev-website OAuth backend) — see §FND-02 v2.**
> FND-03 closed at Plan 01-01 Task 5 (2026-05-21) — Option (D) locked: site publishing endpoints relocated to `apps/api` with OAuth scope+policy as sole gate. Premise of admin_api as write host dissolved for this surface; self-authorized by Brandon. **Option D still holds 2026-05-22, but §FND-03 v2 clarifies the new server-side OAuth-broker (dev-website) that sits in front of `apps/api` for the CLI auth flow.**

---

## FND-01: CloudFront distribution config

### Question

What is the existing CloudFront distribution config for `www.bigscreenvr.com`, and what carve-outs are required to (a) route `/static-pages/*` and `/api/site/pages.json` past the SPA catch-all and (b) scope the existing `403/404 → /index.html` error response to `Accept: text/html` so that asset 404s no longer serve HTML?

> Source: `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 1 (lines 869-872), with the Pitfall #1 framing from `.planning/research/PITFALLS.md` §1.

### Investigation

Scope of search:
- Searched the `website` working tree (`C:\Users\decid\Documents\projects\website`) for `cloudfront`, `DistributionConfig`, `cf-distribution`, `Origin`. Result: no CloudFront configuration is committed to this repo — the website is deployed via Jenkins to S3-fronted CloudFront, but the distribution config itself lives outside the SPA repo (consistent with PITFALLS.md §1's "distribution config inherits CRA-deploy defaults" framing).
- Searched the cloud monorepo working tree (`C:\Users\decid\gsd-workspaces\cloud\cloud`) for the same patterns. Result: CloudFront wiring for `bigscreenvr.com` is not present in the cloud monorepo either (this repo serves API + admin + arda; SPA delivery is separate).
- Inspected adjacent project markers in `.planning/research/SUMMARY.md` and `.planning/research/PITFALLS.md`: confirms that the CRA-on-CloudFront pattern with `403/404 → /index.html` error rewrite is in use today. PITFALLS.md §1 names this as the highest-risk Phase 1+2 gate.
- The distribution config is owned by a devops/infra surface that is not in any of the four repos available locally (website, cloud, bigstack, bigscreen10). It is reasonable to assume it lives in a Terraform module or Jenkins-managed JSON owned by the cloud-infra team. No source-of-truth is reachable from this executor session.

### Answer

The CloudFront distribution serving `www.bigscreenvr.com` is owned by a cloud-infra surface outside the repos available to Claude in this session. The Phase 1 deliverable is the **written carve-out plan**, not the distribution mutation itself (Phase 2 owns the actual change). The plan is:

1. **Higher-precedence behaviors** are added in front of the SPA catch-all for two path patterns:
   - `/static-pages/*` — routed to the new S3 bucket (`bigscreen-static-pages-prod`) via Origin Access Control (OAC), with no error-response rewrite attached. A 404 from S3 must surface as a real 404, not as `/index.html` 200.
   - `/api/site/pages.json` — routed to `apps/api`'s public LB (the same public LB that already serves `apps/api`'s public surface). This already has the `bigscreenvr.com` CORS umbrella per CONTEXT.md §Established Patterns.

2. **Existing `403/404 → /index.html` error response is scoped to `Accept: text/html`** so that:
   - Navigation requests (`Accept: text/html`) still fall through to the SPA shell — preserves SPA deep-linking.
   - Asset requests (CSS, JS, images, fonts) where the `Accept` header does NOT include `text/html` see real 4xx status codes — prevents the "HTML served as CSS" pathology in PITFALLS.md §1.

3. **OAC for the new S3 bucket** — per `.planning/research/STACK.md`, AWS recommends OAC (not legacy OAI) since 2022. The Phase 2 cutover spec must say "use OAC".

Phase 1 commits to the carve-out plan in writing. Phase 2 (CDN-09) executes the distribution mutation against the live config. The infrastructure team retains decision rights on exact precedence ordering; the constraints here are non-negotiable.

### Confidence

MEDIUM

Confidence is MEDIUM (not HIGH) because: (a) the precise current distribution config has not been read; the carve-out plan is correct in *direction* but the *exact* behavior precedence numbers will be set when Phase 2 reads the live config; (b) the plan presumes OAC is feasible on the new bucket (it is, but verifying against any account-level constraints is a Phase 2 step).

This is acceptable: the FND-01 closure paragraph is a *direction-locking* artifact for Phase 2, not an infrastructure deliverable. Phase 1 unblocks because subsequent plans (01-02, 01-03, 01-04, 01-05) do not touch CloudFront — they touch the cloud monorepo against local Postgres + LocalStack only. The Phase 2 plan is the next consumer of this paragraph.

### Evidence source

- `.planning/research/PITFALLS.md` §1 — CloudFront-fallback pitfall framing (asset 404 serving `/index.html` 200)
- `.planning/research/STACK.md` — OAC-vs-OAI guidance (OAC is current best practice since 2022)
- `.planning/research/SUMMARY.md` §Gaps — confirms FND-01 is gated on cloud-infra ownership, not internal-team-ownable in this milestone
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` lines 869-872 — Open Questions item 1
- *Pending* human escalation: cloud-infra owner (to be confirmed at Phase 2 kickoff; not gating Phase 1 closure)

---

## FND-02 v3 (2026-05-22, later): Host correction — api.bigscreencloud.com (SUPERSEDES v2 host fields)

### Question (v3)
Where does the OAuth backend code actually run, given that `dev-website.bigscreencloud.com` is a CRA SPA on CloudFront/S3 with no Node capability?

### Investigation
Plan 01-03 Task 1 (R-NEW-01) discovered outcome (a): `dev-website.bigscreencloud.com` → CloudFront → S3 serving THIS repo's CRA `build/`. No Node backend. Body fingerprint identical to `public/index.html` (CRA bundle `/static/js/main.dbada367.js`, AdSense pub-1838810760934258, Reddit/FB/Twitter pixels, dark-mode pre-paint). `/healthz` returns same SPA HTML (SPA catchall). Cloud monorepo grep for `dev-website`: zero matches. See `01-03-RESEARCH-DELTA.md` for full evidence chain.

### Answer (v3 lock)
**`api.bigscreencloud.com` (the apps/api ELB hostname) directly hosts the OAuth backend.** D-13's CLI-auth handlers (`/api/cli-auth/start`, `/oauth/cli-callback`, `/api/cli-auth/refresh`) mount on `cloud/apps/api/api.ts` and are reached at `https://api.bigscreencloud.com/...`. ACM cert + DNS already in place. No CloudFront layering. No new subdomain. No CNAME flip.

**D-13 flow shape (steps 1–9, refresh) unchanged.** Only host strings change:
- D-13 step 2 host: `dev-website.bigscreencloud.com` → `api.bigscreencloud.com`
- D-13 step 5 redirect: `https://dev-website.bigscreencloud.com/oauth/cli-callback` → `https://api.bigscreencloud.com/oauth/cli-callback`
- D-13 step 7 (fragment-redirect HTML): unchanged
- D-13 step 9: unchanged (CLI calls `apps/api/api/site/*` directly with Bearer)
- D-13 refresh: host swap only
- D-16 redirect URI: same host swap; Arda V1 registration redo against new URI
- D-16 Server IPs: NOT `dev-website` resolved IPs (those are CloudFront edge); = apps/api egress IPs (NAT GW EIP[s] of apps/api VPC). open-q-egress, devops surfaces.
- `ARDA_BSWEB_CLI_CLIENT_SECRET` env var: provisioned on apps/api deploy (not dev-website).

### Confidence
**HIGH** — discovery evidence is concrete (DNS, HTTP fingerprint, repo grep). Host swap is mechanical. D-20 in `01-CONTEXT.md` formalizes the supersedence. Plan 01-03 v3 inherits the host strings; plan-checker v3 will re-verify.

### Evidence source
- `01-03-RESEARCH-DELTA.md` (outcome a, resolution X4)
- `01-CONTEXT.md` §D-20 (supersedence)
- `nslookup api.bigscreencloud.com` → `main-shark-api-elb-491424424.us-west-2.elb.amazonaws.com` (apps/api ELB confirmed)

### What did NOT change vs v2
- Confidential-client architecture (server holds secret).
- D-14 fragment-only token transport.
- D-15 in-memory state map with 5-min TTL.
- D-18 mount file `cloud/apps/api/api.ts`.
- D-17 scope rename `site:* → website:*` already shipped.

---

## FND-02 v2 (2026-05-22): Arda OAuth flow choice — dev-website OAuth backend (SUPERSEDES 2026-05-21 PKCE-loopback lock; SUPERSEDED for host fields by v3 above)

### Question

The 2026-05-21 closure locked PKCE-loopback (`http://127.0.0.1:<random-port>/callback`) against the then-understood Arda design. Arda V1 client registration UI inspection on 2026-05-22 surfaced four contradictions that make PKCE-loopback unbuildable as designed. Re-asked: what auth flow does `bsweb-cli` actually use given Arda V1 reality?

> Source: `ARDA-V1-BLOCKER.md` (2026-05-22), `01-CONTEXT.md` §Re-Discuss decisions D-13..D-16.

### Investigation

Inspected the Arda V1 codebase end-to-end (cloud monorepo, 2026-05-22):

- `auth/OAuthClientDatabase.ts:23,236-238` — `ClientType.Public` exists in enum but explicit throw blocks registration: *"public clients are not supported in v1 — use confidential"*. **No public-client registration path anywhere in V1.**
- `auth/OAuthClientDatabase.ts:195-230` — Redirect URI validation: exact-match host parsed via `new URL()`, no wildcards, no port ranges, no URI fragments. HTTPS required except for `localhost`/`127.0.0.1` (exact-match loopback IS allowed but only at a fixed port — no port-range support, so RFC 8252 random-ephemeral-port pattern fails).
- `auth/OAuthTokens.ts:59-76` — `authenticateClient()` REQUIRES + bcrypt-verifies `client_secret` on every `/oauth/token` call for both `authorization_code` and `refresh_token` grants. **PKCE alone does not authenticate.** A CLI binary cannot safely hold `client_secret` — leaks to disk on every install, copy-paste, or backup.
- `api/src/OAuthApi.ts:138` — `/.well-known/oauth-authorization-server` advertises only `response_types_supported: ["code"]`; no `response_modes_supported` key. **Treat `response_mode=fragment` as NOT supported in V1.**
- `auth/OAuthClientDatabase.ts:51` — Audit enum has `IpRegistrationMismatch` but no enforcement code path found in `auth/` or `api/src/OAuthApi.ts`. The Server IPs registration field appears to drive admin_api SG config, not OAuth-time `/oauth/token` checks.

**Consequence:** the original 2026-05-21 closure assumed PKCE-loopback because Arda support for it was *inferred* from PKCE-support-in-general (Assumption A7 in 01-RESEARCH.md). The V1 reality is that Arda is confidential-client-only with a mandatory secret on every grant call. A server must broker the code exchange on behalf of the CLI.

### Answer

**LOCKED 2026-05-22 (D-13 / D-14 / D-15):** **`dev-website.bigscreencloud.com` becomes the OAuth backend.** It is the only server-side component that talks to Arda's `/oauth/token` on behalf of `bsweb-cli` and is the sole holder of the confidential-client `client_secret`. `bsweb-cli` never holds the secret and never directly hits `/oauth/token`.

Flow (full sequence in `01-CONTEXT.md` §Re-Discuss D-13, summarized here):

1. CLI binds an ephemeral loopback port `P` on `127.0.0.1`.
2. CLI `POST https://dev-website.bigscreencloud.com/api/cli-auth/start {loopback_port: P}` → dev-website generates `state` nonce + PKCE `code_verifier` + `code_challenge`, stores `Map<state, {verifier, port, expires_at}>` in-memory (D-15, 5-minute TTL), returns `{auth_url, state}`.
3. CLI opens `auth_url` in default browser (RFC 8252 §6 system-browser pattern).
4. User signs into Arda admin session (cookie-gated consent UI, per existing Arda design).
5. Arda 302s browser to `https://dev-website.bigscreencloud.com/oauth/cli-callback?code=...&state=...` (exact-match URL registered in Arda per D-16; satisfies the no-wildcard constraint).
6. dev-website `/oauth/cli-callback` handler: lookup `state` → recover `{verifier, port}` → POST to Arda `/oauth/token` with `grant_type=authorization_code`, the recovered verifier, AND `client_secret` from its environment → receive `{access_token, refresh_token, expires_in, scope}`.
7. dev-website returns HTML page to browser with inline JS: `window.location.replace('http://127.0.0.1:' + <port> + '/cb#access_token=...&refresh_token=...&expires_in=...&token_type=Bearer')`. **Fragment, not query** — tokens never appear in dev-website HTTP access logs, CloudFront/LB logs, or any server-visible log (D-14).
8. CLI loopback receives fragment client-side via inline JS that POSTs the parsed fragment to its own `/cb-finalize` endpoint → stores in OS keychain (Phase 2 work).
9. CLI calls `apps/api` `/api/site/*` endpoints directly with `Authorization: Bearer <access_token>` — no further dev-website hop until refresh.

**Refresh:** CLI `POST https://dev-website.bigscreencloud.com/api/cli-auth/refresh {refresh_token}` → dev-website wraps Arda `/oauth/token` `grant_type=refresh_token` with its `client_secret` → returns new tokens. dev-website is stateless secret-proxy for refresh.

**State→port binding (security note):** the `state` nonce is cryptographically bound to the loopback port in dev-website's in-memory map. The redirect script is rendered server-side with the port baked in from the map lookup, not from any URL/cookie input — prevents a malicious page from triggering the JS-redirect with a different port.

**PKCE preserved:** even with confidential-client secret on the server, PKCE still protects the auth code from interception between Arda's 302 and dev-website's exchange call.

### Confidence

HIGH

Locked at 2026-05-22. Decision is grounded in direct cloud-code inspection (not inference). dev-website OAuth backend is implementable on existing cloud infra (per `R-NEW-01`, expected resolution: mount the three CLI-auth endpoints on `apps/api` and CNAME `dev-website.bigscreencloud.com` to `apps/api` LB — researcher to confirm against `cloud/docs/architecture.md` + DNS state before planner writes new tasks). Fragment-redirect mechanism is standard practice (well-trodden by GitHub, Google, etc.). The only Arda V1 limitation remaining (no `response_modes_supported` in `/.well-known`) is sidestepped by client-side fragment rendering, not server-side fragment response.

Risk: the in-memory state map (D-15) loses in-flight flows on dev-website restart. Mitigated by short TTL (5 minutes), clean error path to CLI (`state_unknown`), and the fact that the user simply re-runs `bsweb login`. Persistence (cookie or DB table) is recorded as a deferred idea in `01-CONTEXT.md`.

### Evidence source

- `ARDA-V1-BLOCKER.md` — discovery doc with Q1–Q6 open questions resolved during re-discuss
- `01-CONTEXT.md` §Re-Discuss (2026-05-22) — full lock with D-13..D-16
- `01-DISCUSSION-LOG.md` Re-Discuss section — option matrices and user selections
- `C:\Users\decid\gsd-workspaces\cloud\cloud\auth\OAuthClientDatabase.ts:23,51,195-230,236-238` — ClientType enum, IP audit, redirect URI validation, public-client block
- `C:\Users\decid\gsd-workspaces\cloud\cloud\auth\OAuthTokens.ts:59-76,186,238` — `authenticateClient` confirms secret mandatory on both grants
- `C:\Users\decid\gsd-workspaces\cloud\cloud\api\src\OAuthApi.ts:138` — `/.well-known` metadata
- RFC 8252 §6 — Native App OAuth (still authoritative for the loopback-side)
- human authorization: Brandon (project owner), 2026-05-22, re-discuss session

---

## FND-02 (SUPERSEDED 2026-05-22): Arda OAuth flow choice — PKCE-loopback

### Question

Which OAuth grant flow does the `bsweb-cli` use to obtain user-context tokens from the Arda authorization server: PKCE-loopback (`http://127.0.0.1:<random-port>/callback` redirect URI, zero Arda changes) or device-flow (requires Arda to expose `/oauth/device_authorization` + the device grant on `/oauth/token` + a `/device` confirmation web view)?

> Source: `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 2 (lines 874-877).

### Investigation

Scope of search:
- Inspected `C:\Users\decid\gsd-workspaces\cloud\cloud\docs\services\oauth.md` end-to-end. Confirmed:
  - The Arda provider is shipped per plan 14 (`plans/14-oauth-provider/SPEC_REVISED.md`).
  - The Arda authorize endpoint lives at `webapps/arda` `/oauth/authorize` and requires the user to be logged into Arda's admin session cookie before granting consent (cookie-bound consent UI).
  - The token endpoint lives at `apps/api` `/oauth/token` and supports the `authorization_code` and `refresh_token` grant types.
  - Public discovery is at `/.well-known/oauth-authorization-server` (RFC 8414) on `apps/api`.
  - PKCE is supported (`code_challenge`, `code_challenge_method` accepted on `/oauth/authorize`).
- Searched `cloud/auth/OAuthTokens.ts`, `cloud/auth/OAuthCodes.ts`, and `cloud/api/src/OAuthApi.ts` for a `device_code` grant. Result: **none present.** The token endpoint code path supports `authorization_code` and `refresh_token` only; there is no `/oauth/device_authorization` route.
- The consent UI is **browser-based and cookie-gated** (per oauth.md: "the user must already be logged into arda's admin session cookie before granting consent"). This is significant: device-flow would still need a browser to log into Arda and complete the `/device` confirmation, so it does not actually remove the "user has a browser" assumption.

### Answer

**Recommended (default):** **PKCE-loopback** with redirect URI pattern `http://127.0.0.1:<random-port>/callback`. Rationale:

1. **Zero Arda changes required.** The existing `/oauth/authorize` + `/oauth/token` (`authorization_code` grant) + PKCE support covers the flow end-to-end.
2. **Max's workflow has a local browser available.** The `bsweb-cli` is run from a developer/marketer laptop; popping a local browser to `http://127.0.0.1:<port>/callback` is the standard PKCE-loopback pattern (RFC 8252). Device-flow does not remove the browser requirement because Arda's consent UI is itself browser-gated by an admin session cookie.
3. **Refresh-token rotation already shipped** per oauth.md §Refresh-chain rotation, so long-lived CLI sessions are handled without re-prompting.

Redirect URI shape locked: `http://127.0.0.1:<random-port>/callback`. The CLI binds an ephemeral port on loopback, opens the browser to `/oauth/authorize?...&redirect_uri=http://127.0.0.1:<port>/callback&code_challenge=...`, receives the `code` on the loopback callback, then exchanges the code at `apps/api` `/oauth/token`.

**Fallback (only if explicitly escalated to LOW confidence at Task 5):** device-flow. Requires the Arda team to:
- Add `/oauth/device_authorization` (RFC 8628 §3.1) on `apps/api`.
- Extend `/oauth/token` to accept the `urn:ietf:params:oauth:grant-type:device_code` grant.
- Add a `/device` user-code confirmation view (browser-rendered, cookie-gated like the existing consent UI).

**LOCKED at Plan 01-01 Task 5 (2026-05-21):** PKCE-loopback is the chosen flow. Redirect URI shape `http://127.0.0.1:<random-port>/callback`. No Arda team escalation required — the flow uses already-shipped Arda endpoints (`/oauth/authorize` + `/oauth/token` `authorization_code` grant + PKCE + refresh-token rotation).

### Confidence

HIGH

Locked at Task 5. PKCE support and `authorization_code` grant are both shipped in Arda; the consent UI is already browser-bound (cookie-gated), so device-flow would not remove the browser requirement. Loopback redirect URI is RFC 8252 standard for native/CLI clients. Arda client registration for `bsweb-cli` with this redirect URI shape happens in Plan 03; no flow change required from the Arda team.

### Evidence source

- `C:\Users\decid\gsd-workspaces\cloud\cloud\docs\services\oauth.md` (read end-to-end) — confirms PKCE supported, `authorization_code` + `refresh_token` grants only on `/oauth/token`
- `C:\Users\decid\gsd-workspaces\cloud\cloud\auth\OAuthTokens.ts`, `OAuthCodes.ts`, `OAuthScopes.ts`, `OAuthClientDatabase.ts` — no `device_code` grant or `/oauth/device_authorization` endpoint
- `C:\Users\decid\gsd-workspaces\cloud\cloud\api\AuthApi.ts` — only `authorization_code` + `refresh_token` token-endpoint paths exist
- `.planning/research/SUMMARY.md` §Gaps — confirms Arda does not currently expose `/oauth/device_authorization`
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` Assumption A7 — PKCE-loopback support inferred from PKCE support
- human authorization: Brandon (project owner), 2026-05-21, Plan 01-01 Task 5 checkpoint

---

## FND-03 v3 (2026-05-22, later): CLI-auth surface = api.bigscreencloud.com (SUPERSEDES v2 host)

### Question (v3)
v2 split surfaces between `dev-website` (CLI-auth) and `apps/api` (site-publish). With D-20 unifying both surfaces under `api.bigscreencloud.com`, what is the actual surface topology?

### Answer (v3 lock)
**Both CLI-auth and site-publish handlers live on `apps/api` (`cloud/apps/api/api.ts`), reached at `api.bigscreencloud.com`.** Single surface, single host, single LB. No CloudFront in the OAuth path. Option D (site-publish on apps/api with OAuth scope+policy gate) remains the correct architecture; v2's dev-website-as-separate-broker is collapsed into apps/api (D-20).

### What did NOT change vs v2
- Option D for site-publish endpoints (`/api/site/upload-url`, `/api/site/publish`, `/api/site/rollback`, `/api/site/unpublish`, `/api/site/pages`, `/api/site/pages/:slug/history`, `/site/pages.json`).
- `requireScopeAndPolicy({scopes:['website:write'|'website:read'], policy:Admin})` gating.
- No SG carve-out on admin_api for site-publish path.
- Public read snapshot `/site/pages.json` unauthenticated, under bigscreenvr.com CORS umbrella.

### Confidence
**HIGH** — D-20 supersedence is mechanical; apps/api is the same host that already terminates `api.bigscreencloud.com`; no new infra.

### Evidence source
- `01-CONTEXT.md` §D-20
- `01-03-RESEARCH-DELTA.md` X4 resolution
- `nslookup api.bigscreencloud.com` → main-shark-api-elb

---

## FND-03 v2 (2026-05-22): apps/api as site-publish surface, dev-website as CLI-auth surface (CLARIFIES 2026-05-21 Option D lock; SUPERSEDED for host fields by v3 above)

### Question

The 2026-05-21 closure locked Option D: relocate site publishing endpoints from `apps/admin_api` to `apps/api` (public LB, OAuth scope+policy as sole gate), dissolving the original "how does the CLI reach the IP-restricted admin_api?" question. The 2026-05-22 re-discuss does NOT reopen that decision but DOES add a new server-side surface for the CLI auth flow. Where does that surface live, and how does it sit relative to the apps/api site-publish endpoints?

> Source: `ARDA-V1-BLOCKER.md` contradiction §4 (Plan 01-03 frontmatter `admin_api/admin_api.ts` contradicts FND-03 Option D), `01-CONTEXT.md` §Re-Discuss D-18 (frontmatter correction) + D-13 (dev-website OAuth backend introduces a NEW server surface).

### Investigation

Option D from 2026-05-21 remains the correct surface for the site-publish endpoints (`/api/site/upload-url`, `/api/site/publish`, `/api/site/rollback`, `/api/site/unpublish`, `/api/site/pages`, `/api/site/pages/:slug/history`, plus the public read `/site/pages.json`). The CLI hits these directly with the Arda access_token returned by the dev-website OAuth backend (FND-02 v2). No SG carve-out, no proxy, no VPN on this path.

The new question is: where do the three dev-website CLI-auth endpoints (`POST /api/cli-auth/start`, `GET /oauth/cli-callback`, `POST /api/cli-auth/refresh`) actually run? Three candidates:

- **(a)** The dev/staging deploy of THIS `website` CRA SPA repo. CRA serves static files only; no Node backend available. To host the OAuth backend, an adjacent Node service (Lambda, Express, etc.) would need to be stood up under the `dev-website.bigscreencloud.com` hostname. **New infra workstream.**
- **(b)** A separate new cloud-monorepo service. Also new infra workstream; awkward fit with existing services.
- **(c)** Mount the three CLI-auth handlers on `apps/api` and CNAME `dev-website.bigscreencloud.com` to the `apps/api` LB. The hostname is purely a routing label; `apps/api` already has the bigscreenvr.com CORS umbrella, holds production secrets in its env, and is the same surface that hosts the site-publish endpoints. **Zero new infra. Single secret-handling surface.**

(c) is structurally preferred and consistent with the 2026-05-21 Option D framing. Plan 01-03 replan (R-NEW-01) confirms against `cloud/docs/architecture.md` + DNS state before locking the implementation.

### Answer

**LOCKED 2026-05-22 (D-18 confirms Option D + R-NEW-01 recommends (c)):**

- **Site-publish endpoints** (REG-02..08, AUTH-03) mount on **`cloud/apps/api/api.ts`** under the `/api/site/*` prefix. All write routes wrap `requireScopeAndPolicy({ scopes: ['website:write'], policy: Admin })`. Public read (`/site/pages.json`) is unauthenticated per Phase 1 success criterion §3.
- **CLI-auth endpoints** (`/api/cli-auth/start`, `/oauth/cli-callback`, `/api/cli-auth/refresh`) mount on the same **`apps/api`** service (recommended; researcher confirms against `cloud/docs/architecture.md` before planner writes new tasks). `dev-website.bigscreencloud.com` is a hostname alias (CNAME) for the `apps/api` LB.
- **Plan 01-03 frontmatter** is corrected per D-18: `files_modified` swaps `cloud/apps/admin_api/admin_api.ts` → `cloud/apps/api/api.ts`. URLs in the body switch from `/admin/site/*` to `/api/site/*`.
- **Server IPs field in Arda dashboard** is filled with dev-website's resolved IP(s) per D-16 — since `dev-website` is just an alias for `apps/api`, this resolves to the `apps/api` LB IP(s). Semantically accurate (the only server hitting Arda `/oauth/token` IS apps/api).

Net: the 2026-05-21 Option D lock is preserved (site publishing on apps/api, OAuth gating only, no SG carve-out, no proxy, no VPN). The 2026-05-22 re-discuss adds three more handlers on apps/api (the CLI-auth surface) but introduces no new service, no new SG carve-out, and no change to the OAuth-scope-and-policy gating model for site-publish endpoints.

### Confidence

HIGH

Option D itself has not changed — still locked HIGH. The new dev-website OAuth backend handlers are net-additive code on a service (apps/api) that already exists, already has the public LB, and already holds production secrets via its env-var conventions. R-NEW-01 confirmation against `cloud/docs/architecture.md` is the only remaining check; it does not gate the Option D lock — only the implementation location of the three NEW handlers.

### Evidence source

- `01-CONTEXT.md` §Re-Discuss D-13 (OAuth backend), D-18 (mount target), R-NEW-01 (open research item for planner)
- `01-DISCUSSION-LOG.md` Re-Discuss section
- `ARDA-V1-BLOCKER.md` contradictions §2 + §4
- 2026-05-21 §FND-03 closure (below; still authoritative for the site-publish path)
- human authorization: Brandon (project owner), 2026-05-22, re-discuss session

---

## FND-03 (2026-05-21, still authoritative): admin_api network reachability for CLI traffic

### Question

How does `bsweb-cli` (running on Max's laptop) reach the IP-restricted `apps/admin_api` to call `/admin/site/upload-url`, `/publish`, `/rollback`, `/unpublish`, `/pages`, and `/pages/:slug/history`?

> Source: `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 3 (lines 879-882).

### Investigation

Scope of search:
- Inspected `C:\Users\decid\gsd-workspaces\cloud\cloud\docs\services\oauth.md` and `C:\Users\decid\gsd-workspaces\cloud\cloud\docs\architecture.md`. Confirmed: `apps/admin_api` listens on port 3999 with SG-level IP restriction today; only VPN-bound and internal-CIDR sources reach it.
- Inspected `.planning/research/ARCHITECTURE.md` §Integration Points: lists three options for this gate verbatim:
  - **(A) SG-open with path-based ALB routing** of `/admin/site/*` (and `/admin/oauth/*` if needed for the OAuth flow). Routes specific path prefixes to admin_api while the rest of admin_api stays SG-locked. Cleanest engineering path; preserves read/write service split.
  - **(B) Proxy through `apps/api`.** `apps/api` already has the public LB and CORS umbrella; admin_api routes get a thin authenticated passthrough on `apps/api`. Extra hop; requires careful auth-token forwarding so admin_api still sees the original JWT.
  - **(C) Require Max to be on VPN.** Zero-infra change but kicks the can on multi-user later (Phase 4 onboards more marketers; VPN issuance becomes a bottleneck).
- The OAuth flow itself adds a constraint: the CLI must also hit `apps/admin_api/admin/oauth/*` (for the client-lookup + grant inspection endpoints — see `cloud/docs/services/oauth.md` §Admin endpoints). If option (A) is chosen, the carve-out should include `/admin/oauth/*` in the same path-based ALB rule. If option (B) is chosen, the proxy must forward those too. If option (C) is chosen, VPN covers both.

### Answer

**LOCKED at Plan 01-01 Task 5 (2026-05-21):** **Option (D) — site-publishing endpoints relocated to `apps/api`; OAuth scope+policy (`website:read` / `website:write` + Admin) is the sole gate.** The premise that admin_api hosts these endpoints is dissolved for this surface. No SG carve-out, no proxy, no VPN; FND-03 becomes a non-question.

Locked design:
- Endpoints: `POST /api/site/upload-url`, `POST /api/site/publish`, `POST /api/site/rollback`, `POST /api/site/unpublish`, `GET /api/site/pages`, `GET /api/site/pages/:slug/history`. All mount on `apps/api` (the existing public LB host).
- Gating: every site-write route wraps in `requireScopeAndPolicy({ scopes: ['website:write'], policy: Admin })`. Read routes wrap in `{ scopes: ['website:read'], policy: Admin }` (or scope-only public read if the registry endpoint is meant to be unauthenticated — Plan 02 decides).
- No SG change. No proxy code. No VPN onboarding.
- OAuth flow CLI hits `apps/api/oauth/*` (already public per Arda design); the previous concern about `apps/admin_api/admin/oauth/*` reachability is moot since the publishing endpoints no longer live on admin_api.

Rationale for relocation (vs. options A/B/C):
1. **Other webpages on `www.bigscreenvr.com` already publish without going through admin_api today** (per user, confirmed against the three avenues outlined in ARCHITECTURE.md / research). Site publishing fits the same public-API pattern.
2. **Data sensitivity is low.** Site pages are public marketing content. Threat model does NOT warrant SG-defense-in-depth on top of OAuth.
3. **Industry-standard pattern.** Public OAuth-gated APIs (GitHub, Stripe, Vercel deploy) all rely on bearer-token scope/policy gating without IP restriction.
4. **Defense-in-depth still present at the data layer:** narrow scope `website:write`, Admin policy at route entry, short-lived tokens with refresh rotation (already shipped in Arda), audit table (`site_pages_audit`) captures every mutation with `oauth_subject` + `oauth_client_id` + `actor_claim`.
5. **Multi-user scales trivially.** Phase 4 issues additional `bsweb-cli` tokens; no VPN provisioning per user.
6. **Single auth boundary.** OAuth scope+policy is the only gate. Simpler mental model. Less surface to misconfigure.

Trade-offs documented (preserved for the record; not chosen):
- **(A) SG-open + path-based ALB** — would have required a Terraform/devops repo change owned by cloud-infra. Heavier operational coupling. Preserves read/write service split.
- **(B) Proxy through `apps/api`** — would have required perpetual route-mirroring code on `apps/api`. Maintenance tax.
- **(C) VPN-only** — single-user only; punts multi-user to v2.

Deviation from prior architecture: ARCHITECTURE.md research stated "three-service split: admin_api (writes), apps/api (reads), S3 presigned URLs". For this surface (site publishing), the split is intentionally NOT preserved. The three-service split remains the convention for other admin write surfaces (user mgmt, billing, etc.); site publishing is the documented exception.

### Confidence

HIGH

Locked at Task 5. Project owner authorized relocation based on:
- Existing webpages on www.bigscreenvr.com already publish via non-admin_api avenues (user-confirmed pattern).
- Low data sensitivity (public marketing content).
- Industry-standard public OAuth-gated API pattern.
- Defense-in-depth preserved at data layer (audit table + narrow scope + short tokens).

### Evidence source

- `.planning/research/ARCHITECTURE.md` §Integration Points — original three-option enumeration (now superseded by option D)
- `C:\Users\decid\gsd-workspaces\cloud\cloud\docs\services\oauth.md` §Admin endpoints — confirms `/admin/oauth/*` admin endpoints exist; not needed for the bsweb-cli flow under option (D)
- Existing site-publishing avenues on `www.bigscreenvr.com` that bypass admin_api today (Builder.io CMS, Jenkins-built SPA, S3 static drops) — confirm that public-surface publishing is an established pattern
- human authorization: Brandon (project owner), 2026-05-21, Plan 01-01 Task 5 checkpoint — explicit decision to relocate to apps/api with OAuth-only gating

---

## FND-04: Render-layer (Shadow DOM prototype outcome)

### Question

Does the locked Shadow-DOM-mount choice (D-04) hold up under the four research questions in CONTEXT.md §Shadow-DOM mount research scope when the prototype mounts `bigscreen10`'s built `index.html` inside an SPA `<Page>` wrapper?

### Investigation

**Package decision (Task 3 checkpoint resolution).** Researcher had recommended `react-shadow@20.6.0` but the package was rejected at the Task 3 checkpoint: ~1.5 years stale (last release Jan 2025) at investigation time, with no compelling React-19 readiness motion. We adopted the **zero-dep native `attachShadow` variant** documented at `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` lines 451–483. This removes a supply-chain risk and a peer-dep coupling, at the cost of ~30 LOC of imperative shadow-root assembly inside `useEffect`.

**Prototype shipped:** `src/_prototype/ShadowMount.js` (126 lines, native `attachShadow({ mode: 'open' })`, StrictMode-double-mount guarded via `if (hostRef.current.shadowRoot) return;` before the call — per 01-RESEARCH.md Pitfall 6) + `src/_prototype/index.js` (47 lines, wraps the mount in the existing `<Page>` chrome) + an env-gated route check `if (process.env.REACT_APP_ENABLE_PROTOTYPE === '1' && pathname === '/_prototype/shadow-dom')` positioned above the six-branch dispatcher in `src/App.js` so production builds (which never set the flag) bypass it entirely.

**Content source:** `bigscreen10/timeline/index.html` served via `npx live-server --port=8080 --no-browser .` from `C:\Users\decid\Documents\projects\bigscreen10\timeline`. The `bigscreen10` repo stores its rendered site at `timeline/` (not `dist/`); `index.html` exists at the served root and was confirmed reachable with HTTP 200 during this investigation.

**Static-analysis evidence gathered for the four research questions** (in addition to the prototype source itself):

- `bigscreen10/timeline/index.html` head was read directly. All asset references are relative (`colors_and_type.css`, `style.css?v=145`, `img/Bigscreen_logo_white_single.svg`). No `href="/..."` or `src="/..."` absolute-from-root paths were found. This is the bigscreen10 build assumed for the publishing pipeline.
- `bigscreen10/timeline/colors_and_type.css` contains two `:root { ... }` declarations (line 43 and line 325, the latter wrapped in a `prefers-color-scheme` media query). These are the only `:root` blocks in the bigscreen10 timeline tree.
- Prototype mount strategy was deep-structurally verified (12 distinct token-presence checks): attachShadow open-mode, StrictMode guard, DOMParser, base-element creation+href, fonts.css + reset.css link injection, document.querySelectorAll over `link[rel="stylesheet"], style` cloning, body innerHTML transfer, useEffect dep array `[pageUrl]`, default export, no `react-shadow` import.

**Reduced-fidelity note.** A full browser run of `yarn start` to load `http://localhost:3000/_prototype/shadow-dom` and inspect `#shadow-root (open)` in devtools could NOT be exercised in this executor session: this Windows host has no global `yarn` binary, the repo has no installed `node_modules`, and an `npm install --legacy-peer-deps` attempt fails on the `isolated-vm@5.0.4` native rebuild under Node v25.8.2 (the README pins Node 18.17.1+, and the pre-existing yarn.lock pins `isolated-vm@4.6.0` with the Yarn-only `dependenciesMeta.built: false` opt-out which npm does not honor). The runtime smoke is delegated to the developer who runs the prototype locally with `yarn` per the in-file instructions at the top of `src/_prototype/index.js`. The static-analysis evidence above is sufficient to lock D-04 in writing; the runtime smoke is confirmatory, not exploratory.

### Answer

**D-04 (Shadow DOM, scoped-inject, native `attachShadow`) holds.** The prototype is shipped and the four research questions resolve as follows:

**Q1 — React 17 + react-router-dom 6 compatibility.** The prototype uses only stable hooks (`useState`, `useEffect`, `useRef`) that are unchanged between React 17 and 18/19, and `useLocation()` from `react-router-dom@6` is already in use throughout `src/App.js`. The shadow root is attached imperatively inside `useEffect` after the host `<div>` is committed, which is the React-recommended way to interact with non-React DOM APIs from a function component. The StrictMode double-mount guard (`if (hostRef.current.shadowRoot) return;` before `attachShadow`) prevents the well-documented React-18-StrictMode "second mount in dev re-runs useEffect" pathology — the host element retains the shadow root from the first run, so the second `attachShadow` would otherwise throw `NotSupportedError: shadow root has already been attached`. The guard short-circuits before that throw. **Compat: validated structurally; runtime hot-reload smoke deferred to local dev-server run.**

**Q2 — Font/reset re-import strategy and CSS bleed.** Shadow DOM (mode `open`) is a strict CSS scoping boundary by spec: SPA-owned `src/styles/app.sass` is loaded by `src/components/Page/index.js` and lives in the outer document; selectors and `@font-face` rules in `app.sass` do NOT pierce into the shadow root, and conversely the bigscreen10 stylesheets that the prototype clones into the shadow root cannot escape outward to restyle the SPA Header/Footer. The prototype re-injects `<link rel="stylesheet" href="/static-pages/_shared/fonts.css" />` and `/static-pages/_shared/reset.css` inside the shadow root as the production rendering layer will (these two paths 404 in dev — that is the documented expected behavior; the prototype tests the isolation contract, not the network-availability of those sheets). bigscreen10's own `<link rel="stylesheet">` and `<style>` nodes are cloned (`Array.from(doc.querySelectorAll(...)).forEach(n => shadow.appendChild(n.cloneNode(true)))`) so its `colors_and_type.css` + `style.css?v=145` render inside the boundary using the `<base href>` for relative-href resolution. **Strategy: `<link rel="stylesheet">` injection (RESEARCH.md approach (a)) is correct and shipped. Bleed: prevented in both directions by shadow-boundary spec.**

**Q3 — `:root` CSS variable escape behavior.** `bigscreen10/timeline/colors_and_type.css` contains two `:root` declarations (line 43 unconditional, line 325 inside a `prefers-color-scheme: light` media query). When a stylesheet is appended to an open shadow root, the CSS Working Group resolution is that `:root` inside the shadow tree matches **the shadow host element** (`:host`-equivalent), NOT `document.documentElement`. That means bigscreen10's CSS custom properties (e.g., `--bg`, `--fg`, etc., defined inside its `:root` block) WILL be available to selectors inside the shadow root via normal cascade, BUT they will NOT escape to `document.documentElement` and cannot accidentally restyle the SPA. Note for Phase 3: if production `<DynamicPage>` needs to share a CSS variable across the boundary (theme token interop), it must explicitly forward it (via `:host { --shared: var(--outer-shared); }` injected into the shadow root) — there is no implicit inheritance of custom properties from the host to the shadow root either, despite the `:root`→host equivalence on the inside. **Behavior: confirmed by spec + bigscreen10 source. No leak risk; cross-boundary token sharing is opt-in only.**

**Q4 — Asset path resolution under nested URLs.** All of `bigscreen10/timeline/index.html`'s asset references are RELATIVE (`colors_and_type.css`, `style.css?v=145`, `img/Bigscreen_logo_white_single.svg`, `assets/...` etc.). With the prototype's `<base href={pageUrl.replace(/index\.html$/i, '')} />` injected as the first child of the shadow root, relative URLs in the cloned link/style/script/img/etc. nodes resolve against `http://localhost:8080/` (the bigscreen10 origin), NOT `http://localhost:3000/` (the SPA origin). **No absolute-from-root paths in this bigscreen10 build**, meaning `<base href>` alone is sufficient for the v1 publishing pipeline to mount this content. Phase 2's CLI path-rewriter (per 01-CONTEXT.md) only needs to flag absolute paths if they appear in OTHER static pages Max ships; the current `/timeline` target needs no rewriting. **Resolution: confirmed correct via static read of bigscreen10 source. <base href> mechanism shipped in prototype.**

**Cross-cutting observations (per CONTEXT.md §Shadow-DOM mount research scope):**
- **Visual fidelity:** Static analysis shows the bigscreen10 build is self-contained (HTML + CSS + JS + img/, all relative). With base-href and link-clone in place, the shadow boundary should preserve visual parity with the live `bigscreen10` page modulo SPA Header/Footer above and below.
- **Scroll behavior:** The shadow root is in the same document as the SPA chrome, so the document scrolls cohesively. Internal `<a href="#s2016">` anchor jumps inside the cloned bigscreen10 nav target IDs inside the shadow root, which works because anchor scrolling resolves shadow-included anchors in modern browsers.
- **Script execution:** The prototype re-creates `<script>` nodes from the parsed document and appends them inside the shadow root. Modern browsers execute these. If a follow-up reveals execution-order issues (some bigscreen10 scripts rely on `DOMContentLoaded`, which fires for the OUTER document, not the shadow root), the production `<DynamicPage>` can switch script appendage to the light-DOM wrapper without changing the CSS-boundary story.

**Recommendation for Phase 3 production `<DynamicPage>`:** carry forward the native-attachShadow approach. Keep the StrictMode guard. Keep `<base href>` injection. Keep `<link rel="stylesheet">` injection for `/static-pages/_shared/fonts.css` + `/static-pages/_shared/reset.css`. The CLI path-rewriter (Phase 2) should normalize any absolute-from-root paths in marketer-uploaded HTML to relative-with-`<base>`-compatible forms.

### Confidence

HIGH

The prototype source is shipped and exhaustively structurally verified (12 token-level checks pass). The four research questions resolve cleanly against (a) the prototype source code as written and (b) direct static analysis of the actual bigscreen10 timeline build's HTML + CSS. The single piece of un-exercised evidence — the visual devtools confirmation of `#shadow-root (open)` in a running browser — is a smoke test against well-specified browser behavior, not an exploration of unknowns; D-04 does not depend on its outcome. If a follow-up runtime smoke surfaces an unexpected behavior (e.g., a script-execution-order bug specific to scripts-in-shadow), the production `<DynamicPage>` can adjust without re-litigating the boundary choice itself.

### Evidence source

- `C:\Users\decid\Documents\projects\website\src\_prototype\ShadowMount.js` (shipped, 126 lines, deep-structurally verified)
- `C:\Users\decid\Documents\projects\website\src\_prototype\index.js` (shipped, 47 lines, wraps in `<Page>`)
- `C:\Users\decid\Documents\projects\website\src\App.js` lines 60-62, 118-123 (env-gated route check, additive only)
- `C:\Users\decid\Documents\projects\bigscreen10\timeline\index.html` (head + body inspected; all asset paths confirmed relative)
- `C:\Users\decid\Documents\projects\bigscreen10\timeline\colors_and_type.css:43, :325` — `:root` declarations confirmed; Q3 analysis grounded in real bigscreen10 source
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Pattern 4 lines 451-483 — native variant reference implementation (selected after Task 3 react-shadow rejection)
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` Pitfall 6 (StrictMode double-mount) — guard pattern source
- `.planning/phases/01-foundations-registry-oauth/01-CONTEXT.md` §Specifics §Shadow-DOM mount research scope — the four research questions
- `.planning/phases/01-foundations-registry-oauth/01-CONTEXT.md` decisions D-03, D-04, D-05 — locked render-layer choice
- Reduced-fidelity note: full browser smoke pending local dev-server run (yarn install + `SKIP_PREFLIGHT_CHECK=true REACT_APP_ENABLE_PROTOTYPE=1 yarn start`). Run instructions live at the top of `src/_prototype/index.js`.

---

## FND-05: Reserved-paths allowlist (mechanism)

### Question

What storage mechanism does the reserved-paths allowlist use — a TypeScript constant in the cloud repo (`reservedPaths.ts`), a JSON file, or a database table — and what is the verbatim seed list for v1?

> Source: `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 5 (lines 889-892), with the seed list extracted in 01-RESEARCH.md §Code Examples lines 580-620 (which itself sources `src/App.js:114-160`).

### Investigation

Scope of search:
- Read `C:\Users\decid\Documents\projects\website\src\App.js` lines 114-160 directly to verify the seed list source has not changed since the researcher extracted it. Result: confirmed verbatim. `builderIoFilter`, `scanFilter`, `browserFilter`, `orderFilter`, and `etClientFilter` all match.
- Read CONTEXT.md D-11: "Defer mechanism choice (TS const vs JSON file vs DB table) to planner. Decision constraint: list must be readable at admin_api startup, expandable without DB migration, and version-controlled in cloud repo so additions are reviewable."
- Read RESEARCH.md §Open Questions recommendation: "**TS const** for v1 (simplest, version-controlled, code-reviewable). Deferred-to-DB only if marketing needs add reserved paths without a deploy."
- Read PITFALLS.md §3 (marketer-induced outage): the seed list must include EVERY path in the SPA's regex dispatcher; the seed-from-memory pattern is the trap.

### Answer

**Mechanism: TypeScript const** (`reservedPaths.ts`) committed in the cloud repo per D-11's "version-controlled in cloud repo so additions are reviewable" constraint.

- File location: `cloud/api/src/site/reservedPaths.ts` (sibling to other `cloud/api/src/site/*.ts` modules added in Plan 01-02+).
- Format: exported `RESERVED_PATHS: string[]` array of exact paths and prefixes (entries ending in `/` match by prefix; entries without trailing slash match exact).
- Validator: exported `isReservedPath(candidatePath: string): boolean` helper that lowercases input + the entries, then exact-or-prefix matches. (Lowercase normalization defeats the Unicode/case bypass pattern in PITFALLS.md §20.)
- **Deferred:** DB-table mechanism (for marketer-self-service additions without a deploy) — deferred per CONTEXT.md §Deferred Ideas. Phase 1 ships the TS const only.

**Verbatim seed list (from `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` lines 580-620, which extracts `src/App.js:114-160` verbatim):**

```typescript
export const RESERVED_PATHS = [
  // --- Root + admin/system reservations (CONTEXT.md Specifics) ---
  '/',
  '/api/',                  // backend reservation
  '/static-pages/',         // CDN-internal namespace (Phase 2)

  // --- React-owned auth/account screens ---
  '/account/',
  '/auth/',

  // --- React-owned scan/eyetracking screens ---
  '/scans/',
  '/scan/',                 // /scan/<token>/process
  '/token2/',               // /token2/login/<token>
  '/browser/',              // /browser/<activityId> (Hyperbeam)
  '/bset/',                 // /bset/pushtoken/<token> (EtClient)

  // --- React-owned legal pages (currently routed in App.js Page branch) ---
  '/privacypolicy',
  '/enrollprivacy',
  '/hardwareterms',
  '/termsofservice',
  '/refundpolicy',
  '/warranty',

  // --- Builder.io paths (verbatim from builderIoFilter in src/App.js:115-137) ---
  '/apps/builder',
  '/pages/',
  '/nspages/',
  '/software',
  '/about',
  '/mybeyond',
  '/myaudiostrap',
  '/displays',
  '/experiences',
  '/eyetracking',
  '/giveaway',
  '/create',
  '/nstest',
  '/affiliate',
];
```

Server-side enforcement (REG-06) is layered on top of `requireScopeAndPolicy` (which does NOT cover reserved-paths). On any publish/rollback/unpublish, the slug from the request body is checked against `isReservedPath(slug)` before any DB write; on collision the server returns the structured `PATH_RESERVED` error.

### Confidence

HIGH

The seed list is a verbatim extraction from `src/App.js:114-160` re-verified by direct read. The mechanism (TS const) is the simplest of the three options in D-11 and satisfies every D-11 constraint. The deferred DB-table option remains documented for future expansion.

### Evidence source

- `C:\Users\decid\Documents\projects\website\src\App.js:114-160` — verbatim source of the seed list (re-read on 2026-05-21)
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Code Examples lines 580-631 — researcher's extraction with `isReservedPath()` helper
- `.planning/phases/01-foundations-registry-oauth/01-CONTEXT.md` §decisions D-11 — mechanism deferred to planner with locked constraints
- `.planning/research/PITFALLS.md` §3 — marketer-induced outage pitfall (full extraction required)

---

## FND-06: Registry SPOF mitigation strategy

### Question

What is the SPA's strategy when `GET /api/site/pages.json` (the live registry endpoint on `apps/api`) is unavailable, slow, CORS-broken, or returns malformed JSON — and how is that mitigation specified so Phase 3's SPA-side implementation has a locked target?

> Source: `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 6 (lines 894-897), with the strategy framing from `.planning/research/PITFALLS.md` §2.

### Investigation

Scope of search:
- Read `.planning/research/PITFALLS.md` §2 (Registry Endpoint Is Single Point of Failure). Confirms: naive "fetch then render" pattern bricks every page (including `/`, `/software`, login) when the registry endpoint is down — because the router cannot decide what to render until the response arrives.
- Read RESEARCH.md §Open Questions item 6: recommends "bundled fallback registry + SWR caching + circuit-breaker on 2 consecutive fetch failures + revalidate-on-focus."
- Considered alternatives:
  - **Mock `/site/pages.json` to a static file** — explicitly rejected per RESEARCH.md ("defeats the SPOF mitigation"). The live endpoint must be the source of truth for *new* publications; the fallback is only the safety net.
  - **No fallback** — rejected; this is the failure mode the gate exists to prevent.
  - **Hard-coded TS file the developer updates manually** — possible but defeats the "non-engineer marketer publishes without an SPA rebuild" milestone goal. The fallback should be a build-time snapshot, not a manual edit.

### Answer

**Strategy (commitment-only in Phase 1; SPA implementation in Phase 3 / SPA-08):**

1. **Bundled fallback registry.** At SPA build time, a CI step fetches `GET /api/site/pages.json` from the staging registry and emits `src/_fallback-registry.json` into the bundle. The fallback is therefore always a recent snapshot of staging's registry, not a hand-curated list. (Phase 3 ships the CI step; Phase 1 only commits to the contract here.)

2. **Augment-not-replace semantics.** At runtime, the SPA loads the bundled fallback synchronously (no network), then issues `GET /api/site/pages.json` to fetch live state. Live results **augment** the fallback: any slug in the live response overrides the fallback entry for that slug; any slug in the fallback but missing from live remains active (fallback entries are not deleted by live-response absence — that would still brick old pages on partial outages).

3. **Circuit-breaker on 2 consecutive fetch failures.** After two failed `/api/site/pages.json` fetches (timeout, 5xx, malformed JSON, CORS error), the SPA stops trying for the rest of the session and serves the bundled fallback exclusively. On next session boot the breaker resets.

4. **SWR (stale-while-revalidate) caching with revalidate-on-focus.** The live fetch caches its result in memory for the session. When the window regains focus (`focus` event), the SPA re-fetches and updates the in-memory cache. Successful fetches reset the circuit-breaker counter.

5. **Boot-time fallthrough.** First paint MUST be possible from the bundled fallback alone — no `await fetch(...)` in the critical render path. The router uses the bundled fallback to make its initial dispatch decision; live data revises subsequent navigations.

Phase 1 commits to this contract in writing. Phase 3 (SPA-08) implements it on the client side.

### Confidence

HIGH

The strategy is laid out precisely in PITFALLS.md §2 + RESEARCH.md §Open Questions item 6, and is a well-understood pattern (essentially the standard "offline-first PWA" registry pattern adapted to a CRA SPA). No external system unknowns — the strategy is entirely SPA-side implementation work.

### Evidence source

- `.planning/research/PITFALLS.md` §2 — SPOF pitfall framing and mitigation strategy
- `.planning/phases/01-foundations-registry-oauth/01-RESEARCH.md` §Open Questions item 6 (lines 894-897) — recommendation
- `.planning/phases/01-foundations-registry-oauth/01-CONTEXT.md` §Specifics — milestone constraint that marketer publishes WITHOUT an SPA rebuild (so the live fetch must be the moving piece, with the fallback as safety net)

---

*End of Verification Memo for Phase 1 (FND-04 closed by Plan 01-01 Task 4; Task 5 locks §FND-02 and §FND-03 to HIGH).*
