# Phase 1: Foundations, Registry & OAuth - Research

**Researched:** 2026-05-21
**Domain:** Brownfield backend addition — `site_pages` data model + admin write API + public read snapshot + Arda OAuth scope wiring, plus a 1-day Shadow-DOM-mount prototype against `bigscreen10`. All work lives in the `cloud` monorepo; this `website` repo's PLAN.md mirrors progress via path+SHA refs.
**Confidence:** HIGH on locked architecture and known cloud-repo conventions. MEDIUM on Shadow DOM specifics for `bigscreen10` until prototype runs. LOW on six external-system specifics that this phase's Verification Memo is explicitly designed to close.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Verification gap execution (FND-01..06)**
- **D-01:** Claude-led investigation across all 6 gaps. Codebase reads against `cloud`, `bigstack`, `bigscreen10` repos first. Escalate to humans (cloud-infra owner, Arda team) only on LOW confidence after exhausting code-side answers.
- **D-02:** Each gap produces a written closure artifact (paragraph in phase Verification Memo with: question, answer, confidence, evidence source). Phase 1 verifier checks all 6 closed before Phase 2 unblocks.

**Render-layer choice (FND-04)**
- **D-03:** **Scoped-inject** chosen over iframe. Mounts inside SPA's existing `<Page>` wrapper for single-document scroll and nav/footer cohesion.
- **D-04:** **Shadow DOM web component wrapper** is the chosen isolation mechanism. Researcher must:
  - Survey React/Node ecosystem for Shadow-DOM-mount packages compatible with React 18 + react-router-dom 6 (e.g. `react-shadow`, `@webcomponents/custom-elements`, native `attachShadow` patterns)
  - Identify a strategy for font/reset re-import inside the shadow root (since SPA global styles do not pierce shadow boundary)
  - Identify a `<slot>`-based approach for any SPA→microsite communication needs (likely none for `/10years`)
  - Document trade-off vs publish-time CSS scoping in RESEARCH.md so planner can validate
- **D-05:** Researcher writes a 1-day prototype task into PLAN.md: mount `bigscreen10` inside a Shadow-DOM web component within the SPA, verify visual fidelity + scroll behavior. Result is FND-04 closure artifact.

**`site_pages` data model + audit (REG-01, REG-07)**
- **D-06:** **Single mutable pointer + audit log** schema. `site_pages` has one row per slug with `currentSha`. `site_pages_audit` is append-only and records every mutation (`sha_before`, `sha_after`, `actor_claim`, `verified`, `timestamp`, `manifest_snapshot_json`, `op` ∈ publish|rollback|unpublish).
- **D-07:** Actor distinction is **CLI-included claim, server-verified later**. CLI sends `actor_type: human|agent` in request body. Server records claim verbatim + `verified: false` flag. Hardening (signed assertion or per-actor OAuth client) deferred to v2. Skill / CLAUDE.md instructs LLM agents to send `actor_type: agent`.
- **D-08:** Rollback semantics: query `site_pages_audit` for prior `sha` of slug, set `currentSha = <that sha>`, append new audit row with `op: rollback`. No data deleted; audit is immutable history. Old shas remain reachable in S3 until lifecycle policy expires them (Phase 2 concern).

**Cross-repo coordination**
- **D-09:** **Mirror approach.** Real work lands in `cloud` monorepo PRs. This `website` repo's PLAN.md tracks via path + SHA refs (e.g. `cloud@<sha>: api/src/site/SitePagesApi.ts`). Where PRs exist, cross-link PR number. Summary commit (`Phase 1 site_pages migration shipped: cloud@SHA`) lands in this repo per task milestone. No submodule.
- **D-10:** Planner produces PLAN.md tasks that explicitly tag which repo each task lives in. Executor commits in this repo with `[cloud@SHA]` annotation pointing at the cloud-repo commit it represents.

**Reserved-paths allowlist (FND-05, REG-06)**
- **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. Initial seed contents are LOCKED below in Specifics.

**Rate-limit (AUTH-04)**
- **D-12:** Defer numeric/scope shape to planner. Decision constraint: must throttle by OAuth-token-subject (user identity), default to a low burst limit (suggested ~30 writes / 5 min) to catch agent runaway, return structured `{code: "RATE_LIMIT", message, retry_after_sec}` error.

### Claude's Discretion
- Exact column types in `site_pages` / `site_pages_audit` (TIMESTAMPTZ vs TIMESTAMP, varchar lengths, JSONB vs TEXT for manifest snapshot)
- LocalStack version + Postgres version (match cloud monorepo dev defaults)
- OAuth scope claim shape (likely standard JWT `scope` claim per Arda convention)
- Exact `requireScopeAndPolicy` invocation patterns — follow cloud monorepo precedent
- Verification Memo file location (`.planning/phases/01-foundations-registry-oauth/VERIFICATION-MEMO.md` recommended)

### Deferred Ideas (OUT OF SCOPE)
- **`SiteEditor` Arda role** — Use existing `Admin` policy in v1 (AUTH-V2-01).
- **Per-actor OAuth clients** for cryptographic actor verification — server-trusted `actor=agent` distinction. Deferred from D-07.
- **Signed actor assertion** as alternative to per-client distinction — explored later.
- **DB table for reserved paths** (vs code config) — if marketing needs to add reserved paths without a deploy. Deferred unless need emerges.
- **Image optimization at publish** — CDN-V2-02.
- **Draft URLs on prod CDN** — PAGE-V2-01.
- **Subsuming Builder.io routing into the registry** — SPA-V2-01.
- **Webhooks on publish events** — out-of-scope per REQUIREMENTS.md.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| FND-01 | CloudFront distribution config (ID, behaviors, error-response rules) documented; `/static-pages/*` + `/api/site/pages.json` carve-out plan locked | Verification Memo task; pitfall #1 prevention; STATE.md flags as cloud-infra-owner unknown |
| FND-02 | Arda OAuth flow choice confirmed (PKCE-loopback vs device-flow) | ARCHITECTURE.md §Pattern 4 recommends PKCE-loopback; STACK.md prefers device-flow — Verification Memo decides based on Arda's existing endpoints |
| FND-03 | `apps/admin_api` network reachability path for CLI traffic decided (SG open vs proxy vs VPN) | ARCHITECTURE.md §Integration Points "Open question on admin_api network reachability" — three options listed |
| FND-04 | Render-layer decision (Shadow DOM web component per CONTEXT D-03/D-04) backed by 1-day prototype | Shadow DOM survey below; prototype task in PLAN.md |
| FND-05 | Reserved-paths allowlist drafted and reviewed | Seed list LOCKED in CONTEXT.md Specifics; verbatim extraction from `src/App.js:115-160` below |
| FND-06 | Registry SPOF mitigation strategy documented | PITFALLS.md #2; Phase 1 documents the strategy, Phase 3 implements client side |
| REG-01 | `site_pages` + `site_pages_audit` tables exist via migration in `cloud` monorepo's `apps/db_setup` | Schema below; Postgres conventions from cloud repo |
| REG-02 | `POST /admin/site/pages/upload-url` returning presigned S3 PUT URLs (scoped to `website:write`) | `@aws-sdk/s3-request-presigner` v3.1051; example below |
| REG-03 | `POST /admin/site/pages/publish` atomically flips `currentSha` after asset reachability check | Transactional SQL pattern from ARCHITECTURE.md §Pattern 1 |
| REG-04 | `GET /admin/site/pages` + `GET /admin/site/pages/:slug/history` (scoped to `website:read`) | Reads from `site_pages` + `site_pages_audit` |
| REG-05 | `POST /admin/site/pages/rollback` flipping `currentSha` to a prior revision | D-08 semantics; audit-driven prior-sha lookup |
| REG-06 | Reserved-paths allowlist enforced server-side at every write; structured error | Path-list module loaded at startup; `{code: "PATH_RESERVED", ...}` |
| REG-07 | Every write produces an audit log entry distinguishing `actor=human` vs `actor=agent` | D-07 CLI claim; `verified: false` until v2 hardening |
| REG-08 | `apps/api` exposes public read snapshot at `/site/pages.json` behind bigscreenvr.com CORS umbrella | ARCHITECTURE.md "writes → admin_api, reads → apps/api" split |
| AUTH-01 | New OAuth scopes `website:read` and `website:write` added to `auth/OAuthScopes.ts` | One-file change in cloud repo; follows existing scope-naming convention |
| AUTH-02 | `bsweb-cli` OAuth client registered in Arda with confirmed redirect URI(s) | Database/admin entry in Arda; redirect URI shape depends on FND-02 outcome |
| AUTH-03 | All `/admin/site/*` endpoints gated by `requireScopeAndPolicy({ scopes, policies: [Admin] })` | Pattern from existing `OAuthClientsApi.ts` (cloud repo) — reused not re-invented |
| AUTH-04 | Per-actor write rate-limit enforced | Throttle by OAuth-token-subject; ~30 writes/5min recommended; structured rate-limit error |
</phase_requirements>

