diff --git a/.dockerignore b/.dockerignore
index e4c07a3..10f3922 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,8 +1,22 @@
-fly.toml
-/node_modules
+# Dependencies (reinstalled in Docker)
+node_modules
+
+# Build artifacts (regenerated in Docker)
+.cache
+/build
+/.react-router
+
+# Environment files (provided at runtime)
+.env
+
+# Version control (not needed in image)
+.git
+.github
+
+# Testing (not needed in production)
+test
+test-results
+
+# System files
*.log
.DS_Store
-.env
-/.cache
-/public/build
-/build
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index d3ec73d..d2fe4eb 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -1,4 +1,4 @@
-name: Playwright Tests & Chromatic
+name: Playwright Tests
# This is an unusual job because it's triggered by deploy events rather than
# PR/push. The if condition means we only run on deployment_status events where
@@ -36,32 +36,3 @@ jobs:
name: test-results
path: test-results/
retention-days: 30
-
- chromatic:
- name: Run Chromatic
- if:
- github.event_name == 'deployment_status' && github.event.deployment_status.state ==
- 'success'
- needs: playwright
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - uses: actions/setup-node@v4
- with:
- node-version: '22'
- cache: 'npm'
- - name: Install dependencies
- run: npm install
- - name: Download Playwright test results
- uses: actions/download-artifact@v4
- with:
- name: test-results
- path: ./test-results
- - name: Run Chromatic
- run:
- npx chromatic --playwright --project-token ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- --exit-zero-on-changes
- env:
- CHROMATIC_ARCHIVE_LOCATION: ./test-results
diff --git a/.gitignore b/.gitignore
index f513d29..dca4944 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,8 +19,4 @@ test-results/
/app/components/icons
-// chromatic
-build-archive.log
-test-results/
-
.react-router/
diff --git a/.infra/README.md b/.infra/README.md
index 7451a38..a586adb 100644
--- a/.infra/README.md
+++ b/.infra/README.md
@@ -20,6 +20,40 @@ Note: Infrastructure configuration is stored in this repository until a point in
we have RFD infrastructure that is separate from `cio`. At that point, this infrastructure
should be owned by the RFD service.
+## Storage Provider Configuration
+
+The application supports two storage backends for serving static assets: GCS (Google Cloud
+Storage) and S3 (AWS S3 or S3-compatible services).
+
+### Common Environment Variables
+
+| Variable | Description |
+| ------------------ | -------------------------------------------------------------------------- |
+| `STORAGE_PROVIDER` | Storage backend to use: `gcs` (default) or `s3` |
+| `STORAGE_URL_TTL` | Pre-signed URL expiration time in seconds (optional, defaults to 24 hours) |
+
+### GCS Configuration
+
+| Variable | Description |
+| ------------------ | ------------------------------ |
+| `STORAGE_URL` | Base URL of the GCS CDN bucket |
+| `STORAGE_KEY_NAME` | Name of the signing key |
+| `STORAGE_KEY` | Base64-encoded signing key |
+
+### S3 Configuration
+
+| Variable | Description |
+| ----------------------- | ------------------------------------------------------------ |
+| `S3_BUCKET` | S3 bucket name |
+| `AWS_REGION` | AWS region (standard AWS SDK variable) |
+| `AWS_ACCESS_KEY_ID` | AWS access key (standard AWS SDK variable, or use IAM roles) |
+| `AWS_SECRET_ACCESS_KEY` | AWS secret key (standard AWS SDK variable, or use IAM roles) |
+| `AWS_ENDPOINT_URL` | Custom endpoint for S3-compatible services (optional) |
+
+The S3 integration uses the AWS SDK default credential chain, so credentials can be provided
+via environment variables, IAM instance roles, ECS task roles, or other standard AWS
+methods.
+
### GCP Infrastructure
Image storage and serving is handled by
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a6e7727
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,85 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this
+repository.
+
+## Project Overview
+
+RFD Site is Oxide Computer Company's web frontend for browsing, searching, and reading RFDs
+(Requests for Discussion). Built with React Router v7 (formerly Remix) and deployed on
+Vercel.
+
+## Commands
+
+```bash
+npm run dev # Start dev server on localhost:3000
+npm run build # Production build (react-router build)
+npm run test # Run unit tests with Vitest
+npm run tsc # Type check without emitting
+npm run lint # ESLint
+npm run fmt # Format with Prettier
+npm run fmt:check # Check formatting
+npm run e2ec # Run Playwright E2E tests (Chrome)
+```
+
+### Running a Single Test
+
+```bash
+npm run test -- path/to/file.test.ts # Run specific test file
+npm run test -- --grep "test name" # Run tests matching pattern
+npx playwright test --project=chrome test.ts # Run specific E2E test
+```
+
+### Local RFD Authoring Mode
+
+Preview RFDs from a local clone of the rfd repo:
+
+```bash
+LOCAL_RFD_REPO=~/oxide/rfd npm run dev
+```
+
+This mode reads RFD files directly from the specified directory without needing API
+credentials.
+
+## Architecture
+
+### Data Flow: Local vs Remote Mode
+
+The app operates in two modes controlled by `LOCAL_RFD_REPO` env var:
+
+- **Local mode** (`app/services/rfd.local.server.ts`): Reads AsciiDoc files directly from a
+ local rfd repo clone. Used for authoring/previewing.
+- **Remote mode** (`app/services/rfd.remote.server.ts`): Fetches from the rfd-api backend.
+ Used in production with OAuth authentication.
+
+The unified interface in `app/services/rfd.server.ts` abstracts this, calling either backend
+based on `isLocalMode()`.
+
+### Routing
+
+Uses React Router v7 file-based routing (`@react-router/fs-routes`). Routes are in
+`app/routes/`:
+
+- `_index.tsx` - RFD listing page
+- `rfd.$slug.tsx` - Individual RFD view
+- `auth.*.tsx` - OAuth flows (GitHub, Google)
+- `api.*.tsx` - API endpoints
+
+### Content Rendering
+
+RFDs are written in AsciiDoc and rendered with `@oxide/react-asciidoc`. Custom block
+renderers live in `app/components/AsciidocBlocks/` (Mermaid diagrams, syntax-highlighted
+code listings, images).
+
+### Path Alias
+
+`~/` maps to `./app/` (configured in tsconfig.json).
+
+## Key Dependencies
+
+- `@oxide/react-asciidoc` - AsciiDoc renderer
+- `@oxide/rfd.ts` - TypeScript client for rfd-api
+- `@oxide/design-system` - Oxide's component library
+- `@tanstack/react-query` - Data fetching for PR discussions
+- `shiki` - Syntax highlighting
+- `mermaid` - Diagram rendering
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4af3fd6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM node:22-alpine AS development-dependencies-env
+COPY . /app
+WORKDIR /app
+RUN npm ci
+
+FROM node:22-alpine AS production-dependencies-env
+COPY ./package.json package-lock.json /app/
+WORKDIR /app
+RUN npm ci --omit=dev
+
+FROM node:22-alpine AS build-env
+COPY . /app/
+COPY --from=development-dependencies-env /app/node_modules /app/node_modules
+WORKDIR /app
+RUN npm run build
+
+FROM node:22-alpine
+ENV NODE_ENV=production
+COPY ./package.json package-lock.json /app/
+COPY --from=production-dependencies-env /app/node_modules /app/node_modules
+COPY --from=build-env /app/build /app/build
+COPY --from=build-env /app/public /app/public
+WORKDIR /app
+CMD ["npm", "run", "start"]
diff --git a/README.md b/README.md
index 701c1e9..92d0eb0 100644
--- a/README.md
+++ b/README.md
@@ -99,16 +99,54 @@ combined branch that contains both.
When running in a non-local mode, the following settings must be specified:
- `SESSION_SECRET` - Key that will be used to signed cookies
-- `RFD_API` - Backend RFD API to communicate with (i.e. https://api.server.com)
-- `RFD_API_CLIENT_ID` - OAuth client id create via the RFD API
-- `RFD_API_CLIENT_SECRET` - OAuth client secret create via the RFD API
+
+#### Authentication
+
+##### API URL Configuration
+
+The RFD API URL can be configured in two ways:
+
+- `RFD_API` - Single URL for both server-to-server calls and OAuth redirects (legacy,
+ simplest)
+- `RFD_API_BACKEND_URL` + `RFD_API_FRONTEND_URL` - Split URLs for deployments where rfd-site
+ uses internal networking to reach rfd-api while users access a public endpoint
+
+When using split URLs:
+
+- `RFD_API_BACKEND_URL` - URL for server-to-server API calls (e.g., internal load balancer)
+- `RFD_API_FRONTEND_URL` - URL for OAuth redirects where user's browser is directed
+
+You can mix configurations: set one of the new vars and use `RFD_API` as fallback for the
+other. Existing deployments using only `RFD_API` will continue to work unchanged.
+
+##### OAuth Credentials
+
+- `RFD_API_CLIENT_ID` - OAuth client id created via the RFD API
+- `RFD_API_CLIENT_SECRET` - OAuth client secret created via the RFD API
- `RFD_API_GOOGLE_CALLBACK_URL` - Should be of the form of
`https://{rfd_site_hostname}/auth/google/callback`
- `RFD_API_GITHUB_CALLBACK_URL` - Should be of the form of
`https://{rfd_site_hostname}/auth/github/callback`
+- `RFD_API_MLINK_SECRET` - Client secret for magic link (email) authentication
+
+- `AUTH_PROVIDERS` - Comma-delimited list of enabled authentication providers. Valid values
+ are `github`, `google`, and `email`. If not set, no providers are enabled and login will
+ be unavailable. Each listed provider must have its required environment variables set or
+ the app will fail to start. Examples:
+ - `AUTH_PROVIDERS=github,google` - Enable only GitHub and Google OAuth
+ - `AUTH_PROVIDERS=email` - Enable only email (magic link) authentication
+
+#### Storage
+
- `STORAGE_URL` - Url of bucket for static assets
- `STORAGE_KEY_NAME` - Name of the key defined in `STORAGE_KEY`
- `STORAGE_KEY` - Key for generating signed static asset urls
+
+See [`.infra/README.md`](.infra/README.md) for S3 and additional storage provider
+configuration.
+
+#### GitHub Integration
+
- `GITHUB_APP_ID` - App id for fetching GitHub PR discussions
- `GITHUB_INSTALLATION_ID` - Installation id of GitHub App
- `GITHUB_PRIVATE_KEY` - Private key of the GitHub app for discussion fetching
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 485e4ca..2941573 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -37,7 +37,7 @@ export type SmallRfdItems = {
}
export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
- const { user, rfds, localMode, inlineComments } = useRootLoaderData()
+ const { user, rfds, localMode, inlineComments, features } = useRootLoaderData()
const fetcher = useFetcher()
@@ -59,7 +59,7 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
return false // Returning false prevents default behaviour in Firefox
}, [open])
- useKey('mod+k', toggleSearchMenu, { global: true })
+ useKey('mod+k', toggleSearchMenu, { global: true, enabled: features.search })
return (
- Viewing public RFDs.
+ {config.publicBanner.text || 'Viewing public RFDs'}
-
+
These are the publicly available{' '}
@@ -55,29 +45,26 @@ export function PublicBanner() {
>
RFDs
{' '}
- from Oxide. Those
- with access should{' '}
+ from{' '}
+
+ {config.organization.name}
+
+ . Those with access should{' '}
sign in
{' '}
to view the full directory of RFDs.
-
- We use RFDs both to discuss rough ideas and as a permanent repository for more
- established ones. You can read more about the{' '}
-
- tooling around discussions
-
- .
-
-
- If you're interested in the way we work, and would like to see the process from
- the inside, check out our{' '}
-
- open positions
-
- .
-