# Architecture Research

**Domain:** Dynamic page registry + static-page publishing pipeline on top of an existing CRA SPA at `www.bigscreenvr.com`, integrated with the Arda OAuth provider already shipped in the `cloud` monorepo.
**Researched:** 2026-05-21
**Confidence:** HIGH for current cloud-repo architecture (read directly from `cloud/docs/architecture.md`, `cloud/docs/services/oauth.md`, `cloud/docs/external-services.md`, `cloud/docs/webapps/arda.md`). MEDIUM for the live website hosting model — the `website` SPA repo has no committed hosting config (no `vercel.json`, `cloudfront.json`, Dockerfile, etc.) and the `cloud` repo does not own it; CloudFront + S3 is inferred from `cloud-browser-static/README.md` conventions and the SPA build shape, but the actual distribution lives outside both repos (devops repo + Jenkins).

## Standard Architecture

### System Overview — Recommended target state

```
┌──────────────────────────────────────────────────────────────────────────────┐
│                              MAX'S WORKSTATION                                │
│  ┌───────────────────────┐                                                    │
│  │  Claude Code skill    │  manifest.json, page assets in static-pages repo   │
│  │  + bsweb CLI          │  authored as code (git is version of record)      │
│  └───────────┬───────────┘                                                    │
└──────────────┼────────────────────────────────────────────────────────────────┘
               │ 1. OAuth 2.0 Authorization Code + PKCE
               │    (device-flow-style: browser opens arda consent UI)
               │ 2. Bearer JWT (scope: site:publish)
               ▼
┌──────────────────────────────────────────────────────────────────────────────┐
│                         CLOUD MONOREPO (existing)                            │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────────┐    │
│  │   apps/api       │  │   webapps/arda   │  │   apps/admin_api         │    │
│  │   :3009          │  │   :3010          │  │   :3999 (IP-restricted)  │    │
│  │                  │  │                  │  │                          │    │
│  │ /oauth/token     │  │ /oauth/authorize │  │ /admin/oauth/clients     │    │
│  │ /.well-known/*   │  │ /oauth/decision  │  │ /admin/site/pages  NEW   │    │
│  │                  │  │                  │  │ (page registry CRUD)     │    │
│  └────────┬─────────┘  └────────┬─────────┘  └────────┬─────────────────┘    │
│           │                     │                     │                      │
│           └─────────────────────┴─────────────────────┴──────────────────┐   │
│                                                                          │   │
│                            ┌─────────────────────────────────┐           │   │
│                            │  Postgres (auth/fabricator DB)  │◄──────────┘   │
│                            │  oauth_clients, oauth_grants    │               │
│                            │  site_pages           NEW       │               │
│                            └─────────────────────────────────┘               │
│                            ┌─────────────────────────────────┐               │
│                            │  S3: bigscreen-static-pages NEW │               │
│                            │  /<slug>/<sha>/index.html...    │               │
│                            └─────────────────────────────────┘               │
└──────────────────────────────────────────────────────────────────────────────┘
               ▲                                  ▲
               │ Bearer-key path (apps/api)       │ S3 PutObject
               │ for read-only registry list      │ (presigned URL from
               │                                  │  admin_api OR direct
               │                                  │  SDK call from CLI)
               │                                  │
┌──────────────┴──────────────────────────────────┴────────────────────────────┐
│                          CDN  (CloudFront)                                   │
│                          www.bigscreenvr.com                                 │
│                                                                              │
│  Default behavior:        /*                  → S3 SPA origin (index.html)   │
│  NEW carve-out behavior:  /static-pages/*     → S3 static-pages origin       │
│  NEW carve-out behavior:  /api/site/pages.json → apps/api (registry feed)    │
└──────────────────────────────────────────────────────────────────────────────┘
               ▲                                  ▲
               │ user visits /10years             │ SPA fetches registry
               │                                  │ at runtime
               ▼                                  ▼
┌──────────────────────────────────────────────────────────────────────────────┐
│                        BROWSER (Create-React-App SPA)                        │
│  src/index.js → <App/>                                                       │
│  ├── existing dispatcher branches (Builder.io, BigOrder, Scan, ...)          │
│  └── NEW <DynamicPageRoute>                                                  │
│        ├── fetches /api/site/pages.json (cached + revalidated)               │
│        ├── matches pathname against registry                                 │
│        ├── wraps content in <Page> (Header + Footer) — site chrome           │
│        └── injects/embeds the static page (decision below)                   │
└──────────────────────────────────────────────────────────────────────────────┘
```

### Component Responsibilities

| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| **`bsweb` CLI** (new) | Authenticates Max, packages a page directory from `static-pages` repo, uploads assets to S3, calls registry API to mint/update a route. | Node.js binary (`bin/bsweb`) installable via `npm i -g @bigscreen/bsweb`. Auth via OAuth 2.0 device-authorization-grant-style: opens local callback server + browser to `https://www.bigscreenvr.com/oauth/authorize` (which proxies to arda). Stores refresh token in OS keychain (or `~/.config/bsweb/credentials.json` chmod 600). |
| **Page registry service** (new, in `apps/admin_api`) | Owns `site_pages` table. CRUD over `/admin/site/pages/*`. Validates manifest, allocates S3 prefix `<slug>/<sha>/`, returns presigned upload URL set (or accepts a multipart upload). Emits a read-only JSON snapshot consumed by the SPA. | New file `api/src/site/SitePagesApi.ts` + `apps/admin_api` route registration. Reuses `AuthApi.requireScopeAndPolicy({ scopes: ["website:write", "admin:all"], policies: [Admin] })`. Snapshot endpoint exposed via `apps/api` so it inherits public-CORS + bigscreenvr.com origin allow-list. |
| **Static-page storage** (new S3 bucket) | Stores immutable `<slug>/<sha>/...` trees. Old shas retained for rollback. Lifecycle policy: never expire current sha, expire shas not referenced by registry after 30 days. | `bigscreen-static-pages-prod` bucket, private with CloudFront OAC (origin access control). Server-side default-encrypted (SSE-S3). Object key format `<slug>/<sha>/<path>`. |
| **CloudFront behaviors** (modified) | Two new ordered behaviors before the catch-all SPA behavior: (a) `/static-pages/*` → static-pages S3 origin with `s3-origin-rewrite` function stripping the `/static-pages/<slug>` prefix to `<slug>/<currentSha>/`; (b) `/api/site/pages.json` → `apps/api` origin with short TTL (60s) and stale-while-revalidate. Default catch-all unchanged: serves `/index.html` for SPA. | Existing CloudFront distribution gets two new behaviors. The sha-rewrite needs a CloudFront Function or Lambda@Edge that reads `<slug> → <currentSha>` from a small KV (CloudFront KV Store or origin-shielded `pages.json`). |
| **SPA dynamic route layer** (new in `src/`) | Replaces the regex anti-pattern for *new* pages while leaving Builder.io / auth / scan branches untouched. Fetches registry at runtime, matches pathname, decides how to render the matched page. | New `src/components/DynamicPage/` directory: `useDynamicPageRegistry.js` hook (SWR-style fetch + cache + revalidate-on-focus), `<DynamicPage>` component that resolves the route and renders the page inside `<Page>` (existing layout wrapper from `src/components/Page/index.js`). |
| **Existing Arda OAuth provider** (reused, no changes for v1) | Authorizes the CLI; mints PKCE-bound authorization codes; exchanges for RS256 access + refresh tokens; enforces scopes + policy. Already shipped under plan 14. | `apps/api` `/oauth/token`, `webapps/arda` `/oauth/authorize`, `apps/admin_api` `/admin/oauth/clients/*` + `handleOAuthBearer`. New scope `website:write` (and read counterpart `website:read`) added to `auth/OAuthScopes.ts` with `Admin` (or new `SiteEditor`) as the required `AccessPolicy`. |
| **`site_pages` Postgres table** (new) | Source of truth for which URL paths exist, which sha is current, and metadata (wrapper preference, content-type, owner, audit). | Migration added to `apps/db_setup`. Columns: `slug TEXT PK`, `currentSha TEXT`, `manifest JSONB`, `wrapper TEXT`, `mountMode TEXT` ('inject' \| 'iframe'), `createdAt`, `updatedAt`, `createdBy`, `updatedBy`. |

## Recommended Project Structure

Three repos cooperate. Boundaries match existing org structure — do not collapse them.

```
# Repo 1 — this repo (website SPA)
website/
├── src/
│   ├── App.js                    # KEEP. Add DynamicPageRoute branch ABOVE existing filters
│   ├── components/
│   │   ├── DynamicPage/          # NEW
│   │   │   ├── index.js          # <DynamicPage slug={...} entry={...}>
│   │   │   ├── useRegistry.js    # SWR-like hook reading /api/site/pages.json
│   │   │   ├── InjectMount.js    # innerHTML + base href + scoped CSS approach
│   │   │   └── IframeMount.js    # alt approach for CSS isolation
│   │   └── Page/                 # EXISTING — used as the wrapper
│   └── config.js                 # Add REACT_APP_SITE_REGISTRY_URL

# Repo 2 — cloud (existing monorepo)
cloud/
├── api/src/site/                 # NEW backend module
│   ├── SitePagesApi.ts           # CRUD + presigned upload + read snapshot
│   ├── SitePagesDatabase.ts      # SQL helpers
│   └── SitePagesSchemas.ts       # TypeScript types
├── apps/admin_api/admin_api.ts   # REGISTER routes under /admin/site/*
├── apps/api/api.ts               # REGISTER read-only /site/pages.json under
│                                  # the bigscreenvr.com CORS umbrella
├── apps/db_setup/site_db_setup.ts # NEW table migration
└── auth/OAuthScopes.ts           # ADD website:read, website:write

# Repo 3 — static-pages (new repo)
static-pages/
├── 10years/                      # First page
│   ├── manifest.json             # { slug, wrapper, mountMode, build?, ... }
│   ├── index.html
│   ├── style.css
│   ├── script.js
│   └── assets/
├── (future pages)/
└── .github/workflows/publish.yml # CI runs `bsweb publish ./<dir>` on merge

# Repo 4 — bsweb (new repo OR yarn workspace inside cloud)
bsweb/
├── src/
│   ├── auth.ts                   # OAuth PKCE device-style flow
│   ├── upload.ts                 # multipart S3 upload (or via presigned URLs)
│   ├── publish.ts                # orchestrates: validate manifest, upload, register
│   └── commands/
│       ├── login.ts
│       ├── publish.ts
│       ├── ls.ts
│       └── rollback.ts
└── package.json                  # bin: bsweb
```

