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 (
@@ -78,14 +78,18 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
- - setOpen(false)} /> + {features.search && ( + <> + + setOpen(false)} /> + + )} diff --git a/app/components/NewRfdButton.tsx b/app/components/NewRfdButton.tsx index 3999963..dba51d2 100644 --- a/app/components/NewRfdButton.tsx +++ b/app/components/NewRfdButton.tsx @@ -15,7 +15,7 @@ import Modal from './Modal' const NewRfdButton = () => { const dialog = useDialogStore() - const newRfdNumber = useRootLoaderData().newRfdNumber + const { newRfdNumber, config } = useRootLoaderData() return ( <> @@ -31,7 +31,7 @@ const NewRfdButton = () => {

There is a prototype script in the rfd{' '} repository diff --git a/app/components/PublicBanner.tsx b/app/components/PublicBanner.tsx index 0659f83..14654c6 100644 --- a/app/components/PublicBanner.tsx +++ b/app/components/PublicBanner.tsx @@ -7,35 +7,25 @@ */ import { useDialogStore } from '@ariakit/react' -import { type ReactNode } from 'react' import { Link } from 'react-router' import Icon from '~/components/Icon' +import { useRootLoaderData } from '~/root' import Modal from './Modal' -function ExternalLink({ href, children }: { href: string; children: ReactNode }) { - return ( - - {children} - - ) -} - export function PublicBanner() { + const { config } = useRootLoaderData() const dialog = useDialogStore() + if (!config.publicBanner || !config.publicBanner.enabled) return null + return ( <> {/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */}

- 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 - - . -

+ {config.publicBanner.learnMoreContent && ( +
+ )}
diff --git a/app/components/Search.tsx b/app/components/Search.tsx index eb61d63..1ec9b9b 100644 --- a/app/components/Search.tsx +++ b/app/components/Search.tsx @@ -30,15 +30,19 @@ import { Link, useNavigate } from 'react-router' import Icon from '~/components/Icon' import StatusBadge from '~/components/StatusBadge' import { useSteppedScroll } from '~/hooks/use-stepped-scroll' +import { useRootLoaderData } from '~/root' import type { RfdItem } from '~/services/rfd.server' const Search = ({ open, onClose }: { open: boolean; onClose: () => void }) => { + const { config } = useRootLoaderData() const searchClient = useRef(null) useEffect(() => { - const { searchClient: client } = instantMeiliSearch( - 'https://search.rfd.shared.oxide.computer', - ) + if (!config.features.search || !config.search) { + return + } + + const { searchClient: client } = instantMeiliSearch(config.search.url) // Overriding search function to implement our custom search backend. We provide a search route // that proxies out to the RFD API search endpoint. This route accepts slightly different @@ -91,7 +95,11 @@ const Search = ({ open, onClose }: { open: boolean; onClose: () => void }) => { }) }, } - }, []) + }, [config.features.search, config.search]) + + if (!config.features.search || !config.search) { + return null + } if (searchClient.current) { return ( diff --git a/app/components/rfd/MoreDropdown.tsx b/app/components/rfd/MoreDropdown.tsx index 09d2860..1b92556 100644 --- a/app/components/rfd/MoreDropdown.tsx +++ b/app/components/rfd/MoreDropdown.tsx @@ -9,6 +9,7 @@ import { useDialogStore } from '@ariakit/react' import { useState } from 'react' import { useLoaderData } from 'react-router' +import { useRootLoaderData } from '~/root' import type { loader } from '~/routes/rfd.$slug' import * as DropdownMenu from '../Dropdown' @@ -17,6 +18,7 @@ import RfdJobsMonitor from './RfdJobsMonitor' const MoreDropdown = () => { const { rfd } = useLoaderData() + const { features } = useRootLoaderData() const [dialogOpen, setDialogOpen] = useState(false) const jobsDialogStore = useDialogStore({ open: dialogOpen, setOpen: setDialogOpen }) @@ -32,9 +34,11 @@ const MoreDropdown = () => { Processing jobs - - GitHub discussion - + {features.discussions && ( + + GitHub discussion + + )} GitHub source @@ -48,9 +52,11 @@ const MoreDropdown = () => { )} - - View PDF - + {features.pdf && ( + + View PDF + + )} diff --git a/app/components/rfd/RfdDiscussionDialog.tsx b/app/components/rfd/RfdDiscussionDialog.tsx index f1bf719..d23fb0c 100644 --- a/app/components/rfd/RfdDiscussionDialog.tsx +++ b/app/components/rfd/RfdDiscussionDialog.tsx @@ -22,6 +22,7 @@ import { useMemo } from 'react' import Icon from '~/components/Icon' import { useDiscussionQuery } from '~/hooks/use-discussion-query' +import { useRootLoaderData } from '~/root' import type { IssueCommentType, ListIssueCommentsType, @@ -225,6 +226,7 @@ const DialogContent = ({ discussions: Discussions pullNumber: number }) => { + const { githubRepoUrl } = useRootLoaderData() return (
- +
#{pullNumber}
@@ -262,6 +260,7 @@ const DiscussionReviewGroup = ({ discussions: Discussions pullNumber: number }) => { + const { githubRepoUrl } = useRootLoaderData() const reviewCount = Object.keys(discussions).length return ( @@ -306,7 +305,7 @@ const DiscussionReviewGroup = ({ This discussion has no reviews or comments

[1] * Dan Abramov's post: * https://overreacted.io/making-setinterval-declarative-with-react-hooks/ */ -export const useKey = (key: Key, fn: Callback, { global = false } = {}) => { +export const useKey = (key: Key, fn: Callback, { global = false, enabled = true } = {}) => { const fnRef = useRef(fn) useEffect(() => { @@ -34,6 +34,7 @@ export const useKey = (key: Key, fn: Callback, { global = false } = {}) => { }, [fn]) useEffect(() => { + if (!enabled) return const bind = global ? Mousetrap.bindGlobal : Mousetrap.bind bind(key, (e, combo) => fnRef.current(e, combo)) return () => { @@ -41,5 +42,5 @@ export const useKey = (key: Key, fn: Callback, { global = false } = {}) => { } // JSON.stringify lets us avoid having to memoize the keys at the call site. /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [JSON.stringify(key), global]) + }, [JSON.stringify(key), global, enabled]) } diff --git a/app/root.tsx b/app/root.tsx index 200593d..3cce3b5 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -27,6 +27,7 @@ import styles from '~/styles/index.css?url' import LoadingBar from './components/LoadingBar' import { authenticate, logout } from './services/auth.server' +import { getSiteConfig } from './services/config.server' import { inlineCommentsCookie } from './services/cookies.server' import { isLocalMode } from './services/rfd.local.server' import { @@ -37,15 +38,27 @@ import { } from './services/rfd.server' import { useApplyTheme } from './stores/theme' -export const meta: MetaFunction = () => { - return [{ title: 'RFD / Oxide' }] +export const meta: MetaFunction = ({ data }) => { + if (!data?.config) { + return [{ title: 'RFD' }] + } + + const orgName = data.config.organization.name + const description = data.config.site.description + return description + ? [{ title: `RFD / ${orgName}` }, { name: 'description', content: description }] + : [{ title: `RFD / ${orgName}` }] } export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] export const loader = async ({ request }: LoaderFunctionArgs) => { + const config = await getSiteConfig() const inlineComments = (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true + const githubRepoUrl = config.discussions + ? `https://${config.discussions.host || 'github.com'}/${config.discussions.owner}/${config.discussions.repo}` + : null const user = await authenticate(request) try { @@ -55,6 +68,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const labels = rfds ? getLabels(rfds) : [] return { + config, + features: config.features, inlineComments, user, rfds, @@ -62,6 +77,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { labels, localMode: isLocalMode(), newRfdNumber: provideNewRfdNumber([...rfds]), + githubRepoUrl, } } catch { // The only error that should be caught here is the unauthenticated error. @@ -71,6 +87,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Convince remix that a return type will always be provided return { + config, + features: config.features, inlineComments, user, rfds: [], @@ -78,6 +96,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { labels: [], localMode: isLocalMode(), newRfdNumber: undefined, + githubRepoUrl, } } @@ -110,7 +129,13 @@ const queryClient = new QueryClient() // Mirrors logic in app/stores/theme.ts — must stay in sync. const themeInitScript = `(function(){try{var p=localStorage.getItem('theme-preference');if(p!=='dark'&&p!=='light'&&p!=='system')p='dark';var r=p==='system'?(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'):p;document.documentElement.dataset.theme=r;}catch(_){document.documentElement.dataset.theme='dark';}})();` -const Layout = ({ children }: { children: React.ReactNode }) => ( +const Layout = ({ + children, + headScript, +}: { + children: React.ReactNode + headScript?: string +}) => ( @@ -122,9 +147,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => ( - {/* Use plausible analytics only on Vercel */} - {process.env.NODE_ENV === 'production' && ( -