The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #19 from musicjunkieg/docs/railway-deploy-spec

docs: land Railway deploy spec + plan (#31)

authored by

chaos gremlin and committed by
GitHub
f21c6733 dc12e406

+1186
+748
docs/superpowers/plans/2026-04-10-railway-deploy.md
··· 1 + # Deploy Understory to Railway Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Deploy Understory to Railway with staging + production environments, self-hosted OAuth client metadata, and a custom domain (`understory.watch`). 6 + 7 + **Architecture:** Code changes (standalone output, postbuild script, shared OAuth metadata builder, metadata route) land on a feature branch, get validated locally, then pushed to staging for Railway validation. Railway infrastructure (project, environments, env vars, domains) is configured via Railway MCP tools/CLI. Production goes live when staging is validated and the custom domain DNS propagates. 8 + 9 + **Tech Stack:** Next.js 16 (standalone output), Railway (Nixpacks), AT Protocol OAuth (`@atproto/oauth-client-node`) 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-10-railway-deploy.md` 12 + 13 + **Chainlink Issue:** #31 14 + 15 + --- 16 + 17 + ## File Structure 18 + 19 + | File | Action | Responsibility | 20 + |------|--------|----------------| 21 + | `next.config.ts` | Modify | Add `output: "standalone"`, `outputFileTracingIncludes` for `data/`, remove Tailscale dev origin | 22 + | `package.json` | Modify | Add `postbuild` script; change `start` to `node .next/standalone/server.js` | 23 + | `src/lib/auth/metadata.ts` | Create | Shared `buildClientMetadata(appUrl)` — single source of truth for OAuth metadata | 24 + | `src/app/oauth/client-metadata.json/route.ts` | Create | Self-hosted AT Protocol OAuth client metadata endpoint | 25 + | `src/lib/auth/client.ts` | Modify | Import `buildClientMetadata`, remove `OAUTH_CLIENT_ID` dependency | 26 + | `src/app/oauth/login/route.ts` | Modify | Update `client.authorize()` scope from `transition:generic` to granular scopes via `OAUTH_SCOPE` constant | 27 + 28 + --- 29 + 30 + ## Chunk 1: Code changes (feature branch) 31 + 32 + ### Task 1: Create the feature branch 33 + 34 + - [ ] **Step 1: Create branch** 35 + 36 + ```bash 37 + git checkout main 38 + git pull --ff-only 39 + git checkout -b feat/railway-deploy 40 + ``` 41 + 42 + --- 43 + 44 + ### Task 2: Next.js standalone config + postbuild script 45 + 46 + **Files:** 47 + - Modify: `next.config.ts` 48 + - Modify: `package.json` 49 + 50 + - [ ] **Step 1: Read the current `next.config.ts`** 51 + 52 + Read `/Users/bryan.guffey/Code/Understory/next.config.ts` and confirm the current contents match: 53 + 54 + ```ts 55 + import type { NextConfig } from "next"; 56 + 57 + const nextConfig: NextConfig = { 58 + experimental: { 59 + viewTransition: true, 60 + }, 61 + allowedDevOrigins: [ 62 + "127.0.0.1", 63 + "bryans-mac-mini.wildebeest-puffin.ts.net", 64 + ], 65 + }; 66 + 67 + export default nextConfig; 68 + ``` 69 + 70 + - [ ] **Step 2: Update `next.config.ts`** 71 + 72 + Replace the config object with: 73 + 74 + ```ts 75 + import type { NextConfig } from "next"; 76 + 77 + const nextConfig: NextConfig = { 78 + output: "standalone", 79 + outputFileTracingIncludes: { 80 + "/*": ["./data/**/*"], 81 + }, 82 + experimental: { 83 + viewTransition: true, 84 + }, 85 + allowedDevOrigins: [ 86 + "127.0.0.1", 87 + ], 88 + }; 89 + 90 + export default nextConfig; 91 + ``` 92 + 93 + Changes: 94 + - Added `output: "standalone"` — Railway needs this for optimized builds 95 + - Added `outputFileTracingIncludes` — tells `@vercel/nft` to include the `data/` directory in the standalone output trace 96 + - Removed `bryans-mac-mini.wildebeest-puffin.ts.net` from `allowedDevOrigins` — Tailscale dev URL shouldn't ship to production 97 + 98 + - [ ] **Step 3: Update `package.json` scripts** 99 + 100 + Read `package.json` first, then modify the `"scripts"` section. Use `npm pkg set` to non-destructively add/modify scripts: 101 + 102 + ```bash 103 + npm pkg set scripts.postbuild="cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cp -r data .next/standalone/data" 104 + npm pkg set scripts.start="node .next/standalone/server.js" 105 + ``` 106 + 107 + The `postbuild` script runs automatically after `npm run build` (npm lifecycle hook). It copies three things into the standalone directory that Next.js deliberately excludes: 108 + - `.next/static/` — CSS and JS bundles (without these, pages load with no styles or interactivity) 109 + - `public/` — static assets (currently empty, but correct for future use) 110 + - `data/` — talk + transcript JSON files (loaded at runtime via `fs.readFileSync(path.resolve(process.cwd(), "data", ...))`) 111 + 112 + The `start` script changes from `next start` to `node .next/standalone/server.js` because standalone mode produces its own server that doesn't need the full Next.js CLI. 113 + 114 + - [ ] **Step 4: Verify the build works locally** 115 + 116 + Run: `npm run build` 117 + 118 + Expected: 119 + - Build succeeds 120 + - `.next/standalone/` directory exists 121 + - `.next/standalone/server.js` exists 122 + - `.next/standalone/.next/static/` exists (from postbuild) 123 + - `.next/standalone/public/` exists (from postbuild) 124 + - `.next/standalone/data/` exists with `talks.json` and `transcripts/` (from postbuild) 125 + 126 + Verify the standalone directory contents: 127 + 128 + ```bash 129 + ls .next/standalone/server.js 130 + ls .next/standalone/.next/static/ 131 + ls .next/standalone/data/talks.json 132 + ls .next/standalone/data/transcripts/ | head -5 133 + ``` 134 + 135 + - [ ] **Step 5: Verify existing tests still pass** 136 + 137 + Run: `npm test` 138 + Expected: 40/40 tests pass (scoring tests, unrelated to deploy changes but confirms nothing broke). 139 + 140 + - [ ] **Step 6: Verify tsc and eslint** 141 + 142 + Run in parallel: 143 + - `npx tsc --noEmit` — Expected: clean 144 + - `npx eslint src/` — Expected: clean 145 + 146 + - [ ] **Step 7: Commit** 147 + 148 + ```bash 149 + git add next.config.ts package.json 150 + git commit -m "feat: add standalone output + postbuild script for Railway deploy 151 + 152 + - output: 'standalone' for optimized Railway builds 153 + - outputFileTracingIncludes for data/ directory 154 + - postbuild copies .next/static, public, data into standalone dir 155 + - start script changed to node .next/standalone/server.js 156 + - removed Tailscale dev origin from allowedDevOrigins" 157 + ``` 158 + 159 + --- 160 + 161 + ### Task 3: Shared OAuth metadata builder 162 + 163 + **Files:** 164 + - Create: `src/lib/auth/metadata.ts` 165 + 166 + - [ ] **Step 1: Create the shared metadata module** 167 + 168 + Create `src/lib/auth/metadata.ts`: 169 + 170 + ```ts 171 + /** 172 + * Shared AT Protocol OAuth client metadata builder. 173 + * 174 + * Used by both the client-metadata.json route handler (serves the metadata 175 + * to PDS servers during the OAuth flow) and the NodeOAuthClient constructor 176 + * (uses the metadata locally for the authorization handshake). Defined once 177 + * here so the two can never drift — drift causes cryptic OAuth mismatch errors. 178 + */ 179 + 180 + export const CLIENT_METADATA_PATH = "/oauth/client-metadata.json"; 181 + 182 + /** 183 + * Granular OAuth scopes for Understory. Replaces the overpermissioned 184 + * `transition:generic` transitional scope with the minimum permissions 185 + * the app actually needs: 186 + * 187 + * - `atproto` — required for all AT Protocol OAuth sessions (identity) 188 + * - `rpc:app.bsky.graph.getFollows?aud=*` — read the user's follows 189 + * - `rpc:app.bsky.feed.searchPosts?aud=*` — search for conference posts 190 + * 191 + * Understory never writes records, so no write scopes are requested. 192 + * The `?aud=*` suffix allows the call to be proxied to any AppView service. 193 + */ 194 + export const OAUTH_SCOPE = [ 195 + "atproto", 196 + "rpc:app.bsky.graph.getFollows?aud=*", 197 + "rpc:app.bsky.feed.searchPosts?aud=*", 198 + ].join(" "); 199 + 200 + export function buildClientMetadata(appUrl: string) { 201 + const clientId = `${appUrl}${CLIENT_METADATA_PATH}`; 202 + return { 203 + client_id: clientId, 204 + client_name: "Understory", 205 + client_uri: appUrl, 206 + redirect_uris: [`${appUrl}/oauth/callback`], 207 + grant_types: ["authorization_code", "refresh_token"], 208 + response_types: ["code"], 209 + scope: OAUTH_SCOPE, 210 + application_type: "web" as const, 211 + dpop_bound_access_tokens: true, 212 + token_endpoint_auth_method: "none" as const, 213 + }; 214 + } 215 + ``` 216 + 217 + The `as const` assertions on `application_type` and `token_endpoint_auth_method` are needed because the metadata is now built in a separate function (not inline in the `NodeOAuthClient` constructor). Without contextual typing from the call site, TypeScript widens `"web"` and `"none"` to `string`, which won't match the SDK's union-of-literal types (`"web" | "native"`, `"none" | "client_secret_post" | ...`). The `as const` keeps them as string literals. The other array fields (`redirect_uris`, `grant_types`, `response_types`) must remain mutable (no top-level `as const`) because the SDK's Zod input types expect mutable tuples. If `tsc --noEmit` passes without the `as const` assertions, they can be safely removed — the verification step will tell you. 218 + 219 + - [ ] **Step 2: Verify tsc is clean** 220 + 221 + Run: `npx tsc --noEmit` 222 + Expected: clean. 223 + 224 + --- 225 + 226 + ### Task 4: Self-hosted client metadata route 227 + 228 + **Files:** 229 + - Create: `src/app/oauth/client-metadata.json/route.ts` 230 + 231 + - [ ] **Step 1: Create the route directory** 232 + 233 + ```bash 234 + mkdir -p src/app/oauth/client-metadata.json 235 + ``` 236 + 237 + > **Note:** The folder name contains a literal dot (`.json`). This is intentional — Next.js App Router treats directory names as route segments, so this creates a route at `/oauth/client-metadata.json`. The dot is legal in directory names on all platforms. If any build tooling issue arises, the fallback is to rename to `client-metadata/` and update `CLIENT_METADATA_PATH` in `metadata.ts`. 238 + 239 + - [ ] **Step 2: Create the route handler** 240 + 241 + Create `src/app/oauth/client-metadata.json/route.ts`: 242 + 243 + ```ts 244 + import { NextResponse } from "next/server"; 245 + import { buildClientMetadata } from "@/lib/auth/metadata"; 246 + 247 + export async function GET() { 248 + const appUrl = process.env.APP_URL; 249 + if (!appUrl) { 250 + return NextResponse.json( 251 + { error: "APP_URL not configured" }, 252 + { status: 500 }, 253 + ); 254 + } 255 + return NextResponse.json(buildClientMetadata(appUrl)); 256 + } 257 + ``` 258 + 259 + - [ ] **Step 3: Verify the route works locally** 260 + 261 + Start the dev server: `npm run dev` (in another terminal or background). 262 + 263 + Then: 264 + ```bash 265 + curl http://127.0.0.1:3000/oauth/client-metadata.json 266 + ``` 267 + 268 + Expected: JSON response with `client_id` = `http://127.0.0.1:3000/oauth/client-metadata.json`, `redirect_uris` = `["http://127.0.0.1:3000/oauth/callback"]`, etc. 269 + 270 + If the route returns 404, the dot-in-folder-name isn't working. Use the fallback: rename the directory to `client-metadata/` (no `.json`), update `CLIENT_METADATA_PATH` in `metadata.ts` to `"/oauth/client-metadata"`, and re-test. 271 + 272 + Stop the dev server after verification. 273 + 274 + - [ ] **Step 4: Verify tsc and eslint** 275 + 276 + Run in parallel: 277 + - `npx tsc --noEmit` — Expected: clean 278 + - `npx eslint src/` — Expected: clean 279 + 280 + --- 281 + 282 + ### Task 5: Update OAuth client to use shared metadata 283 + 284 + **Files:** 285 + - Modify: `src/lib/auth/client.ts` 286 + 287 + - [ ] **Step 1: Read the current file** 288 + 289 + Read `src/lib/auth/client.ts`. Current contents (for reference): 290 + 291 + ```ts 292 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 293 + import type { NodeSavedState, NodeSavedSession } from "@atproto/oauth-client-node"; 294 + 295 + const stateStore = new Map<string, NodeSavedState>(); 296 + const sessionStore = new Map<string, NodeSavedSession>(); 297 + 298 + function createClient(): NodeOAuthClient { 299 + const clientId = process.env.OAUTH_CLIENT_ID; 300 + const appUrl = process.env.APP_URL; 301 + 302 + if (!clientId || !appUrl) { 303 + throw new Error( 304 + "Missing OAUTH_CLIENT_ID or APP_URL environment variables. " + 305 + "See docs/superpowers/specs/2026-04-06-oauth.md for setup instructions.", 306 + ); 307 + } 308 + 309 + return new NodeOAuthClient({ 310 + clientMetadata: { 311 + client_id: clientId, 312 + client_name: "Understory (Development)", 313 + client_uri: appUrl, 314 + redirect_uris: [`${appUrl}/oauth/callback`], 315 + grant_types: ["authorization_code", "refresh_token"], 316 + response_types: ["code"], 317 + scope: "atproto rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.searchPosts?aud=*", 318 + application_type: "web", 319 + dpop_bound_access_tokens: true, 320 + token_endpoint_auth_method: "none", 321 + }, 322 + stateStore: { ... }, // full Map-backed implementation — preserved in Step 2 323 + sessionStore: { ... }, // full Map-backed implementation — preserved in Step 2 324 + }); 325 + } 326 + 327 + // globalThis.__oauthClient caching pattern also present — preserved in Step 2 328 + ``` 329 + 330 + > **Note:** The snippet above truncates the stateStore, sessionStore, and globalThis caching pattern for brevity. Step 2's "After" code includes the complete implementations of all three. They are preserved unchanged. 331 + 332 + - [ ] **Step 2: Rewrite `createClient` to use `buildClientMetadata`** 333 + 334 + Replace the entire file with: 335 + 336 + ```ts 337 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 338 + import type { 339 + NodeSavedState, 340 + NodeSavedSession, 341 + } from "@atproto/oauth-client-node"; 342 + import { buildClientMetadata } from "./metadata"; 343 + 344 + const stateStore = new Map<string, NodeSavedState>(); 345 + const sessionStore = new Map<string, NodeSavedSession>(); 346 + 347 + function createClient(): NodeOAuthClient { 348 + const appUrl = process.env.APP_URL; 349 + 350 + if (!appUrl) { 351 + throw new Error( 352 + "Missing APP_URL environment variable. " + 353 + "Set to your app's public URL (e.g., https://understory.watch).", 354 + ); 355 + } 356 + 357 + return new NodeOAuthClient({ 358 + clientMetadata: buildClientMetadata(appUrl), 359 + stateStore: { 360 + async get(key: string) { 361 + return stateStore.get(key); 362 + }, 363 + async set(key: string, value: NodeSavedState) { 364 + stateStore.set(key, value); 365 + }, 366 + async del(key: string) { 367 + stateStore.delete(key); 368 + }, 369 + }, 370 + sessionStore: { 371 + async get(sub: string) { 372 + return sessionStore.get(sub); 373 + }, 374 + async set(sub: string, value: NodeSavedSession) { 375 + sessionStore.set(sub, value); 376 + }, 377 + async del(sub: string) { 378 + sessionStore.delete(sub); 379 + }, 380 + }, 381 + }); 382 + } 383 + 384 + // Cache on globalThis to survive Next.js hot reload 385 + declare global { 386 + var __oauthClient: NodeOAuthClient | undefined; 387 + } 388 + 389 + export function getOAuthClient(): NodeOAuthClient { 390 + if (!globalThis.__oauthClient) { 391 + globalThis.__oauthClient = createClient(); 392 + } 393 + return globalThis.__oauthClient; 394 + } 395 + ``` 396 + 397 + Key changes: 398 + - Removed `OAUTH_CLIENT_ID` env var — `client_id` is now derived from `APP_URL` via `buildClientMetadata` 399 + - Imported `buildClientMetadata` from `./metadata` (shared source of truth) 400 + - Updated error message to reference only `APP_URL` 401 + - `client_name` is now `"Understory"` (via the builder, not `"Understory (Development)"`) 402 + 403 + - [ ] **Step 3: Update the login route to use the shared scope constant** 404 + 405 + Read `src/app/oauth/login/route.ts`. Line 18 has `scope: "atproto transition:generic"` hardcoded in the `client.authorize()` call. Replace it with the `OAUTH_SCOPE` constant from `metadata.ts`: 406 + 407 + ```ts 408 + import { NextRequest, NextResponse } from "next/server"; 409 + import { getOAuthClient } from "@/lib/auth/client"; 410 + import { OAUTH_SCOPE } from "@/lib/auth/metadata"; 411 + 412 + export async function POST(request: NextRequest) { 413 + try { 414 + const body = await request.json(); 415 + const handle = body.handle?.trim(); 416 + 417 + if (!handle) { 418 + return NextResponse.json( 419 + { error: "Handle is required" }, 420 + { status: 400 }, 421 + ); 422 + } 423 + 424 + const client = getOAuthClient(); 425 + const url = await client.authorize(handle, { 426 + scope: OAUTH_SCOPE, 427 + }); 428 + 429 + return NextResponse.json({ redirect: url.toString() }); 430 + } catch (error) { 431 + console.error("OAuth login error:", error); 432 + return NextResponse.json( 433 + { 434 + error: 435 + error instanceof Error 436 + ? error.message 437 + : "Failed to start authentication", 438 + }, 439 + { status: 400 }, 440 + ); 441 + } 442 + } 443 + ``` 444 + 445 + This ensures the scope requested at authorization time matches the scope declared in the client metadata (both read from the same `OAUTH_SCOPE` constant). 446 + 447 + - [ ] **Step 4: Update local `.env`** 448 + 449 + Edit `.env` to remove `OAUTH_CLIENT_ID`: 450 + 451 + ``` 452 + APP_URL=http://127.0.0.1:3000 453 + ASSEMBLYAI_API_KEY=<your-key-here> 454 + ``` 455 + 456 + > **Note:** `.env` is gitignored — this change is local only. 457 + 458 + - [ ] **Step 5: Verify the full OAuth flow locally** 459 + 460 + Start the dev server: `npm run dev` 461 + 462 + 1. Open `http://127.0.0.1:3000` 463 + 2. Click "Log in" 464 + 3. Enter a Bluesky handle 465 + 4. Complete the OAuth flow (redirects to PDS → back to callback) 466 + 5. Verify: avatar appears in the Nav 467 + 6. Verify: `/api/crawl` returns data (authenticated) 468 + 469 + If the OAuth flow fails, check: 470 + - Does `curl http://127.0.0.1:3000/oauth/client-metadata.json` return valid metadata with matching `client_id`? 471 + - Is `APP_URL=http://127.0.0.1:3000` in `.env`? 472 + - Restart the dev server after `.env` changes (Next.js caches env vars) 473 + 474 + Stop the dev server after verification. 475 + 476 + - [ ] **Step 6: Verify tsc, eslint, and tests** 477 + 478 + Run in parallel: 479 + - `npx tsc --noEmit` — Expected: clean 480 + - `npx eslint src/` — Expected: clean 481 + - `npm test` — Expected: 40/40 pass 482 + 483 + - [ ] **Step 7: Run a full production build and verify** 484 + 485 + Run: `npm run build` 486 + 487 + Expected: build succeeds, standalone output created with all three copied directories. 488 + 489 + Verify the build output includes the metadata route: 490 + 491 + ``` 492 + Route (app) 493 + ... 494 + ├ ƒ /oauth/client-metadata.json ← NEW 495 + ... 496 + ``` 497 + 498 + - [ ] **Step 8: Commit all OAuth changes together** 499 + 500 + ```bash 501 + git add src/lib/auth/metadata.ts \ 502 + src/app/oauth/client-metadata.json/route.ts \ 503 + src/lib/auth/client.ts \ 504 + src/app/oauth/login/route.ts 505 + git commit -m "feat: self-hosted OAuth metadata + granular scopes, drop OAUTH_CLIENT_ID 506 + 507 + - metadata.ts: shared buildClientMetadata(appUrl) + OAUTH_SCOPE constant. 508 + Single source of truth for both the metadata route and NodeOAuthClient. 509 + - OAUTH_SCOPE: replace transition:generic with granular read-only scopes 510 + (atproto + rpc:getFollows + rpc:searchPosts). Understory never writes. 511 + - /oauth/client-metadata.json route: serves metadata to PDS servers. 512 + - client.ts: derives client_id from APP_URL (no OAUTH_CLIENT_ID env var). 513 + - login/route.ts: uses OAUTH_SCOPE constant instead of hardcoded scope. 514 + - Eliminates the cimd-service.fly.dev dependency for auth." 515 + ``` 516 + 517 + --- 518 + 519 + ## Chunk 2: Railway infrastructure 520 + 521 + ### Task 6: Create Railway project and environments 522 + 523 + This task uses Railway MCP tools or CLI. No git commits — infrastructure is configured out-of-band. 524 + 525 + - [ ] **Step 1: Check Railway CLI is authenticated** 526 + 527 + ```bash 528 + railway whoami 529 + ``` 530 + 531 + Expected: shows your Railway account. If not authenticated, run `railway login`. 532 + 533 + - [ ] **Step 2: Create the Railway project** 534 + 535 + Use the Railway MCP tool `create-project-and-link` or CLI: 536 + 537 + ```bash 538 + railway init 539 + ``` 540 + 541 + Name the project "understory". Link it to the GitHub repo `musicjunkieg/understory`. 542 + 543 + Alternatively, use the MCP tool: 544 + - Tool: `mcp__railway-mcp-server__create-project-and-link` 545 + - Project name: "understory" 546 + 547 + - [ ] **Step 3: Create staging environment** 548 + 549 + Use the Railway MCP tool `create-environment`: 550 + - Name: "staging" 551 + - Source branch: `staging` 552 + 553 + Or via CLI: 554 + ```bash 555 + railway environment create staging 556 + ``` 557 + 558 + - [ ] **Step 4: Create the `staging` branch and push it** 559 + 560 + ```bash 561 + git checkout main 562 + git checkout -b staging 563 + git push -u origin staging 564 + git checkout feat/railway-deploy 565 + ``` 566 + 567 + This creates the `staging` branch (initially identical to `main`) so Railway's staging environment has a branch to track. 568 + 569 + - [ ] **Step 5: Generate a domain for the staging environment** 570 + 571 + Use the MCP tool `generate-domain` for the staging environment, or check the Railway dashboard for the auto-generated domain. 572 + 573 + Note the domain (e.g., `understory-staging-abc123.up.railway.app`) — it's needed for the `APP_URL` env var in the next step. 574 + 575 + - [ ] **Step 6: Set environment variables — staging** 576 + 577 + Switch Railway context to staging, then set env vars using the domain from Step 5: 578 + 579 + Use the MCP tool `set-variables` for the staging environment, or: 580 + 581 + ```bash 582 + railway link --environment staging 583 + railway variables set APP_URL=https://<staging-domain-from-step-5>.up.railway.app 584 + railway variables set HOSTNAME=0.0.0.0 585 + ``` 586 + 587 + - [ ] **Step 7: Set environment variables — production** 588 + 589 + Switch Railway context to production: 590 + 591 + ```bash 592 + railway link --environment production 593 + railway variables set APP_URL=https://understory.watch 594 + railway variables set HOSTNAME=0.0.0.0 595 + ``` 596 + 597 + --- 598 + 599 + ### Task 7: Push to staging and validate 600 + 601 + - [ ] **Step 1: Merge feature branch into staging** 602 + 603 + ```bash 604 + git checkout staging 605 + git merge feat/railway-deploy 606 + git push 607 + ``` 608 + 609 + Railway auto-deploys the staging environment. 610 + 611 + - [ ] **Step 2: Monitor the deploy** 612 + 613 + Use the MCP tool `list-deployments` or: 614 + 615 + ```bash 616 + railway logs --environment staging 617 + ``` 618 + 619 + Wait for the deploy to succeed. Check build logs for: 620 + - `next build` succeeds 621 + - `postbuild` script runs (copies static/public/data) 622 + - Server starts on the assigned port 623 + 624 + - [ ] **Step 3: Validate staging — pages** 625 + 626 + Using the staging `*.up.railway.app` URL: 627 + 628 + 1. Hit the landing page — should load with full styles 629 + 2. Hit `/talks` — should show 115+ talk grid 630 + 3. Hit `/talk/<any-rkey>` — should show transcript + HLS video player 631 + 632 + If pages load without styles → postbuild didn't copy `.next/static/`. Check build logs. 633 + If `/talks` crashes with ENOENT → postbuild didn't copy `data/`. Check build logs. 634 + 635 + - [ ] **Step 4: Validate staging — OAuth metadata** 636 + 637 + ```bash 638 + curl https://<staging-domain>.up.railway.app/oauth/client-metadata.json 639 + ``` 640 + 641 + Expected: JSON with `client_id` matching the staging URL. 642 + 643 + - [ ] **Step 5: Validate staging — OAuth flow** 644 + 645 + 1. Open the staging URL in a browser 646 + 2. Click "Log in" 647 + 3. Enter a Bluesky handle 648 + 4. Complete the OAuth flow 649 + 5. Verify: avatar appears in Nav 650 + 6. Hit `/api/crawl` in the browser console: `fetch('/api/crawl').then(r => r.json()).then(d => console.log(d))` 651 + 652 + If OAuth fails with a redirect mismatch → `APP_URL` in Railway doesn't match the actual staging domain. Update it and redeploy. 653 + 654 + - [ ] **Step 6: Validate staging — cache** 655 + 656 + Hit `/api/crawl` again. Should return `cached: true`. 657 + 658 + --- 659 + 660 + ### Task 8: Configure production domain and promote 661 + 662 + - [ ] **Step 1: Add custom domain to Railway production** 663 + 664 + Use the Railway dashboard or MCP tools to add `understory.watch` as a custom domain for the production environment. Railway will provide a CNAME target. 665 + 666 + - [ ] **Step 2: Configure DNS at your registrar** 667 + 668 + At your registrar, create a CNAME record: 669 + 670 + ``` 671 + understory.watch → <cname-target-from-railway> 672 + ``` 673 + 674 + Railway auto-provisions an SSL certificate via Let's Encrypt once DNS resolves. 675 + 676 + - [ ] **Step 3: Promote staging to production** 677 + 678 + ```bash 679 + git checkout main 680 + git merge staging 681 + git push 682 + ``` 683 + 684 + Railway auto-deploys the production environment. 685 + 686 + - [ ] **Step 4: Wait for DNS propagation** 687 + 688 + Check propagation: 689 + 690 + ```bash 691 + dig understory.watch CNAME 692 + ``` 693 + 694 + Expected: shows the Railway CNAME target. DNS propagation typically takes 5–30 minutes. 695 + 696 + - [ ] **Step 5: Validate production** 697 + 698 + Once DNS resolves: 699 + 700 + 1. `https://understory.watch` loads with valid SSL certificate (check the padlock) 701 + 2. `/talks` shows the talk grid with full styles 702 + 3. `/talk/<any-rkey>` shows transcript + HLS video 703 + 4. `/oauth/client-metadata.json` shows `client_id` = `https://understory.watch/oauth/client-metadata.json` 704 + 5. Full OAuth flow completes — login, avatar in Nav, `/api/crawl` returns data 705 + 6. Second `/api/crawl` call returns `cached: true` 706 + 707 + - [ ] **Step 6: Clean up the feature branch** 708 + 709 + ```bash 710 + git branch -d feat/railway-deploy 711 + git push origin --delete feat/railway-deploy 712 + ``` 713 + 714 + --- 715 + 716 + ### Task 9: Final verification and wrap-up 717 + 718 + - [ ] **Step 1: Confirm all acceptance criteria** 719 + 720 + Walk through spec §11 acceptance criteria: 721 + 722 + **Code:** 723 + - [ ] `next.config.ts` has `output: "standalone"` and `outputFileTracingIncludes` 724 + - [ ] `package.json` has `postbuild` script and standalone `start` command 725 + - [ ] `src/lib/auth/metadata.ts` exports `buildClientMetadata` 726 + - [ ] `/oauth/client-metadata.json` route works 727 + - [ ] `client.ts` uses `buildClientMetadata(appUrl)` (no `OAUTH_CLIENT_ID`) 728 + - [ ] Build produces standalone output with data + static + public 729 + - [ ] tsc clean, eslint clean, tests pass 730 + 731 + **Infrastructure:** 732 + - [ ] Railway project "understory" exists 733 + - [ ] Staging environment deploys from `staging` branch 734 + - [ ] Production environment deploys from `main` branch 735 + - [ ] Env vars set in both environments 736 + 737 + **Staging:** 738 + - [ ] Staging auto-deploys and all features work 739 + 740 + **Production:** 741 + - [ ] `https://understory.watch` loads with valid SSL 742 + - [ ] OAuth + crawl work on production 743 + 744 + - [ ] **Step 2: Use the finishing-a-development-branch skill** 745 + 746 + Since the feature branch was already merged into `staging` → `main` and deleted in Task 8 Step 6, the finishing skill's main value here is confirming everything is wrapped up. The work is already live. 747 + 748 + Invoke `superpowers:finishing-a-development-branch` to formally close out.
+438
docs/superpowers/specs/2026-04-10-railway-deploy.md
··· 1 + # Deploy Understory to Railway Spec 2 + 3 + **Date:** 2026-04-10 4 + **Issue:** Chainlink #31 5 + **Status:** Approved (pending review) 6 + **Depends on:** PR #9 (scoring algorithm — merged), PR #5 (social graph crawler — merged) 7 + **Unblocks:** #32 (write submission post), #33 (open source on GitHub) 8 + 9 + --- 10 + 11 + ## 1. Goal 12 + 13 + Deploy the Understory Next.js application to Railway with a custom domain (`understory.watch`), self-hosted AT Protocol OAuth client metadata, and the 125MB `data/` directory correctly bundled. After this work, the site is publicly accessible with working OAuth login, social graph crawling, and talk browsing. 14 + 15 + --- 16 + 17 + ## 2. Background 18 + 19 + Understory is currently dev-only behind Tailscale Funnel. The conference ended March 29 20 + — every day the site isn't live is a day the project's audience shrinks. The codebase is production-ready: 115+ talk pages with HLS video + transcripts, an OAuth login flow, a social graph crawler, and a scoring engine. What's missing is the deployment itself. 21 + 22 + **Current state:** 23 + - Next.js 16 / React 19 / TypeScript 5 24 + - AT Protocol OAuth via `@atproto/oauth-client-node`, sessions in-memory (ephemeral by design) 25 + - `data/talks.json` (talk index) and `data/transcripts/*.json` (128 transcript files, ~125MB total) loaded at runtime via `fs.readFileSync` 26 + - OAuth `client_id` currently points at `cimd-service.fly.dev` (shared dev-only metadata host) 27 + - No Railway config exists in the repo 28 + 29 + **Target state:** 30 + - Live at `https://understory.watch` 31 + - Self-hosted OAuth client metadata (no cimd-service dependency) 32 + - Railway auto-deploys on push to `main` 33 + - Two environment variables: `APP_URL` and `HOSTNAME=0.0.0.0` 34 + 35 + --- 36 + 37 + ## 3. Code Changes 38 + 39 + ### 3.1 `next.config.ts` — standalone output + data tracing 40 + 41 + Add `output: "standalone"` for Railway's builder, and `outputFileTracingIncludes` to ensure the `data/` directory is bundled into the standalone output. 42 + 43 + The `data/` directory contains `talks.json` and 128 transcript JSON files loaded at runtime via dynamically-constructed `fs.readFileSync` paths (e.g., `path.join(DATA_DIR, "transcripts", `${rkey}.json`)`) which `@vercel/nft` cannot trace automatically. The `outputFileTracingIncludes` directive explicitly includes them. 44 + 45 + Remove the Tailscale-specific `allowedDevOrigins` entry — it's a dev convenience that shouldn't ship to production. Keep the `127.0.0.1` entry (harmless, useful if someone clones and runs locally). 46 + 47 + **Before:** 48 + ```ts 49 + const nextConfig: NextConfig = { 50 + experimental: { 51 + viewTransition: true, 52 + }, 53 + allowedDevOrigins: [ 54 + "127.0.0.1", 55 + "bryans-mac-mini.wildebeest-puffin.ts.net", 56 + ], 57 + }; 58 + ``` 59 + 60 + **After:** 61 + ```ts 62 + const nextConfig: NextConfig = { 63 + output: "standalone", 64 + outputFileTracingIncludes: { 65 + "/*": ["./data/**/*"], 66 + }, 67 + experimental: { 68 + viewTransition: true, 69 + }, 70 + allowedDevOrigins: [ 71 + "127.0.0.1", 72 + ], 73 + }; 74 + ``` 75 + 76 + ### 3.1b `package.json` — postbuild script + standalone start command 77 + 78 + Next.js standalone mode fundamentally changes how the app is served: 79 + 80 + 1. **Static assets excluded.** `.next/static/` (CSS/JS bundles) and `public/` are deliberately excluded from the standalone output. Without them, pages load with no styles and no client-side JavaScript (no hydration, no HLS player, no interactivity). 81 + 82 + 2. **Data directory not at `process.cwd()/data`.** The standalone server runs from `.next/standalone/`, so `process.cwd()` resolves there — not the project root. The `outputFileTracingIncludes` config puts traced files under `.next/standalone/.next/server/`, but `path.resolve(process.cwd(), "data")` still looks for `.next/standalone/data/`. 83 + 84 + 3. **Start command changes.** The standalone server is `node .next/standalone/server.js`, not `next start`. Using `next start` would bypass the standalone output entirely, defeating the optimization. 85 + 86 + **Fix:** Add a `postbuild` script that copies the three missing pieces into the standalone directory, and change the `start` script: 87 + 88 + ```json 89 + "scripts": { 90 + "dev": "next dev", 91 + "build": "next build", 92 + "postbuild": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cp -r data .next/standalone/data", 93 + "start": "node .next/standalone/server.js", 94 + ... 95 + } 96 + ``` 97 + 98 + The `postbuild` script runs automatically after `npm run build` (npm lifecycle hook). It copies: 99 + - `.next/static/` → `.next/standalone/.next/static/` (CSS/JS bundles) 100 + - `public/` → `.next/standalone/public/` (currently empty, but correct for future static assets) 101 + - `data/` → `.next/standalone/data/` (talk + transcript JSON files) 102 + 103 + The `start` script changes from `next start` to `node .next/standalone/server.js` so Railway (and local `npm start`) use the standalone server. 104 + 105 + **Note:** The standalone server reads `PORT` and `HOSTNAME` environment variables. Railway sets `$PORT` automatically, but `HOSTNAME` defaults to `localhost` which won't work on Railway — it needs `HOSTNAME=0.0.0.0` to bind on all interfaces. This is set as a Railway env var in §4.2. 106 + 107 + ### 3.2 `src/lib/auth/metadata.ts` — shared client metadata builder 108 + 109 + The AT Protocol OAuth spec requires that `client_id` is a URL that resolves to a JSON document containing the client metadata. PDS servers fetch this URL during the auth flow and compare it against what the client claims locally. If they don't match, the flow fails with a cryptic mismatch error. 110 + 111 + To prevent drift, the metadata object is defined **once** in a shared module and imported by both the HTTP route (§3.3) and the OAuth client (§3.4). 112 + 113 + **Create:** `src/lib/auth/metadata.ts` 114 + 115 + ```ts 116 + export const CLIENT_METADATA_PATH = "/oauth/client-metadata.json"; 117 + 118 + /** 119 + * Granular OAuth scopes — replaces the overpermissioned `transition:generic`. 120 + * Understory only reads follows and searches posts; it never writes records. 121 + */ 122 + export const OAUTH_SCOPE = [ 123 + "atproto", 124 + "rpc:app.bsky.graph.getFollows?aud=*", 125 + "rpc:app.bsky.feed.searchPosts?aud=*", 126 + ].join(" "); 127 + 128 + export function buildClientMetadata(appUrl: string) { 129 + const clientId = `${appUrl}${CLIENT_METADATA_PATH}`; 130 + return { 131 + client_id: clientId, 132 + client_name: "Understory", 133 + client_uri: appUrl, 134 + redirect_uris: [`${appUrl}/oauth/callback`], 135 + grant_types: ["authorization_code", "refresh_token"], 136 + response_types: ["code"], 137 + scope: OAUTH_SCOPE, 138 + application_type: "web", 139 + dpop_bound_access_tokens: true, 140 + token_endpoint_auth_method: "none", 141 + }; 142 + } 143 + ``` 144 + 145 + `buildClientMetadata` is a pure function: given `APP_URL`, it returns the exact metadata object. Both the route handler and `NodeOAuthClient` call it with the same `APP_URL`, making drift structurally impossible. 146 + 147 + `OAUTH_SCOPE` is also exported and used by `src/app/oauth/login/route.ts` in the `client.authorize()` call, so the scope requested at authorization time always matches the scope declared in the client metadata. 148 + 149 + ### 3.3 `src/app/oauth/client-metadata.json/route.ts` — self-hosted metadata endpoint 150 + 151 + **Route path:** `src/app/oauth/client-metadata.json/route.ts` 152 + 153 + This creates a Next.js App Router route handler at `/oauth/client-metadata.json`. The folder name contains a literal dot — Next.js App Router supports this (directories are just filesystem names), but if any tooling issue arises during testing, the fallback is to rename the folder to `client-metadata` (serving at `/oauth/client-metadata`) and update `CLIENT_METADATA_PATH` in `metadata.ts` accordingly. The AT Protocol spec does not mandate a specific path — only that `client_id` resolves to valid metadata. 154 + 155 + ```ts 156 + import { NextResponse } from "next/server"; 157 + import { buildClientMetadata } from "@/lib/auth/metadata"; 158 + 159 + export async function GET() { 160 + const appUrl = process.env.APP_URL; 161 + if (!appUrl) { 162 + return NextResponse.json( 163 + { error: "APP_URL not configured" }, 164 + { status: 500 }, 165 + ); 166 + } 167 + return NextResponse.json(buildClientMetadata(appUrl)); 168 + } 169 + ``` 170 + 171 + **Why a route handler and not a static JSON file:** The `client_id` and `redirect_uris` contain the app's URL, which varies by environment (localhost in dev, `understory.watch` in production). A dynamic route reads `APP_URL` at request time so the same code works everywhere without build-time substitution. 172 + 173 + **Content-Type:** `NextResponse.json()` automatically sets `Content-Type: application/json`, which is what PDS servers expect. 174 + 175 + ### 3.4 `src/lib/auth/client.ts` — derive `client_id` from `APP_URL` 176 + 177 + Replace the `OAUTH_CLIENT_ID` environment variable with metadata derived from `APP_URL` via the shared `buildClientMetadata` function. 178 + 179 + **Changes:** 180 + 181 + 1. Remove the `OAUTH_CLIENT_ID` env var check — it's no longer needed. 182 + 2. Import and call `buildClientMetadata(appUrl)` for the `clientMetadata` field. 183 + 3. Update `client_name` from `"Understory (Development)"` to `"Understory"` (handled by the shared builder). 184 + 185 + **After:** 186 + ```ts 187 + import { buildClientMetadata } from "./metadata"; 188 + 189 + function createClient(): NodeOAuthClient { 190 + const appUrl = process.env.APP_URL; 191 + 192 + if (!appUrl) { 193 + throw new Error( 194 + "Missing APP_URL environment variable. " + 195 + "Set to your app's public URL (e.g., https://understory.watch).", 196 + ); 197 + } 198 + 199 + return new NodeOAuthClient({ 200 + clientMetadata: buildClientMetadata(appUrl), 201 + // ... stateStore and sessionStore unchanged 202 + }); 203 + } 204 + ``` 205 + 206 + ### 3.5 `.env` update for local development 207 + 208 + Update the local `.env` to remove `OAUTH_CLIENT_ID` (no longer used) and ensure `APP_URL` is set: 209 + 210 + ``` 211 + APP_URL=http://127.0.0.1:3000 212 + ASSEMBLYAI_API_KEY=<kept for offline transcription scripts> 213 + ``` 214 + 215 + The `.env` file is gitignored (not committed). This change is documented so developers know what to set after cloning. 216 + 217 + --- 218 + 219 + ## 4. Railway Infrastructure 220 + 221 + ### 4.1 Environments: staging + production 222 + 223 + The project uses two Railway environments with a promotion workflow: 224 + 225 + | Environment | Branch | Domain | Purpose | 226 + |---|---|---|---| 227 + | **Staging** | `staging` | Auto-generated `*.up.railway.app` | Validate changes before production. Push here first. | 228 + | **Production** | `main` | `understory.watch` (custom domain) | Public-facing. Code arrives via `staging` → `main` merge. | 229 + 230 + **Workflow:** Push to `staging` branch → Railway auto-deploys staging → validate on the Railway domain → merge `staging` → `main` → Railway auto-deploys production. 231 + 232 + This means you can make tweaks, test them on the staging URL, and only promote to production when confident. The two environments run the same code at different points in time, differentiated only by their `APP_URL` and domain. 233 + 234 + ### 4.2 Create project and services 235 + 236 + Using Railway MCP tools or CLI: 237 + 238 + 1. Create a new Railway project named "understory" 239 + 2. Link to the GitHub repo `musicjunkieg/understory` 240 + 3. Create two environments: 241 + - **Production** environment: deploys from `main` branch 242 + - **Staging** environment: deploys from `staging` branch 243 + 4. Railway auto-detects Next.js and uses its Node.js builder (Nixpacks) for both 244 + 5. Each environment gets its own service instance, env vars, and domain 245 + 246 + ### 4.3 Environment variables 247 + 248 + **Staging environment:** 249 + 250 + | Variable | Value | Notes | 251 + |---|---|---| 252 + | `APP_URL` | `https://<staging>.up.railway.app` | The auto-generated Railway domain for staging. Set after Railway creates it. | 253 + | `HOSTNAME` | `0.0.0.0` | Required. Standalone server defaults to `localhost`; `0.0.0.0` binds on all interfaces. | 254 + 255 + **Production environment:** 256 + 257 + | Variable | Value | Notes | 258 + |---|---|---| 259 + | `APP_URL` | `https://understory.watch` | The custom domain. | 260 + | `HOSTNAME` | `0.0.0.0` | Same as staging. | 261 + 262 + `NODE_ENV=production` is set automatically by Railway in both environments. `PORT` is set automatically. `ASSEMBLYAI_API_KEY` is not needed at runtime (only for offline build scripts). 263 + 264 + ### 4.4 Domain configuration 265 + 266 + **Staging:** Uses the auto-generated `*.up.railway.app` domain. No custom domain needed — it's for validation only. 267 + 268 + **Production:** 269 + 1. Add `understory.watch` as a custom domain in the production environment 270 + 2. Railway provides a CNAME target (e.g., `<hash>.railway.app`) 271 + 3. At your registrar, create a CNAME record: `understory.watch` → `<railway-cname-target>` 272 + 4. Railway auto-provisions an SSL certificate via Let's Encrypt 273 + 5. DNS propagation typically takes 5–30 minutes 274 + 275 + ### 4.5 Branch setup 276 + 277 + Create the `staging` branch from `main` and push it: 278 + 279 + ```bash 280 + git checkout main 281 + git checkout -b staging 282 + git push -u origin staging 283 + ``` 284 + 285 + Going forward: feature branches merge into `staging` for testing, then `staging` merges into `main` for production release. 286 + 287 + --- 288 + 289 + ## 5. OAuth Flow in Production 290 + 291 + The OAuth flow works as follows after deployment: 292 + 293 + 1. User clicks "Log in" on `understory.watch` 294 + 2. App calls `client.authorize(handle)` which initiates the OAuth handshake 295 + 3. The PDS at the user's handle fetches `https://understory.watch/oauth/client-metadata.json` to validate the client 296 + 4. PDS confirms `redirect_uris` includes `https://understory.watch/oauth/callback` 297 + 5. User is redirected to their PDS's authorization page 298 + 6. After approval, PDS redirects to `https://understory.watch/oauth/callback` with an auth code 299 + 7. App exchanges the code for tokens, stores session in-memory, sets the `understory_did` cookie 300 + 8. User sees their avatar in the Nav; `/api/crawl` becomes available 301 + 302 + **Session lifecycle:** 303 + - Sessions are in-memory `Map` objects (ephemeral by design) 304 + - On redeploy: all sessions are lost; users' next request transparently re-authenticates via `client.restore(did)` using the DID from their browser cookie 305 + - Cookie settings: `httpOnly: true`, `secure: true` (automatic in production via `NODE_ENV`), `sameSite: "lax"`, `path: "/"`, `maxAge: 7 days` 306 + - No database or Redis needed 307 + 308 + --- 309 + 310 + ## 6. Data Bundling 311 + 312 + The `data/` directory (125MB) is included in every deploy via `outputFileTracingIncludes` AND the `postbuild` copy script (§3.1b). Both mechanisms work together: `outputFileTracingIncludes` ensures Next.js traces the files for its file system, and `postbuild` copies them to `.next/standalone/data/` so `process.cwd()/data` resolves correctly in the standalone server. 313 + 314 + The files are: 315 + 316 + - `data/talks.json` — master index (~180 talks), read by: 317 + - `src/app/talks/page.tsx` (SSR at request time — uses `cookies()` for auth check, so cannot be statically generated) 318 + - `src/app/talk/[rkey]/page.tsx` (`generateStaticParams` provides the rkey list at build time, but pages are SSR'd at request time due to `cookies()`) 319 + - `src/lib/crawl/crawler.ts` (runtime, cached once per server lifecycle) 320 + - `data/transcripts/*.json` — 128 transcript files, read at request time by `src/app/talk/[rkey]/page.tsx` 321 + 322 + **Note:** Despite using `generateStaticParams`, both talk pages call `getAuthUser()` → `cookies()`, which is a dynamic API. This forces Next.js to server-render them on every request rather than statically generating them at build time. The `generateStaticParams` function only provides the list of valid rkeys for routing — it does not enable static generation when dynamic APIs are present. 323 + 324 + **To update data:** Run `npm run build-talk-index` and/or `npm run transcribe` locally, commit the updated JSON files, push to `main`. Railway redeploys with the new data. 325 + 326 + **The data is static** — it was generated from ATmosphereConf VODs (conference ended April 5, 2026) and won't change unless a re-crawl is needed (e.g., new talks added retroactively). 327 + 328 + --- 329 + 330 + ## 7. Verification Plan 331 + 332 + ### 7.1 Pre-deploy (local) 333 + 334 + Before pushing to `staging`: 335 + - [ ] `npm run build` succeeds with `output: "standalone"` — the `.next/standalone/` directory is created 336 + - [ ] The `postbuild` script ran: `.next/standalone/data/`, `.next/standalone/.next/static/`, and `.next/standalone/public/` all exist 337 + - [ ] `npm test` — all 40 scoring tests pass (unrelated to deploy, but confirms nothing broke) 338 + - [ ] `npx tsc --noEmit` — clean 339 + - [ ] `npx eslint src/` — clean 340 + - [ ] `GET /oauth/client-metadata.json` on `localhost:3000` returns valid metadata JSON with `client_id` matching `http://127.0.0.1:3000/oauth/client-metadata.json` 341 + - [ ] OAuth login flow works locally with the self-hosted metadata (no cimd-service) 342 + 343 + ### 7.2 Post-deploy — staging 344 + 345 + Push code to `staging` branch → Railway auto-deploys the staging environment. Using the auto-generated `*.up.railway.app` URL: 346 + 347 + - [ ] Landing page loads with correct styles (confirms standalone build + static assets served) 348 + - [ ] `/talks` shows 115+ talk grid (confirms `data/talks.json` was bundled) 349 + - [ ] `/talk/<any-rkey>` shows transcript + HLS video player (confirms transcript JSON files were bundled) 350 + - [ ] `/oauth/client-metadata.json` returns valid metadata with `client_id` matching the staging Railway URL 351 + - [ ] OAuth login flow completes — avatar appears in Nav 352 + - [ ] `/api/crawl` returns `TalkMentions` data (authenticated) 353 + - [ ] Second `/api/crawl` call returns `cached: true` (confirms in-memory cache works) 354 + - [ ] Build logs show no errors or unexpected warnings 355 + 356 + ### 7.3 Promote to production 357 + 358 + After staging passes, merge `staging` → `main`. Railway auto-deploys the production environment. 359 + 360 + ### 7.4 Post-DNS — production (custom domain) 361 + 362 + After pointing `understory.watch` to Railway's production CNAME and DNS propagates: 363 + - [ ] `https://understory.watch` loads with valid SSL certificate 364 + - [ ] Page loads with correct styles (CSS/JS bundles served) 365 + - [ ] `/talks` and `/talk/<any-rkey>` work (data bundled) 366 + - [ ] `/oauth/client-metadata.json` shows `client_id` = `https://understory.watch/oauth/client-metadata.json` 367 + - [ ] Full OAuth flow works on the production domain 368 + - [ ] `/api/crawl` works on the production domain 369 + 370 + --- 371 + 372 + ## 8. Edge Cases 373 + 374 + - **DNS not propagated yet:** The Railway-generated domain still works as a fallback. The OAuth flow won't work on the custom domain until DNS resolves AND `APP_URL` is updated to the custom domain. 375 + - **Railway cold start:** First request after idle/redeploy takes 2–5 seconds (Node.js startup + module loading + first `fs.readFileSync`). Subsequent requests are fast. Not a concern for this traffic level. 376 + - **Mid-deploy OAuth flow:** A user clicking "Log in" during the ~10 second deploy window gets an error. The in-flight OAuth state is lost. They retry and it works. Acceptable. 377 + - **`data/` directory missing in standalone:** If `outputFileTracingIncludes` doesn't work as expected (unlikely but possible with future Next.js versions), the `/talks` page will crash with `ENOENT`. The verification plan catches this before the custom domain goes live. 378 + - **Cookie not set on Railway domain:** If the browser blocks cookies on `*.up.railway.app` (some aggressive tracker-blockers flag `*.railway.app` subdomains), OAuth won't work on the Railway domain but will work on the custom domain. Not a blocker — the Railway domain is for smoke testing only. 379 + 380 + --- 381 + 382 + ## 9. Files Changed 383 + 384 + | File | Action | Responsibility | 385 + |------|--------|----------------| 386 + | `next.config.ts` | Modify | Add `output: "standalone"`, `outputFileTracingIncludes`, remove Tailscale dev origin | 387 + | `package.json` | Modify | Add `postbuild` script (copy static/public/data into standalone); change `start` to `node .next/standalone/server.js` | 388 + | `src/lib/auth/metadata.ts` | Create | Shared `buildClientMetadata(appUrl)` function, single source of truth for OAuth metadata | 389 + | `src/app/oauth/client-metadata.json/route.ts` | Create | Self-hosted AT Protocol OAuth client metadata endpoint | 390 + | `src/lib/auth/client.ts` | Modify | Import `buildClientMetadata`, remove `OAUTH_CLIENT_ID` dependency | 391 + | `src/app/oauth/login/route.ts` | Modify | Update `client.authorize()` scope from `transition:generic` to granular scopes | 392 + 393 + 5 files touched in the codebase. The rest is Railway CLI/MCP configuration (not committed to git). 394 + 395 + --- 396 + 397 + ## 10. Non-goals 398 + 399 + - **Redis/database for session persistence.** Sessions are ephemeral by design. `client.restore(did)` transparently re-authenticates on the next request after a redeploy. No user action required. 400 + - **Dockerfile or custom builder.** Railway's Nixpacks auto-detects Next.js and handles the build. No custom config needed. 401 + - **CDN or caching layer.** Railway serves static assets with built-in caching. Dynamic routes are server-rendered per request, which is correct for authenticated endpoints like `/api/crawl`. 402 + - **Health check endpoint.** Railway uses automatic TCP health checks. A custom `/health` route would be dead code. 403 + - **CI/CD pipeline.** Railway auto-deploys from GitHub on push to `main`. No GitHub Actions workflow needed. 404 + - **Monitoring/alerting.** Can be added later if needed. Railway provides basic logs and metrics in its dashboard. 405 + - **Rate limiting.** The Bluesky/Constellation APIs are called server-side per authenticated user. At conference-talk traffic levels, rate limiting is unnecessary. 406 + 407 + --- 408 + 409 + ## 11. Acceptance Criteria 410 + 411 + ### Code 412 + - [ ] `next.config.ts` has `output: "standalone"` and `outputFileTracingIncludes` for `data/` 413 + - [ ] `package.json` has `postbuild` script copying `.next/static`, `public`, `data` into standalone directory 414 + - [ ] `package.json` `start` script is `node .next/standalone/server.js` 415 + - [ ] `src/lib/auth/metadata.ts` exports `buildClientMetadata`, imported by both route handler and `client.ts` 416 + - [ ] `/oauth/client-metadata.json` route exists and returns valid metadata 417 + - [ ] `src/lib/auth/client.ts` uses `buildClientMetadata(appUrl)` (no `OAUTH_CLIENT_ID` env var) 418 + - [ ] `npm run build` produces a working standalone output with `data/`, `.next/static/`, and `public/` present in `.next/standalone/` 419 + - [ ] `npx tsc --noEmit` clean, `npx eslint src/` clean, `npm test` passes 420 + 421 + ### Railway infrastructure 422 + - [ ] Railway project "understory" exists with a service linked to `musicjunkieg/understory` 423 + - [ ] Two environments configured: staging (deploys from `staging` branch) and production (deploys from `main` branch) 424 + - [ ] `APP_URL` and `HOSTNAME=0.0.0.0` set in both environments (different `APP_URL` values) 425 + - [ ] `staging` branch exists and is pushed to origin 426 + 427 + ### Staging validation 428 + - [ ] Staging auto-deploys on push to `staging`; build logs clean 429 + - [ ] Landing page, `/talks`, `/talk/[rkey]` all work on the staging Railway domain 430 + - [ ] OAuth flow completes on staging 431 + - [ ] `/api/crawl` returns valid crawl data when authenticated on staging 432 + 433 + ### Production validation 434 + - [ ] Production auto-deploys on merge to `main`; build logs clean 435 + - [ ] Custom domain `understory.watch` configured with valid SSL 436 + - [ ] Landing page, `/talks`, `/talk/[rkey]` all work on `https://understory.watch` 437 + - [ ] Full OAuth flow works on `https://understory.watch` 438 + - [ ] `/api/crawl` returns valid crawl data when authenticated on production