### Structure Rationale

- **Three repos, not one:** The SPA, the backend, and the page content each have different deploy cadences and different reviewers. The SPA needs Jenkins (security). The backend follows the cloud monorepo's existing build process. Page content flows daily through Max's CLI without code review.
- **Backend goes into `apps/admin_api`, not a new service:** OAuth already lives there. The bearer-verification middleware (`handleOAuthBearer`), scope enforcement (`AuthApi.requireScopeAndPolicy`), audit log, and IP allow-list are all already wired. Standing up a new service would duplicate all of that and double the ops surface.
- **Snapshot endpoint goes on `apps/api`, not `apps/admin_api`:** The SPA reads the registry from the *public* internet, with no user logged in for a marketing page render. `apps/admin_api` is IP-restricted (Bigscreen office only). `apps/api` is the only service with a public LB and the bigscreenvr.com CORS allow-list. The snapshot is a read-only projection — `apps/admin_api` writes, `apps/api` reads.
- **`bsweb` as separate repo (or yarn workspace), not inside `static-pages`:** Versions independently of content; can be `npm i -g`'d by Max once and forgotten. Putting CLI code into the content repo conflates "I am editing the framework" with "I am editing my page."

## Architectural Patterns

### Pattern 1: Live Registry, Immutable Content

**What:** The page registry table is the single mutable pointer; uploaded assets are immutable under `<slug>/<sha>/`. Publishing a new version uploads to a new sha prefix, then atomically swaps the `currentSha` column. Rollback is reverting the column to a previous sha.

**When to use:** Any deploy model where (a) the SPA cannot wait for an SPA rebuild, (b) you want rollback in seconds, (c) cache invalidation is a concern.

**Trade-offs:**
- (+) CloudFront caches `<slug>/<sha>/` paths forever — no purge needed, ever.
- (+) Rollback is a single SQL UPDATE.
- (–) Adds an indirection (CloudFront Function or origin-shielded `pages.json` lookup) to rewrite `/static-pages/10years/foo.png` → `s3://bucket/10years/<sha>/foo.png`.
- (–) Storage cost grows monotonically until lifecycle policy kicks in.

**Example (registry pointer swap):**
```typescript
// In SitePagesApi.ts
await db.tx(async (t) => {
  await t.none(
    "INSERT INTO site_pages (slug, currentSha, manifest, updatedAt, updatedBy) " +
    "VALUES ($(slug), $(sha), $(manifest), $(now), $(actor)) " +
    "ON CONFLICT (slug) DO UPDATE SET currentSha = EXCLUDED.currentSha, " +
    "manifest = EXCLUDED.manifest, updatedAt = EXCLUDED.updatedAt, updatedBy = EXCLUDED.updatedBy",
    { slug, sha, manifest, now: Date.now(), actor: req.currentAccount.id }
  );
  await t.none(
    "INSERT INTO site_pages_audit (slug, sha, action, actor, at) VALUES ($1, $2, $3, $4, $5)",
    [slug, sha, "published", req.currentAccount.id, Date.now()]
  );
});
```

### Pattern 2: SPA Catches Through-Routes (No CloudFront Behavior Required for v1)

**What:** For the *initial* `/10years` flat-URL milestone, the simplest model is: CloudFront keeps its catch-all behavior serving `index.html` (the SPA), the SPA dispatcher recognizes `/10years` via the new `DynamicPageRoute` branch, fetches the registry, and either injects the page's HTML or mounts an iframe pointing at the asset URL on the static-pages CDN path.

**When to use:** First milestone. Reduces CloudFront config changes to a minimum (only `/api/site/pages.json` and optionally `/static-pages/*` need new behaviors).

**Trade-offs:**
- (+) Lowest-risk path through existing infra; no CDN behavior ordering bugs.
- (+) Site chrome (Header/Footer from `<Page>`) wraps the new page naturally because rendering stays in the SPA tree.
- (–) Loading a static HTML inside React introduces the "two HTML documents" problem (see Pitfall 3 below).
- (–) Slightly slower first paint than serving the static HTML directly via CloudFront — the SPA has to boot first.

**Example (dispatcher branch added in App.js):**
```javascript
// src/App.js — added BEFORE the existing builderIoFilter branch
const { match, page } = useDynamicPageMatch(pathname);  // hook reading registry
if (match) {
  return (
    <Page account={account} localeState={localeState}>
      <DynamicPage page={page} />
    </Page>
  );
}
// ... existing branches unchanged
```

