Plan for specialty chronological feed of everyone you follow with capacity for 1000 users per deployed instance
personal-feed-plan.md edited
882 lines 27 kB view raw view rendered
1# Plan: Personal AT Proto AppView — Next.js Frontend + Aurora Prism Backend on Digital Ocean 2 3 4--- 5 6## Overview 7 8We are building a **two-service architecture** on a single Digital Ocean Droplet: 9 101. **Aurora Prism** (unchanged backend) — XRPC AppView API, Jetstream firehose consumer, 11 PostgreSQL storage, Redis cache, AT Proto OAuth 122. **Next.js App** (new frontend) — App Router frontend consuming Aurora Prism's XRPC 13 API via `@atproto/api`, SSR'd pages, proper authentication, clean UX 14 15Nginx sits in front of both, terminating TLS and routing traffic: 16 17``` 18User → Nginx (443) → / → Next.js (3000) 19 → /xrpc/* → Aurora Prism (5000) 20 → /health → Aurora Prism (5000) 21``` 22 23--- 24 25## Phase 1: Infrastructure — Digital Ocean Setup 26 27### 1.1 Droplet Selection 28 29**Recommended: General Purpose 8 GB / 4 vCPU — $48/month** 30 31| Spec | Value | Reasoning | 32|------|-------|-----------| 33| RAM | 8 GB | Postgres (2GB) + Aurora Prism (1.5GB) + Next.js (512MB) + Redis (512MB) + OS headroom | 34| vCPU | 4 | Firehose consumer + API serving + Next.js SSR are all CPU-bound under load | 35| Disk | 50 GB SSD (base) | OS + applications; all DB data lives on Block Storage | 36| Region | Closest to you | NYC3, SFO3, or AMS3 recommended | 37| OS | Ubuntu 22.04 LTS | Stable, excellent Docker support | 38 39**Add Block Storage: 100 GB — $10/month** 40Mount at `/mnt/appdata`. The PostgreSQL data directory lives here so you can resize 41storage without recreating the Droplet. 42 43**Total: ~$58/month.** At 1000 users that is about $0.06/user/month. 44 45**Minimum viable (tight):** 4 GB / 2 vCPU at $24/month. Works, but PostgreSQL alone 46consumes ~2GB, leaving little headroom. Not recommended for production. 47 48### 1.2 Digital Ocean Firewall 49 50Configure in the DO Cloud Console (survives Droplet recreation, unlike `ufw`): 51 52| Direction | Port | Protocol | Source | 53|-----------|------|----------|--------| 54| Inbound | 22 | TCP | Your IP only | 55| Inbound | 80 | TCP | All IPv4/IPv6 | 56| Inbound | 443 | TCP | All IPv4/IPv6 | 57| Outbound | All | All | All | 58 59All inter-service traffic (Postgres, Redis, internal Next.js/Aurora Prism calls) 60stays on Docker's internal bridge network — no external port exposure needed. 61 62### 1.3 Droplet Bootstrap Script 63 64```bash 65#!/bin/bash 66# Run as root on fresh Ubuntu 22.04 Droplet 67 68apt-get update && apt-get upgrade -y 69 70# Docker 71curl -fsSL https://get.docker.com | sh 72apt-get install -y docker-compose-plugin 73 74# Nginx + Certbot 75apt-get install -y nginx certbot python3-certbot-nginx 76 77# App user (do not run everything as root) 78useradd -m -s /bin/bash appuser 79usermod -aG docker appuser 80 81# Mount Block Storage (confirm device name in DO console — usually /dev/sda or /dev/disk/by-id/...) 82VOLUME_DEVICE=/dev/sda 83mkfs.ext4 $VOLUME_DEVICE 84mkdir -p /mnt/appdata 85echo "$VOLUME_DEVICE /mnt/appdata ext4 defaults,nofail 0 2" >> /etc/fstab 86mount -a 87 88# Create data directories 89mkdir -p /mnt/appdata/postgres /mnt/appdata/redis 90chown -R 999:999 /mnt/appdata/postgres # matches postgres UID inside Docker image 91``` 92 93--- 94 95## Critical Requirement: Complete, Chronological, Gap-Free Timelines 96 97This is the hardest constraint in the system. It has three distinct sub-problems: 98 99### Problem 1 — The Jetstream is a Live Stream, Not an Archive 100 101Jetstream starts delivering events from "now." If Aurora Prism is not yet subscribed to 102a DID when that person posts, the post is missed forever from the stream. This happens in 103two scenarios: 104- A user follows someone **after** Aurora Prism started (the new follow's past posts are absent) 105- Aurora Prism restarts and the cursor is lost or stale (events during downtime are missed) 106 107**Solution A — `wantedDids` with dynamic updates:** 108Jetstream supports subscribing to a specific set of DIDs via the `wantedDids` query 109parameter. Aurora Prism must dynamically update this subscription as users follow/unfollow. 110When a new follow happens, the subscription must expand to include the new DID *and* a 111backfill of that DID's history must be triggered from their PDS directly. 112 113``` 114wss://jetstream2.us-east.bsky.network/subscribe 115 ?wantedCollections=app.bsky.feed.post 116 &wantedCollections=app.bsky.feed.repost 117 &wantedCollections=app.bsky.feed.like 118 &wantedDids=did:plc:abc123 119 &wantedDids=did:plc:def456 120 ... 121``` 122 123**Verify:** Check whether Aurora Prism's data-plane supports `wantedDids` filtering and 124dynamic reconnection when the follow set changes. If not, this requires a code 125contribution to Aurora Prism. 126 127**Solution B — Full firehose with local filtering (simpler but expensive):** 128Subscribe to the full Jetstream (all posts, ~850 MB/day) and filter locally by follow 129graph. No missed events, but storage grows with the whole network, not just followed DIDs. 130At 1000 users this is manageable; at 10,000 it becomes unsustainable. 131 132**Recommendation:** Start with Solution B (full Jetstream, local filter) for reliability. 133Optimize to `wantedDids` later when the scale justifies the added complexity. 134 135### Problem 2 — Cursor Persistence for Crash Recovery 136 137Jetstream assigns each event a `time_us` (microsecond Unix timestamp) as a cursor. 138Aurora Prism must persist this cursor to PostgreSQL (or Redis) after processing each 139event page. On restart, the consumer reconnects with `?cursor=<last_time_us>` to 140replay any events missed during downtime. 141 142``` 143wss://jetstream2.us-east.bsky.network/subscribe?cursor=1708123456789000 144``` 145 146Jetstream retains a rolling 72-hour window of events. This means: 147- Downtime up to 72 hours → full recovery on restart with cursor 148- Downtime over 72 hours → gap in timeline, requires PDS backfill to fill it 149 150**Verify:** Confirm Aurora Prism's cursor service persists `time_us` durably (not just 151in-memory). Check the `migrations/` and `server/` directories for cursor table schema. 152 153### Problem 3 — Backfill on New Follow 154 155When user A follows user B, posts B made before A started following them need to be 156fetched. This cannot come from Jetstream (it only goes forward). It must come from a 157direct `com.atproto.repo.listRecords` call to B's PDS. 158 159``` 160GET https://bsky.social/xrpc/com.atproto.repo.listRecords 161 ?repo=<did> 162 &collection=app.bsky.feed.post 163 &limit=100 164``` 165 166Aurora Prism has `BACKFILL_DAYS` for startup backfill, but we need **on-demand backfill 167per follow event**. The flow must be: 168 169``` 170User follows DID B 171 → Write follow to PDS (via Aurora Prism write proxy) 172 → Aurora Prism detects new follow in firehose 173 → Triggers backfill job for DID B: fetch all posts from B's PDS 174 → Stores in PostgreSQL with correct timestamps 175 → Timeline is now complete for B retroactively 176``` 177 178**Verify:** Check if Aurora Prism's data-plane handles `app.bsky.graph.follow` events 179and triggers per-DID backfill. If not, this is a required code contribution. 180 181### Problem 4 — Deduplication 182 183Because posts can arrive from both Jetstream (live) and PDS backfill (historical), 184the same post (identified by `uri` = `at://did/app.bsky.feed.post/rkey`) must be 185idempotently upserted, not inserted twice. 186 187Aurora Prism's PostgreSQL schema should have a unique constraint on `uri` in the posts 188table. **Verify this constraint exists** in `migrations/` before relying on it. 189 190### Summary: What Must Be True for Complete Timelines 191 192| Requirement | How to satisfy it | 193|-------------|------------------| 194| No missed live posts | Persistent cursor resume on restart | 195| No missed historical posts | Per-follow PDS backfill triggered at follow time | 196| No duplicate posts | Unique constraint on `uri` + upsert logic | 197| Correct chronological order | Order by `indexedAt` or post `createdAt` timestamp | 198| Recovery from long downtime | PDS re-backfill if cursor window (72h) is exceeded | 199 200**These must be verified against Aurora Prism's actual implementation before launch.** 201If any are missing, they represent required additions to the codebase — not optional 202optimizations. 203 204--- 205 206## Phase 2: Aurora Prism Configuration 207 208### 2.1 Clone and Generate Keys 209 210Aurora Prism is used **completely unmodified** — we treat it as a managed backend. 211We configure it only via environment variables. 212 213```bash 214git clone https://github.com/dollspace-gay/Aurora-Prism.git /opt/aurora-prism 215cd /opt/aurora-prism 216./oauth-keyset-json.sh # generates OAuth JWKS keypair → oauth-keys.json 217./setup-did-and-keys.sh # generates did:web document 218``` 219 220### 2.2 Aurora Prism `.env` 221 222```env 223# /opt/aurora-prism/.env 224 225DATABASE_URL=postgresql://aurora:STRONG_POSTGRES_PASSWORD@postgres:5432/aurora_prism 226REDIS_URL=redis://redis:6379 227SESSION_SECRET=<openssl rand -base64 32> 228PORT=5000 229 230# AT Proto identity — must match your domain exactly 231APPVIEW_DID=did:web:your-domain.com 232APPVIEW_HOSTNAME=your-domain.com 233 234# Full Jetstream — all posts (~850 MB/day). No wantedDids filter until per-follow 235# PDS backfill + dynamic DID reconnection are confirmed in Aurora Prism. Completeness first. 236RELAY_URL=wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post&wantedCollections=app.bsky.feed.repost&wantedCollections=app.bsky.graph.follow 237 238# Data management 239DATA_RETENTION_DAYS=30 # prune unprotected (non-followed) content after 30 days 240BACKFILL_DAYS=7 # backfill 7 days of history per new user login 241``` 242 243### 2.3 What Aurora Prism Handles 244 245Aurora Prism is the source of truth for all AT Proto concerns: 246 247- XRPC API surface (52 Bluesky-compatible endpoints) 248- AT Proto OAuth 2.0 (initiation, callback, token refresh) 249- Write proxying to users' own PDSes (likes, posts, follows etc. go to bsky.social or wherever the user's PDS is) 250- Jetstream consumption and PostgreSQL indexing 251- Health endpoints at `/health` and `/ready` 252- `did:web` DID document at `/.well-known/did.json` 253 254--- 255 256## Phase 3: Next.js Application 257 258### 3.1 Project Init 259 260```bash 261cd /opt 262npx create-next-app@latest nextjs-app \ 263 --typescript --tailwind --app --no-src-dir --import-alias "@/*" 264 265cd nextjs-app 266npm install @atproto/api iron-session swr @tailwindcss/typography 267``` 268 269### 3.2 Key Dependencies 270 271| Package | Purpose | 272|---------|---------| 273| `@atproto/api` | Full typed XRPC client — point at Aurora Prism instead of bsky.social | 274| `iron-session` | Encrypted, signed HTTP-only session cookies for server-side auth state | 275| `swr` | Client-side data fetching, caching, revalidation | 276| `@tailwindcss/typography` | Rich text rendering for post bodies | 277 278### 3.3 Environment Variables 279 280```env 281# /opt/nextjs-app/.env.local 282 283# Internal Docker network URL (server-to-server, never touches the internet) 284AURORA_PRISM_INTERNAL_URL=http://aurora-prism:5000 285 286# Public URL (used in OAuth redirects and client-side JS) 287NEXT_PUBLIC_AURORA_PRISM_URL=https://your-domain.com 288 289# Session cookie encryption — must be different from Aurora Prism's SESSION_SECRET 290SESSION_SECRET=<openssl rand -base64 32> 291SESSION_COOKIE_NAME=myappview-session 292 293NEXT_PUBLIC_APP_NAME="My AT Proto AppView" 294``` 295 296### 3.4 AT Proto Agent Factory 297 298```typescript 299// lib/atproto.ts 300import { AtpAgent } from '@atproto/api' 301 302// Server-side: uses internal Docker network URL (fast, no TLS overhead) 303export function createServerAgent(accessJwt?: string): AtpAgent { 304 const agent = new AtpAgent({ 305 service: process.env.AURORA_PRISM_INTERNAL_URL!, 306 }) 307 if (accessJwt) { 308 // Attach the user's session so Aurora Prism returns personalized data 309 agent.session = { 310 accessJwt, 311 refreshJwt: '', 312 handle: '', 313 did: '', 314 active: true, 315 } 316 } 317 return agent 318} 319``` 320 321### 3.5 Session Handling 322 323```typescript 324// lib/session.ts 325import { getIronSession } from 'iron-session' 326import { cookies } from 'next/headers' 327 328export interface SessionData { 329 did: string 330 handle: string 331 accessJwt: string 332 refreshJwt: string 333} 334 335const sessionOptions = { 336 cookieName: process.env.SESSION_COOKIE_NAME!, 337 password: process.env.SESSION_SECRET!, 338 cookieOptions: { 339 secure: process.env.NODE_ENV === 'production', 340 httpOnly: true, 341 sameSite: 'lax' as const, 342 }, 343} 344 345export async function getSession() { 346 return getIronSession<SessionData>(await cookies(), sessionOptions) 347} 348``` 349 350### 3.6 Authentication API Routes 351 352**Login** (`app/api/auth/login/route.ts`): 353 354```typescript 355import { NextRequest, NextResponse } from 'next/server' 356import { getSession } from '@/lib/session' 357 358export async function POST(req: NextRequest) { 359 const { identifier, password } = await req.json() 360 361 // Forward credentials to Aurora Prism's createSession endpoint 362 const res = await fetch( 363 `${process.env.AURORA_PRISM_INTERNAL_URL}/xrpc/com.atproto.server.createSession`, 364 { 365 method: 'POST', 366 headers: { 'Content-Type': 'application/json' }, 367 body: JSON.stringify({ identifier, password }), 368 } 369 ) 370 371 if (!res.ok) { 372 const err = await res.json() 373 return NextResponse.json({ error: err.message ?? 'Login failed' }, { status: 401 }) 374 } 375 376 const data = await res.json() 377 const session = await getSession() 378 session.did = data.did 379 session.handle = data.handle 380 session.accessJwt = data.accessJwt 381 session.refreshJwt = data.refreshJwt 382 await session.save() 383 384 return NextResponse.json({ success: true, handle: data.handle }) 385} 386``` 387 388**Logout** (`app/api/auth/logout/route.ts`): 389 390```typescript 391import { NextResponse } from 'next/server' 392import { getSession } from '@/lib/session' 393 394export async function POST() { 395 const session = await getSession() 396 session.destroy() 397 return NextResponse.json({ success: true }) 398} 399``` 400 401### 3.7 Core Pages 402 403**File structure:** 404 405``` 406app/ 407├── layout.tsx Root layout with nav, session provider 408├── page.tsx Home / timeline (SSR) 409├── login/ 410│ └── page.tsx Login form (client component) 411├── profile/ 412│ └── [handle]/ 413│ └── page.tsx Profile + author feed (SSR + OG tags) 414├── post/ 415│ └── [...uri]/ 416│ └── page.tsx Post thread view (SSR) 417├── notifications/ 418│ └── page.tsx Notifications list 419└── api/ 420 ├── auth/ 421 │ ├── login/route.ts 422 │ └── logout/route.ts 423 ├── timeline/route.ts Paginated timeline (used by client for infinite scroll) 424 └── actions/ 425 ├── like/route.ts 426 ├── repost/route.ts 427 └── post/route.ts 428``` 429 430**Timeline page** (`app/page.tsx`): 431 432```typescript 433import { redirect } from 'next/navigation' 434import { getSession } from '@/lib/session' 435import { createServerAgent } from '@/lib/atproto' 436import { Timeline } from '@/components/Timeline' 437 438export default async function HomePage() { 439 const session = await getSession() 440 if (!session.did) redirect('/login') 441 442 const agent = createServerAgent(session.accessJwt) 443 const timeline = await agent.getTimeline({ limit: 50 }) 444 445 return <Timeline initialData={timeline.data} session={session} /> 446} 447``` 448 449**Profile page** (`app/profile/[handle]/page.tsx`): 450 451```typescript 452import { createServerAgent } from '@/lib/atproto' 453 454export async function generateMetadata({ params }: { params: { handle: string } }) { 455 const agent = createServerAgent() 456 const profile = await agent.getProfile({ actor: params.handle }) 457 return { 458 title: profile.data.displayName ?? `@${params.handle}`, 459 description: profile.data.description, 460 openGraph: { images: profile.data.avatar ? [profile.data.avatar] : [] }, 461 } 462} 463 464export default async function ProfilePage({ params }: { params: { handle: string } }) { 465 const agent = createServerAgent() 466 const [profile, feed] = await Promise.all([ 467 agent.getProfile({ actor: params.handle }), 468 agent.getAuthorFeed({ actor: params.handle, limit: 50 }), 469 ]) 470 return <ProfileView profile={profile.data} feed={feed.data} /> 471} 472``` 473 474### 3.8 Write Actions (Server-Side Proxy) 475 476All mutations go through Next.js API routes, keeping AT Proto credentials server-side: 477 478```typescript 479// app/api/actions/like/route.ts 480import { NextRequest, NextResponse } from 'next/server' 481import { getSession } from '@/lib/session' 482import { createServerAgent } from '@/lib/atproto' 483 484export async function POST(req: NextRequest) { 485 const session = await getSession() 486 if (!session.accessJwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 487 488 const { uri, cid } = await req.json() 489 const agent = createServerAgent(session.accessJwt) 490 await agent.like(uri, cid) 491 return NextResponse.json({ success: true }) 492} 493 494export async function DELETE(req: NextRequest) { 495 const session = await getSession() 496 if (!session.accessJwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 497 498 const { likeUri } = await req.json() 499 const agent = createServerAgent(session.accessJwt) 500 await agent.deleteLike(likeUri) 501 return NextResponse.json({ success: true }) 502} 503``` 504 505### 3.9 `next.config.ts` 506 507```typescript 508import type { NextConfig } from 'next' 509 510const nextConfig: NextConfig = { 511 output: 'standalone', // Required for the optimized Docker production build 512 images: { 513 remotePatterns: [ 514 { protocol: 'https', hostname: 'cdn.bsky.app' }, 515 { protocol: 'https', hostname: '*.bsky.network' }, 516 { protocol: 'https', hostname: 'your-domain.com' }, 517 ], 518 }, 519} 520 521export default nextConfig 522``` 523 524--- 525 526## Phase 4: Docker Compose 527 528### 4.1 Master `docker-compose.yml` 529 530```yaml 531# /opt/docker-compose.yml 532version: '3.9' 533 534services: 535 536 postgres: 537 image: postgres:14-alpine 538 restart: unless-stopped 539 environment: 540 POSTGRES_DB: aurora_prism 541 POSTGRES_USER: aurora 542 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 543 volumes: 544 - /mnt/appdata/postgres:/var/lib/postgresql/data 545 networks: 546 - appnet 547 healthcheck: 548 test: ["CMD-SHELL", "pg_isready -U aurora"] 549 interval: 10s 550 timeout: 5s 551 retries: 5 552 command: > 553 postgres 554 -c shared_buffers=2GB 555 -c effective_cache_size=6GB 556 -c work_mem=16MB 557 -c maintenance_work_mem=256MB 558 -c max_connections=200 559 -c checkpoint_completion_target=0.9 560 -c wal_buffers=64MB 561 562 redis: 563 image: redis:7-alpine 564 restart: unless-stopped 565 volumes: 566 - /mnt/appdata/redis:/data 567 networks: 568 - appnet 569 # 512MB is plenty for 1000 users — Aurora Prism defaults to 8GB which is excessive 570 command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru 571 healthcheck: 572 test: ["CMD", "redis-cli", "ping"] 573 interval: 10s 574 retries: 5 575 576 aurora-prism: 577 image: ghcr.io/dollspace-gay/aurora-prism:latest 578 restart: unless-stopped 579 depends_on: 580 postgres: 581 condition: service_healthy 582 redis: 583 condition: service_healthy 584 env_file: /opt/aurora-prism/.env 585 environment: 586 DATABASE_URL: postgresql://aurora:${POSTGRES_PASSWORD}@postgres:5432/aurora_prism 587 REDIS_URL: redis://redis:6379 588 volumes: 589 - /opt/aurora-prism/oauth-keys.json:/app/oauth-keys.json:ro 590 networks: 591 - appnet 592 healthcheck: 593 test: ["CMD", "curl", "-f", "http://localhost:5000/health"] 594 interval: 10s 595 timeout: 5s 596 retries: 10 597 598 nextjs-app: 599 build: 600 context: /opt/nextjs-app 601 dockerfile: Dockerfile 602 restart: unless-stopped 603 depends_on: 604 aurora-prism: 605 condition: service_healthy 606 env_file: /opt/nextjs-app/.env.local 607 environment: 608 NODE_ENV: production 609 AURORA_PRISM_INTERNAL_URL: http://aurora-prism:5000 610 networks: 611 - appnet 612 613 nginx: 614 image: nginx:alpine 615 restart: unless-stopped 616 depends_on: 617 - aurora-prism 618 - nextjs-app 619 ports: 620 - "80:80" 621 - "443:443" 622 volumes: 623 - /opt/nginx/nginx.conf:/etc/nginx/nginx.conf:ro 624 - /etc/letsencrypt:/etc/letsencrypt:ro 625 networks: 626 - appnet 627 628networks: 629 appnet: 630 driver: bridge 631``` 632 633**`.env`** at `/opt/.env` (shared secrets): 634 635```env 636POSTGRES_PASSWORD=generate_a_very_strong_password_here 637``` 638 639### 4.2 Next.js Dockerfile 640 641```dockerfile 642# /opt/nextjs-app/Dockerfile 643FROM node:20-alpine AS base 644 645FROM base AS deps 646WORKDIR /app 647COPY package.json package-lock.json ./ 648RUN npm ci 649 650FROM base AS builder 651WORKDIR /app 652COPY --from=deps /app/node_modules ./node_modules 653COPY . . 654RUN npm run build 655 656FROM base AS runner 657WORKDIR /app 658ENV NODE_ENV=production 659 660RUN addgroup -S nodejs && adduser -S nextjs -G nodejs 661 662COPY --from=builder /app/public ./public 663COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 664COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 665 666USER nextjs 667EXPOSE 3000 668ENV PORT=3000 HOSTNAME="0.0.0.0" 669CMD ["node", "server.js"] 670``` 671 672--- 673 674## Phase 5: Nginx Configuration 675 676```nginx 677# /opt/nginx/nginx.conf 678events { worker_connections 1024; } 679 680http { 681 limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m; 682 limit_req_zone $binary_remote_addr zone=general:10m rate=120r/m; 683 684 upstream aurora_prism { server aurora-prism:5000; keepalive 32; } 685 upstream nextjs { server nextjs-app:3000; keepalive 32; } 686 687 # HTTP → HTTPS 688 server { 689 listen 80; 690 server_name your-domain.com; 691 return 301 https://$host$request_uri; 692 } 693 694 server { 695 listen 443 ssl; 696 server_name your-domain.com; 697 698 ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; 699 ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; 700 ssl_protocols TLSv1.2 TLSv1.3; 701 702 add_header X-Frame-Options "SAMEORIGIN"; 703 add_header X-Content-Type-Options "nosniff"; 704 705 # AT Proto did:web document (served by Aurora Prism) 706 location /.well-known/ { 707 proxy_pass http://aurora_prism; 708 proxy_set_header Host $host; 709 } 710 711 # Aurora Prism XRPC API 712 location /xrpc/ { 713 limit_req zone=api burst=20 nodelay; 714 proxy_pass http://aurora_prism; 715 proxy_set_header Host $host; 716 proxy_set_header X-Real-IP $remote_addr; 717 proxy_set_header X-Forwarded-Proto $scheme; 718 proxy_read_timeout 60s; 719 } 720 721 # Aurora Prism health + OAuth endpoints 722 location ~ ^/(health|ready|oauth)/ { 723 proxy_pass http://aurora_prism; 724 proxy_set_header Host $host; 725 } 726 727 # Next.js static assets (aggressively cached) 728 location /_next/static/ { 729 proxy_pass http://nextjs; 730 expires 1y; 731 add_header Cache-Control "public, immutable"; 732 } 733 734 # Everything else → Next.js 735 location / { 736 limit_req zone=general burst=50 nodelay; 737 proxy_pass http://nextjs; 738 proxy_set_header Host $host; 739 proxy_set_header X-Real-IP $remote_addr; 740 proxy_set_header X-Forwarded-Proto $scheme; 741 proxy_http_version 1.1; 742 proxy_set_header Upgrade $http_upgrade; 743 proxy_set_header Connection "upgrade"; 744 } 745 } 746} 747``` 748 749--- 750 751## Phase 6: TLS + AT Proto Identity 752 753### 6.1 TLS Certificate 754 755```bash 756# Bootstrap: get cert before enabling HTTPS in Nginx 757# Nginx must be running on port 80 with a simple HTTP config first 758certbot certonly --nginx -d your-domain.com --email your@email.com --agree-tos --non-interactive 759 760# Auto-renew via cron 761(crontab -l; echo "0 3 * * * certbot renew --quiet && docker exec nginx nginx -s reload") | crontab - 762``` 763 764### 6.2 AT Proto `did:web` Identity 765 766The `did:web:your-domain.com` identity requires: 767- `https://your-domain.com/.well-known/did.json` — served automatically by Aurora Prism 768- `https://your-domain.com/.well-known/atproto-did` — also served by Aurora Prism 769 770Both are routed through Nginx via the `/.well-known/` location block above. 771 772**Critical:** Run `./setup-did-and-keys.sh` before first launch. The `APPVIEW_DID` and 773`APPVIEW_HOSTNAME` env vars in Aurora Prism's `.env` must match your domain exactly. 774This cannot be changed easily after launch without regenerating keys. 775 776--- 777 778## Phase 7: Deploy + Operations 779 780### 7.1 Initial Deploy Sequence 781 782```bash 783# 1. On Droplet: ensure directories exist 784mkdir -p /opt/aurora-prism /opt/nextjs-app /opt/nginx 785 786# 2. Push configs (from local machine via rsync or scp) 787rsync -avz ./nextjs-app/ droplet:/opt/nextjs-app/ 788rsync -avz ./nginx/ droplet:/opt/nginx/ 789# Aurora Prism is cloned directly on the Droplet (step in Phase 2) 790 791# 3. Start all services 792cd /opt 793docker compose up -d 794 795# 4. Run Aurora Prism DB migrations 796docker compose exec aurora-prism npm run db:push 797 798# 5. Verify health 799curl https://your-domain.com/health 800curl https://your-domain.com/xrpc/com.atproto.server.describeServer 801``` 802 803### 7.2 Update Script 804 805```bash 806#!/bin/bash 807# /opt/deploy.sh 808set -e 809 810docker pull ghcr.io/dollspace-gay/aurora-prism:latest 811docker compose build nextjs-app 812docker compose up -d --no-deps aurora-prism nextjs-app 813docker compose exec aurora-prism npm run db:push 814 815echo "Deploy complete ✓" 816``` 817 818### 7.3 Monitoring 819 820```bash 821# Install DO Monitoring Agent (free, adds CPU/memory/disk alerts in DO console) 822curl -sSL https://repos.insights.digitalocean.com/install.sh | sudo bash 823 824# Watch logs 825docker compose logs -f # all services 826docker compose logs -f aurora-prism # just the AppView 827docker compose logs -f nextjs-app # just the Next.js app 828``` 829 830--- 831 832## Cost Summary 833 834| Line item | Cost/month | 835|-----------|-----------| 836| DO Droplet — 8 GB / 4 vCPU General Purpose | $48 | 837| DO Block Storage — 100 GB | $10 | 838| Domain name (annualized) | ~$1 | 839| Let's Encrypt TLS | Free | 840| DO Monitoring Agent | Free | 841| **Total** | **~$59/month** | 842 843--- 844 845## Todo List 846 847> To be added as a granular checklist by your agent 848--- 849 850## Open Questions for Annotation 851 8521. **Aurora Prism cursor persistence — must verify before launch.** Does Aurora Prism 853 write the Jetstream `time_us` cursor to PostgreSQL after each event batch? If it only 854 stores it in-memory, a process restart causes a timeline gap for the downtime window. 855 This must be confirmed by reading the `data-plane/` source. If absent, it must be added 856 before launch — it is a prerequisite for the completeness guarantee. 857 8582. **Does Aurora Prism trigger per-follow PDS backfill?** When an `app.bsky.graph.follow` 859 event is processed, does the data-plane automatically fetch the new followee's post 860 history from their PDS? If not, new follows will have no historical posts until the 861 next manual backfill cycle. This is likely the biggest gap to verify — if missing, 862 it requires a code contribution to Aurora Prism's data-plane. 863 8643. **Upsert semantics on the posts table.** The PostgreSQL schema must use 865 `INSERT ... ON CONFLICT (uri) DO NOTHING` (or equivalent upsert) so Jetstream live 866 events and PDS backfill don't create duplicate posts. Verify the unique constraint on 867 `uri` exists in the `migrations/` folder before launch. 868 8694. **Full AT Proto OAuth 2.0 (DPoP/PKCE) vs app-password auth?** The plan currently 870 covers app-password auth (simpler, works today). True OAuth 2.0 is the decentralized 871 ideal but adds significant implementation complexity. Which do you want first? 872 8735. **Should Aurora Prism's built-in dashboard be accessible** (e.g. at `/admin`) 874 for monitoring, or fully hidden behind Nginx in production? 875 8766. **CI/CD pipeline?** GitHub Actions can SSH into the Droplet and run `deploy.sh` 877 on every push to main — do you want that wired up as part of the plan? 878 8797. **Domain name chosen?** The `did:web` identity, TLS certificate, and Nginx config 880 all depend on a fixed domain. It must be decided before running `./setup-did-and-keys.sh` 881 — the domain is baked into Aurora Prism's signing keys and cannot be changed 882 without a full key rotation.