Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

add readme's, dont cache jwks.json

Changed files
+382 -14
cli
src
+69 -5
README.md
··· 1 1 # Wisp.place 2 - A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place) 2 + 3 + Decentralized static site hosting on the AT Protocol. [https://wisp.place](https://wisp.place) 4 + 5 + ## What is this? 6 + 7 + Host static sites in your AT Protocol repo, served with CDN distribution. Your PDS holds the cryptographically signed manifest and files - the source of truth. Hosting services index and serve them fast. 8 + 9 + ## Quick Start 10 + 11 + ```bash 12 + # Using the web interface 13 + Visit https://wisp.place and sign in 14 + 15 + # Or use the CLI 16 + cd cli 17 + cargo build --release 18 + ./target/release/wisp-cli your-handle.bsky.social --path ./my-site --site my-site 19 + ``` 20 + 21 + Your site appears at `https://sites.wisp.place/{your-did}/{site-name}` or your custom domain. 3 22 4 - /src is the main backend 23 + ## Architecture 5 24 6 - /hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses 25 + - **`/src`** - Main backend (OAuth, site management, custom domains) 26 + - **`/hosting-service`** - Microservice that serves cached sites from disk 27 + - **`/cli`** - Rust CLI for direct PDS uploads 28 + - **`/public`** - React frontend 7 29 8 - /cli is the wisp-cli, a way to upload sites directly to the pds 30 + ### How it works 31 + 32 + 1. Sites stored as `place.wisp.fs` records in your AT Protocol repo 33 + 2. Files compressed (gzip) and base64-encoded as blobs 34 + 3. Hosting service watches firehose, caches sites locally 35 + 4. Sites served via custom domains or `*.wisp.place` subdomains 9 36 10 - full readme soon 37 + ## Development 38 + 39 + ```bash 40 + # Backend 41 + bun install 42 + bun run src/index.ts 43 + 44 + # Hosting service 45 + cd hosting-service 46 + cargo run 47 + 48 + # CLI 49 + cd cli 50 + cargo build 51 + ``` 52 + 53 + ## Limits 54 + 55 + - Max file size: 100MB (PDS limit) 56 + - Max site size: 300MB 57 + - Max files: 2000 58 + 59 + ## Tech Stack 60 + 61 + - Backend: Bun + Elysia + PostgreSQL 62 + - Frontend: React 19 + Tailwind 4 + Radix UI 63 + - Hosting: Rust microservice 64 + - CLI: Rust + Jacquard (AT Protocol library) 65 + - Protocol: AT Protocol OAuth + custom lexicons 66 + 67 + ## License 68 + 69 + MIT 70 + 71 + ## Links 72 + 73 + - [AT Protocol](https://atproto.com) 74 + - [Jacquard Library](https://tangled.org/@nonbinary.computer/jacquard)
+271
cli/README.md
··· 1 + # Wisp CLI 2 + 3 + A command-line tool for deploying static sites to your AT Protocol repo to be served on [wisp.place](https://wisp.place), an AT indexer to serve such sites. 4 + 5 + ## Why? 6 + 7 + The PDS serves as a way to verfiably, cryptographically prove that you own your site. That it was you (or at least someone who controls your account) who uploaded it. It is also a manifest of each file in the site to ensure file integrity. Keeping hosting seperate ensures that you could move your site across other servers or even serverless solutions to ensure speedy delievery while keeping it backed by an absolute source of truth being the manifest record and the blobs of each file in your repo. 8 + 9 + ## Features 10 + 11 + - Deploy static sites directly to your AT Protocol repo 12 + - Supports both OAuth and app password authentication 13 + - Preserves directory structure and file integrity 14 + 15 + ## Soon 16 + 17 + -- Host sites 18 + -- Manage and delete sites 19 + -- Metrics and logs for self hosting. 20 + 21 + ## Installation 22 + 23 + ### From Source 24 + 25 + ```bash 26 + cargo build --release 27 + ``` 28 + 29 + Check out the build scripts for cross complation using nix-shell. 30 + 31 + The binary will be available at `target/release/wisp-cli`. 32 + 33 + ## Usage 34 + 35 + ### Basic Deployment 36 + 37 + Deploy the current directory: 38 + 39 + ```bash 40 + wisp-cli nekomimi.ppet --path . --site my-site 41 + ``` 42 + 43 + Deploy a specific directory: 44 + 45 + ```bash 46 + wisp-cli alice.bsky.social --path ./dist/ --site my-site 47 + ``` 48 + 49 + ### Authentication Methods 50 + 51 + #### OAuth (Recommended) 52 + 53 + By default, the CLI uses OAuth authentication with a local loopback server: 54 + 55 + ```bash 56 + wisp-cli alice.bsky.social --path ./my-site --site my-site 57 + ``` 58 + 59 + This will: 60 + 1. Open your browser for authentication 61 + 2. Save the session to a file (default: `/tmp/wisp-oauth-session.json`) 62 + 3. Reuse the session for future deployments 63 + 64 + Specify a custom session file location: 65 + 66 + ```bash 67 + wisp-cli alice.bsky.social --path ./my-site --site my-site --store ~/.wisp-session.json 68 + ``` 69 + 70 + #### App Password 71 + 72 + For headless environments or CI/CD, use an app password: 73 + 74 + ```bash 75 + wisp-cli alice.bsky.social --path ./my-site --site my-site --password YOUR_APP_PASSWORD 76 + ``` 77 + 78 + **Note:** When using `--password`, the `--store` option is ignored. 79 + 80 + ## Command-Line Options 81 + 82 + ``` 83 + wisp-cli [OPTIONS] <INPUT> 84 + 85 + Arguments: 86 + <INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL 87 + 88 + Options: 89 + -p, --path <PATH> Path to the directory containing your static site [default: .] 90 + -s, --site <SITE> Site name (defaults to directory name) 91 + --store <STORE> Path to auth store file (only used with OAuth) [default: /tmp/wisp-oauth-session.json] 92 + --password <PASSWORD> App Password for authentication (alternative to OAuth) 93 + -h, --help Print help 94 + -V, --version Print version 95 + ``` 96 + 97 + ## How It Works 98 + 99 + 1. **Authentication**: Authenticates using OAuth or app password 100 + 2. **File Processing**: 101 + - Recursively walks the directory tree 102 + - Skips hidden files (starting with `.`) 103 + - Detects MIME types automatically 104 + - Compresses files with gzip 105 + - Base64 encodes compressed content 106 + 3. **Upload**: 107 + - Uploads files as blobs to your PDS 108 + - Processes up to 5 files concurrently 109 + - Creates a `place.wisp.fs` record with the site manifest 110 + 4. **Deployment**: Site is immediately available at `https://sites.wisp.place/{did}/{site-name}` 111 + 112 + ## File Processing 113 + 114 + All files are automatically: 115 + 116 + - **Compressed** with gzip (level 9) 117 + - **Base64 encoded** to bypass PDS content sniffing 118 + - **Uploaded** as `application/octet-stream` blobs 119 + - **Stored** with original MIME type metadata 120 + 121 + The hosting service automatically decompresses non HTML/CSS/JS files when serving them. 122 + 123 + ## Limitations 124 + 125 + - **Max file size**: 100MB per file (after compression) (this is a PDS limit, but not enforced by the CLI in case yours is higher) 126 + - **Max file count**: 2000 files 127 + - **Site name** must follow AT Protocol rkey format rules (alphanumeric, hyphens, underscores) 128 + 129 + ## Deploy with CI/CD 130 + 131 + ### GitHub Actions 132 + 133 + ```yaml 134 + name: Deploy to Wisp 135 + on: 136 + push: 137 + branches: [main] 138 + 139 + jobs: 140 + deploy: 141 + runs-on: ubuntu-latest 142 + steps: 143 + - uses: actions/checkout@v3 144 + 145 + - name: Setup Node 146 + uses: actions/setup-node@v3 147 + with: 148 + node-version: '25' 149 + 150 + - name: Install dependencies 151 + run: npm install 152 + 153 + - name: Build site 154 + run: npm run build 155 + 156 + - name: Download Wisp CLI 157 + run: | 158 + curl -L https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 159 + chmod +x wisp-cli 160 + 161 + - name: Deploy to Wisp 162 + env: 163 + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} 164 + run: | 165 + ./wisp-cli alice.bsky.social \ 166 + --path ./dist \ 167 + --site my-site \ 168 + --password "$WISP_APP_PASSWORD" 169 + ``` 170 + 171 + ### Tangled.org 172 + 173 + ```yaml 174 + when: 175 + - event: ['push'] 176 + branch: ['main'] 177 + - event: ['manual'] 178 + 179 + engine: 'nixery' 180 + 181 + clone: 182 + skip: false 183 + depth: 1 184 + submodules: false 185 + 186 + dependencies: 187 + nixpkgs: 188 + - nodejs 189 + - coreutils 190 + - curl 191 + github:NixOS/nixpkgs/nixpkgs-unstable: 192 + - bun 193 + 194 + environment: 195 + SITE_PATH: 'dist' 196 + SITE_NAME: 'my-site' 197 + WISP_HANDLE: 'your-handle.bsky.social' 198 + 199 + steps: 200 + - name: build site 201 + command: | 202 + export PATH="$HOME/.nix-profile/bin:$PATH" 203 + 204 + # regenerate lockfile 205 + rm package-lock.json bun.lock 206 + bun install @rolldown/binding-linux-arm64-gnu --save-optional 207 + bun install 208 + 209 + # build with vite 210 + bun node_modules/.bin/vite build 211 + 212 + - name: deploy to wisp 213 + command: | 214 + # Download Wisp CLI 215 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 216 + chmod +x wisp-cli 217 + 218 + # Deploy to Wisp 219 + ./wisp-cli \ 220 + "$WISP_HANDLE" \ 221 + --path "$SITE_PATH" \ 222 + --site "$SITE_NAME" \ 223 + --password "$WISP_APP_PASSWORD" 224 + ``` 225 + 226 + ### Generic Shell Script 227 + 228 + ```bash 229 + # Use app password from environment variable 230 + wisp-cli alice.bsky.social --path ./dist --site my-site --password "$WISP_APP_PASSWORD" 231 + ``` 232 + 233 + ## Output 234 + 235 + Upon successful deployment, you'll see: 236 + 237 + ``` 238 + Deployed site 'my-site': at://did:plc:abc123xyz/place.wisp.fs/my-site 239 + Available at: https://sites.wisp.place/did:plc:abc123xyz/my-site 240 + ``` 241 + 242 + ### Dependencies 243 + 244 + - **jacquard**: AT Protocol client library 245 + - **clap**: Command-line argument parsing 246 + - **tokio**: Async runtime 247 + - **flate2**: Gzip compression 248 + - **base64**: Base64 encoding 249 + - **walkdir**: Directory traversal 250 + - **mime_guess**: MIME type detection 251 + 252 + ## License 253 + 254 + MIT License 255 + 256 + ## Contributing 257 + 258 + Just don't give me entirely claude slop especailly not in the PR description itself. You should be responsible for code you submit and aware of what it even is you're submitting. 259 + 260 + ## Links 261 + 262 + - **Website**: https://wisp.place 263 + - **Main Repository**: https://tangled.org/@nekomimi.pet/wisp.place-monorepo 264 + - **AT Protocol**: https://atproto.com 265 + - **Jacquard Library**: https://tangled.org/@nonbinary.computer/jacquard 266 + 267 + ## Support 268 + 269 + For issues and questions: 270 + - Check the main wisp.place documentation 271 + - Open an issue in the main repository
+6 -1
src/index.ts
··· 110 110 .get('/client-metadata.json', () => { 111 111 return createClientMetadata(config) 112 112 }) 113 - .get('/jwks.json', async () => { 113 + .get('/jwks.json', async ({ set }) => { 114 + // Prevent caching to ensure clients always get fresh keys after rotation 115 + set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' 116 + set.headers['Pragma'] = 'no-cache' 117 + set.headers['Expires'] = '0' 118 + 114 119 const keys = await getCurrentKeys() 115 120 if (!keys.length) return { keys: [] } 116 121
+36 -8
src/lib/db.ts
··· 36 36 ) 37 37 `; 38 38 39 - // Domains table maps subdomain -> DID 39 + // Domains table maps subdomain -> DID (now supports up to 3 domains per user) 40 40 await db` 41 41 CREATE TABLE IF NOT EXISTS domains ( 42 42 domain TEXT PRIMARY KEY, 43 - did TEXT UNIQUE NOT NULL, 43 + did TEXT NOT NULL, 44 44 rkey TEXT, 45 45 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 46 46 ) ··· 69 69 await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`; 70 70 } catch (err) { 71 71 // Column might already exist, ignore 72 + } 73 + 74 + // Remove the unique constraint on domains.did to allow multiple domains per user 75 + try { 76 + await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`; 77 + } catch (err) { 78 + // Constraint might already be removed, ignore 72 79 } 73 80 74 81 // Custom domains table for BYOD (bring your own domain) ··· 189 196 export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 190 197 191 198 export const getDomainByDid = async (did: string): Promise<string | null> => { 192 - const rows = await db`SELECT domain FROM domains WHERE did = ${did}`; 199 + const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 193 200 return rows[0]?.domain ?? null; 194 201 }; 195 202 196 203 export const getWispDomainInfo = async (did: string) => { 197 - const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`; 204 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 198 205 return rows[0] ?? null; 199 206 }; 200 207 208 + export const getAllWispDomains = async (did: string) => { 209 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`; 210 + return rows; 211 + }; 212 + 213 + export const countWispDomains = async (did: string): Promise<number> => { 214 + const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`; 215 + return Number(rows[0]?.count ?? 0); 216 + }; 217 + 201 218 export const getDidByDomain = async (domain: string): Promise<string | null> => { 202 219 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; 203 220 return rows[0]?.did ?? null; ··· 251 268 export const claimDomain = async (did: string, handle: string): Promise<string> => { 252 269 const h = handle.trim().toLowerCase(); 253 270 if (!isValidHandle(h)) throw new Error('invalid_handle'); 271 + 272 + // Check if user already has 3 domains 273 + const existingCount = await countWispDomains(did); 274 + if (existingCount >= 3) { 275 + throw new Error('domain_limit_reached'); 276 + } 277 + 254 278 const domain = toDomain(h); 255 279 try { 256 280 await db` ··· 258 282 VALUES (${domain}, ${did}) 259 283 `; 260 284 } catch (err) { 261 - // Unique constraint violations -> already taken or DID already claimed 285 + // Unique constraint violations -> already taken 262 286 throw new Error('conflict'); 263 287 } 264 288 return domain; ··· 283 307 } 284 308 }; 285 309 286 - export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => { 310 + export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => { 287 311 await db` 288 312 UPDATE domains 289 313 SET rkey = ${siteRkey} 290 - WHERE did = ${did} 314 + WHERE domain = ${domain} 291 315 `; 292 316 }; 293 317 294 318 export const getWispDomainSite = async (did: string): Promise<string | null> => { 295 - const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`; 319 + const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 296 320 return rows[0]?.rkey ?? null; 321 + }; 322 + 323 + export const deleteWispDomain = async (domain: string): Promise<void> => { 324 + await db`DELETE FROM domains WHERE domain = ${domain}`; 297 325 }; 298 326 299 327 // Session timeout configuration (30 days in seconds)