### Pattern 3: Inject vs Iframe — Inject for v1, Iframe Documented as Escape Hatch

**What:** The mount mode is declared in the manifest (`mountMode: "inject" | "iframe"`). For `/10years` v1, **inject** is the recommendation: fetch the page's `index.html`, extract its `<body>` content, set it as `innerHTML` inside a `<div>` wrapped by `<Page>`. CSS files from the page are injected as `<link>` tags into `<head>` with a `data-bsweb-page="10years"` attribute (so they can be torn down when the user navigates away). Scripts are run via dynamic `<script>` injection.

**When to use:** Most pages — preserves site chrome cohesion, lets the page participate in client-side navigation, share fonts, etc.

**Trade-offs (inject):**
- (+) Single document, single scroll, site chrome works.
- (–) CSS collisions: page's `.button` may clash with SPA's `.button`. Mitigation: build step prefixes selectors with `[data-bsweb-page="10years"]`. For bigscreen10 specifically the existing CSS uses generic selectors — this is the highest-risk integration item.
- (–) JS pollution: page scripts may grab `window`, install global handlers. Mitigation: wrap each script in an IIFE during the build, and call manifest-declared `onUnmount` when navigating away.

**Trade-offs (iframe):**
- (+) Perfect CSS isolation.
- (–) Breaks single-scroll experience; sizing is awkward; site chrome wraps but feels detached.
- (–) Cross-frame messaging needed if the page wants to know it's embedded.

**Example (inject mount):**
```javascript
// src/components/DynamicPage/InjectMount.js
useEffect(() => {
  const root = containerRef.current;
  // Fetch the page bundle
  fetch(`/static-pages/${page.slug}/index.html`)
    .then(r => r.text())
    .then(html => {
      const doc = new DOMParser().parseFromString(html, "text/html");
      // Inject stylesheets, scoped via attribute selector
      doc.querySelectorAll("link[rel=stylesheet], style").forEach(node => {
        const clone = node.cloneNode(true);
        clone.setAttribute("data-bsweb-page", page.slug);
        document.head.appendChild(clone);
      });
      root.innerHTML = doc.body.innerHTML;
      root.setAttribute("data-bsweb-page", page.slug);
      // Execute scripts in order
      doc.querySelectorAll("script").forEach(s => {
        const tag = document.createElement("script");
        if (s.src) tag.src = s.src;
        else tag.textContent = s.textContent;
        tag.setAttribute("data-bsweb-page", page.slug);
        root.appendChild(tag);
      });
    });
  return () => {
    // Tear down on unmount
    document.querySelectorAll(`[data-bsweb-page="${page.slug}"]`)
      .forEach(n => n.remove());
  };
}, [page.slug]);
```

### Pattern 4: OAuth-via-Browser-Loopback for CLI Auth

**What:** The CLI does not implement a custom auth flow. It opens a local HTTP server on a random port (e.g., `http://127.0.0.1:53917/callback`), constructs the same `/oauth/authorize` URL the existing OAuth provider serves, opens the user's browser, and waits for the redirect back. PKCE protects against code interception.

**When to use:** Any time a desktop tool needs to act on behalf of a human admin without storing the admin's password. This is the standard pattern (gcloud, aws sso, gh auth login).

**Trade-offs:**
- (+) Reuses the existing OAuth provider with zero backend changes (the OAuth client just gets registered with `redirect_uri: http://127.0.0.1:*/callback`).
- (+) Refresh-token rotation already implemented; CLI stores a long-lived refresh token, exchanges for short-lived access tokens on demand.
- (–) Requires Max to have an open browser session in arda. (Acceptable — Max already does.)
- (–) The OAuth client `redirectUris` field must allow `http://127.0.0.1:*` — confirm the existing validator does (RFC 8252 says it should; verify in `OAuthClientDatabase.ts` during phase 2).

**Example (CLI auth flow):**
```typescript
// bsweb/src/auth.ts
async function login() {
  const port = await findFreePort();
  const verifier = randomBytes(32).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  const state = randomBytes(16).toString("base64url");

  const url = new URL("https://www.bigscreenvr.com/oauth/authorize");
  url.searchParams.set("client_id", BSWEB_CLIENT_ID);
  url.searchParams.set("redirect_uri", `http://127.0.0.1:${port}/callback`);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("scope", "website:write");
  url.searchParams.set("state", state);

  const server = http.createServer(/* wait for /callback?code=...&state=... */);
  server.listen(port);
  open(url.toString());
  const { code } = await waitForCallback(server, state);

  // Exchange at apps/api directly (public endpoint)
  const tokens = await fetch("https://dev-fire-api.bigscreencloud.com/oauth/token", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      client_id: BSWEB_CLIENT_ID,
      code_verifier: verifier,
      redirect_uri: `http://127.0.0.1:${port}/callback`,
    }),
  }).then(r => r.json());

  saveTokens(tokens);  // OS keychain or ~/.config/bsweb/creds.json
}
```

## Data Flow

### Publish Flow — Max runs `bsweb publish ./10years`

```
[Max types `bsweb publish ./10years`]
    ↓
