Plan for specialty chronological feed of everyone you follow with capacity for 1000 users per deployed instance
personal-feed-plan.md
edited
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.