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

api docs

+460 -2
+49 -1
apps/main-app/src/routes/admin.ts
··· 13 13 } 14 14 }) 15 15 // Login 16 + /** 17 + * POST /api/admin/login 18 + * Success: { success: true } with admin_session cookie set. 19 + * Failure (401): { error: 'Invalid credentials' } 20 + */ 16 21 .post( 17 22 '/login', 18 23 async ({ body, cookie, set }) => { ··· 52 57 ) 53 58 54 59 // Logout 60 + /** 61 + * POST /api/admin/logout 62 + * Success: { success: true } and clears admin_session cookie. 63 + */ 55 64 .post('/logout', ({ cookie }) => { 56 65 const sessionId = cookie.admin_session?.value 57 66 if (sessionId && typeof sessionId === 'string') { ··· 69 78 }) 70 79 71 80 // Check auth status 81 + /** 82 + * GET /api/admin/status 83 + * Authenticated: { authenticated: true, username } 84 + * Not authenticated: { authenticated: false } 85 + */ 72 86 .get('/status', ({ cookie }) => { 73 87 const sessionId = cookie.admin_session?.value 74 88 if (!sessionId || typeof sessionId !== 'string') { ··· 94 108 }) 95 109 96 110 // Get logs (protected) 111 + /** 112 + * GET /api/admin/logs 113 + * Success: { logs } 114 + * Unauthorized (401): { error: 'Unauthorized' } 115 + */ 97 116 .get('/logs', async ({ query, cookie, set }) => { 98 117 const check = requireAdmin({ cookie, set }) 99 118 if (check) return check ··· 145 164 }) 146 165 147 166 // Get errors (protected) 167 + /** 168 + * GET /api/admin/errors 169 + * Success: { errors } 170 + * Unauthorized (401): { error: 'Unauthorized' } 171 + */ 148 172 .get('/errors', async ({ query, cookie, set }) => { 149 173 const check = requireAdmin({ cookie, set }) 150 174 if (check) return check ··· 190 214 }) 191 215 192 216 // Get metrics (protected) 217 + /** 218 + * GET /api/admin/metrics 219 + * Success: { overall, mainApp, hostingService, timeWindow } 220 + * Unauthorized (401): { error: 'Unauthorized' } 221 + */ 193 222 .get('/metrics', async ({ query, cookie, set }) => { 194 223 const check = requireAdmin({ cookie, set }) 195 224 if (check) return check ··· 239 268 }) 240 269 241 270 // Get database stats (protected) 271 + /** 272 + * GET /api/admin/database 273 + * Success: { stats, recentSites, recentDomains } 274 + * Failure (500): { error, message } 275 + */ 242 276 .get('/database', async ({ cookie, set }) => { 243 277 const check = requireAdmin({ cookie, set }) 244 278 if (check) return check ··· 292 326 }) 293 327 294 328 // Get cache stats (protected) 329 + /** 330 + * GET /api/admin/cache 331 + * Success: hosting service cache stats payload. 332 + * Failure (503|500): { error, message } 333 + */ 295 334 .get('/cache', async ({ cookie, set }) => { 296 335 const check = requireAdmin({ cookie, set }) 297 336 if (check) return check ··· 327 366 }) 328 367 329 368 // Get sites listing (protected) 369 + /** 370 + * GET /api/admin/sites 371 + * Success: { sites, customDomains } 372 + * Failure (500): { error, message } 373 + */ 330 374 .get('/sites', async ({ query, cookie, set }) => { 331 375 const check = requireAdmin({ cookie, set }) 332 376 if (check) return check ··· 381 425 }) 382 426 383 427 // Get system health (protected) 428 + /** 429 + * GET /api/admin/health 430 + * Success: { uptime, memory, timestamp } 431 + * Unauthorized (401): { error: 'Unauthorized' } 432 + */ 384 433 .get('/health', ({ cookie, set }) => { 385 434 const check = requireAdmin({ cookie, set }) 386 435 if (check) return check ··· 405 454 sign: ['admin_session'] 406 455 }) 407 456 }) 408 -
+25
apps/main-app/src/routes/auth.ts
··· 13 13 sign: ['did'] 14 14 } 15 15 }) 16 + /** 17 + * GET /api/auth/login 18 + * 302 redirect to the AT Protocol OAuth authorize URL. 19 + * On error, redirects to /?error=missing_handle or /?error=auth_failed. 20 + */ 16 21 .get('/api/auth/login', async (c) => { 17 22 // GET endpoint for initiating OAuth via atproto.wisp.place entryway 18 23 // Accepts: login_hint (handle) or pds (server) ··· 42 47 return c.redirect('/?error=auth_failed') 43 48 } 44 49 }) 50 + /** 51 + * POST /api/auth/signin 52 + * Success: { url } where url is the OAuth authorize URL. 53 + * Failure: { error, details }. 54 + */ 45 55 .post('/api/auth/signin', async (c) => { 46 56 let handle = 'unknown' 47 57 try { ··· 58 68 return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) } 59 69 } 60 70 }) 71 + /** 72 + * GET /api/auth/callback 73 + * 302 redirect to /onboarding (new users) or /editor (existing users). 74 + * On error, redirects to /?error=auth_failed. 75 + */ 61 76 .get('/api/auth/callback', async (c) => { 62 77 try { 63 78 const params = new URLSearchParams(c.query) ··· 111 126 return c.redirect('/?error=auth_failed') 112 127 } 113 128 }) 129 + /** 130 + * POST /api/auth/logout 131 + * Success: { success: true } 132 + * Failure: { error: 'Logout failed' } 133 + */ 114 134 .post('/api/auth/logout', async (c) => { 115 135 try { 116 136 const cookieSession = c.cookie ··· 136 156 return { error: 'Logout failed' } 137 157 } 138 158 }) 159 + /** 160 + * GET /api/auth/status 161 + * Authenticated: { authenticated: true, did } 162 + * Not authenticated: { authenticated: false } 163 + */ 139 164 .get('/api/auth/status', async (c) => { 140 165 try { 141 166 const auth = await authenticateRequest(client, c.cookie)
+44 -1
apps/main-app/src/routes/domain.ts
··· 35 35 } 36 36 }) 37 37 // Public endpoints (no auth required) 38 + /** 39 + * GET /api/domain/check 40 + * Success: { available, domain } or { available: false, reason: 'invalid' }. 41 + * Failure: { available: false }. 42 + */ 38 43 .get('/check', async ({ query }) => { 39 44 try { 40 45 const handle = (query.handle || "") ··· 60 65 }; 61 66 } 62 67 }) 68 + /** 69 + * GET /api/domain/registered 70 + * 200: { registered: true, type: 'wisp' | 'custom', domain, did, rkey, verified? } 71 + * 404: { registered: false } 72 + * 400: { error: 'Domain parameter required' } 73 + */ 63 74 .get('/registered', async ({ query, set }) => { 64 75 try { 65 76 const domain = (query.domain || "").trim().toLowerCase(); ··· 90 101 const auth = await requireAuth(client, cookie) 91 102 return { auth } 92 103 }) 104 + /** 105 + * POST /api/domain/claim 106 + * Success: { success: true, domain } 107 + */ 93 108 .post('/claim', async ({ body, auth }) => { 94 109 try { 95 110 const { handle } = body as { handle?: string }; ··· 133 148 throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`); 134 149 } 135 150 }) 151 + /** 152 + * POST /api/domain/update 153 + * Success: { success: true, domain } 154 + */ 136 155 .post('/update', async ({ body, auth }) => { 137 156 try { 138 157 const { handle } = body as { handle?: string }; ··· 175 194 throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); 176 195 } 177 196 }) 197 + /** 198 + * POST /api/domain/custom/add 199 + * Success: { success: true, id, domain, verified: false } 200 + */ 178 201 .post('/custom/add', async ({ body, auth }) => { 179 202 try { 180 203 const { domain } = body as { domain: string }; ··· 266 289 throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 267 290 } 268 291 }) 292 + /** 293 + * POST /api/domain/custom/verify 294 + * Success: { success: true, verified, error, found } 295 + */ 269 296 .post('/custom/verify', async ({ body, auth }) => { 270 297 try { 271 298 const { id } = body as { id: string }; ··· 294 321 throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 295 322 } 296 323 }) 324 + /** 325 + * DELETE /api/domain/custom/:id 326 + * Success: { success: true } 327 + */ 297 328 .delete('/custom/:id', async ({ params, auth }) => { 298 329 try { 299 330 const { id } = params; ··· 317 348 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 318 349 } 319 350 }) 351 + /** 352 + * POST /api/domain/wisp/map-site 353 + * Success: { success: true } 354 + */ 320 355 .post('/wisp/map-site', async ({ body, auth }) => { 321 356 try { 322 357 const { domain, siteRkey } = body as { domain: string; siteRkey: string | null }; ··· 334 369 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 335 370 } 336 371 }) 372 + /** 373 + * DELETE /api/domain/wisp/:domain 374 + * Success: { success: true } 375 + */ 337 376 .delete('/wisp/:domain', async ({ params, auth }) => { 338 377 try { 339 378 const { domain } = params; ··· 373 412 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 374 413 } 375 414 }) 415 + /** 416 + * POST /api/domain/custom/:id/map-site 417 + * Success: { success: true } 418 + */ 376 419 .post('/custom/:id/map-site', async ({ params, body, auth }) => { 377 420 try { 378 421 const { id } = params; ··· 396 439 logger.error('[Domain] Custom domain map error', err); 397 440 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 398 441 } 399 - }); 442 + });
+15
apps/main-app/src/routes/site.ts
··· 20 20 const auth = await requireAuth(client, cookie) 21 21 return { auth } 22 22 }) 23 + /** 24 + * DELETE /api/site/:rkey 25 + * Success: { success: true, message } 26 + * Failure: { success: false, error } 27 + */ 23 28 .delete('/:rkey', async ({ params, auth }) => { 24 29 const { rkey } = params 25 30 ··· 120 125 } 121 126 } 122 127 }) 128 + /** 129 + * GET /api/site/:rkey/settings 130 + * Success: place.wisp.settings record or default settings object. 131 + * Failure: { success: false, error } 132 + */ 123 133 .get('/:rkey/settings', async ({ params, auth }) => { 124 134 const { rkey } = params 125 135 ··· 171 181 } 172 182 } 173 183 }) 184 + /** 185 + * POST /api/site/:rkey/settings 186 + * Success: { success: true, uri, cid } 187 + * Failure: { success: false, error } 188 + */ 174 189 .post('/:rkey/settings', async ({ params, body, auth }) => { 175 190 const { rkey } = params 176 191
+24
apps/main-app/src/routes/user.ts
··· 21 21 const auth = await requireAuth(client, cookie) 22 22 return { auth } 23 23 }) 24 + /** 25 + * GET /api/user/status 26 + * Success: { did, hasSites, hasDomain, domain, sitesCount } 27 + */ 24 28 .get('/status', async ({ auth }) => { 25 29 try { 26 30 // Check if user has any sites ··· 41 45 throw new Error('Failed to get user status') 42 46 } 43 47 }) 48 + /** 49 + * GET /api/user/info 50 + * Success: { did, handle } 51 + */ 44 52 .get('/info', async ({ auth }) => { 45 53 try { 46 54 let handle = 'unknown' ··· 65 73 throw new Error('Failed to get user info') 66 74 } 67 75 }) 76 + /** 77 + * GET /api/user/sites 78 + * Success: { sites } 79 + */ 68 80 .get('/sites', async ({ auth }) => { 69 81 try { 70 82 const sites = await getSitesByDid(auth.did) ··· 74 86 throw new Error('Failed to get sites') 75 87 } 76 88 }) 89 + /** 90 + * GET /api/user/domains 91 + * Success: { wispDomains: [{ domain, rkey }], customDomains } 92 + */ 77 93 .get('/domains', async ({ auth }) => { 78 94 try { 79 95 // Get all wisp.place subdomains with mappings (up to 3) ··· 94 110 throw new Error('Failed to get domains') 95 111 } 96 112 }) 113 + /** 114 + * POST /api/user/sync 115 + * Success: { success: true, synced, errors } 116 + */ 97 117 .post('/sync', async ({ auth }) => { 98 118 try { 99 119 logger.debug('[User] Manual sync requested for', { did: auth.did }) ··· 109 129 throw new Error('Failed to sync sites') 110 130 } 111 131 }) 132 + /** 133 + * GET /api/user/site/:rkey/domains 134 + * Success: { rkey, domains } 135 + */ 112 136 .get('/site/:rkey/domains', async ({ auth, params }) => { 113 137 try { 114 138 const { rkey } = params
+12
apps/main-app/src/routes/wisp.ts
··· 861 861 const auth = await requireAuth(client, cookie) 862 862 return { auth } 863 863 }) 864 + /** 865 + * GET /wisp/upload-progress/:jobId 866 + * SSE stream of upload progress events for the current user. 867 + * 404: { error: 'Job not found' } 868 + * 403: { error: 'Unauthorized' } 869 + */ 864 870 .get( 865 871 '/upload-progress/:jobId', 866 872 async ({ params: { jobId }, auth, set }) => { ··· 951 957 return new Response(stream); 952 958 } 953 959 ) 960 + /** 961 + * POST /wisp/upload-files 962 + * Success (empty upload): { success: true, uri, cid, fileCount: 0, siteName } 963 + * Success (async upload): { success: true, jobId, message } 964 + * Failure: throws error with message "Failed to upload files: ..." 965 + */ 954 966 .post( 955 967 '/upload-files', 956 968 async ({ body, auth }) => {
+4
docs/astro.config.mjs
··· 30 30 { label: 'Redirects & Rewrites', slug: 'redirects' }, 31 31 ], 32 32 }, 33 + { 34 + label: 'Reference', 35 + autogenerate: { directory: 'reference' }, 36 + }, 33 37 ], 34 38 customCss: ['./src/styles/custom.css'], 35 39 }),
+287
docs/src/content/docs/reference/main-app-api.md
··· 1 + --- 2 + title: Main App API 3 + description: Expected responses from the main-app Elysia routes. 4 + --- 5 + 6 + These endpoints power the main wisp.place backend (Bun + Elysia). Responses below are the shapes returned by the handlers in `apps/main-app/src/routes/*`. 7 + 8 + Notes: 9 + - Authenticated routes rely on the signed `did` cookie. If authentication fails, the handler throws and Elysia returns an error response. 10 + - Admin routes rely on the signed `admin_session` cookie. Unauthorized requests return `401 { error: 'Unauthorized' }`. 11 + 12 + ## Auth (`/api/auth/*`) 13 + 14 + ### `GET /api/auth/login` 15 + Redirects to the AT Protocol OAuth authorize URL. 16 + 17 + - **302** → OAuth URL 18 + - **302** → `/?error=missing_handle` if no handle/PDS provided 19 + - **302** → `/?error=auth_failed` on failure 20 + 21 + ### `POST /api/auth/signin` 22 + ```json 23 + { "url": "https://..." } 24 + ``` 25 + On failure: 26 + ```json 27 + { "error": "Authentication failed", "details": "..." } 28 + ``` 29 + 30 + ### `GET /api/auth/callback` 31 + Redirects after OAuth completes. 32 + 33 + - **302** → `/onboarding` (no sites or domain) 34 + - **302** → `/editor` (existing user) 35 + - **302** → `/?error=auth_failed` on failure 36 + 37 + ### `POST /api/auth/logout` 38 + ```json 39 + { "success": true } 40 + ``` 41 + On failure: 42 + ```json 43 + { "error": "Logout failed" } 44 + ``` 45 + 46 + ### `GET /api/auth/status` 47 + Authenticated: 48 + ```json 49 + { "authenticated": true, "did": "did:plc:..." } 50 + ``` 51 + Not authenticated: 52 + ```json 53 + { "authenticated": false } 54 + ``` 55 + 56 + ## User (`/api/user/*`) 57 + 58 + ### `GET /api/user/status` 59 + ```json 60 + { 61 + "did": "did:plc:...", 62 + "hasSites": true, 63 + "hasDomain": false, 64 + "domain": null, 65 + "sitesCount": 3 66 + } 67 + ``` 68 + 69 + ### `GET /api/user/info` 70 + ```json 71 + { "did": "did:plc:...", "handle": "user.bsky.social" } 72 + ``` 73 + 74 + ### `GET /api/user/sites` 75 + ```json 76 + { "sites": [/* site rows */] } 77 + ``` 78 + 79 + ### `GET /api/user/domains` 80 + ```json 81 + { 82 + "wispDomains": [{ "domain": "name.wisp.place", "rkey": "site-rkey" }], 83 + "customDomains": [/* custom domain rows */] 84 + } 85 + ``` 86 + 87 + ### `POST /api/user/sync` 88 + ```json 89 + { "success": true, "synced": 2, "errors": [] } 90 + ``` 91 + 92 + ### `GET /api/user/site/:rkey/domains` 93 + ```json 94 + { "rkey": "site-rkey", "domains": [/* domain rows */] } 95 + ``` 96 + 97 + ## Domain (`/api/domain/*`) 98 + 99 + ### `GET /api/domain/check` 100 + ```json 101 + { "available": true, "domain": "name.wisp.place" } 102 + ``` 103 + Invalid handle: 104 + ```json 105 + { "available": false, "reason": "invalid" } 106 + ``` 107 + 108 + ### `GET /api/domain/registered` 109 + Registered: 110 + ```json 111 + { "registered": true, "type": "wisp", "domain": "name.wisp.place", "did": "did:plc:...", "rkey": "site-rkey" } 112 + ``` 113 + Custom domain: 114 + ```json 115 + { "registered": true, "type": "custom", "domain": "example.com", "did": "did:plc:...", "rkey": "site-rkey", "verified": true } 116 + ``` 117 + Unregistered: 118 + ```json 119 + { "registered": false } 120 + ``` 121 + Missing domain: 122 + ```json 123 + { "error": "Domain parameter required" } 124 + ``` 125 + 126 + ### `POST /api/domain/claim` 127 + ```json 128 + { "success": true, "domain": "name.wisp.place" } 129 + ``` 130 + 131 + ### `POST /api/domain/update` 132 + ```json 133 + { "success": true, "domain": "name.wisp.place" } 134 + ``` 135 + 136 + ### `POST /api/domain/custom/add` 137 + ```json 138 + { "success": true, "id": "abcdef1234567890", "domain": "example.com", "verified": false } 139 + ``` 140 + 141 + ### `POST /api/domain/custom/verify` 142 + ```json 143 + { "success": true, "verified": true, "error": null, "found": true } 144 + ``` 145 + 146 + ### `DELETE /api/domain/custom/:id` 147 + ```json 148 + { "success": true } 149 + ``` 150 + 151 + ### `POST /api/domain/wisp/map-site` 152 + ```json 153 + { "success": true } 154 + ``` 155 + 156 + ### `DELETE /api/domain/wisp/:domain` 157 + ```json 158 + { "success": true } 159 + ``` 160 + 161 + ### `POST /api/domain/custom/:id/map-site` 162 + ```json 163 + { "success": true } 164 + ``` 165 + 166 + ## Site (`/api/site/*`) 167 + 168 + ### `DELETE /api/site/:rkey` 169 + ```json 170 + { "success": true, "message": "Site deleted successfully" } 171 + ``` 172 + On failure: 173 + ```json 174 + { "success": false, "error": "..." } 175 + ``` 176 + 177 + ### `GET /api/site/:rkey/settings` 178 + Returns the `place.wisp.settings` record when present, otherwise defaults: 179 + ```json 180 + { "indexFiles": ["index.html"], "cleanUrls": false, "directoryListing": false } 181 + ``` 182 + On failure: 183 + ```json 184 + { "success": false, "error": "..." } 185 + ``` 186 + 187 + ### `POST /api/site/:rkey/settings` 188 + ```json 189 + { "success": true, "uri": "at://...", "cid": "bafy..." } 190 + ``` 191 + On validation failure: 192 + ```json 193 + { "success": false, "error": "Only one of spaMode, directoryListing, or custom404 can be enabled" } 194 + ``` 195 + 196 + ## Wisp Uploads (`/wisp/*`) 197 + 198 + ### `GET /wisp/upload-progress/:jobId` 199 + Server-sent events stream for upload progress. 200 + 201 + - **event:** `progress` → `{ status, progress, result, error }` 202 + - **event:** `done` → `result` 203 + - **event:** `error` → `{ error }` 204 + 205 + Errors: 206 + ```json 207 + { "error": "Job not found" } 208 + ``` 209 + ```json 210 + { "error": "Unauthorized" } 211 + ``` 212 + 213 + ### `POST /wisp/upload-files` 214 + Empty upload (no files): 215 + ```json 216 + { "success": true, "uri": "at://...", "cid": "bafy...", "fileCount": 0, "siteName": "my-site" } 217 + ``` 218 + Async upload: 219 + ```json 220 + { "success": true, "jobId": "...", "message": "Upload started. Connect to /wisp/upload-progress/..." } 221 + ``` 222 + 223 + ## Admin (`/api/admin/*`) 224 + 225 + ### `POST /api/admin/login` 226 + ```json 227 + { "success": true } 228 + ``` 229 + Invalid credentials (401): 230 + ```json 231 + { "error": "Invalid credentials" } 232 + ``` 233 + 234 + ### `POST /api/admin/logout` 235 + ```json 236 + { "success": true } 237 + ``` 238 + 239 + ### `GET /api/admin/status` 240 + Authenticated: 241 + ```json 242 + { "authenticated": true, "username": "admin" } 243 + ``` 244 + Not authenticated: 245 + ```json 246 + { "authenticated": false } 247 + ``` 248 + 249 + ### `GET /api/admin/logs` 250 + ```json 251 + { "logs": [/* combined logs */] } 252 + ``` 253 + 254 + ### `GET /api/admin/errors` 255 + ```json 256 + { "errors": [/* combined errors */] } 257 + ``` 258 + 259 + ### `GET /api/admin/metrics` 260 + ```json 261 + { "overall": {}, "mainApp": {}, "hostingService": {}, "timeWindow": 3600000 } 262 + ``` 263 + 264 + ### `GET /api/admin/database` 265 + ```json 266 + { "stats": {}, "recentSites": [], "recentDomains": [] } 267 + ``` 268 + 269 + ### `GET /api/admin/cache` 270 + Returns the hosting service cache stats payload or: 271 + ```json 272 + { "error": "Failed to fetch cache stats from hosting service", "message": "Hosting service unavailable" } 273 + ``` 274 + 275 + ### `GET /api/admin/sites` 276 + ```json 277 + { "sites": [/* sites */], "customDomains": [/* domains */] } 278 + ``` 279 + 280 + ### `GET /api/admin/health` 281 + ```json 282 + { 283 + "uptime": 12345, 284 + "memory": { "heapUsed": 123, "heapTotal": 456, "rss": 789 }, 285 + "timestamp": "2026-01-22T00:00:00.000Z" 286 + } 287 + ```