[bsweb CLI]
    ├── (a) Loads cached refresh token; if expired or absent → runs login flow
    │       opens browser → arda /oauth/authorize → Max clicks Allow →
    │       arda mints code → POST /oauth/token at apps/api → access+refresh tokens
    ├── (b) Reads ./10years/manifest.json; validates slug, mountMode, etc.
    ├── (c) Computes deterministic content sha (e.g., sha256 of tarball)
    ├── (d) Asks admin_api: POST /admin/site/pages/upload-url
    │       { slug: "10years", sha: "abc123..." }
    │       → admin_api authorizes (scope: website:write, policy: Admin)
    │       → returns N presigned PUT URLs (one per file) for s3://bigscreen-static-pages/10years/abc123.../
    ├── (e) PUTs each file to its presigned URL
    ├── (f) Asks admin_api: POST /admin/site/pages/publish
    │       { slug: "10years", sha: "abc123...", manifest }
    │       → admin_api validates all expected keys exist in S3
    │       → upserts site_pages row, audit-logs the event
    │       → returns { liveUrl: "https://www.bigscreenvr.com/10years" }
    └── (g) Prints success + URL to Max's terminal
```

### Render Flow — User visits `https://www.bigscreenvr.com/10years`

```
[Browser: GET https://www.bigscreenvr.com/10years]
    ↓
[CloudFront]
    ├── matches catch-all behavior /* (no /api/site, no /static-pages prefix)
    └── serves /index.html from SPA S3 origin (cached)
    ↓
[Browser: parses index.html, loads SPA bundle.js]
    ↓
[src/index.js → <App/>]
    ├── App calls useDynamicPageMatch(pathname)
    └── useDynamicPageMatch hits /api/site/pages.json (via CloudFront, cached 60s)
        ↓
        [CloudFront]
            └── matches /api/site/* behavior → forwards to apps/api origin
            ↓
        [apps/api GET /site/pages.json]
            ├── reads site_pages table (or in-memory cache, ~5s TTL)
            └── returns [{ slug: "10years", currentSha: "abc123",
                          mountMode: "inject", wrapper: "page" }, ...]
    ↓
[<App/> sees match for "/10years"]
    ├── returns <Page><DynamicPage page={...} /></Page>
    │            (Header + DynamicPage + Footer)
    ↓
[<DynamicPage> → InjectMount]
    ├── fetch("/static-pages/10years/index.html")
    │     ↓
    │   [CloudFront /static-pages/* behavior]
    │     ├── CF Function rewrites URL: /static-pages/10years/index.html
    │     │                              → s3://bigscreen-static-pages/10years/abc123/index.html
    │     └── serves from S3 (cached forever — content-hashed)
    │     ↓
    │   returns HTML body
    ├── parses, injects styles + body + scripts under [data-bsweb-page="10years"]
    └── page renders inside React's <Page> wrapper with site chrome
```

### State Management

```
[Server: site_pages table in Postgres]
    ↓ (HTTP GET, polled / SWR)
[CDN-cached JSON at /api/site/pages.json] ← 60s edge TTL + stale-while-revalidate
    ↓ (fetched once per SPA session, cached in React)
[<App/> registry context provider]
    ↓ (read by routing decision in dispatcher)
[<DynamicPageRoute>] → matches pathname → renders <DynamicPage>
```

No new global state library required. The registry is fetched once per page load (cached in the SPA module), refetched on focus.

### Key Data Flows

1. **Publish:** `bsweb` → OAuth → presigned S3 PUTs → POST `/admin/site/pages/publish` → DB row swap → snapshot ETag bumps within 5s.
2. **Render:** SPA load → registry fetch (cacheable) → `/static-pages/<slug>/...` fetch (immutable, sha-pinned) → DOM mount.
3. **Rollback:** Max runs `bsweb rollback 10years --to <previous-sha>` → POST `/admin/site/pages/rollback` → DB UPDATE → snapshot bumps → next render serves old sha. No S3 deletes, no CloudFront invalidation.
4. **Inspection:** `bsweb ls`, `bsweb show 10years` → GET `/admin/site/pages` and `/admin/site/pages/:slug/history` (audit log).

## Suggested Build Order

Phase order is bottom-up because every phase de-risks the next. **Do not start the CLI before the registry endpoint exists** — debugging an end-to-end flow against vapor is the largest time sink in this kind of work.