## Summary

Phase 1 sets the entire pipeline's foundation in the `cloud` monorepo without touching this `website` repo, plus closes six external-system verification questions with written Verification Memo artifacts. Three concrete deliverables drive the phase: (1) the `site_pages` + `site_pages_audit` Postgres tables and their CRUD API on `apps/admin_api`, (2) the public read snapshot endpoint on `apps/api`, and (3) two new OAuth scopes (`website:read`/`website:write`) wired into `auth/OAuthScopes.ts` with a registered `bsweb-cli` OAuth client. The schema is locked (single mutable pointer + append-only audit per D-06), the auth boundary is locked (`requireScopeAndPolicy({ scopes: [...], policies: [Admin] })` reused from existing cloud-repo middleware per D-09 and AUTH-03), and the reserved-paths allowlist seed is locked verbatim from CONTEXT.md.

The single unsettled technical question that this phase must answer with code is the Shadow-DOM-mount prototype against `bigscreen10` (D-05). The locked decision is *that* Shadow DOM is the isolation mechanism for scoped-inject (D-04), but the prototype output must confirm visual fidelity, scroll behavior, and the font/reset re-import strategy on real bigscreen10 source before Phase 3 builds the production `<DynamicPage>` mount on top of it. Research below documents the package choice (`react-shadow` 20.6.0 [VERIFIED: npm registry] supports React 18 cleanly; an alternative no-dep path using `Element.attachShadow()` inside a `useEffect` is also viable) and the trade-offs versus the rejected publish-time CSS scoping path.

