···11- Wisp.place - Decentralized Static Site Hosting
11+# Wisp.place - Codebase Overview
22+33+**Project URL**: https://wisp.place
44+55+A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution.
66+77+---
88+99+## ๐๏ธ Architecture Overview
1010+1111+### Multi-Part System
1212+1. **Main Backend** (`/src`) - OAuth, site management, custom domains
1313+2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
1414+3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
1515+4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
1616+1717+### Tech Stack
1818+- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
1919+- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
2020+- **CLI**: Rust with Jacquard (AT Protocol library)
2121+- **Database**: PostgreSQL for session/domain/site caching
2222+- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
2323+2424+---
2525+2626+## ๐ Directory Structure
2727+2828+### `/src` - Main Backend Server
2929+**Purpose**: Core server handling OAuth, site management, custom domains, admin features
3030+3131+**Key Routes**:
3232+- `/api/auth/*` - OAuth signin/callback/logout/status
3333+- `/api/domain/*` - Custom domain management (BYOD)
3434+- `/wisp/*` - Site upload and management
3535+- `/api/user/*` - User info and site listing
3636+- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
3737+3838+**Key Files**:
3939+- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
4040+- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
4141+- `lib/db.ts` - PostgreSQL schema and queries for all tables
4242+- `lib/wisp-auth.ts` - Cookie-based authentication middleware
4343+- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
4444+- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
4545+- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
4646+- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
4747+- `lib/admin-auth.ts` - Simple username/password admin authentication
4848+- `lib/observability.ts` - Logging, error tracking, metrics collection
4949+- `routes/auth.ts` - OAuth flow handlers
5050+- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
5151+- `routes/domain.ts` - Domain claiming/verification API
5252+- `routes/user.ts` - User status/info/sites listing
5353+- `routes/site.ts` - Site metadata and file retrieval
5454+- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
5555+5656+### `/lexicons` & `src/lexicons/`
5757+**Purpose**: AT Protocol Lexicon definitions for custom data types
5858+5959+**Key File**: `fs.json` - Defines `place.wisp.fs` record format
6060+- **structure**: Virtual filesystem manifest with tree structure
6161+- **site**: string identifier
6262+- **root**: directory object containing entries
6363+- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
6464+- **directory**: array of entries (recursive)
6565+- **entry**: name + node (file or directory)
6666+6767+**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
6868+6969+### `/hosting-service`
7070+**Purpose**: Lightweight microservice that serves cached sites from disk
7171+7272+**Architecture**:
7373+- Routes by domain lookup in PostgreSQL
7474+- Caches site content locally on first access or firehose event
7575+- Listens to AT Protocol firehose for new site records
7676+- Automatically downloads and caches files from PDS
7777+- SSRF-protected fetch (timeout, size limits, private IP blocking)
7878+7979+**Routes**:
8080+1. Custom domains (`/*`) โ lookup custom_domains table
8181+2. Wisp subdomains (`/*.wisp.place/*`) โ lookup domains table
8282+3. DNS hash routing (`/hash.dns.wisp.place/*`) โ lookup custom_domains by hash
8383+4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ fetch from PDS if not cached
8484+8585+**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
8686+8787+### `/cli`
8888+**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
8989+9090+**Flow**:
9191+1. Authenticate with handle + app password or OAuth
9292+2. Walk directory tree, compress files
9393+3. Upload blobs to PDS via agent
9494+4. Create place.wisp.fs record with manifest
9595+5. Store site in database cache
9696+9797+**Auth Methods**:
9898+- `--password` flag for app password auth
9999+- OAuth loopback server for browser-based auth
100100+- Supports both (password preferred if provided)
101101+102102+---
103103+104104+## ๐ Key Concepts
105105+106106+### Custom Domains (BYOD - Bring Your Own Domain)
107107+**Process**:
108108+1. User claims custom domain via API
109109+2. System generates hash (SHA256(domain + secret))
110110+3. User adds DNS records:
111111+ - TXT at `_wisp.example.com` = their DID
112112+ - CNAME at `example.com` = `{hash}.dns.wisp.place`
113113+4. Background worker checks verification every 10 minutes
114114+5. Once verified, custom domain routes to their hosted sites
115115+116116+**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
117117+118118+### Wisp Subdomains
119119+**Process**:
120120+1. Handle claimed on first signup (e.g., alice โ alice.wisp.place)
121121+2. Stored in `domains` table mapping domain โ DID
122122+3. Served by hosting service
123123+124124+### Site Storage
125125+**Locations**:
126126+- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
127127+- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
128128+- **File Cache**: Hosting service caches downloaded files on disk
129129+130130+**Limits**:
131131+- MAX_SITE_SIZE: 300MB total
132132+- MAX_FILE_SIZE: 100MB per file
133133+- MAX_FILE_COUNT: 2000 files
134134+135135+### File Compression Strategy
136136+**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
137137+138138+**Process**:
139139+1. All files gzip-compressed (level 9)
140140+2. Compressed content base64-encoded
141141+3. Uploaded as `application/octet-stream` MIME type
142142+4. Blob metadata stores original MIME type + encoding flag
143143+5. Hosting service decompresses on serve
144144+145145+---
146146+147147+## ๐ Data Flow
148148+149149+### User Registration โ Site Upload
150150+```
151151+1. OAuth signin โ state/session stored in DB
152152+2. Cookie set with DID
153153+3. Sync sites from PDS to cache DB
154154+4. If no sites/domain โ redirect to onboarding
155155+5. User creates site โ POST /wisp/upload-files
156156+6. Files compressed, uploaded as blobs
157157+7. place.wisp.fs record created
158158+8. Site cached in DB
159159+9. Hosting service notified via firehose
160160+```
161161+162162+### Custom Domain Setup
163163+```
164164+1. User claims domain (DB check + allocation)
165165+2. System generates hash
166166+3. User adds DNS records (_wisp.domain TXT + CNAME)
167167+4. Background worker verifies every 10 min
168168+5. Hosting service routes based on verification status
169169+```
170170+171171+### Site Access
172172+```
173173+Hosting Service:
174174+1. Request arrives at custom domain or *.wisp.place
175175+2. Domain lookup in PostgreSQL
176176+3. Check cache for site files
177177+4. If not cached:
178178+ - Fetch from PDS using DID + rkey
179179+ - Decompress files
180180+ - Save to disk cache
181181+5. Serve files (with HTML path rewriting)
182182+```
183183+184184+---
185185+186186+## ๐ ๏ธ Important Implementation Details
187187+188188+### OAuth Implementation
189189+- **State & Session Storage**: PostgreSQL (with expiration)
190190+- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
191191+- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
192192+- **Session Timeout**: 30 days
193193+- **State Timeout**: 1 hour
194194+195195+### Security Headers
196196+- X-Frame-Options: DENY
197197+- X-Content-Type-Options: nosniff
198198+- Strict-Transport-Security: max-age=31536000
199199+- Content-Security-Policy (configured for Elysia + React)
200200+- X-XSS-Protection: 1; mode=block
201201+- Referrer-Policy: strict-origin-when-cross-origin
202202+203203+### Admin Authentication
204204+- Simple username/password (hashed with bcrypt)
205205+- Session-based cookie auth (24hr expiration)
206206+- Separate `admin_session` cookie
207207+- Initial setup prompted on startup
208208+209209+### Observability
210210+- **Logging**: Structured logging with service tags + event types
211211+- **Error Tracking**: Captures error context (message, stack, etc.)
212212+- **Metrics**: Request counts, latencies, error rates
213213+- **Log Levels**: debug, info, warn, error
214214+- **Collection**: Centralized log collector with in-memory buffer
215215+216216+---
217217+218218+## ๐ Database Schema
219219+220220+### oauth_states
221221+- key (primary key)
222222+- data (JSON)
223223+- created_at, expires_at (timestamps)
222433- Architecture Overview
225225+### oauth_sessions
226226+- sub (primary key - subject/DID)
227227+- data (JSON with OAuth session)
228228+- updated_at, expires_at
422955- Wisp.Place a two-service application that provides static site hosting on the AT
66- Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
230230+### oauth_keys
231231+- kid (primary key - key ID)
232232+- jwk (JSON Web Key)
233233+- created_at
234234+235235+### domains
236236+- domain (primary key - e.g., alice.wisp.place)
237237+- did (unique - user's DID)
238238+- rkey (optional - record key)
239239+- created_at
240240+241241+### custom_domains
242242+- id (primary key - UUID)
243243+- domain (unique - e.g., example.com)
244244+- did (user's DID)
245245+- rkey (optional)
246246+- verified (boolean)
247247+- last_verified_at (timestamp)
248248+- created_at
249249+250250+### sites
251251+- id, did, rkey, site_name
252252+- created_at, updated_at
253253+- Indexes on (did), (did, rkey), (rkey)
254254+255255+### admin_users
256256+- username (primary key)
257257+- password_hash (bcrypt)
258258+- created_at
259259+260260+---
261261+262262+## ๐ Key Workflows
263263+264264+### Sign In Flow
265265+1. POST /api/auth/signin with handle
266266+2. System generates state token
267267+3. Redirects to PDS OAuth endpoint
268268+4. PDS redirects back to /api/auth/callback?code=X&state=Y
269269+5. Validate state (CSRF protection)
270270+6. Exchange code for session
271271+7. Store session in DB, set DID cookie
272272+8. Sync sites from PDS
273273+9. Redirect to /editor or /onboarding
274274+275275+### File Upload Flow
276276+1. POST /wisp/upload-files with siteName + files
277277+2. Validate site name (rkey format rules)
278278+3. For each file:
279279+ - Check size limits
280280+ - Read as ArrayBuffer
281281+ - Gzip compress
282282+ - Base64 encode
283283+4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
284284+5. Create manifest with all blob refs
285285+6. putRecord() for place.wisp.fs with manifest
286286+7. Upsert to sites table
287287+8. Return URI + CID
728888- Service 1: Main App (Port 8000, Bun runtime, elysia.js)
99- - User-facing editor and API
1010- - OAuth authentication (AT Protocol)
1111- - File upload processing (gzip + base64 encoding)
1212- - Domain management (subdomains + custom domains)
1313- - DNS verification worker
1414- - React frontend
289289+### Domain Verification Flow
290290+1. POST /api/custom-domains/claim
291291+2. Generate hash = SHA256(domain + secret)
292292+3. Store in custom_domains with verified=false
293293+4. Return hash for user to configure DNS
294294+5. Background worker periodically:
295295+ - Query custom_domains where verified=false
296296+ - Verify TXT record at _wisp.domain
297297+ - Verify CNAME points to hash.dns.wisp.place
298298+ - Update verified flag + last_verified_at
299299+6. Hosting service routes when verified=true
153001616- Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
1717- - AT Protocol Firehose listener for real-time updates
1818- - Serves hosted websites from local cache
1919- - Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
2020- - Distributed locking for multi-instance coordination
301301+---
213022222- Tech Stack
303303+## ๐จ Frontend Structure
233042424- - Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
2525- - Frontend: React 19, Tailwind CSS v4, Shadcn UI
305305+### `/public`
306306+- **index.tsx** - Landing page with sign-in form
307307+- **editor/editor.tsx** - Site editor/management UI
308308+- **admin/admin.tsx** - Admin dashboard
309309+- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
310310+- **styles/global.css** - Tailwind + custom styles
263112727- Key Features
312312+### Page Flow
313313+1. `/` - Landing page (sign in / get started)
314314+2. `/editor` - Main app (requires auth)
315315+3. `/admin` - Admin console (requires admin auth)
316316+4. `/onboarding` - First-time user setup
283172929- - AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
3030- - File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
3131- - Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
3232- - Real-time Sync: Firehose worker listens for site updates and caches files locally
3333- - Atomic Updates: Safe cache swapping without downtime
318318+---
319319+320320+## ๐ Notable Implementation Patterns
321321+322322+### File Handling
323323+- Files stored as base64-encoded gzip in PDS blobs
324324+- Metadata preserves original MIME type
325325+- Hosting service decompresses on serve
326326+- Workaround for PDS image pipeline issues with HTML
327327+328328+### Error Handling
329329+- Comprehensive logging with context
330330+- Graceful degradation (e.g., site sync failure doesn't break auth)
331331+- Structured error responses with details
332332+333333+### Performance
334334+- Site sync: Batch fetch up to 100 records per request
335335+- Blob upload: Parallel promises for all files
336336+- DNS verification: Batched background worker (10 min intervals)
337337+- Caching: Two-tier (DB + disk in hosting service)
338338+339339+### Validation
340340+- Lexicon validation on manifest creation
341341+- Record type checking
342342+- Domain format validation
343343+- Site name format validation (AT Protocol rkey rules)
344344+- File size limits enforced before upload
345345+346346+---
347347+348348+## ๐ Known Quirks & Workarounds
349349+350350+1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
351351+352352+2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
353353+354354+3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
355355+356356+4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
357357+358358+5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
359359+360360+---
361361+362362+## ๐ Environment Variables
363363+364364+- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
365365+- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
366366+- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
367367+- `NODE_ENV` - production/development
368368+- `HOSTING_PORT` - Hosting service port (default: 3001)
369369+- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
370370+371371+---
372372+373373+## ๐งโ๐ป Development Notes
374374+375375+### Adding New Features
376376+1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
377377+2. **DB changes**: Add migration in db.ts
378378+3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
379379+4. **Admin features**: Add to /api/admin endpoints
380380+381381+### Testing
382382+- Run with `bun test`
383383+- CSRF tests in lib/csrf.test.ts
384384+- Utility tests in lib/wisp-utils.test.ts
385385+386386+### Debugging
387387+- Check logs via `/api/admin/logs` (requires admin auth)
388388+- DNS verification manual trigger: POST /api/admin/verify-dns
389389+- Health check: GET /api/health (includes DNS verifier status)
390390+391391+---
392392+393393+## ๐ Deployment Considerations
394394+395395+1. **Secrets**: Admin password, OAuth keys, database credentials
396396+2. **HTTPS**: Required (HSTS header enforces it)
397397+3. **CDN**: Custom domains require DNS configuration
398398+4. **Scaling**:
399399+ - Main server: Horizontal scaling with session DB
400400+ - Hosting service: Independent scaling, disk cache per instance
401401+5. **Backups**: PostgreSQL database critical; firehose provides recovery
402402+403403+---
404404+405405+## ๐ Related Technologies
406406+407407+- **AT Protocol**: Decentralized identity, OAuth 2.0
408408+- **Jacquard**: Rust library for AT Protocol interactions
409409+- **Elysia**: Bun web framework (similar to Express/Hono)
410410+- **Lexicon**: AT Protocol's schema definition language
411411+- **Firehose**: Real-time event stream of repo changes
412412+- **PDS**: Personal Data Server (where users' data stored)
413413+414414+---
415415+416416+## ๐ฏ Project Goals
417417+418418+โ Decentralized site hosting (data owned by users)
419419+โ Custom domain support with DNS verification
420420+โ Fast CDN distribution via hosting service
421421+โ Developer tools (CLI + API)
422422+โ Admin dashboard for monitoring
423423+โ Zero user data retention (sites in PDS, sessions in DB only)
424424+425425+---
426426+427427+**Last Updated**: November 2025
428428+**Status**: Active development