| # | Phase | Why this phase, this order |
|---|-------|---------------------------|
| 1 | **Registry data model + admin_api endpoints** (no auth gating yet — local-dev only) | The contract drives everything downstream. Build `site_pages` table, `SitePagesApi.ts`, exercised by `tests/site/*.ts` (ts-mocha) against local Postgres + LocalStack S3. Output: a CRUD API with stub auth. |
| 2 | **OAuth integration: scope + CLI client registration** | Add `website:read` / `website:write` to `OAuthScopes.ts`. Register a `bsweb-cli` OAuth client in `oauth_clients` (dev environment). Verify `127.0.0.1` redirect URIs are accepted. Switch admin_api endpoints to require `requireScopeAndPolicy({ scopes: ["website:write"], policies: [Admin] })`. Output: same CRUD endpoints, now properly gated. |
| 3 | **`bsweb` CLI MVP** (login + publish + ls) | Implements PKCE loopback auth, manifest validation, S3 upload, publish call. Test against the dev admin_api. Output: Max (or a developer) can run `bsweb publish ./10years` and see a row in the dev DB. No SPA changes yet. |
| 4 | **CloudFront `/static-pages/*` behavior + sha-rewrite Function** | Stand up the S3 bucket with OAC, add the CF behavior, write the CloudFront Function that does `<slug> → <currentSha>` rewrite. Tested by: `curl https://dev.bigscreenvr.com/static-pages/10years/index.html` returns the uploaded file. Snapshot endpoint at `/api/site/pages.json` added to apps/api at this point too. Output: static content reachable end-to-end, registry readable publicly. |
| 5 | **SPA `<DynamicPage>` + `<App/>` dispatch branch** | Add the branch ABOVE existing filters in `App.js` so a registry hit short-circuits the regex dispatcher. Build `useDynamicPageMatch` hook, `<InjectMount>`, CSS scoping. Test by visiting `/10years` on a dev SPA pointed at the dev registry. Output: `/10years` renders wrapped in Header/Footer in dev. **This phase is where the CSS-collision pitfall is exercised — schedule extra time.** |
| 6 | **bigscreen10 content adaptation + namespace-route variant** | Copy `bigscreen10/timeline/` into the new `static-pages/10years/` directory, write its `manifest.json`, prefix CSS selectors if needed. Add a sub-page (e.g., `/10years/intro`) to validate the namespace routing model. |
| 7 | **Production cutover + Jenkins coordination** | Promote the OAuth client to prod, deploy the new admin_api routes, add the CloudFront behaviors to the prod distribution, ship the SPA changes via Jenkins (one Jenkins deploy, the last one needed for the milestone). Smoke-test rollback. |
| 8 | **Claude Code skill + CLAUDE.md** | Document the CLI in a Claude Code skill so Max can issue natural-language deploy requests. Mostly documentation work; trivially achievable once the CLI is stable. |

**Critical dependencies in this ordering:**
- Phase 4 depends on phase 1 (snapshot reads the table).
- Phase 5 depends on phase 4 (SPA can't fetch a registry that doesn't exist publicly).
- Phase 6 depends on phase 5 (no point adapting content until the rendering pipe is live).
- Phase 2 (OAuth) could technically be deferred to after phase 5, but doing it early forces the auth contract to be honest — deferring "I'll add auth later" tends to leak a no-auth deploy path into prod.

## Scaling Considerations

| Scale | Architecture Adjustments |
|-------|--------------------------|
| 1-10 pages, 0-100 publishes/year | Current design suffices. Postgres + S3 + a 60s edge cache on the snapshot endpoint. No worker, no async pipeline. |
| 10-100 pages, daily publishes | Add a Redis cache in front of the snapshot endpoint (reuse the existing Redis the cloud services already depend on). Move audit log to the existing `oauth_audit_log` style append-only table for queryability. |
| 100+ pages, multiple editors per day | (a) Promote `SiteEditor` to a first-class `AccessPolicy` so non-Admin marketing staff can publish without admin privileges. (b) Move the manifest validation server-side into a build worker (the cloud monorepo already has `cloud_worker` patterns to copy). (c) Consider per-page CDN behaviors only if some pages need bespoke headers (SEO, security). |

### Scaling Priorities

1. **First bottleneck (probable): snapshot endpoint freshness vs SPA navigation expectations.** A 60s edge cache means a freshly-published page may take up to a minute to surface in the SPA. If Max wants instant feedback, the CLI can issue a CloudFront invalidation of `/api/site/pages.json` on every publish (cheap, single-path invalidation), OR the snapshot endpoint can carry an ETag and the SPA can `If-None-Match` on focus.
2. **Second bottleneck (eventually): inject mount's CSS isolation.** Once 5+ pages with overlapping class names exist, selector collisions become inevitable. Mitigate by formalizing the build-time selector-prefixing in `bsweb publish` (this is a hard pattern to enforce post-hoc, easy to bake in).
3. **Storage growth:** S3 storage at ~10 MB/page × 100 pages × 10 shas retained = 10 GB. Trivial cost (~$0.25/mo). Lifecycle rule to expire shas not referenced by registry for 30 days handles it.

## Anti-Patterns

### Anti-Pattern 1: Standing up a new microservice for the registry

