# Stack Research — Dynamic Page Registry + Static-Page Pipeline

**Domain:** Brownfield SPA augmentation — runtime page registry + Node CLI publishing pipeline + CloudFront/S3 static delivery wrapped in React layout
**Researched:** 2026-05-21
**Confidence:** HIGH on libraries and versions; MEDIUM on AWS infra specifics (CloudFront topology will be confirmed against the live `cloud` repo during Phase 1)

> **Reading order.** The highest-leverage decision is **how static page content actually reaches the screen** — iframe vs. inject vs. shadow DOM vs. web components. Skip to [§5 The Render-Layer Decision](#5-the-render-layer-decision-highest-leverage) if you have time for nothing else. Everything below it (registry shape, CLI, build hook, CloudFront topology) flows from that choice.

---

## TL;DR — Prescriptive Picks

| Concern | Pick | One-line rationale |
|---|---|---|
| Page render layer (in React layout) | **Sandboxed `<iframe srcdoc>` for first cut → migrate to same-origin `<iframe src>` once asset hosting is in place** | Bulletproof CSS/JS isolation. Page authors keep absolute-from-root paths working. Same-origin allows `postMessage` height sync; CSP-defended; no DOMPurify cat-and-mouse. |
| Iframe height auto-resize | **`@open-iframe-resizer/core` 6.x (MIT)** | Fork of the original after it relicensed to GPL/commercial in 2024. MIT-licensed, ~20 kB, drop-in. |
| Dynamic route discovery in React SPA | **Migrate the regex dispatcher to `createBrowserRouter` + `patchRoutesOnNavigation`** | The only first-party React Router v6 API for runtime-discovered routes. Works on react-router-dom **6.4+**; SPA is already on `^6.0.2` and trivially bumpable. |
| React Router version | **Stay on `react-router-dom` v6 (bump to latest 6.x, currently 6.28+)** | v7 has the API but forces a package rename + adds Framework Mode features the SPA doesn't need. Migration cost is not justified inside this milestone. |
| Page registry transport | **HTTP JSON endpoint owned by `cloud-api` (or whichever Arda backend already serves SPA config). Cached at edge with short TTL; CLI publishes by `POST /admin/pages` to invalidate.** | Reuses existing Arda OAuth boundary. JSON is trivially CDN-cacheable. No new infra. |
| Registry client fetch | **Plain `fetch` from `BigApi` with `stale-while-revalidate` semantics in CloudFront — no SWR/React Query library** | The SPA today uses zero state libraries (per `.planning/codebase/ARCHITECTURE.md`); a single startup fetch + cache fits the codebase. |
| Static page hosting | **Separate S3 bucket `bigscreen-static-pages` mounted under CloudFront behavior `/_pages/*` (versioned prefix `/_pages/<slug>/<sha>/...`). User-facing URL stays clean (`/10years`) — SPA fetches & embeds.** | Versioned prefix gives atomic deploys + instant rollback without S3 versioning gymnastics. Origin separation means SPA invalidations and page invalidations are independent. |
| CloudFront cache invalidation | **Invalidations are explicit — `aws-sdk` v3 `@aws-sdk/client-cloudfront` `CreateInvalidationCommand`** | First 1,000 invalidation paths per month are free; versioned prefixes mean we usually only invalidate the **registry JSON**, not the page assets. |
| CLI framework | **Commander.js 14.x** | 0 dependencies, ~25 ms cold start, simplest API, requires Node ≥20 (we already require ≥18.17). |
| OAuth flow for CLI | **RFC 8628 Device Authorization Grant** | Industry standard for CLIs (`gh auth login`, `aws sso login`). Browserless polling. Requires Arda team to expose `/oauth/device_authorization` + `/oauth/token`. |
| OAuth client library | **`openid-client` 6.x** | Certified OpenID Connect client; built-in device flow helper; one dependency. |
| Token storage | **`keytar` ❌ deprecated → `@napi-rs/keyring` 1.x** | OS keychain (macOS Keychain / Windows Credential Manager / libsecret). `keytar` is archived since 2023. |
| S3 upload from CLI | **`@aws-sdk/client-s3` + `@aws-sdk/lib-storage`** | `lib-storage` handles multipart and concurrency. CLI receives short-lived STS credentials from Arda after OAuth — never embeds long-term keys. |
| Optional build hook | **GitHub Actions runs in repo CI, NOT in our backend.** Manifest declares `"build": { "command": "npm run build", "outputDir": "dist" }`. CLI never executes arbitrary commands locally either — it uploads the **already-built** artifact. | Eliminates entire sandboxing problem class. We never run marketer-supplied build code on our servers. Bigscreen10 already builds in GH Actions; same pattern. |
| Claude Code skill packaging | **Anthropic Agent Skills format — `.claude/skills/publish-page/SKILL.md` with YAML frontmatter (`name`, `description`)** | Open standard (Dec 2025); adopted by Codex CLI & ChatGPT. SKILL.md ships in the `static-pages` repo so Max gets it automatically. |
| HTML sanitization (if we ever inject) | **`dompurify` 3.4.x** | The DOMPurify recommendation is conditional — we should *not* sanitize-and-inject; we should iframe. DOMPurify is here only as a fallback dependency for any small Builder-style RichText insertion in the registry payload. |

---

## 1. Page Registry — Endpoint + Client Fetcher

### Recommended Stack

| Technology | Version | Purpose | Why |
|---|---|---|---|
| Plain JSON over HTTPS, served by an existing Arda backend route | n/a | Live registry mapping URL paths → page descriptors | The SPA already speaks JSON to Arda via `BigApi`. Reuses superagent + Bearer auth. Zero new tech surface. |
| `createBrowserRouter` + `patchRoutesOnNavigation` | react-router-dom **6.28+** | Hook to fetch routes that aren't statically declared | First-party React Router v6 API. Stable since 6.9 (Apr 2023) under `unstable_` prefix, stabilized as `patchRoutesOnNavigation` in 6.20. ([Lazy Route Discovery](https://reactrouter.com/explanation/lazy-route-discovery)) |
| CloudFront edge caching with `stale-while-revalidate` on the registry endpoint | — | Sub-100ms reads at all PoPs; tolerates Arda backend hiccups | Registry JSON is small (< 50 KB even at 100s of pages); cache TTL 60s + SWR 60s means new pages appear within ~1 minute globally without manual invalidation. CLI can also explicitly invalidate `/api/v1/pages` after publish for instant propagation. |

### Registry Schema (proposed — locked at planning, not research)

```jsonc
// GET /api/v1/pages
{
  "version": "2026-05-21T18:00:00Z",
  "pages": [
    {
      "slug": "10years",
      "path": "/10years",
      "matchMode": "exact",          // "exact" | "prefix" — supports /10years vs /10years/*
      "renderer": "iframe",          // "iframe" | "builder" | "react"  (future-proof)
      "source": "https://static.bigscreenvr.com/_pages/10years/abc123/index.html",
      "wrap": "site-chrome",         // "site-chrome" | "bare"
      "title": "10 Years of Bigscreen",
      "deployedAt": "2026-05-21T17:45:00Z",
      "deployedBy": "max@bigscreenvr.com"
    }
  ]
}
```

### Why not GraphQL / tRPC / WebSocket-pushed updates

- **GraphQL:** overkill for a single read-mostly document.
- **tRPC:** ties registry types to a Node API which isn't where Arda lives.
- **WebSocket push:** registry changes are infrequent (Max publishes a page maybe once a week). Polling + CDN SWR is simpler, cheaper, and survives backend restarts gracefully.

### Why not React Query / SWR / Zustand

The SPA today has **no global state library** (`.planning/codebase/ARCHITECTURE.md` §State Management). The registry is fetched once on mount in `App.js`, stored in `useState`, prop-drilled to the new `<DynamicRouteHost>` component (or, after the router migration, fed into `createBrowserRouter` config). Adding React Query for this one resource doubles the bundle's data-layer surface area and isn't justified.

---

## 2. CloudFront + S3 Topology for Static Pages

### Recommended Setup

| Component | Choice | Notes |
|---|---|---|
| Hosting model | **Single CloudFront distribution, multiple S3 origins via behaviors** | One distribution = one cert, one config surface. ([AWS: multiple origins](https://repost.aws/knowledge-center/cloudfront-distribution-serve-content)) |
| SPA bundle origin | Existing S3 bucket (unchanged) | Behavior: default `*` → SPA bucket |
| Static-pages origin | **New S3 bucket `bigscreen-static-pages`** | Behavior: `/_pages/*` → static-pages bucket, OAC-secured |
| Origin Access Control | **OAC (not OAI)** | OAI is legacy; OAC supports SSE-KMS, all AWS regions, and is the only mode AWS recommends since 2022. ([OAC docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)) |
| Path layout in S3 | `/_pages/<slug>/<git-sha>/...` | Versioned prefix = atomic deploy. New SHA = new immutable folder. Rollback = registry update only. |
| Cache key for `/_pages/*` | URL-only, **long TTL (1 year)** with `Cache-Control: public, max-age=31536000, immutable` | Since SHA is in the path, content never changes for a given URL. |
| Cache key for `/api/v1/pages` | URL-only, **short TTL (60s) + stale-while-revalidate (60s)** | Tradeoff between freshness and resilience. |
| Invalidation strategy | **Almost never invalidate page assets** (immutable URLs). **Always invalidate `/api/v1/pages*`** on every CLI publish. | Stays under AWS's 1,000 free invalidations/month forever. |
| User-facing URL | `https://www.bigscreenvr.com/10years` | NEVER `https://www.bigscreenvr.com/_pages/...`. The `_pages` prefix is implementation; the SPA iframes the page in via the registry. |

### Why Versioned Prefix (Not S3 Versioning)

S3's built-in object versioning is fine for backup but **terrible for atomic multi-file deploys**: a microsite has 50+ files (HTML, CSS, JS, images) and you cannot atomically swap the set of "current" versions across all of them. A new `<sha>/` prefix per deploy gives you:

- Atomic visibility (registry flip = the only switch)
- Free rollback (registry flip back)
- Side-by-side comparison (preview before flipping)
- No race conditions during partial uploads (in-flight users keep hitting the old SHA)

This pattern is what Vercel, Netlify, and AWS Amplify do internally.

### Library: `@aws-sdk/client-s3` v3 + `@aws-sdk/lib-storage` v3

| Package | Version | Why |
|---|---|---|
| `@aws-sdk/client-s3` | 3.700+ | Modern, modular AWS SDK. Tree-shakable. |
| `@aws-sdk/lib-storage` | 3.700+ | `Upload` class handles multipart for large files and concurrency for many small files. |
| `@aws-sdk/client-cloudfront` | 3.700+ | `CreateInvalidationCommand`. |
| `@aws-sdk/credential-providers` | 3.700+ | `fromTemporaryCredentials` consumes the STS token Arda issues post-OAuth. |

### What NOT to Use

| Avoid | Why | Use instead |
|---|---|---|
| `aws-sdk` v2 | Maintenance mode since Sept 2024; full EOL late 2025 | `@aws-sdk/*` v3 |
| S3 static website hosting (HTTP only) | No HTTPS at origin → can't use OAC | OAC + private bucket |
| Origin Access Identity (OAI) | Legacy; missing features | OAC |
| CloudFront Lambda@Edge for path rewriting | Overkill, adds latency, costs money | CloudFront Functions for the small URL twist of stripping `/_pages/` if needed |

---

## 3. CLI Tooling for Marketers

### Recommended Stack

| Technology | Version | Purpose | Why |
|---|---|---|---|
| **Commander.js** | **14.0.x** | CLI framework | 0 deps, ~25ms cold start, simplest API of the three contenders. Requires Node ≥20 (the SPA repo's CRA build already runs on Node 22 per commit `7f83139`). ([commander](https://www.npmjs.com/package/commander)) |
| **`openid-client`** | **6.x** | OAuth 2.0 + OIDC client | Certified OIDC RP; built-in device-flow helpers (`Client.deviceAuthorization()` + `.poll()`). One dep. |
| **`@napi-rs/keyring`** | **1.x** | OS-native secret storage for refresh tokens | macOS Keychain / Windows Credential Manager / libsecret. **Replaces `keytar`, which has been archived since Sep 2023.** Rust-backed via NAPI; works on Node ≥14. |
| **`@aws-sdk/client-s3` + `@aws-sdk/lib-storage`** | 3.700+ | Upload built artifacts | (See §2.) CLI receives short-lived STS credentials from Arda after OAuth — never embeds long-term keys. |
| **`@aws-sdk/client-cloudfront`** | 3.700+ | Trigger registry invalidation post-publish | (See §2.) |
| **`prompts`** | 2.4.x | Interactive prompts (confirm, select page, etc.) | 7M weekly downloads; tiny; no native deps; sane API. |
| **`kleur`** | 4.1.x | Terminal colors | 0 dep, faster than `chalk`, ESM/CJS dual. |
| **`ora`** | 8.x | Spinners | 6M weekly downloads; pure ESM in v6+ — fine since CLI is greenfield. |
| **`zod`** | 3.23.x | Manifest schema validation (`manifest.json` per page) | Same library increasingly used inside the `cloud` repo per `bigstack` conventions; gives readable error messages to Max. |

### Installation

```bash
# CLI package — to live in static-pages repo (or a small standalone `bigscreen-cli` repo)
npm install \
  commander \
  openid-client \
  @napi-rs/keyring \
  @aws-sdk/client-s3 \
  @aws-sdk/lib-storage \
  @aws-sdk/client-cloudfront \
  @aws-sdk/credential-providers \
  prompts \
  kleur \
  ora \
  zod
```

### Why Commander over Oclif and Yargs

| | Commander 14 | Oclif 4 | Yargs 17 |
|---|---|---|---|
| Cold start | ~25 ms | ~135 ms | ~48 ms |
| Deps | 0 | ~30 | ~7 |
| API style | Imperative, chainable | Class-based, file-per-command, scaffolded | Functional |
| Learning curve | Lowest | Highest | Mid |
| Plugin system | None | Built-in | None |
| Ideal use | Small focused CLIs | Multi-team SaaS CLIs (Salesforce, Heroku) | One-off scripts |

The publishing CLI is small (~5-8 commands: `login`, `logout`, `whoami`, `publish`, `unpublish`, `list`, `status`, maybe `preview`). Oclif's scaffolding pays off at 20+ commands with team contribution. Commander is the right ergonomic fit. ([CLI comparison](https://www.grizzlypeaksoftware.com/library/cli-framework-comparison-commander-vs-yargs-vs-oclif-utxlf9v9))

### OAuth Device Flow — Why This Specifically

Max runs the CLI in a terminal, possibly over SSH, possibly on a machine without a default browser. The Device Authorization Grant (RFC 8628) is the only OAuth flow designed for this:

```
$ bigscreen login
→ Open https://arda.bigscreenvr.com/device  in your browser
→ Enter code:  WXYZ-1234
[polling…]
✓ Logged in as max@bigscreenvr.com (role: site-editor)
```

The CLI never sees Max's password. Arda's existing OAuth server needs to expose:

- `POST /oauth/device_authorization` — issues `device_code`, `user_code`, `verification_uri`, `interval`, `expires_in`
- `POST /oauth/token` (with `grant_type=urn:ietf:params:oauth:grant-type:device_code`)
- A new web view at `/device` that asks the logged-in admin to confirm the `user_code`

If Arda is built on a standard OAuth/OIDC server (Auth0, Keycloak, Hydra, IdentityServer), device flow is a config flag. If Arda is bespoke, it's ~150 lines of new code. **This is the single biggest external dependency in the milestone — confirm with the Arda team in Phase 1.** ([RFC 8628](https://oauth.net/2/device-flow/), [Stack Overflow walkthrough](https://stackoverflow.blog/2026/05/11/oauth-2-0-device-flow-explained-for-engineers-especially-for-backend-engineers/))

### Token Storage: Why Keyring over File or Env Var

| Storage | Theft surface | UX |
|---|---|---|
| `.env` file in home dir | Any process with FS read can lift the refresh token | Mediocre |
| `keytar` (archived) | OS keychain, but unmaintained — won't build cleanly on Node 22+ | Was good |
| **`@napi-rs/keyring`** | OS keychain, requires user keychain unlock | Great |
| AWS SSO-style cached token | Same FS theft risk; needs custom expiry math | Mediocre |

### Claude Code Skill Companion

```
static-pages/
├── .claude/
│   └── skills/
│       └── publish-page/
│           └── SKILL.md           # YAML frontmatter + concise body
└── pages/
    └── 10years/
        ├── manifest.json
        └── (source files)
```

`SKILL.md` is the [Anthropic Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) format, open-standard as of Dec 2025 and adopted by OpenAI Codex CLI and ChatGPT. The skill instructs Claude how to invoke `bigscreen` CLI on Max's behalf — read the page's `manifest.json`, run any optional local preview, then `bigscreen publish ./pages/<slug>`.

Frontmatter shape (minimal):

```yaml
---
name: publish-page
description: Publish or update a static page to bigscreenvr.com. Use when the user wants to deploy a microsite (e.g. /10years), change a published page's URL, or roll back a page.
---
```

Keep the body **terse** — every line is a per-turn token cost once the skill is loaded. Reference longer docs (`references/manifest-schema.md`, `references/troubleshooting.md`) from the body but don't inline them. ([Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices))

### What NOT to Use

| Avoid | Why | Use instead |
|---|---|---|
| `keytar` | Archived Sep 2023; native module pain on modern Node | `@napi-rs/keyring` |
| `chalk` v5 | Pure ESM — fine, but `kleur` is faster and dual-format | `kleur` |
| `inquirer` v9+ | Pure ESM + 50+ deps; CVE history | `prompts` |
| `simple-oauth2` | Doesn't support device flow out of the box | `openid-client` |
| Storing tokens in `~/.bigscreen/config.json` plaintext | FS theft trivial | Keyring |
| Implicit / Authorization Code (with PKCE) on CLI | Requires spawning a local HTTP server to catch the redirect — flaky on locked-down corp networks | Device Flow |

---

## 4. Embedding Static HTML/CSS/JS Inside React Layout

### The Decision: Iframe (sandboxed, srcdoc → src)

The static page is hosted at `https://static.bigscreenvr.com/_pages/10years/<sha>/index.html` (CloudFront same-distribution = same-origin to the SPA, no CORS pain). The SPA renders it inside an iframe wrapped in the React nav header + footer.

```jsx
// Inside the React layout, replacing the regex dispatcher's Builder branch
<Page header={<Header/>} footer={<Footer/>}>
  <iframe
    src={registry.find(p => p.path === pathname).source}
    title={page.title}
    style={{ width: '100%', border: 0 }}
    // No sandbox attr — same-origin needs DOM access for height sync.
    // Add 'sandbox="allow-scripts allow-same-origin"' if same-origin is acceptable.
  />
</Page>
```

Plus `@open-iframe-resizer/core` for height sync (one line of script in both parent and child).

### Trade-off Matrix

| Concern | iframe | innerHTML inject | Shadow DOM | Web Components |
|---|---|---|---|---|
| **CSS isolation** | Perfect (separate `Document`) | None — page CSS leaks both ways | Style-scoped (Shadow Root) — perfect for CSS | Same as Shadow DOM if used |
| **JS isolation** | Perfect (separate `window`, `globalThis`) | None — `window.foo` collisions, double `bigscreen10` boot, ReactGA double-init | None — same `window` | None — same `window` |
| **Asset path handling** | Native — `<img src="/img/logo.png">` resolves against iframe document URL (works as-is) | Broken — must rewrite all paths to absolute origin URLs at build or fetch time | Broken — same issue, plus Shadow DOM doesn't help with paths | Broken — same |
| **XSS risk from untrusted authors** | Contained to iframe origin | High — DOMPurify must perfectly sanitize every script | Low for CSS, none for JS | None for both |
| **Page author cognitive load** | Zero — they author normal HTML | High — they author HTML that won't collide with SPA | Medium — they need to know they're in a Shadow Root | Highest — they must author Web Components |
| **`bigscreen10` works as-is** | ✓ (just deploy its `dist/` to S3) | ✗ (CSS variables, font loads, script globals would collide with SPA) | ✗ (script globals still collide) | ✗ (would require rewriting) |
| **Height sync needed?** | Yes — iframe-resizer | No — inline | No — inline | No — inline |
| **SEO / crawlability** | Iframe content is indexed but separately ([per Google 2023 guidance](https://developers.google.com/search/docs/crawling-indexing/special-tags)); not ideal | Best (same document) | Best | Best |
| **Analytics (GA/Meta Pixel)** | Tricky — pixels live in parent only; need `postMessage` to forward page-view events | Native | Native | Native |
| **Implementation effort** | 1 day | 1 week (write a robust injector + sanitizer + path rewriter) | 1 week (less mature than iframe) | 2+ weeks (rewrite `bigscreen10`) |

### Why Iframe Wins for v1.0

1. **`bigscreen10` works on day one.** The first deployment target is a fully built microsite with its own SASS, fonts, JS. Any approach other than iframe requires rewriting it. The point of this milestone is to get `/10years` live — not to rewrite the microsite.

2. **CSS isolation is non-negotiable for marketing pages.** The SPA's global SASS (in `src/styles/`) is *already* leaky (per ARCHITECTURE.md §Style Layer, multiple global resets and "blocks"). Any inject approach would have us debugging visual regressions on every Max publish. The cost is one extra HTTP request per page load (the iframe).

3. **Authoring story is the simplest possible.** Max (or Max's LLM) writes a normal HTML file. No build config to think about. No "watch out for `.button` colliding with SPA's button". This is the *exact* property Builder.io has and that we'd lose with inject.

4. **Untrusted-ish content boundary.** Even though Max is an internal employee, a page might be authored by a contractor or LLM. Iframe gives us a security boundary that catches mistakes (rogue script, broken CSP) without taking down the SPA.

5. **Height sync is a 5kB problem.** `@open-iframe-resizer/core` (MIT, 19.7kB) handles it. The only ergonomic loss vs. inject is solved.

### Why Not Shadow DOM

Shadow DOM gives style scoping but **not script scoping** — `bigscreen10` would still pollute `window`, conflict with `ReactGA`, and so on. It's the right tool for "embeddable widget with style isolation" and the wrong tool for "drop a whole microsite into a page slot". ([Shadow DOM React](https://github.com/sibiraj-s/react-shadow), [Shadow DOM security analysis](https://cybersguards.com/shadow-dom/))

### Why Not Web Components

Same script-scope issue, plus the page author has to *write* their HTML as a custom element. We'd be telling Max "to publish a page, package it as a Web Component" — that's a worse DX than Jenkins.

### Why Not innerHTML / `dangerouslySetInnerHTML`

- CSS collisions are basically inevitable.
- Script tags inside `dangerouslySetInnerHTML` **don't execute** in React; you'd need a separate script-injection routine.
- DOMPurify is excellent for sanitizing user *rich-text*, not for sandboxing entire microsites with their own JS. You'd be tuning the allowlist forever. ([React docs warning](https://react.dev/reference/react-dom/components/common#applying-dangerous-html-via-dangerouslysetinnerhtml))

### Iframe Hardening Recipe

```jsx
<iframe
  src={page.source}
  title={page.title}
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
  referrerPolicy="strict-origin-when-cross-origin"
  loading="lazy"
  style={{ width: '100%', border: 0 }}
  // Height is managed by @open-iframe-resizer/core via postMessage.
/>
```

CSP on the SPA's `index.html`:

```
Content-Security-Policy: frame-src 'self' https://static.bigscreenvr.com;
```

Cross-origin pixel forwarding pattern (so GA tracks page-views on `/10years`):

```js
// In SPA — listen for page-view events from child iframe
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://www.bigscreenvr.com') return;
  if (e.data?.type === 'pageview') ReactGA.pageview(e.data.path);
});
```

And the page-author SDK (single script the build hook injects into each page):

```js
// /static-pages/lib/bigscreen-page-sdk.js  — injected automatically by CLI build step
window.bigscreen = {
  trackPageview: (path) => window.parent.postMessage({type: 'pageview', path}, '*'),
  trackEvent:    (n, p) => window.parent.postMessage({type: 'event', name: n, props: p}, '*'),
};
```

---

## 5. The Render-Layer Decision (Highest-Leverage)

Recapping because this drives everything else:

**Pick: iframe** — same-origin (`static.bigscreenvr.com` is a CloudFront alias of the same distribution serving the SPA), `sandbox="allow-scripts allow-same-origin"`, height auto-resized via `@open-iframe-resizer/core`.

**Consequences:**
- ✅ `bigscreen10` ships unchanged.
- ✅ Future pages can be built by any framework (Vite, Astro, plain HTML) with no SPA-side coordination.
- ✅ Security boundary is structural, not policy.
- ❌ Adds one HTTP request per page navigation (mitigated by CloudFront edge cache + iframe `loading="lazy"`).
- ❌ Pixel-tracking needs a small `postMessage` shim (we control both sides — manageable).
- ❌ SEO crawling treats the iframe content separately. **Recommend**: also expose each page directly at `https://static.bigscreenvr.com/_pages/<slug>/` (no SPA chrome) with `<link rel="canonical" href="https://www.bigscreenvr.com/10years">` so Google indexes the canonical URL while seeing all the content.

**Fallback if iframe blocks something later:** the registry's `renderer` field is intentionally open. We can add `"renderer": "react"` for SPA-owned screens and `"renderer": "shadow-dom"` later if we ever need it. **The registry shape is what locks us in, not the renderer choice.**

---

## 6. Optional Build Hooks — Security via Architecture, Not Sandboxing

### The Hard Rule

**Bigscreen infrastructure never executes marketer-supplied build commands.** Build hooks (`npm run build`, `vite build`, etc.) run inside the `static-pages` GitHub repo's own GitHub Actions, scoped to the repo's secrets. The CLI uploads **pre-built artifacts only**.

### Why This Beats Sandboxing

The instinct to put builds on our backend ("Max changes a page → Arda runs `npm install && npm run build` → uploads to S3") is a security tar-pit:

- npm install pulls thousands of packages; one malicious dep = RCE in our build infra.
- Reproducible isolation requires Docker / nsjail / Firecracker / pod-sandbox per build, all of which need maintenance.
- Build minutes get expensive fast.
- Build determinism (Node versions, native bindings) is hard to give Max a debug story for.

The whole problem disappears if builds run in GitHub Actions, which is **already a hardened, sandboxed CI environment** that GitHub maintains for us. ([CI/CD security best practices](https://blog.codacy.com/ci/cd-pipeline-security-best-practices), [Container Build Lens](https://docs.aws.amazon.com/wellarchitected/latest/container-build-lens/securing-containerized-build-pipelines.html))

### Recommended Flow

```
1. Max edits page source in pages/10years/   (in static-pages repo, via Cursor/Claude Code)
2. Max runs:   bigscreen publish ./pages/10years
3. CLI reads manifest.json:
   {
     "slug": "10years",
     "build": { "command": "npm run build", "outputDir": "dist" }  // optional
   }
4a. If a "build" block exists, CLI checks if dist/ is fresh (compares mtimes vs source); if stale, it errors with:
    "Build is stale. Run `npm run build` first, or push to GitHub to let CI build."
4b. If no "build" block, CLI uses the page directory as-is (bigscreen10 is already-built static, so this branch).
5. CLI computes SHA of the upload set → uploads to s3://bigscreen-static-pages/_pages/10years/<sha>/
6. CLI calls Arda admin API: POST /admin/pages with new registry entry → Arda updates registry table.
7. CLI calls CloudFront CreateInvalidation for /api/v1/pages*.
8. Within ~30s, the SPA's next registry fetch sees the new entry → /10years now routes to the new SHA.
```

### Alternative: CI-Driven Publish

For repeatability, GitHub Actions can `bigscreen publish` on every merge to main — using a CI-issued credential (Arda gives Actions its own short-lived OIDC-based token, same flow as Max but no human). This is a Phase 4+ enhancement, not required for v1.0.

### What NOT to Build

| Avoid | Why |
|---|---|
| Backend-side build runner (Arda runs builds) | Sandboxing tax + supply chain blast radius |
| `isolated-vm` or `vm2` to run JS in-process | Both have CVE histories; `vm2` is archived |
| Docker-per-build on Arda | Maintenance burden + 30s+ cold starts per publish |
| Pre-installed Node toolchain on CLI host | Brittle — Max's Node version drift will break things |

---

## 7. Version Compatibility & Migration Notes

| Concern | Current state | After milestone |
|---|---|---|
| `react-router-dom` | `^6.0.2` declarative `<BrowserRouter>` + nested `<Routes>` (per `src/App.js`) | `^6.28` `createBrowserRouter` + `<RouterProvider>` + `patchRoutesOnNavigation`. No breaking changes to existing routes if you migrate incrementally per [official guide](https://reactrouter.com/6.30.3/upgrading/v6-data). |
| `react` | `^17.0.1` (legacy `ReactDOM.render`) | **Recommendation: stay on 17 for this milestone.** All the libs above support React 17+. Migrating to React 18's `createRoot` is a separate concern and not blocking. |
| Node | 18.17.1+ documented; CRA build fixed for Node 22 (commit `7f83139`) | Node ≥20 needed for Commander 14; CLI ships its own engines field. |
| CRA `react-scripts` | 5.0.1 (deprecated upstream but functional) | Unchanged. Iframe approach means we don't have to fight CRA's webpack config for runtime route loading. |
| `@builder.io/react` | `^3.0.6` | Unchanged. Builder.io continues serving its pages; registry can flag a renderer as `"builder"` to route through it. |
| Existing `BigApi` (`src/api.js`) | superagent-based | Unchanged. Add `BigApi.getPageRegistry()` and `BigApi.adminPublishPage()` — same auth, same patterns. |

---

## 8. Sources

### Context7-verified (HIGH confidence)

- `/remix-run/react-router` — `patchRoutesOnNavigation` API, examples, `createBrowserRouter` data router patterns
- `/tj/commander.js` — Commander 14 confirmed current (May 2025 release, Node ≥20)

### Official docs (HIGH confidence)

- [React Router — Lazy Route Discovery](https://reactrouter.com/explanation/lazy-route-discovery)
- [React Router 6.30.3 — createBrowserRouter](https://reactrouter.com/6.30.3/routers/create-browser-router)
- [Anthropic — Equipping agents for the real world with Agent Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)
- [Anthropic — Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
- [Claude Code — Extend Claude with skills](https://code.claude.com/docs/en/skills)
- [AWS — CloudFront with multiple origins](https://repost.aws/knowledge-center/cloudfront-distribution-serve-content)
- [AWS — Restrict access to an Amazon S3 origin (OAC)](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)
- [AWS — Configure OAC for CloudFront distributions with Amazon S3 origins](https://www.repost.aws/knowledge-center/cloudfront-oac-origins)
- [AWS — CloudfrontClient (JS SDK v3)](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cloudfront/)
- [RFC 8628 — OAuth 2.0 Device Authorization Grant](https://oauth.net/2/device-flow/)
- [DOMPurify on npm](https://www.npmjs.com/package/dompurify)
- [iframe-resizer license change to GPL/commercial](https://github.com/davidjbradshaw/iframe-resizer/issues/1265)
- [Open Iframe Resizer (MIT alternative)](https://lemick.github.io/open-iframe-resizer/guides/getting-started/)
- [`@open-iframe-resizer/core` on npm](https://www.npmjs.com/package/@open-iframe-resizer/core)
- [Commander 14 release on GitHub](https://github.com/tj/commander.js/releases)

### Secondary / industry (MEDIUM confidence — verified across multiple sources)

- [Stack Overflow — OAuth 2.0 Device Flow Explained (May 2026)](https://stackoverflow.blog/2026/05/11/oauth-2-0-device-flow-explained-for-engineers-especially-for-backend-engineers/)
- [Grizzly Peak Software — CLI Framework Comparison](https://www.grizzlypeaksoftware.com/library/cli-framework-comparison-commander-vs-yargs-vs-oclif-utxlf9v9)
- [LogRocket — Best practices for React iframes](https://blog.logrocket.com/best-practices-react-iframes/)
- [Medium / David Lewis — iframes vs Web Components 2025](https://dp-lewis.medium.com/iframes-vs-web-components-which-one-actually-performs-better-in-2025-4db95784eb9f)
- [CyberGuards — Shadow DOM Guide: Security & Use Cases in 2025](https://cybersguards.com/shadow-dom/)
- [Jscrambler — Improving iframe security](https://jscrambler.com/blog/improving-iframe-security)
- [Codacy — CI/CD Pipeline Security Best Practices](https://blog.codacy.com/ci/cd-pipeline-security-best-practices)
- [OWASP — CI/CD Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/CI_CD_Security_Cheat_Sheet.html)
- [AWS Well-Architected — Securing containerized build pipelines](https://docs.aws.amazon.com/wellarchitected/latest/container-build-lens/securing-containerized-build-pipelines.html)

### Open items requiring confirmation in Phase 1 (LOW confidence pending verification)

- **Arda OAuth's device-flow support.** The Arda team must confirm whether `/oauth/device_authorization` already exists or needs to be implemented.
- **Actual CloudFront distribution ID + origin layout.** Confirm via `cloud` repo (`.planning/PROJECT.md` flags this as open).
- **Whether `static.bigscreenvr.com` exists as a CloudFront alias today** — if not, we add it as part of this milestone or use a path behavior under `www.bigscreenvr.com` (same-origin is preferred for iframe `postMessage`).
- **Site-editor role in Arda.** Confirm whether the existing `admin` role can be reused or whether a new scoped role is needed.

---

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