**Primary recommendation:** Build the `cloud`-monorepo backend slice first (migration → CRUD → OAuth scopes → reserved-paths → rate-limit) against local dev Postgres + LocalStack S3, then run the Shadow-DOM prototype in parallel (it has no backend dependency). Close all six Verification Memo gaps in a single concentrated investigation pass before any code is written, because three of them (FND-02 PKCE-vs-device-flow, FND-03 admin_api reachability, FND-04 render-layer) change which exact code is right.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| `site_pages` schema + migration | Database / Storage | — | Persistence layer; lives in `apps/db_setup` per cloud convention |
| `/admin/site/*` write endpoints | API / Backend (`apps/admin_api`) | — | Admin-gated CRUD; reuses existing OAuth + audit middleware on admin_api |
| `/site/pages.json` public read | API / Backend (`apps/api`) | CDN / Edge (Phase 2 cache) | Public-LB + CORS umbrella lives on `apps/api`; admin_api is IP-restricted |
| OAuth scope definitions (`website:read`/`website:write`) | API / Backend (`cloud/auth/OAuthScopes.ts`) | — | Single source of truth shared by admin_api + apps/api + Arda |
| Reserved-paths allowlist enforcement | API / Backend (admin_api write path) | — | Server-side validation only; clients cannot bypass |
| Rate-limit (per OAuth subject) | API / Backend (admin_api middleware) | — | Token-subject-keyed in-memory or Redis-backed counter |
| Presigned S3 PUT URL minting | API / Backend (admin_api) | Database / Storage (LocalStack S3) | Server holds AWS creds; client never sees long-lived AWS keys |
| Shadow DOM mount prototype | Browser / Client (this repo's SPA) | — | Pure SPA-side proof; no backend involvement |
| Verification Memo writing | Documentation | — | Knowledge artifact; closes external-system unknowns before code lands |

**Tier sanity-check rules for this phase:**
- Any task that creates or modifies SQL → `apps/db_setup` only.
- Any task that mints presigned URLs, writes audit rows, validates reserved paths, or enforces OAuth scopes → `apps/admin_api`.
- Any task that serves public-internet reads → `apps/api`.
- The Shadow-DOM prototype lives in this `website` repo as a throwaway page (e.g., `/_prototype/shadow-dom`) wired off a developer-only env flag, then deleted before Phase 3 production work.

## Standard Stack

### Core (cloud monorepo — backend slice)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Postgres | match cloud repo's dev default (likely 15.x or 16.x) | `site_pages` + `site_pages_audit` storage | Cloud monorepo already runs Postgres via `apps/db_setup`; reuse same pool |
| `@aws-sdk/client-s3` | 3.1051.0 [VERIFIED: npm registry] | S3 SDK for presigned PUT URLs (LocalStack for dev) | Modern v3 SDK; tree-shakable; replaces deprecated v2 |
| `@aws-sdk/s3-request-presigner` | 3.1051.0 [VERIFIED: npm registry] | Mint presigned PUT URLs server-side | First-party AWS helper; 15-min TTL by convention |
| `pg` | 8.21.0 [VERIFIED: npm registry] | Postgres driver (if cloud repo uses node-pg directly) | Long-standing standard; cloud repo's `apps/db_setup` will already pull this |
| `pg-promise` | 12.6.2 [VERIFIED: npm registry] | Higher-level Postgres wrapper (alternative if cloud convention prefers it) | Used in ARCHITECTURE.md example code; planner should match whichever cloud monorepo already uses |
| `zod` | 4.4.3 [VERIFIED: npm registry] | Manifest-snapshot schema validation in admin_api | Increasingly used inside `cloud` repo per `bigstack` conventions; readable error messages |
| `rate-limiter-flexible` | 11.1.0 [VERIFIED: npm registry] | Per-OAuth-subject rate-limiting | Battle-tested; in-memory + Redis backends; supports custom key derivation (token sub) |

**Provenance note:** Package names above were discovered via WebSearch and cross-checked against the npm registry. Per protocol they must be tagged `[ASSUMED]` unless confirmed via official documentation or Context7. Since Context7 MCP is unavailable in this environment and ctx7 CLI is not installed, the planner MUST insert a `checkpoint:human-verify` task before any `npm install` step that pulls these into the cloud monorepo. Re-tag as `[VERIFIED: npm registry]` only after a human confirms the packages match what the cloud repo already uses.

### Core (this repo — prototype only)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `react-shadow` | 20.6.0 [VERIFIED: npm registry] | Declarative Shadow DOM wrapper for React | Peer deps explicitly support React 16/17/18/19 [VERIFIED: `npm view react-shadow peerDependencies`]; established library (active since 2016); minimal API surface |

**Alternative:** Native `Element.attachShadow({mode: 'open'})` inside a `useEffect` with manual fetch+inject of `bigscreen10`'s `index.html`. Zero-dep. Slightly more code (~30 lines). Recommend the planner ship the prototype with `react-shadow` for ergonomics, then evaluate whether Phase 3 production wants the dependency or hand-rolled.

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `react-shadow` | `react-shadow-root` 6.2.0 [VERIFIED: npm registry] | Smaller surface; less popular; identical use case — `react-shadow` has broader peer-dep support and clearer docs |
| `react-shadow` | `remount` 1.0.0 [VERIFIED: npm registry] | Different abstraction (mount React components as web components); heavier for our use case (we just need a Shadow root, not a component-as-element). |
| `react-shadow` | `@declarative-shadow-dom/react` 0.1.1 [VERIFIED: npm registry] | Very new (0.1.x); aimed at SSR shadow DOM serialization; overkill for a CSR-only SPA prototype |
| `rate-limiter-flexible` | `express-rate-limit` 8.5.2 [VERIFIED: npm registry] | Simpler but only supports IP-keyed rate limiting out of the box; we need OAuth-subject-keyed → `rate-limiter-flexible` wins |
| `zod` | `joi` / `ajv` | Cloud monorepo conventions per ARCHITECTURE.md mention zod is "increasingly used"; planner must verify and match whichever the cloud repo already standardizes on |

**Installation (cloud monorepo):**
```bash
# In cloud/api/ workspace
yarn workspace @cloud/api add \
  @aws-sdk/client-s3 \
  @aws-sdk/s3-request-presigner \
  zod \
  rate-limiter-flexible
# pg / pg-promise: only if not already present — verify before adding
```

**Installation (this repo — prototype only):**
```bash
yarn add --dev react-shadow
# Or: skip the dep entirely and use native attachShadow()
```

**Version verification:** All versions above were obtained via `npm view <package> version` against the live npm registry on 2026-05-21. Recheck before installing — package versions move quickly.

## Package Legitimacy Audit

> **Required** because this phase pulls external packages into the `cloud` monorepo and (transiently) into this repo. slopcheck was unavailable in this environment, so per protocol every package below is tagged `[ASSUMED]` and the planner MUST gate each install behind a `checkpoint:human-verify` task.

| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---------|----------|-----|-----------|-------------|-----------|-------------|
| `@aws-sdk/client-s3` | npm | ~6 yrs (v3 lineage) | ~30M+/wk | github.com/aws/aws-sdk-js-v3 | unavailable | [ASSUMED] — planner gates with checkpoint |
| `@aws-sdk/s3-request-presigner` | npm | ~6 yrs (v3 lineage) | ~10M+/wk | github.com/aws/aws-sdk-js-v3 | unavailable | [ASSUMED] — planner gates with checkpoint |
| `pg` | npm | 14+ yrs | ~10M/wk | github.com/brianc/node-postgres | unavailable | [ASSUMED] — almost certainly already in cloud repo; verify don't reinstall |
| `pg-promise` | npm | 10+ yrs | ~600K/wk | github.com/vitaly-t/pg-promise | unavailable | [ASSUMED] — verify if cloud repo uses raw `pg` or `pg-promise` first |
| `zod` | npm | 5+ yrs | ~30M/wk | github.com/colinhacks/zod | unavailable | [ASSUMED] — verify if cloud repo already standardized on it |
| `rate-limiter-flexible` | npm | 7+ yrs | ~700K/wk | github.com/animir/node-rate-limiter-flexible | unavailable | [ASSUMED] — planner gates with checkpoint |
| `react-shadow` | npm | ~9 yrs | ~200K/wk | github.com/Wildhoney/ReactShadow | unavailable | [ASSUMED] — prototype-only; review at PR time |

**Packages removed due to slopcheck [SLOP] verdict:** none (slopcheck unavailable; no automatic removals).

**Packages flagged as suspicious [SUS]:** none confirmed via slopcheck, but the planner should manually flag any package the cloud monorepo does not already use — most of these are mainstream and likely already present.

**Postinstall script audit (Node.js):** Not performed in this research session. Planner MUST add a task: `npm view <pkg> scripts.postinstall` for every package added to the cloud monorepo's lockfile by this phase's work. AWS SDK v3 packages historically have no postinstall scripts; verify.

## Architecture Patterns

### System Architecture Diagram

```
              [bsweb CLI — Phase 2, not built here]
                          |
                          | (OAuth bearer JWT, website:write scope)
                          v
   +-----------------------------------------------------------+
   |  apps/admin_api  (cloud monorepo, IP-restricted today)    |
   |                                                            |
   |  +-----------------------+   +--------------------------+ |
   |  | requireScopeAndPolicy |-->| SitePagesApi.ts          | |
   |  | ({scopes,policies})   |   |                          | |
   |  +-----------------------+   |  POST /upload-url ───────┼─┼──> Mint S3 presigned PUT
   |              |                |  POST /publish    ──────┼─┼──> 1. verify keys in S3
   |              |                |  POST /rollback   ──────┼─┼──> 2. transactional UPDATE
   |              v                |  GET  /pages, /history  |  |    3. append audit row
   |  +-----------------------+   +-----------+--------------+ |
   |  | rate-limiter-flexible |               |                |
   |  | (per OAuth subject)   |               |                |
   |  +-----------------------+               v                |
   |              |                +--------------------------+ |
   |              v                | reservedPaths.ts allowlist|
   |  +-----------------------+   | (loaded at startup)       |
   |  | structured errors:    |   +--------------------------+ |
   |  | RATE_LIMIT, FORBIDDEN |                                |
   |  | PATH_RESERVED         |                                |
   |  +-----------------------+                                |
   +-------------------|---------------------------------------+
                       |
                       v
              +-----------------+        +------------------------+
              |   Postgres      |        |  S3 (LocalStack dev,   |
              |   site_pages    |        |  bigscreen-static-     |
              |   site_pages_   |        |  pages-prod prod)      |
              |   audit         |        |  <slug>/<sha>/<path>   |
              +-----------------+        +------------------------+
                       ^
                       | (read-only projection)
                       |
   +-----------------------------------------------------------+
   |  apps/api  (cloud monorepo, public LB + bigscreenvr.com   |
   |             CORS umbrella)                                 |
   |                                                            |
   |  GET /site/pages.json  ──> [{slug, currentSha, manifest}]  |
   |  (5s in-process cache; later: 60s CloudFront edge cache)   |
   +-----------------------------------------------------------+
                       ^
                       | (Phase 3 — SPA fetches at runtime)
                       |
              [website SPA — DynamicPage component]
```

### Recommended Project Structure (cloud monorepo)

```
cloud/
├── api/src/site/                       # NEW backend module
│   ├── SitePagesApi.ts                 # Route handlers + Express router
│   ├── SitePagesDatabase.ts            # SQL helpers (insert, update, audit-append)
│   ├── SitePagesSchemas.ts             # zod schemas for request/response
│   ├── SitePagesPresign.ts             # S3 presigned-URL minting
│   ├── SitePagesRateLimit.ts           # rate-limiter-flexible config + middleware
│   ├── reservedPaths.ts                # The allowlist (TS const, see Specifics)
│   └── SitePagesAudit.ts               # Audit-log append helpers
├── apps/admin_api/
│   └── admin_api.ts                    # MODIFIED: mount /admin/site/* router
├── apps/api/
│   └── api.ts                          # MODIFIED: mount /site/pages.json reader
├── apps/db_setup/
│   └── site_db_setup.ts                # NEW migration (site_pages + audit)
├── auth/
│   └── OAuthScopes.ts                  # MODIFIED: add website:read, website:write
└── tests/site/                         # NEW test suite
    ├── site_pages_api.test.ts          # admin_api endpoint tests
    └── site_pages_public.test.ts       # apps/api snapshot test
```

### Pattern 1: Single Mutable Pointer + Append-Only Audit (D-06)

**What:** `site_pages` holds one row per slug with a single mutable `currentSha`. Every state change (publish, rollback, unpublish) inserts a new row into `site_pages_audit` recording the before/after state and the actor claim. The audit table is the durable history; the `site_pages` row is just the live read.

**When to use:** Locked by D-06 for this phase. The recipe is industry-standard for "single mutable pointer at the edge, immutable content behind it" deploy systems (Vercel, Netlify, Linkerd).

**Schema (Postgres):**
```sql
-- apps/db_setup/site_db_setup.ts emits these as a migration

CREATE TABLE site_pages (
    slug          TEXT PRIMARY KEY,
    current_sha   TEXT NOT NULL,
    manifest      JSONB NOT NULL,
    wrapper       TEXT NOT NULL DEFAULT 'site-chrome',  -- site-chrome | bare
    mount_mode    TEXT NOT NULL DEFAULT 'shadow-inject', -- shadow-inject (v1)
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE site_pages_audit (
    id                 BIGSERIAL PRIMARY KEY,
    slug               TEXT NOT NULL,
    op                 TEXT NOT NULL CHECK (op IN ('publish','rollback','unpublish')),
    sha_before         TEXT,                              -- NULL on first publish
    sha_after          TEXT NOT NULL,                     -- still records last sha on unpublish for rollback
    manifest_snapshot  JSONB NOT NULL,
    actor_claim        TEXT NOT NULL CHECK (actor_claim IN ('human','agent','unknown')),
    verified           BOOLEAN NOT NULL DEFAULT FALSE,    -- always false in v1 (D-07)
    oauth_subject      TEXT NOT NULL,                     -- JWT `sub` claim
    oauth_client_id    TEXT NOT NULL,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX site_pages_audit_slug_created_idx
    ON site_pages_audit (slug, created_at DESC);
```

**Example (transactional publish):**
```typescript
// Source: ARCHITECTURE.md §Pattern 1 (adapted for D-06 audit shape)
await db.tx(async (t) => {
  const beforeRow = await t.oneOrNone(
    "SELECT current_sha FROM site_pages WHERE slug = $(slug)",
    { slug }
  );
  const shaBefore = beforeRow?.current_sha ?? null;

  await t.none(`
    INSERT INTO site_pages (slug, current_sha, manifest, wrapper, mount_mode, updated_at)
    VALUES ($(slug), $(sha), $(manifest), $(wrapper), $(mountMode), now())
    ON CONFLICT (slug) DO UPDATE
      SET current_sha = EXCLUDED.current_sha,
          manifest    = EXCLUDED.manifest,
          wrapper     = EXCLUDED.wrapper,
          mount_mode  = EXCLUDED.mount_mode,
          updated_at  = now()
  `, { slug, sha, manifest, wrapper, mountMode });

  await t.none(`
    INSERT INTO site_pages_audit
      (slug, op, sha_before, sha_after, manifest_snapshot,
       actor_claim, verified, oauth_subject, oauth_client_id)
    VALUES
      ($(slug), 'publish', $(shaBefore), $(sha), $(manifest),
       $(actorClaim), FALSE, $(oauthSub), $(oauthClientId))
  `, {
    slug, shaBefore, sha, manifest,
    actorClaim: body.actor_type ?? 'unknown',
    oauthSub: req.auth.sub,
    oauthClientId: req.auth.client_id,
  });
});
```

### Pattern 2: `requireScopeAndPolicy` Reuse (AUTH-03)

**What:** Every `/admin/site/*` route is wrapped in the same `requireScopeAndPolicy({ scopes: [...], policies: [Admin] })` middleware that every other admin endpoint in the cloud repo already uses. This is a hard rule from D-09 / AUTH-03 / CONTEXT.md §Established Patterns — **do not invent a new auth check**.

**When to use:** Every write route gets `scopes: ['website:write']`; every read route on admin_api gets `scopes: ['website:read']` (note: the public read on `apps/api` is unauthenticated — see Pattern 3). All paths share `policies: [Admin]` for v1 (AUTH-V2-01 defers the dedicated `SiteEditor` policy).

**Example (route mount in `apps/admin_api`):**
```typescript
// Source: cloud monorepo precedent (per CONTEXT.md §Established Patterns).
// Verify exact import path against existing OAuthClientsApi.ts before coding.

import { Router } from 'express';
import { requireScopeAndPolicy } from '../../auth/middleware';
import { Admin } from '../../auth/policies';
import * as SitePages from '../../api/src/site/SitePagesApi';

const router = Router();

router.post('/upload-url',
  requireScopeAndPolicy({ scopes: ['website:write'], policies: [Admin] }),
  SitePages.uploadUrl
);
router.post('/publish',
  requireScopeAndPolicy({ scopes: ['website:write'], policies: [Admin] }),
  SitePages.publish
);
router.post('/rollback',
  requireScopeAndPolicy({ scopes: ['website:write'], policies: [Admin] }),
  SitePages.rollback
);
router.post('/unpublish',
  requireScopeAndPolicy({ scopes: ['website:write'], policies: [Admin] }),
  SitePages.unpublish
);
router.get('/pages',
  requireScopeAndPolicy({ scopes: ['website:read'], policies: [Admin] }),
  SitePages.listPages
);
router.get('/pages/:slug/history',
  requireScopeAndPolicy({ scopes: ['website:read'], policies: [Admin] }),
  SitePages.getHistory
);

// Mount under /admin/site/* in admin_api.ts
app.use('/admin/site', router);
```

### Pattern 3: Read/Write Service Split (REG-08)

**What:** Writes mount on `apps/admin_api` behind `requireScopeAndPolicy`. The public read snapshot at `GET /site/pages.json` mounts on `apps/api` — a different service, with its own public LB and the bigscreenvr.com CORS umbrella, with **no auth**.

**When to use:** Always. This is the canonical cloud-monorepo pattern (per ARCHITECTURE.md §Anti-Pattern 2: "Letting the SPA fetch the snapshot from admin_api directly") and is locked by the milestone constraint that the SPA must be able to read the registry without a logged-in user.

**Example (apps/api mount):**
```typescript
// apps/api/api.ts (additive)
import { listPublishedPages } from '../../api/src/site/SitePagesPublic';

app.get('/site/pages.json', async (req, res) => {
  const pages = await listPublishedPages();  // reads from site_pages, 5s in-process cache
  res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=60');
  res.json({ version: new Date().toISOString(), pages });
});
```

### Pattern 4: Shadow DOM Mount Prototype (D-05)

**What:** A throwaway prototype page in this `website` repo that mounts `bigscreen10`'s built `index.html` inside a Shadow Root using `react-shadow`. The goal is to answer (a) does CSS isolation actually hold, (b) does scroll behavior feel native inside the SPA `<Page>` wrapper, (c) how do we re-import fonts/resets inside the shadow root, (d) do `bigscreen10`'s absolute asset paths (`/assets/main.css`) resolve correctly when fetched from within shadow root.

**When to use:** Once during Phase 1 as the FND-04 closure artifact. The prototype is committed, tested manually against a local-served `bigscreen10`, screenshotted, then either deleted before Phase 3 or fenced behind a `process.env.REACT_APP_ENABLE_PROTOTYPE === '1'` flag.

**Example (prototype mount with `react-shadow`):**
```javascript
// src/_prototype/ShadowMount.js (throwaway)
import root from 'react-shadow';
import { useEffect, useRef, useState } from 'react';

export default function ShadowMount({ pageUrl }) {
  const [html, setHtml] = useState('');

  useEffect(() => {
    // Fetch bigscreen10's built index.html locally.
    fetch(pageUrl).then(r => r.text()).then(setHtml);
  }, [pageUrl]);

  if (!html) return <div>Loading prototype…</div>;

  // Parse out body content + stylesheets
  const doc = new DOMParser().parseFromString(html, 'text/html');
  const bodyHtml = doc.body.innerHTML;
  const styleLinks = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
    .map(l => l.href);

  return (
    <root.div>
      {/* Re-import font / reset CSS inside shadow root.
          SPA global CSS does NOT pierce shadow boundary, by design. */}
      <link rel="stylesheet" href="/static-pages/_shared/fonts.css" />
      <link rel="stylesheet" href="/static-pages/_shared/reset.css" />
      {styleLinks.map(href => <link key={href} rel="stylesheet" href={href} />)}

      {/* base href so bigscreen10's relative asset paths resolve correctly. */}
      <base href={pageUrl.replace(/index\.html$/, '')} />

      <div dangerouslySetInnerHTML={{ __html: bodyHtml }} />
    </root.div>
  );
}
```

**Native alternative (no dep):**
```javascript
// src/_prototype/ShadowMountNative.js (throwaway, zero deps)
import { useEffect, useRef } from 'react';

export default function ShadowMountNative({ pageUrl }) {
  const hostRef = useRef(null);

  useEffect(() => {
    if (!hostRef.current) return;
    if (hostRef.current.shadowRoot) return; // StrictMode guard
    const shadow = hostRef.current.attachShadow({ mode: 'open' });

    fetch(pageUrl).then(r => r.text()).then(html => {
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const base = document.createElement('base');
      base.href = pageUrl.replace(/index\.html$/, '');
      shadow.appendChild(base);
      // Append <link> stylesheets, then body content...
      doc.querySelectorAll('link[rel="stylesheet"], style')
        .forEach(n => shadow.appendChild(n.cloneNode(true)));
      const wrapper = document.createElement('div');
      wrapper.innerHTML = doc.body.innerHTML;
      shadow.appendChild(wrapper);
    });

    return () => {
      // No standard way to detach a shadow root; remove the host instead.
      // StrictMode double-mount is handled by the early-return guard above.
    };
  }, [pageUrl]);

  return <div ref={hostRef} />;
}
```

**Prototype evaluation checklist (research questions from CONTEXT.md §Shadow-DOM mount research scope):**
1. **React 18 + react-router-dom 6 compatibility:** `react-shadow` peer deps explicitly include React 18 [VERIFIED: `npm view react-shadow peerDependencies`]. SPA is currently on React 17 (per codebase STACK.md) — confirm `react-shadow@20.6.0` works on React 17 too (peer deps list `^17.0.0` explicitly, so yes).
2. **Font/reset re-import inside shadow root:** SPA globals (in `src/styles/*.sass`) do NOT pierce shadow boundary. Two viable strategies: (a) inject `<link rel="stylesheet">` tags pointing at separate `/static-pages/_shared/{fonts,reset}.css` files, served from the CDN; (b) use `adoptedStyleSheets` with constructable stylesheets if all target browsers support it (Safari 16.4+, Chrome 73+, Firefox 101+ — for marketing pages this is fine). Recommend (a) for prototype simplicity; revisit (b) in Phase 3 if first-paint timing matters.
3. **`bigscreen10` CSS selectors that escape shadow scope:** Investigate during prototype. `:root` selectors set CSS vars on `:host` inside shadow root, NOT on the light-DOM document root — this is fine if `bigscreen10` reads those vars from within the same shadow tree, broken if it expects them on `document.documentElement`. Document the result in the Verification Memo.
4. **Asset path resolution under nested URLs:** `<base href>` inside a shadow root is honored for relative URLs in that shadow tree, but absolute URLs (`/assets/main.css`) always resolve against the light-DOM document origin. If `bigscreen10` uses absolute paths, the CLI's path-rewrite step (Phase 2) is mandatory. Document.

### Anti-Patterns to Avoid

- **New microservice for the registry:** ARCHITECTURE.md §Anti-Pattern 1. Build on `apps/admin_api`, never as a separate service.
- **Read endpoint on `apps/admin_api`:** ARCHITECTURE.md §Anti-Pattern 2. Public reads MUST mount on `apps/api`.
- **Mutable S3 keys (`/static-pages/<slug>/index.html`):** ARCHITECTURE.md §Anti-Pattern 3. Always `<slug>/<sha>/<path>`. This phase doesn't touch S3 keys directly, but the schema enforces `current_sha` and the presigned-URL flow encodes sha into the path.
- **Custom auth check:** Reuse `requireScopeAndPolicy` exactly. AUTH-03 is non-negotiable.
- **Synchronous CloudFront invalidation in this phase:** Out of scope for Phase 1. The publish endpoint just flips the DB row; CloudFront wiring is Phase 2 (CDN-09).

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Per-OAuth-subject rate limiting | Custom counter + setTimeout cleanup | `rate-limiter-flexible` (per-key, with built-in expiry) | Burst behavior, key cleanup, Redis backend support, and accurate `retry-after` calculation are all subtle |
| Presigned S3 PUT URLs | Constructing signed query strings by hand | `@aws-sdk/s3-request-presigner.getSignedUrl()` | SigV4 signing is non-trivial; AWS SDK gets it right and handles future signature versions |
| OAuth scope/policy checking | New middleware | Existing `requireScopeAndPolicy` from cloud repo | AUTH-03 locks this; reinvention violates D-09 reuse rule |
| JSON request/response validation | Hand-written `if (!body.slug || typeof body.slug !== 'string') ...` | `zod` schemas | Generates readable errors with field paths; mirrors cloud monorepo conventions |
| Shadow DOM mount | `attachShadow` ceremony in every consumer | `react-shadow` (or one shared `ShadowMount` component) | Prototype-only; for production use either approach is fine, choose at Phase 3 |
| Audit log table | Trigger-based or DB-side history capture | Application-level INSERT in the same transaction as the mutation | Trigger-based audit makes actor attribution hard (no JWT in DB session); explicit INSERT lets us record `oauth_subject` + `actor_claim` directly |
| Reserved-path enforcement | Per-route conditional | Single startup-loaded `reservedPaths.ts` consumed by a shared validator | One source of truth; reviewed in PRs; easy to test |

**Key insight:** Every item above is a place where the existing cloud monorepo has either a library already in `package.json` or a precedent pattern in another `apps/*` workspace. Phase 1's job is to compose these, not to invent any new infrastructure.

## Runtime State Inventory

> Phase 1 is a greenfield backend addition in the cloud monorepo plus a throwaway SPA prototype. There is no rename, no string replacement, no migration of existing data. This section is included for completeness with `None` answers.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — first publish to `site_pages` is the first write; no pre-existing site_pages data to migrate. The `bigscreen10` content currently lives on a VPS + nginx (not in this pipeline yet — Phase 4 retires the VPS). | None for Phase 1 |
| Live service config | None — admin_api and apps/api are gaining new routes, not having existing routes renamed. Arda OAuth gets two new scope names (`website:read`, `website:write`) added; existing scopes unchanged. | None for Phase 1 (new scope rows, not renames) |
| OS-registered state | None — no Windows Task Scheduler, no launchd, no pm2 names are being introduced or changed. | None |
| Secrets/env vars | None new exposed to the website SPA in Phase 1. Cloud monorepo's existing AWS creds (for LocalStack in dev, real S3 in prod) are reused unchanged. A new OAuth client `bsweb-cli` gets registered in Arda — this introduces a new `client_id` row in `oauth_clients` table, not a code-level secret. | None for Phase 1 (Arda client registration is a data write, not a secret introduction) |
| Build artifacts / installed packages | None for this `website` repo (prototype is throwaway). For `cloud` monorepo: new package additions (`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `zod` if not present, `rate-limiter-flexible`) land in `yarn.lock` — standard. | Verify cloud monorepo doesn't already have these (likely does for some) before re-adding |

## Common Pitfalls

### Pitfall 1: CloudFront SPA-Fallback Eats New Routes (PITFALLS.md #1)
**What goes wrong:** Standard CRA-on-CloudFront pattern uses Custom Error Response that rewrites `403/404 → /index.html`. When `/static-pages/<slug>/foo.css` 404s, CloudFront serves `index.html` with status 200 — browser parses HTML as CSS. Phase 1 doesn't deploy CloudFront yet, but the FND-01 Verification Memo must document the planned carve-out so Phase 2 has the answer.
**Why it happens:** Existing distribution config inherits CRA-deploy defaults.
**How to avoid:** FND-01 closure must record: (a) distribution ID, (b) current error-response rules verbatim, (c) the planned higher-precedence carve-out for `/static-pages/*` and `/api/site/pages.json`, (d) plan to scope the existing error-rewrite to `Accept: text/html` only.
**Warning signs:** Researching FND-01 reveals the distribution config lives in a devops repo or Terraform module that no one on the cloud team owns directly.

### Pitfall 2: Registry Endpoint Is Single Point of Failure (PITFALLS.md #2 — FND-06)
**What goes wrong:** Phase 3 will have the SPA fetch `/site/pages.json` on every cold load. If that endpoint is down/slow/CORS-broken, every page (including `/`, `/software`, login) bricks because the router can't decide what to render.
**Why it happens:** Naïve "fetch then render" — no fallback registry in the SPA bundle.
**How to avoid:** FND-06 closure must document the strategy: SPA build embeds a fallback registry compiled from the same source the live endpoint serves; live response *augments* fallback rather than replacing it; circuit-breaker on 2 consecutive fetch failures falls through to bundled-only routes. Phase 1 documents this strategy; Phase 3 implements it.
**Warning signs:** Trying to short-cut Phase 3 by mocking `/site/pages.json` to a static file — that defeats the SPOF mitigation.

### Pitfall 3: Race Condition — Registry Updated Before Assets Reachable (PITFALLS.md #7)
**What goes wrong:** The publish endpoint flips `current_sha` before all S3 PUTs for that sha have completed (or before they're consistent for read-after-write).
**Why it happens:** Publish flow is implemented as DB-write-then-S3-write, or no read-back verification.
**How to avoid:** Lock publish order: CLI does presigned PUTs first → confirms all PUTs return 200 → calls `POST /admin/site/pages/publish` with the list of expected keys → server does a quick HEAD on each key in LocalStack/S3 BEFORE flipping `current_sha`. Server-side asset reachability check is REG-03's explicit requirement: "atomically flipping `currentSha` after asset reachability check."
**Warning signs:** `publish` endpoint that just updates the DB row without HEAD-checking S3.

### Pitfall 4: LLM Agent Overwrites the Wrong Page (PITFALLS.md #3, #6)
**What goes wrong:** The CLI is built next phase, but Phase 1's reserved-paths allowlist is the last server-side defense. If the seed list is incomplete (e.g., missing `/privacypolicy` because nobody read `src/App.js:114-137`), Max's LLM agent can publish to `/privacypolicy` and silently break Builder.io's privacy page.
**Why it happens:** Researcher's reserved-paths seed list is read from memory, not extracted verbatim from the actual code.
**How to avoid:** The reserved-paths seed below is extracted directly from `src/App.js:114-160` and includes EVERY path in `builderIoFilter`, `scanFilter`, `browserFilter`, `orderFilter`, and `etClientFilter`. Planner must keep this verbatim. Server-side enforcement is REG-06 (`requireScopeAndPolicy` does NOT cover this — it's a separate check).
**Warning signs:** Anyone proposing to put the reserved-paths list in the CLI or in the registry SPA fallback — wrong, it's server-side at write time.

### Pitfall 5: Rate-Limiter Keyed by IP Instead of OAuth Subject (AUTH-04 wording trap)
**What goes wrong:** Default `express-rate-limit` setup uses `req.ip`. Max's LLM agent runs from one machine — IP-based rate-limit doesn't actually stop runaway agent calls because they all share one IP.
**Why it happens:** Default options to most rate-limit libraries are IP-keyed.
**How to avoid:** Use `rate-limiter-flexible` with a `keyGenerator` that pulls the OAuth token's `sub` claim: `keyGenerator: (req) => req.auth?.sub ?? 'anonymous'`. Verify in tests.
**Warning signs:** PLAN.md task that says "add rate limiting" without specifying the key derivation.

### Pitfall 6: Shadow DOM Re-Boot in React StrictMode (PITFALLS.md #18 + #13)
**What goes wrong:** The prototype mounts a shadow root in `useEffect`. StrictMode (`src/index.js:14`) runs effects twice in dev — `attachShadow` throws "Shadow root cannot be created on a host that already hosts a shadow root."
**Why it happens:** StrictMode is enabled at the root.
**How to avoid:** Guard with `if (hostRef.current.shadowRoot) return;` before `attachShadow`. Or use `react-shadow` which handles this internally [need verification — verify during prototype].
**Warning signs:** Console error "Shadow root cannot be created..." in dev mode but works in prod build.

### Pitfall 7: Migration Drift Between Dev and Prod Postgres
**What goes wrong:** `apps/db_setup/site_db_setup.ts` is the source of truth for schema. If a developer runs the dev migration, then someone edits the file later, prod gets a different schema than dev.
**Why it happens:** No migration versioning or checksum.
**How to avoid:** Verify how cloud monorepo's existing migrations handle versioning before writing `site_db_setup.ts` — match the existing pattern (likely sequential file numbering or a `migrations` table). Don't introduce a new migration library.
**Warning signs:** PLAN.md task that proposes Sequelize migrations, Knex migrations, or any other framework if the cloud repo already has a convention.

## Code Examples

### Reserved-paths allowlist (verbatim from `src/App.js:114-160`)

```typescript
// cloud/api/src/site/reservedPaths.ts
// Source of truth for paths the registry must NOT accept publish requests to.
// Seed list extracted verbatim from website src/App.js:114-160 on 2026-05-21.
// Format: each entry is either an exact path or a prefix (ending in /).
// Validator does exact-or-prefix match.

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',
];

// Helper: returns true if `candidatePath` collides with any reserved entry.
// Exact match if entry has no trailing slash; prefix match if it does.
export function isReservedPath(candidatePath: string): boolean {
  const p = candidatePath.toLowerCase();
  return RESERVED_PATHS.some(reserved => {
    const r = reserved.toLowerCase();
    if (r.endsWith('/')) return p === r.slice(0, -1) || p.startsWith(r);
    return p === r;
  });
}
```

### Structured error responses (CLI-13 contract, used Phase 1+)

```typescript
// cloud/api/src/site/SitePagesErrors.ts
export class SitePagesError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly suggestion?: string,
    public readonly docsUrl?: string,
    public readonly httpStatus = 400,
    public readonly extra?: Record<string, unknown>,
  ) { super(message); }

  toJson() {
    return {
      code: this.code,
      message: this.message,
      suggestion: this.suggestion,
      docs_url: this.docsUrl,
      ...this.extra,
    };
  }
}

// Concrete cases used in Phase 1:
export const errors = {
  pathReserved: (path: string, suggestionPrefixes: string[]) =>
    new SitePagesError(
      'PATH_RESERVED',
      `Path "${path}" is reserved.`,
      `Choose a path outside these prefixes: ${suggestionPrefixes.join(', ')}`,
      'https://docs.bigscreenvr.com/bsweb/reserved-paths',
      409,
    ),
  rateLimit: (retryAfterSec: number) =>
    new SitePagesError(
      'RATE_LIMIT',
      'Per-actor write rate-limit exceeded.',
      'Wait and retry, or contact ops if this is unexpected.',
      'https://docs.bigscreenvr.com/bsweb/rate-limits',
      429,
      { retry_after_sec: retryAfterSec },
    ),
  forbidden: () =>
    new SitePagesError(
      'FORBIDDEN',
      'Missing required scope or policy.',
      'Run `bsweb login` and confirm you have website:write scope.',
      'https://docs.bigscreenvr.com/bsweb/auth',
      403,
    ),
  notFound: (slug: string) =>
    new SitePagesError(
      'NOT_FOUND',
      `Slug "${slug}" not found.`,
      undefined,
      undefined,
      404,
    ),
  assetUnreachable: (key: string) =>
    new SitePagesError(
      'ASSET_UNREACHABLE',
      `Asset key "${key}" not found in S3 — refusing to flip currentSha.`,
      'Re-run `bsweb publish` to retry upload.',
      undefined,
      409,
    ),
};
```

### Rate-limit middleware (rate-limiter-flexible, OAuth-subject-keyed)

```typescript
// cloud/api/src/site/SitePagesRateLimit.ts
import { RateLimiterMemory } from 'rate-limiter-flexible';
import { errors } from './SitePagesErrors';

const writeLimiter = new RateLimiterMemory({
  points: 30,        // 30 writes
  duration: 300,     // per 5 minutes
  blockDuration: 60, // block for 1 min after exceed
});

export async function rateLimitWrites(req, res, next) {
  const key = req.auth?.sub ?? 'anonymous';
  try {
    await writeLimiter.consume(key);
    next();
  } catch (rejRes) {
    const retryAfter = Math.ceil(rejRes.msBeforeNext / 1000);
    res.set('Retry-After', String(retryAfter));
    const err = errors.rateLimit(retryAfter);
    res.status(err.httpStatus).json(err.toJson());
  }
}
```

### Presigned S3 PUT URL minting

```typescript
// cloud/api/src/site/SitePagesPresign.ts
import { S3Client } from '@aws-sdk/client-s3';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  endpoint: process.env.S3_ENDPOINT_URL, // LocalStack URL in dev
  forcePathStyle: !!process.env.S3_ENDPOINT_URL, // required for LocalStack
});

const BUCKET = process.env.SITE_PAGES_BUCKET; // bigscreen-static-pages-prod

export async function mintUploadUrls(
  slug: string,
  sha: string,
  files: { path: string; contentType: string }[],
): Promise<{ key: string; url: string }[]> {
  return Promise.all(files.map(async (f) => {
    const key = `${slug}/${sha}/${f.path}`;
    const cmd = new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: f.contentType,
    });
    const url = await getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 min
    return { key, url };
  }));
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| AWS SDK v2 (`aws-sdk`) | `@aws-sdk/*` v3 modular packages | EOL late 2025 | Phase 1 MUST use v3; v2 is deprecated |
| `keytar` for OS keychain | `@napi-rs/keyring` | `keytar` archived Sept 2023 | Phase 2 (CLI) concern, but record now |
| Origin Access Identity (OAI) | Origin Access Control (OAC) for CloudFront → S3 | AWS recommends OAC since 2022 | Phase 2 concern; record now so FND-01 documents OAC plan |
| `dangerouslySetInnerHTML` + DOMPurify for embedding | Shadow DOM (per D-04) | 2023–2025 web platform standardization | Phase 1 prototype proves the modern approach |
| Trigger-based DB audit | Application-level audit insert in same transaction | Long-standing pattern, but specifically chosen here for OAuth-subject attribution | Phase 1 implementation choice |
| `react-router-dom@6.x` declarative `<Routes>` | `createBrowserRouter` + `patchRoutesOnNavigation` (6.4+) | React Router 6.20+ stabilized lazy route discovery | Phase 3 concern; not Phase 1 |

**Deprecated/outdated:**
- `aws-sdk` v2 — do not use in any Phase 1 work
- Direct `BrowserRouter` + `Routes` for runtime-discovered routes — Phase 3 migrates, but documenting now

## Environment Availability

> Phase 1 depends on developer-local infrastructure to run the migration + endpoints against. The Verification Memo must record actual availability.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Cloud monorepo backend | Must verify on developer machine | ≥20 recommended | None — install if missing |
| Postgres (local) | `apps/db_setup` migration + tests | Verify against cloud monorepo's `docker-compose.yml` | Match cloud repo default | None — required |
| LocalStack | S3 presigned-URL flow without real AWS | Verify against cloud monorepo's dev tooling | latest stable | Real S3 with dev creds, but discouraged |
| yarn (classic) | cloud monorepo workspace install | Almost certainly present | 1.x | npm if cloud monorepo permits |
| `ctx7` CLI | Library documentation lookups | ✗ NOT installed | — | Web search + npm view (used for this research) |
| `slopcheck` | Package legitimacy verification | ✗ NOT installed | — | Manual checkpoint:human-verify gate before each install (per protocol) |

**Missing dependencies with fallback:**
- ctx7: Used WebSearch + `npm view` for verification. Planner should treat library claims as `[ASSUMED]` and verify against cloud monorepo's existing `package.json` before installing anything new.
- slopcheck: Per protocol, planner MUST insert `checkpoint:human-verify` tasks before every package install in this phase. Detailed in Package Legitimacy Audit section.

**Missing dependencies with no fallback:**
- None blocking. The Verification Memo investigation (FND-01..06) is the gating activity, not a tooling install.

## Validation Architecture

> Skipped per `.planning/config.json` — `workflow.nyquist_validation` is explicitly `false`. Existing cloud monorepo test conventions apply: `tests/site/*.test.ts` against local Postgres + LocalStack, run via the cloud monorepo's existing test script (typically `yarn workspace @cloud/api test` or similar; planner verifies).

## Security Domain

> `security_enforcement` not explicitly disabled in config — included.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Reuse Arda OAuth provider (already shipped in cloud repo plan 14). No new authentication code. |
| V3 Session Management | yes | OAuth bearer JWT per request; no session state on admin_api. JWT lifetime managed by Arda. |
| V4 Access Control | yes | `requireScopeAndPolicy({ scopes: ['website:write'\|'website:read'], policies: [Admin] })` on every `/admin/site/*` endpoint. Public `/site/pages.json` is read-only and contains no PII — intentionally unauthenticated. |
| V5 Input Validation | yes | `zod` schemas for every request body. Path validation against reserved-paths allowlist. Slug must match `[a-z0-9-]+` (lowercase, hyphens only). Manifest snapshot validated against JSON schema before insert. |
| V6 Cryptography | yes | No new cryptography — JWT verification handled by Arda's existing middleware. Presigned URL signing handled by AWS SDK SigV4. Never hand-roll. |
| V8 Data Protection | partial | Audit log retains OAuth subject + client_id; treat as PII-adjacent. Snapshot JSONB may contain page titles/descriptions — never user PII by design. |
| V9 Communication | yes | All endpoints HTTPS-only via existing LB termination. CORS umbrella on `apps/api` already allowlists `bigscreenvr.com` (per CONTEXT.md §Established Patterns). |
| V11 Business Logic | yes | Reserved-paths allowlist prevents production-outage paths. Per-actor rate limit prevents runaway agent. Read-after-write asset reachability check prevents broken-publish race. |
| V12 Files and Resources | yes | Presigned URLs scoped to one specific S3 key (not bucket-wide). 15-min TTL. Bucket has OAC-only access (Phase 2 enforces; this phase mints URLs assuming OAC). |
| V13 API and Web Service | yes | Structured error contract (`{code, message, suggestion, docs_url}`) is the public API for LLM consumers. Versioning via `version` field on `/site/pages.json` response. |

### Known Threat Patterns for {Node + Postgres + AWS SDK}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection in slug/manifest fields | Tampering | Parameterized queries via `pg`/`pg-promise` — never string-interpolate user input into SQL |
| Reserved-path bypass via Unicode / case / trailing slash | Tampering | Lowercase + trim before allowlist check; reject mixed-case slugs at validate time (per PITFALLS.md #20) |
| Token theft via XSS on bigscreenvr.com | Spoofing | Phase 1 admin_api never sets cookies. JWT lives in CLI process only. Public `/site/pages.json` requires no token. |
| Replay attack on `publish` | Tampering | OAuth JWT has short TTL (managed by Arda); presigned URLs 15-min TTL; idempotent publish keyed by `(slug, sha)` so a replay either no-ops or fails the asset-reachability check |
| Audit log tampering | Repudiation | Append-only by convention (no UPDATE/DELETE in code paths); planner may add a DB-level CHECK or move audit to a separate role with insert-only grant if cloud convention requires |
| Rate-limit DoS by hitting an endpoint while logged-out | DoS | `requireScopeAndPolicy` rejects before reaching rate-limiter; logged-out requests don't consume rate-limit budget |
| Presigned URL leakage | Information Disclosure | URLs are single-key-scoped + short-TTL; logged at INFO not DEBUG; never returned over insecure channels |
| Cross-actor rate-limit gaming (alternate OAuth clients) | Tampering | Single OAuth client `bsweb-cli` for v1 (per D-07); per-actor differentiation is claim-only (not server-enforced). Acceptable trade-off given single-user (Max) scope. |

### Phase-Specific Security Notes

- The `actor_claim` field is intentionally NOT a security boundary in v1 (D-07: `verified: false`). The audit log records claim verbatim for forensic value; do not surface "verified" agent vs human distinction in UI or downstream decisions until v2 hardening adds per-client OAuth distinction.
- `apps/admin_api` is IP-restricted at SG level today. FND-03 closure must record the network path the CLI will use — until then, treat the OAuth scope check as the auth boundary, not the SG. Document explicitly in Verification Memo.
- Public `/site/pages.json` returns slugs + paths + manifest metadata. This data is intentionally public (it's literally the routing table for publicly-visible pages). Confirm no manifest field carries internal-only data before publishing.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Cloud monorepo's `apps/db_setup` uses a specific migration convention (sequential file naming or migrations table) | Pattern 1, Pitfall 7 | If convention differs, `site_db_setup.ts` won't be picked up by the dev migration runner |
| A2 | Cloud monorepo's `requireScopeAndPolicy` middleware accepts the exact `{ scopes: string[], policies: Policy[] }` shape | Pattern 2 | If signature differs, all six route mounts need to change shape |
| A3 | `Admin` is exported from a known location like `cloud/auth/policies` | Pattern 2 | Import path may differ; verify before coding |
| A4 | `apps/api` already has CORS configured for bigscreenvr.com (per CONTEXT.md §Established Patterns) | REG-08 / Pattern 3 | If not, need new CORS rule on apps/api before SPA can call `/site/pages.json` |
| A5 | `zod`, `rate-limiter-flexible`, `pg`/`pg-promise` versions match what cloud monorepo currently uses | Standard Stack | Re-adding the wrong version causes lock churn / build break |
| A6 | LocalStack S3 presigned-URL flow works identically to real S3 with `forcePathStyle: true` | Code Example (Presign) | Some SigV4 corners differ; verify via integration test |
| A7 | Arda OAuth provider already supports PKCE-loopback redirect URIs (`http://127.0.0.1:*/callback`) | FND-02 closure scope | If not, Arda team must add support OR fall back to device flow (which also needs Arda changes) |
| A8 | Arda's `/oauth/device_authorization` endpoint either exists or is feasible to add | FND-02 closure scope | If neither flow is feasible, CLI auth design needs a different path (e.g., long-lived API key — much worse posture) |
| A9 | `apps/admin_api`'s SG can be opened for path-based ALB routing on `/admin/site/*` and `/admin/oauth/*` without unacceptable risk | FND-03 closure scope | If SG must remain closed, CLI must proxy through `apps/api` (more plumbing) or require VPN |
| A10 | `bigscreen10`'s built `dist/` exists locally for the prototype | FND-04 prototype | If not, prototype researcher must build it from source first — adds ~30 min |
| A11 | `react-shadow@20.6.0` works on React 17 (this repo's current version) | Pattern 4 prototype | Peer deps list `^17.0.0` so should be fine; falsifiable in 5 min |
| A12 | Postgres `JSONB` is the right type for `manifest_snapshot` (vs `TEXT`) | Schema | JSONB allows querying via GIN index later; TEXT is simpler. Either works; planner picks based on cloud convention. |
| A13 | The single bsweb OAuth client suffices for v1 (no per-actor clients) | D-07 | Locked decision; risk only if security review later requires distinction |
| A14 | Postgres version on cloud dev (15.x or 16.x) supports all SQL features used in the migration | Schema | All features in the schema above are Postgres 12+; should be safe |
| A15 | Rate-limit threshold of 30 writes / 5 min is appropriate for Max's workflow | D-12 | Locked but defer-to-planner — adjust based on actual publish cadence later |

**If this table is empty:** Not applicable — there are 15 assumed claims, all of which need verification either during planning (cloud monorepo conventions A1–A6, A12, A14) or during the Verification Memo phase (A7–A10, A13).

## Open Questions (DEFERRED — closed by Plan 01-01 Task 1 VERIFICATION-MEMO)

These map 1:1 onto the FND-01..06 Verification Memo gates. Each must be answered (with confidence level) before its dependent phase work can proceed.

1. **FND-01: CloudFront distribution config**
   - What we know: One CloudFront distribution fronts www.bigscreenvr.com. Existing config inherits CRA-deploy defaults including 403/404 → /index.html error response.
   - What's unclear: Distribution ID, exact behavior ordering, where config is managed (Terraform / devops repo / Jenkins-managed JSON).
   - Recommendation: First investigation pass — grep `cloud` monorepo + adjacent devops repos for `cloudfront`, `distribution`, `DistributionConfig`. If nothing found, escalate to cloud-infra owner with a specific question list (per D-01).

2. **FND-02: OAuth flow choice (PKCE-loopback vs device-flow)**
   - What we know: Arda OAuth provider is shipped (cloud plan 14). Arda supports PKCE. Arda does NOT currently expose `/oauth/device_authorization` (per SUMMARY.md §Gaps).
   - What's unclear: Whether PKCE-loopback is acceptable given Max's workflow (always has a local browser?), or whether device-flow is worth the Arda backend additions.
   - Recommendation: Default to PKCE-loopback unless Max's workflow excludes it (e.g., SSH-only context). PKCE-loopback requires zero Arda changes; device-flow requires Arda to expose `/oauth/device_authorization` + `/oauth/token` (device grant) + a `/device` confirmation web view.

3. **FND-03: admin_api network reachability for CLI traffic**
   - What we know: `apps/admin_api` is IP-restricted at SG level today (per ARCHITECTURE.md §Integration Points). Three options exist: (A) SG-open for path-based routing of `/admin/site/*` + `/admin/oauth/*`; (B) proxy through `apps/api`; (C) require Max on VPN.
   - What's unclear: Which path the cloud-infra owner is willing to operate.
   - Recommendation: Option (A) is the cleanest engineering path and preserves the read/write service split. Option (C) is acceptable for v1 (single user) but kicks the can on multi-user. Document trade-off in the Memo and let cloud-infra pick.

4. **FND-04: Render-layer (Shadow DOM prototype outcome)**
   - What we know: Decision locked at Shadow DOM web component (D-04). `react-shadow@20.6.0` supports React 17/18. `bigscreen10` is plain static HTML + CSS + JS.
   - What's unclear: Visual fidelity, scroll behavior, font/reset re-import strategy, and asset-path resolution under nested URLs — all answered by running the 1-day prototype.
   - Recommendation: Run prototype against locally-built `bigscreen10` first. Document results in Verification Memo with screenshots.

5. **FND-05: Reserved-paths allowlist**
   - What we know: Seed list LOCKED in CONTEXT.md Specifics + verbatim extraction above from `src/App.js:114-160`.
   - What's unclear: Storage mechanism (TS const vs JSON vs DB table). Per D-11 the planner picks. Recommendation: **TS const** for v1 (simplest, version-controlled, code-reviewable). Deferred-to-DB only if marketing needs add reserved paths without a deploy.
   - Recommendation: Implement as `reservedPaths.ts` (see Code Examples above), reviewed in PR like any other code change.

6. **FND-06: Registry SPOF mitigation strategy**
   - What we know: PITFALLS.md #2 spells out the strategy: bundled fallback registry + SWR caching + circuit-breaker. Phase 1 documents; Phase 3 implements.
   - What's unclear: How "bundled fallback registry" is generated — at SPA build time from a snapshot of `/site/pages.json`? Or hardcoded in a TS file the developer updates manually?
   - Recommendation: Add a CI step (Phase 3) that fetches `/site/pages.json` from staging at SPA build time and emits a `src/_fallback-registry.json`. SPA imports both; the live one augments the bundled one. Phase 1 closure just commits to this approach, doesn't implement.

## Sources

### Primary (HIGH confidence)
- `.planning/research/SUMMARY.md` — Executive synthesis; Phase 1 rationale
- `.planning/research/PITFALLS.md` — Phase 1 gate pitfalls #1–#4 + #6, #7
- `.planning/research/ARCHITECTURE.md` — Three-service split; `site_pages` Pattern 1; admin_api reachability open question
- `.planning/research/STACK.md` — Library choices (AWS SDK v3, rate-limit, presign)
- `.planning/research/FEATURES.md` — Reserved-paths feature treatment; manifest schema fields
- `.planning/codebase/ARCHITECTURE.md` — Existing SPA dispatcher (`src/App.js:114-160`)
- `.planning/codebase/CONCERNS.md` — Security posture, react-scripts deprecation, no CSP today
- `.planning/codebase/INTEGRATIONS.md` — Existing API surface (BigApi, no website-side backend)
- `.planning/phases/01-foundations-registry-oauth/01-CONTEXT.md` — All locked decisions
- `.planning/phases/01-foundations-registry-oauth/01-DISCUSSION-LOG.md` — Alternatives considered
- `src/App.js:114-160` — Reserved-paths verbatim extraction
- npm registry — package version verification (2026-05-21)
- `npm view react-shadow peerDependencies` — confirmed React 16/17/18/19 + react-dom support

### Secondary (MEDIUM confidence)
- [Quick Peek: React App Mounted on a Shadow DOM Root](https://dev.to/alexkhismatulin/quick-peek-react-app-mounted-on-a-shadow-dom-root-49m6) — patterns for shadow root mounting under React 18
- [react-shadow on npm](https://www.npmjs.com/package/react-shadow) — package overview
- [Render React element inside shadow DOM in React v18](https://gourav.io/blog/render-react) — alternative patterns
- [GitHub - Wildhoney/ReactShadow](https://github.com/Wildhoney/ReactShadow) — source repo

### Tertiary (LOW confidence — explicitly flagged for Verification Memo closure)
- CloudFront distribution config — UNKNOWN location; FND-01 must close
- Arda OAuth device-flow vs PKCE-loopback support — FND-02 must close
- admin_api SG reachability path — FND-03 must close
- `bigscreen10` CSS behavior inside shadow root — FND-04 prototype must close
- Exact migration convention in `apps/db_setup` — verify against cloud repo before coding
- Exact `requireScopeAndPolicy` middleware signature — verify against cloud repo's `OAuthClientsApi.ts` before coding
- Cloud monorepo's existing test runner / framework — verify before writing `tests/site/*`

## Metadata

**Confidence breakdown:**
- Architecture (cloud-monorepo three-service split, `site_pages` schema, audit pattern) — HIGH — locked in CONTEXT.md and read directly from `.planning/research/ARCHITECTURE.md` which itself was sourced from cloud monorepo docs
- Standard stack (AWS SDK v3, rate-limit, zod) — MEDIUM — versions verified on npm but `[ASSUMED]` per protocol; planner must gate installs behind checkpoint
- Shadow DOM mount approach — MEDIUM — `react-shadow` peer deps verified; prototype outcome will move to HIGH
- Reserved paths seed list — HIGH — verbatim extraction from `src/App.js:114-160`
- Pitfalls — HIGH — derived from existing PITFALLS.md plus codebase evidence
- External system unknowns (FND-01..06) — LOW — by design; that's why the Verification Memo exists

**Research date:** 2026-05-21
**Valid until:** 2026-06-20 (30 days for backend slice; the Verification Memo gates may invalidate sooner if external systems change)