**What people do:** Create a new `apps/site_api` workspace because "the website is its own domain."
**Why it's wrong:** OAuth wiring, audit logging, scope enforcement, IP allow-listing, secrets loading, CloudWatch logging — all of these are already configured on `apps/admin_api`. A new service costs ~2 weeks of repeated plumbing for zero benefit.
**Do this instead:** Add `api/src/site/SitePagesApi.ts` and register its routes on `apps/admin_api`. One module, one route registration, same security posture as every other admin endpoint.

### Anti-Pattern 2: Letting the SPA fetch the snapshot from admin_api directly

**What people do:** Point the SPA's registry fetch at `https://admin-api.bigscreencloud.com/admin/site/pages`.
**Why it's wrong:** `apps/admin_api` is IP-restricted at the security-group level. Public browsers cannot reach it. Also it requires the admin bearer key.
**Do this instead:** Mirror a read-only snapshot to `apps/api` (which has the public LB and the bigscreenvr.com CORS allow-list). Writes still go through admin_api; reads come from the public api.

### Anti-Pattern 3: Mutable S3 keys (`/static-pages/<slug>/...`)

**What people do:** Overwrite files at `s3://bucket/10years/index.html` on each publish.
**Why it's wrong:** CloudFront caches forever, so an overwrite either requires an invalidation per file (slow, expensive) or `Cache-Control: no-cache` (defeats CDN). Rollback requires re-uploading old content. Two clients viewing the page mid-publish see inconsistent JS/CSS.
**Do this instead:** Content-hash the upload path: `s3://bucket/<slug>/<sha>/<path>`. Old shas stay reachable for rollback. Make the registry pointer the only mutable thing. The CloudFront Function does the `<slug>` → `<sha>` lookup at edge.

### Anti-Pattern 4: Building Phase 5 (SPA dispatcher) before Phase 4 (CDN behavior)

**What people do:** Wire up `<DynamicPage>` against a mocked registry first because "the SPA work is more fun."
**Why it's wrong:** Mock-driven UI work hides the actual CSS-collision problem until the very last step, when there's no time to fix it. The whole point of phasing is to surface integration problems early.
**Do this instead:** Get a single real static file served through CloudFront, end-to-end, **before** writing the React mount component. Then the React work has a real fetch target and the CSS pain is visible from day 1.

### Anti-Pattern 5: Skipping content-hash on the manifest

**What people do:** Trust the CLI to pick a unique sha per publish, e.g. by timestamp.
**Why it's wrong:** Re-publishing identical content creates duplicate S3 storage; debugging "is this the same content?" becomes file-by-file diffing.
**Do this instead:** Sha is a deterministic hash of the page directory contents. Re-publishing identical content is a no-op (same sha, same row, no DB change).

## Integration Points

### External Services

| Service | Integration Pattern | Notes |
|---------|---------------------|-------|
| **apps/api** (existing) | New read endpoint `GET /site/pages.json`. CORS already allows bigscreenvr.com origin. | Trivial addition. Reuses the existing express server, no new infra. |
| **apps/admin_api** (existing) | New route module under `/admin/site/*`. Gated by `requireScopeAndPolicy`. | Same pattern as `OAuthClientsApi.ts`. Reuses every middleware. |
| **OAuth provider** (existing — plan 14 shipped) | New scopes `website:read` / `website:write` added to `OAuthScopes.ts`. New OAuth client `bsweb-cli` registered via existing arda Developers UI. | No code changes to the provider itself. Just data: scopes + a client row. |
| **CloudFront distribution** (existing) | Two new behaviors: `/api/site/pages.json` → apps/api origin (TTL 60s); `/static-pages/*` → static-pages S3 bucket via OAC + CloudFront Function for sha-rewrite. | Lives in the devops repo / Terraform. Confirm location during phase 1. |
| **S3** (new bucket) | `bigscreen-static-pages-prod` with OAC-only access (no public ACL). Lifecycle rule to expire orphaned shas after 30 days. | New bucket, new IAM role for admin_api to mint presigned PUT URLs. |
| **Postgres** (existing, fabricator pool) | New table `site_pages` + `site_pages_audit`. Migration in `apps/db_setup/site_db_setup.ts`. | Reuses the pool already wired into admin_api. |

### Internal Boundaries

| Boundary | Communication | Notes |
|----------|---------------|-------|
| `bsweb CLI` ↔ `apps/api` | HTTPS POST `/oauth/token` (token exchange) — public, no API key | Standard OAuth — already public per plan 14. |
| `bsweb CLI` ↔ `apps/admin_api` | HTTPS to `/admin/site/*` with `Authorization: Bearer <OAuth JWT>` | Goes through admin_api's public LB? **OPEN QUESTION** — see below. Admin_api is IP-restricted today; OAuth-bearer requests from arbitrary developer/marketer IPs need a path in. Option A: add a new ALB target group for `/admin/oauth/*` and `/admin/site/*` that bypasses the IP restriction (relying on OAuth scope + bearer for security). Option B: tunnel through arda (acceptable for v1 but extra hop). |
| `bsweb CLI` ↔ S3 | HTTPS PUT to presigned URLs | No long-lived AWS credentials in the CLI. Presigned URLs scoped to single object + 15-min TTL. |
| `apps/api` ↔ Postgres | Existing pool; read-only query | Hot path — cache in process for 5s. |
| `apps/admin_api` ↔ Postgres | Existing pool; read+write | Audit-logged. |
| `SPA` ↔ `apps/api` | `fetch('/api/site/pages.json')` via CloudFront | Same-origin (no CORS pain). |
| `SPA` ↔ static-pages S3 | `fetch('/static-pages/<slug>/...')` via CloudFront | Same-origin. CF Function rewrites to sha-pinned S3 key. |

