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

updates

Changed files
+143 -18
public
src
+3 -2
bun.lock
··· 1 { 2 "lockfileVersion": 1, 3 "workspaces": { 4 "": { 5 "name": "elysia-static", ··· 21 "@radix-ui/react-tabs": "^1.1.13", 22 "@tanstack/react-query": "^5.90.2", 23 "actor-typeahead": "^0.1.1", 24 - "atproto-ui": "^0.11.1", 25 "class-variance-authority": "^0.7.1", 26 "clsx": "^2.1.1", 27 "elysia": "latest", ··· 401 402 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 403 404 - "atproto-ui": ["atproto-ui@0.11.1", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-RpX9OGx3GDw0uL2X0Lw0bgzqEKKhfMeFuTUIgJuAa3W3MlLBH6h4qOWzaHXdrVQpru+6SQ0HznfRlQHK6nYRkQ=="], 405 406 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 407
··· 1 { 2 "lockfileVersion": 1, 3 + "configVersion": 0, 4 "workspaces": { 5 "": { 6 "name": "elysia-static", ··· 22 "@radix-ui/react-tabs": "^1.1.13", 23 "@tanstack/react-query": "^5.90.2", 24 "actor-typeahead": "^0.1.1", 25 + "atproto-ui": "^0.11.3", 26 "class-variance-authority": "^0.7.1", 27 "clsx": "^2.1.1", 28 "elysia": "latest", ··· 402 403 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 404 405 + "atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="], 406 407 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 408
+1 -1
package.json
··· 25 "@radix-ui/react-tabs": "^1.1.13", 26 "@tanstack/react-query": "^5.90.2", 27 "actor-typeahead": "^0.1.1", 28 - "atproto-ui": "^0.11.1", 29 "class-variance-authority": "^0.7.1", 30 "clsx": "^2.1.1", 31 "elysia": "latest",
··· 25 "@radix-ui/react-tabs": "^1.1.13", 26 "@tanstack/react-query": "^5.90.2", 27 "actor-typeahead": "^0.1.1", 28 + "atproto-ui": "^0.11.3", 29 "class-variance-authority": "^0.7.1", 30 "clsx": "^2.1.1", 31 "elysia": "latest",
+3 -4
public/index.tsx
··· 13 import { Button } from '@public/components/ui/button' 14 import { Card } from '@public/components/ui/card' 15 import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 16 - import 'atproto-ui/styles.css' 17 18 //Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 19 interface Actor { ··· 212 width: '100%', 213 listStyle: 'none', 214 overflow: 'hidden', 215 - backgroundColor: 'rgba(255, 255, 255, 0.7)', 216 backgroundClip: 'padding-box', 217 backdropFilter: 'blur(12px)', 218 WebkitBackdropFilter: 'blur(12px)', 219 - border: '1px solid hsl(var(--border))', 220 borderRadius: '8px', 221 boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 222 padding: '4px', ··· 278 whiteSpace: 'nowrap', 279 overflow: 'hidden', 280 textOverflow: 'ellipsis', 281 - color: 'hsl(var(--foreground))' 282 }} 283 > 284 {actor.handle}
··· 13 import { Button } from '@public/components/ui/button' 14 import { Card } from '@public/components/ui/card' 15 import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 16 17 //Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 18 interface Actor { ··· 211 width: '100%', 212 listStyle: 'none', 213 overflow: 'hidden', 214 + backgroundColor: 'rgba(255, 255, 255, 0.8)', 215 backgroundClip: 'padding-box', 216 backdropFilter: 'blur(12px)', 217 WebkitBackdropFilter: 'blur(12px)', 218 + border: '1px solid rgba(0, 0, 0, 0.1)', 219 borderRadius: '8px', 220 boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 221 padding: '4px', ··· 277 whiteSpace: 'nowrap', 278 overflow: 'hidden', 279 textOverflow: 'ellipsis', 280 + color: '#000000' 281 }} 282 > 283 {actor.handle}
+10 -2
src/index.ts
··· 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14 } from './lib/oauth-client' 15 import { authRoutes } from './routes/auth' 16 import { wispRoutes } from './routes/wisp' 17 import { domainRoutes } from './routes/domain' ··· 30 31 // Initialize admin setup (prompt if no admin exists) 32 await promptAdminSetup() 33 34 const client = await getOAuthClient(config) 35 ··· 63 maxRequestBodySize: 1024 * 1024 * 128 * 3, 64 development: Bun.env.NODE_ENV !== 'production' ? true : false, 65 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 66 } 67 }) 68 // Observability middleware ··· 96 }) 97 .onError(observabilityMiddleware('main-app').onError) 98 .use(csrfProtection()) 99 - .use(authRoutes(client)) 100 .use(wispRoutes(client)) 101 .use(domainRoutes(client)) 102 .use(userRoutes(client)) 103 .use(siteRoutes(client)) 104 - .use(adminRoutes()) 105 .use( 106 await staticPlugin({ 107 prefix: '/'
··· 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14 } from './lib/oauth-client' 15 + import { getCookieSecret } from './lib/db' 16 import { authRoutes } from './routes/auth' 17 import { wispRoutes } from './routes/wisp' 18 import { domainRoutes } from './routes/domain' ··· 31 32 // Initialize admin setup (prompt if no admin exists) 33 await promptAdminSetup() 34 + 35 + // Get or generate cookie signing secret 36 + const cookieSecret = await getCookieSecret() 37 38 const client = await getOAuthClient(config) 39 ··· 67 maxRequestBodySize: 1024 * 1024 * 128 * 3, 68 development: Bun.env.NODE_ENV !== 'production' ? true : false, 69 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 70 + }, 71 + cookie: { 72 + secrets: cookieSecret, 73 + sign: true 74 } 75 }) 76 // Observability middleware ··· 104 }) 105 .onError(observabilityMiddleware('main-app').onError) 106 .use(csrfProtection()) 107 + .use(authRoutes(client, cookieSecret)) 108 .use(wispRoutes(client)) 109 .use(domainRoutes(client)) 110 .use(userRoutes(client)) 111 .use(siteRoutes(client)) 112 + .use(adminRoutes(cookieSecret)) 113 .use( 114 await staticPlugin({ 115 prefix: '/'
+29
src/lib/db.ts
··· 36 ) 37 `; 38 39 // Domains table maps subdomain -> DID (now supports up to 3 domains per user) 40 await db` 41 CREATE TABLE IF NOT EXISTS domains ( ··· 716 total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 717 }; 718 };
··· 36 ) 37 `; 38 39 + // Cookie secrets table for signed cookies 40 + await db` 41 + CREATE TABLE IF NOT EXISTS cookie_secrets ( 42 + id TEXT PRIMARY KEY DEFAULT 'default', 43 + secret TEXT NOT NULL, 44 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 45 + ) 46 + `; 47 + 48 // Domains table maps subdomain -> DID (now supports up to 3 domains per user) 49 await db` 50 CREATE TABLE IF NOT EXISTS domains ( ··· 725 total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 726 }; 727 }; 728 + 729 + // Cookie secret management - ensure we have a secret for signing cookies 730 + export const getCookieSecret = async (): Promise<string> => { 731 + // Check if secret already exists 732 + const rows = await db`SELECT secret FROM cookie_secrets WHERE id = 'default' LIMIT 1`; 733 + 734 + if (rows.length > 0) { 735 + return rows[0].secret as string; 736 + } 737 + 738 + // Generate new secret if none exists 739 + const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 character random string 740 + await db` 741 + INSERT INTO cookie_secrets (id, secret, created_at) 742 + VALUES ('default', ${secret}, EXTRACT(EPOCH FROM NOW())) 743 + `; 744 + 745 + console.log('[CookieSecret] Generated new cookie signing secret'); 746 + return secret; 747 + };
+65 -3
src/routes/admin.ts
··· 4 import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 import { db } from '../lib/db' 6 7 - export const adminRoutes = () => 8 new Elysia({ prefix: '/api/admin' }) 9 // Login 10 .post( ··· 35 body: t.Object({ 36 username: t.String(), 37 password: t.String() 38 }) 39 } 40 ) ··· 47 } 48 cookie.admin_session.remove() 49 return { success: true } 50 }) 51 52 // Check auth status ··· 65 authenticated: true, 66 username: session.username 67 } 68 }) 69 70 // Get logs (protected) ··· 109 ) 110 111 return { logs: allLogs.slice(0, filter.limit || 100) } 112 }) 113 114 // Get errors (protected) ··· 147 ) 148 149 return { errors: allErrors.slice(0, filter.limit || 100) } 150 }) 151 152 // Get metrics (protected) ··· 189 hostingService: hostingServiceStats, 190 timeWindow 191 } 192 }) 193 194 // Get database stats (protected) ··· 204 205 // Get recent sites (including those without domains) 206 const recentSites = await db` 207 - SELECT 208 s.did, 209 s.rkey, 210 s.display_name, ··· 235 message: error instanceof Error ? error.message : String(error) 236 } 237 } 238 }) 239 240 // Get sites listing (protected) ··· 247 248 try { 249 const sites = await db` 250 - SELECT 251 s.did, 252 s.rkey, 253 s.display_name, ··· 282 message: error instanceof Error ? error.message : String(error) 283 } 284 } 285 }) 286 287 // Get system health (protected) ··· 301 }, 302 timestamp: new Date().toISOString() 303 } 304 }) 305
··· 4 import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 import { db } from '../lib/db' 6 7 + export const adminRoutes = (cookieSecret: string) => 8 new Elysia({ prefix: '/api/admin' }) 9 // Login 10 .post( ··· 35 body: t.Object({ 36 username: t.String(), 37 password: t.String() 38 + }), 39 + cookie: t.Cookie({ 40 + admin_session: t.String() 41 + }, { 42 + secrets: cookieSecret, 43 + sign: ['admin_session'] 44 }) 45 } 46 ) ··· 53 } 54 cookie.admin_session.remove() 55 return { success: true } 56 + }, { 57 + cookie: t.Cookie({ 58 + admin_session: t.Optional(t.String()) 59 + }, { 60 + secrets: cookieSecret, 61 + sign: ['admin_session'] 62 + }) 63 }) 64 65 // Check auth status ··· 78 authenticated: true, 79 username: session.username 80 } 81 + }, { 82 + cookie: t.Cookie({ 83 + admin_session: t.Optional(t.String()) 84 + }, { 85 + secrets: cookieSecret, 86 + sign: ['admin_session'] 87 + }) 88 }) 89 90 // Get logs (protected) ··· 129 ) 130 131 return { logs: allLogs.slice(0, filter.limit || 100) } 132 + }, { 133 + cookie: t.Cookie({ 134 + admin_session: t.Optional(t.String()) 135 + }, { 136 + secrets: cookieSecret, 137 + sign: ['admin_session'] 138 + }) 139 }) 140 141 // Get errors (protected) ··· 174 ) 175 176 return { errors: allErrors.slice(0, filter.limit || 100) } 177 + }, { 178 + cookie: t.Cookie({ 179 + admin_session: t.Optional(t.String()) 180 + }, { 181 + secrets: cookieSecret, 182 + sign: ['admin_session'] 183 + }) 184 }) 185 186 // Get metrics (protected) ··· 223 hostingService: hostingServiceStats, 224 timeWindow 225 } 226 + }, { 227 + cookie: t.Cookie({ 228 + admin_session: t.Optional(t.String()) 229 + }, { 230 + secrets: cookieSecret, 231 + sign: ['admin_session'] 232 + }) 233 }) 234 235 // Get database stats (protected) ··· 245 246 // Get recent sites (including those without domains) 247 const recentSites = await db` 248 + SELECT 249 s.did, 250 s.rkey, 251 s.display_name, ··· 276 message: error instanceof Error ? error.message : String(error) 277 } 278 } 279 + }, { 280 + cookie: t.Cookie({ 281 + admin_session: t.Optional(t.String()) 282 + }, { 283 + secrets: cookieSecret, 284 + sign: ['admin_session'] 285 + }) 286 }) 287 288 // Get sites listing (protected) ··· 295 296 try { 297 const sites = await db` 298 + SELECT 299 s.did, 300 s.rkey, 301 s.display_name, ··· 330 message: error instanceof Error ? error.message : String(error) 331 } 332 } 333 + }, { 334 + cookie: t.Cookie({ 335 + admin_session: t.Optional(t.String()) 336 + }, { 337 + secrets: cookieSecret, 338 + sign: ['admin_session'] 339 + }) 340 }) 341 342 // Get system health (protected) ··· 356 }, 357 timestamp: new Date().toISOString() 358 } 359 + }, { 360 + cookie: t.Cookie({ 361 + admin_session: t.Optional(t.String()) 362 + }, { 363 + secrets: cookieSecret, 364 + sign: ['admin_session'] 365 + }) 366 }) 367
+32 -6
src/routes/auth.ts
··· 1 - import { Elysia } from 'elysia' 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 - import { getSitesByDid, getDomainByDid } from '../lib/db' 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 import { authenticateRequest } from '../lib/wisp-auth' 6 import { logger } from '../lib/observability' 7 8 - export const authRoutes = (client: NodeOAuthClient) => new Elysia() 9 .post('/api/auth/signin', async (c) => { 10 let handle = 'unknown' 11 try { ··· 36 } 37 38 const cookieSession = c.cookie 39 - cookieSession.did.value = session.did 40 41 // Sync sites from PDS to database cache 42 logger.debug('[Auth] Syncing sites from PDS for', session.did) ··· 66 logger.error('[Auth] OAuth callback error', err) 67 return c.redirect('/?error=auth_failed') 68 } 69 }) 70 .post('/api/auth/logout', async (c) => { 71 try { ··· 73 const did = cookieSession.did?.value 74 75 // Clear the session cookie 76 - cookieSession.did.value = '' 77 - cookieSession.did.maxAge = 0 78 79 // If we have a DID, try to revoke the OAuth session 80 if (did && typeof did === 'string') { ··· 92 logger.error('[Auth] Logout error', err) 93 return { error: 'Logout failed' } 94 } 95 }) 96 .get('/api/auth/status', async (c) => { 97 try { ··· 109 logger.error('[Auth] Status check error', err) 110 return { authenticated: false } 111 } 112 })
··· 1 + import { Elysia, t } from 'elysia' 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 + import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db' 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 import { authenticateRequest } from '../lib/wisp-auth' 6 import { logger } from '../lib/observability' 7 8 + export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia() 9 .post('/api/auth/signin', async (c) => { 10 let handle = 'unknown' 11 try { ··· 36 } 37 38 const cookieSession = c.cookie 39 + cookieSession.did.set({ 40 + value: session.did, 41 + httpOnly: true, 42 + secure: process.env.NODE_ENV === 'production', 43 + sameSite: 'lax', 44 + maxAge: 30 * 24 * 60 * 60 // 30 days 45 + }) 46 47 // Sync sites from PDS to database cache 48 logger.debug('[Auth] Syncing sites from PDS for', session.did) ··· 72 logger.error('[Auth] OAuth callback error', err) 73 return c.redirect('/?error=auth_failed') 74 } 75 + }, { 76 + cookie: t.Cookie({ 77 + did: t.Optional(t.String()) 78 + }, { 79 + secrets: cookieSecret, 80 + sign: ['did'] 81 + }) 82 }) 83 .post('/api/auth/logout', async (c) => { 84 try { ··· 86 const did = cookieSession.did?.value 87 88 // Clear the session cookie 89 + cookieSession.did.remove() 90 91 // If we have a DID, try to revoke the OAuth session 92 if (did && typeof did === 'string') { ··· 104 logger.error('[Auth] Logout error', err) 105 return { error: 'Logout failed' } 106 } 107 + }, { 108 + cookie: t.Cookie({ 109 + did: t.Optional(t.String()) 110 + }, { 111 + secrets: cookieSecret, 112 + sign: ['did'] 113 + }) 114 }) 115 .get('/api/auth/status', async (c) => { 116 try { ··· 128 logger.error('[Auth] Status check error', err) 129 return { authenticated: false } 130 } 131 + }, { 132 + cookie: t.Cookie({ 133 + did: t.Optional(t.String()) 134 + }, { 135 + secrets: cookieSecret, 136 + sign: ['did'] 137 + }) 138 })