···327327 // Expand subfs nodes before caching
328328 const expandedRoot = await expandSubfsNodes(record.root, pdsEndpoint);
329329330330- // Verify all subfs nodes were expanded (defensive check)
330330+ // Verify all subfs nodes were expanded
331331 const remainingSubfs = extractSubfsUris(expandedRoot);
332332 if (remainingSubfs.length > 0) {
333333 console.warn(`[Cache] Warning: ${remainingSubfs.length} subfs nodes remain unexpanded after expansion`, remainingSubfs);
334334 }
335335336336- // ===== VALIDATE LIMITS BEFORE DOWNLOADING ANY BLOBS =====
337337-338338- // 1. Validate file count limit
336336+ // Validate file count limit
339337 const fileCount = countFilesInDirectory(expandedRoot);
340338 if (fileCount > MAX_FILE_COUNT) {
341339 throw new Error(`Site exceeds file count limit: ${fileCount} files (max ${MAX_FILE_COUNT})`);
342340 }
343341 console.log(`[Cache] File count validation passed: ${fileCount} files (limit: ${MAX_FILE_COUNT})`);
344342345345- // 2. Validate total size from blob metadata in manifest (before downloading)
343343+ // Validate total size from blob metadata
346344 const totalBlobSize = calculateTotalBlobSize(expandedRoot);
347345 if (totalBlobSize > MAX_SITE_SIZE) {
348346 throw new Error(`Site exceeds size limit: ${(totalBlobSize / 1024 / 1024).toFixed(2)}MB (max ${(MAX_SITE_SIZE / 1024 / 1024).toFixed(0)}MB)`);
349347 }
350348 console.log(`[Cache] Size validation passed: ${(totalBlobSize / 1024 / 1024).toFixed(2)}MB (limit: ${(MAX_SITE_SIZE / 1024 / 1024).toFixed(0)}MB)`);
351349352352- // All validations passed, proceed with caching
353350 // Get existing cache metadata to check for incremental updates
354351 const existingMetadata = await getCacheMetadata(did, rkey);
355352 const existingFileCids = existingMetadata?.fileCids || {};
···564561565562 console.log(`[Cache] Fetching blob for file: ${filePath}, CID: ${cid}`);
566563567567- // Allow up to MAX_BLOB_SIZE per file blob, with 5 minute timeout
568564 let content = await safeFetchBlob(blobUrl, { maxSize: MAX_BLOB_SIZE, timeout: 300000 });
569565570566 // If content is base64-encoded, decode it back to raw binary (gzipped or not)
+116-401
claude.md
···11-# Wisp.place - Codebase Overview
11+The project is wisp.place. It is a static site hoster built on top of the AT Protocol. The overall basis of the project is that users upload site assets to their PDS as blobs, and creates a manifest record listing every blob as well as site name. The hosting service then catches events relating to the site (create, read, upload, delete) and handles them appropriately.
2233-**Project URL**: https://wisp.place
33+The lexicons look like this:
44+```typescript
55+//place.wisp.fs
66+interface Main {
77+ $type: 'place.wisp.fs'
88+ site: string
99+ root: Directory
1010+ fileCount?: number
1111+ createdAt: string
1212+}
41355-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.
1414+interface File {
1515+ $type?: 'place.wisp.fs#file'
1616+ type: 'file'
1717+ blob: BlobRef
1818+ encoding?: 'gzip'
1919+ mimeType?: string
2020+ base64?: boolean
2121+}
62277----
2323+interface Directory {
2424+ $type?: 'place.wisp.fs#directory'
2525+ type: 'directory'
2626+ entries: Entry[]
2727+}
82899-## ๐๏ธ Architecture Overview
2929+interface Entry {
3030+ $type?: 'place.wisp.fs#entry'
3131+ name: string
3232+ node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
3333+}
10341111-### 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
3535+interface Subfs {
3636+ $type?: 'place.wisp.fs#subfs'
3737+ type: 'subfs'
3838+ subject: string // AT-URI pointing to a place.wisp.subfs record
3939+ flat?: boolean
4040+}
23412424----
4242+//place.wisp.subfs
4343+interface Main {
4444+ $type: 'place.wisp.subfs'
4545+ root: Directory
4646+ fileCount?: number
4747+ createdAt: string
4848+}
25492626-## ๐ Directory Structure
5050+interface File {
5151+ $type?: 'place.wisp.subfs#file'
5252+ type: 'file'
5353+ blob: BlobRef
5454+ encoding?: 'gzip'
5555+ mimeType?: string
5656+ base64?: boolean
5757+}
27582828-### `/src` - Main Backend Server
2929-**Purpose**: Core server handling OAuth, site management, custom domains, admin features
5959+interface Directory {
6060+ $type?: 'place.wisp.subfs#directory'
6161+ type: 'directory'
6262+ entries: Entry[]
6363+}
30643131-**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)
6565+interface Entry {
6666+ $type?: 'place.wisp.subfs#entry'
6767+ name: string
6868+ node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
6969+}
37703838-**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)
7171+interface Subfs {
7272+ $type?: 'place.wisp.subfs#subfs'
7373+ type: 'subfs'
7474+ subject: string // AT-URI pointing to another place.wisp.subfs record
7575+}
55765656-### `/lexicons` & `src/lexicons/`
5757-**Purpose**: AT Protocol Lexicon definitions for custom data types
7777+//place.wisp.settings
7878+interface Main {
7979+ $type: 'place.wisp.settings'
8080+ directoryListing: boolean
8181+ spaMode?: string
8282+ custom404?: string
8383+ indexFiles?: string[]
8484+ cleanUrls: boolean
8585+ headers?: CustomHeader[]
8686+}
58875959-**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
8888+interface CustomHeader {
8989+ $type?: 'place.wisp.settings#customHeader'
9090+ name: string
9191+ value: string
9292+ path?: string // Optional glob pattern
9393+}
16094```
16195162162-### 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-```
9696+The main differences between place.wisp.fs and place.wisp.subfs:
9797+ - place.wisp.fs has a required site field
9898+ - place.wisp.fs#subfs has an optional flat field that place.wisp.subfs#subfs doesn't have
17099171171-### 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-```
100100+The project is a monorepo. The package handler it uses for the typescript side is Bun. For the Rust cli, it is cargo.
183101184184----
102102+### Typescript Bun Workspace Layout
185103186186-## ๐ ๏ธ Important Implementation Details
104104+Bun workspaces: `packages/@wisp/*`, `apps/main-app`, `apps/hosting-service`
187105188188-### 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
106106+There are two typescript apps
107107+**`apps/main-app`** - Main backend (Bun + Elysia)
194108195195-### 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)
224224-225225-### oauth_sessions
226226-- sub (primary key - subject/DID)
227227-- data (JSON with OAuth session)
228228-- updated_at, expires_at
229229-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
288288-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
300300-301301----
302302-303303-## ๐จ Frontend Structure
304304-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
311311-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
317317-318318----
109109+- OAuth authentication and session management
110110+- Site CRUD operations via PDS
111111+- Custom domain management
112112+- Admin database view in /admin
113113+- React frontend in public/
319114320320-## ๐ Notable Implementation Patterns
115115+**`apps/hosting-service`** - CDN static file server (Node + Hono)
321116322322-### 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
117117+- Watches AT Protocol firehose for `place.wisp.fs` record changes
118118+- Downloads and caches site files to disk
119119+- Serves sites at `https://sites.wisp.place/{did}/{site-name}` and custom domains
120120+- Handles redirects (`_redirects` file support) and routing logic
121121+- Backfill mode for syncing existing sites
327122328328-### 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
123123+### Shared Packages (`packages/@wisp/*`)
332124333333-### 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)
125125+- **`lexicons`** - AT Protocol lexicons (`place.wisp.fs`, `place.wisp.subfs`, `place.wisp.settings`) with
126126+ generated TypeScript types
127127+- **`fs-utils`** - Filesystem tree building, manifest creation, subfs splitting logic
128128+- **`atproto-utils`** - AT Protocol helpers (blob upload, record operations, CID handling)
129129+- **`database`** - PostgreSQL schema and queries
130130+- **`constants`** - Shared constants (limits, file patterns, default settings)
131131+- **`observability`** - OpenTelemetry instrumentation
132132+- **`safe-fetch`** - Wrapped fetch with timeout/retry logic
338133339339-### 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
134134+### CLI
359135360360----
136136+**`cli/`** - Rust CLI using Jacquard (AT Protocol library)
137137+- Direct PDS uploads without interacting with main-app
138138+- Can also do the same firehose watching, caching, and serving hosting-service does, just without domain management
361139362362-## ๐ Environment Variables
140140+### Other Directories
363141364364-- `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
142142+- **`docs/`** - Astro documentation site
143143+- **`binaries/`** - Compiled CLI binaries for distribution