**Open question on admin_api network reachability:** The current `cloud/docs/architecture.md` explicitly states `apps/admin_api` is IP-restricted at the security-group level. The OAuth provider doc says admin_api accepts OAuth bearers, but doesn't say whether the security group has been opened up for OAuth traffic from the public internet. **This must be confirmed in phase 1** — if admin_api remains IP-restricted, `bsweb publish` must either (a) be run from a Bigscreen-allowlisted IP (acceptable if Max is on the office VPN), or (b) traffic must be proxied through `apps/api` (more plumbing), or (c) the SG must be opened for `/admin/oauth/*` and `/admin/site/*` only via path-based ALB routing.

## Concrete Recommendations Summary

1. **Registry endpoint location:** `apps/admin_api` for writes (CRUD), `apps/api` for the public read snapshot at `/site/pages.json`. Reason: existing OAuth and policy enforcement already live in admin_api; public reads require apps/api's public LB + CORS umbrella. Do **not** create a new service.

2. **OAuth flow:** Authorization Code + PKCE with browser-loopback redirect on the CLI side. Refresh tokens stored in OS keychain (fall back to `~/.config/bsweb/credentials.json`, chmod 600). New scopes `website:read` and `website:write` added to `auth/OAuthScopes.ts`. For v1, gate behind existing `Admin` policy; promote `SiteEditor` to a first-class policy in a later milestone once usage patterns are clear.

3. **S3 layout:** Single bucket `bigscreen-static-pages-prod`, object keys `<slug>/<sha>/<path>`. CloudFront OAC, private bucket policy. Lifecycle rule expires shas not referenced by the registry after 30 days. Presigned PUT URLs minted by admin_api for the CLI to upload to.

4. **CDN routing:** Add **two** CloudFront behaviors, leave the existing catch-all in place:
   - `/api/site/pages.json` → apps/api origin, 60s edge TTL, stale-while-revalidate.
   - `/static-pages/*` → static-pages S3 origin with CloudFront Function rewriting `/static-pages/<slug>/<path>` to `<slug>/<currentSha>/<path>` (the CF Function reads from CloudFront KV Store keyed by slug, which the publish flow updates).
   - All other paths continue to hit the existing SPA `index.html` — the SPA absorbs `/10years` and any future flat-URL or namespace-URL static pages via the dynamic registry.

5. **Mount mode:** Inject by default (HTML extracted + CSS scoped via attribute selector). Iframe documented as an escape hatch when CSS isolation is strictly required.

6. **Build order:** Backend first (data model + endpoints), OAuth second (scopes + client), CLI third, CDN fourth, SPA dispatcher fifth, content sixth, prod cutover seventh, Claude Code skill last. Do not skip ahead; the integration problems live at the seams between phases 4-5.

## Sources

- `C:/Users/decid/Documents/projects/cloud/docs/architecture.md` — HIGH confidence on service topology, ports, auth tiers.
- `C:/Users/decid/Documents/projects/cloud/docs/services/oauth.md` — HIGH confidence on OAuth provider being shipped, PKCE, scope model, audit log, feature flags.
- `C:/Users/decid/Documents/projects/cloud/docs/external-services.md` — HIGH confidence on S3, Postgres, Redis usage and OAuth env vars.
- `C:/Users/decid/Documents/projects/cloud/docs/webapps/arda.md` — HIGH confidence on arda being the consent UI, proxy model, and OAuth client admin surface.
- `C:/Users/decid/Documents/projects/cloud/docs/workspaces.md` — HIGH confidence on `cloud/website/` being a shared client library, not a backend.
- `C:/Users/decid/Documents/projects/cloud/website/src/{api.js,config.js}` — direct inspection. Confirmed no website-side backend exists in cloud repo.
- `C:/Users/decid/Documents/projects/bigscreen10/deploy/{README.md,nginx-bigscreen10.conf}` — direct inspection. Confirmed bigscreen10 is plain static HTML on a VPS today, single index.html + assets directory.
- `.planning/codebase/ARCHITECTURE.md` and `.planning/codebase/INTEGRATIONS.md` — HIGH confidence on the SPA's current regex dispatcher, layout wrappers, and the absence of a hosting/CI config in the website repo.
- RFC 8252 (OAuth for Native Apps) and RFC 7636 (PKCE) — standard references for the CLI loopback flow. MEDIUM confidence from training; verifiable against the `auth/OAuthCodes.ts` source if needed.

---
*Architecture research for: dynamic page registry + static-page publishing pipeline*
*Researched: 2026-05-21*
