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