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

fixes

+21 -11
hosting-service/src/lib/firehose.ts
··· 175 175 176 176 try { 177 177 if (commit.operation === 'create' || commit.operation === 'update') { 178 - await this.handleCreateOrUpdate(did, commit.rkey, commit.record); 178 + // Pass the CID from the event for verification 179 + await this.handleCreateOrUpdate(did, commit.rkey, commit.record, commit.cid); 179 180 } else if (commit.operation === 'delete') { 180 181 await this.handleDelete(did, commit.rkey); 181 182 } ··· 189 190 } 190 191 } 191 192 192 - private async handleCreateOrUpdate(did: string, site: string, record: any) { 193 + private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) { 193 194 this.log('Processing create/update', { did, site }); 194 195 195 196 if (!this.validateRecord(record)) { ··· 207 208 208 209 this.log('Resolved PDS', { did, pdsEndpoint }); 209 210 210 - // Verify record exists on PDS 211 + // Verify record exists on PDS and fetch its CID 212 + let verifiedCid: string; 211 213 try { 212 - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; 213 - const recordRes = await safeFetch(recordUrl); 214 + const result = await fetchSiteRecord(did, site); 214 215 215 - if (!recordRes.ok) { 216 - this.log('Record not found on PDS, skipping cache', { 216 + if (!result) { 217 + this.log('Record not found on PDS, skipping cache', { did, site }); 218 + return; 219 + } 220 + 221 + verifiedCid = result.cid; 222 + 223 + // Verify event CID matches PDS CID (prevent cache poisoning) 224 + if (eventCid && eventCid !== verifiedCid) { 225 + this.log('CID mismatch detected - potential spoofed event', { 217 226 did, 218 227 site, 219 - status: recordRes.status, 228 + eventCid, 229 + verifiedCid 220 230 }); 221 231 return; 222 232 } 223 233 224 - this.log('Record verified on PDS', { did, site }); 234 + this.log('Record verified on PDS', { did, site, cid: verifiedCid }); 225 235 } catch (err) { 226 236 this.log('Failed to verify record on PDS', { 227 237 did, ··· 231 241 return; 232 242 } 233 243 234 - // Cache the record 235 - await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint); 244 + // Cache the record with verified CID 245 + await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); 236 246 237 247 // Upsert site to database 238 248 await upsertSite(did, site, fsRecord.site);
+7 -4
hosting-service/src/lib/html-rewriter.ts
··· 77 77 let rewritten = html; 78 78 79 79 // Rewrite each attribute type 80 + // Use more specific patterns to prevent ReDoS attacks 80 81 for (const attr of REWRITABLE_ATTRIBUTES) { 81 82 if (attr === 'srcset') { 82 - // Special handling for srcset 83 + // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 84 + // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 83 85 const srcsetRegex = new RegExp( 84 - `\\b${attr}\\s*=\\s*"([^"]*)"`, 86 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 85 87 'gi' 86 88 ); 87 89 rewritten = rewritten.replace(srcsetRegex, (match, value) => { ··· 90 92 }); 91 93 } else { 92 94 // Regular attributes with quoted values 95 + // Limit whitespace to prevent catastrophic backtracking 93 96 const doubleQuoteRegex = new RegExp( 94 - `\\b${attr}\\s*=\\s*"([^"]*)"`, 97 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 95 98 'gi' 96 99 ); 97 100 const singleQuoteRegex = new RegExp( 98 - `\\b${attr}\\s*=\\s*'([^']*)'`, 101 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 99 102 'gi' 100 103 ); 101 104
+73 -5
hosting-service/src/lib/utils.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { WispFsRecord, Directory, Entry, File } from './types'; 3 - import { existsSync, mkdirSync } from 'fs'; 4 - import { writeFile } from 'fs/promises'; 3 + import { existsSync, mkdirSync, readFileSync } from 'fs'; 4 + import { writeFile, readFile } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 6 import { CID } from 'multiformats/cid'; 7 + import { createHash } from 'crypto'; 7 8 8 9 const CACHE_DIR = './cache/sites'; 10 + const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL 11 + 12 + interface CacheMetadata { 13 + recordCid: string; 14 + cachedAt: number; 15 + did: string; 16 + rkey: string; 17 + } 9 18 10 19 // Type guards for different blob reference formats 11 20 interface IpldLink { ··· 97 106 } 98 107 } 99 108 100 - export async function fetchSiteRecord(did: string, rkey: string): Promise<WispFsRecord | null> { 109 + export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> { 101 110 try { 102 111 const pdsEndpoint = await getPdsForDid(did); 103 112 if (!pdsEndpoint) return null; 104 113 105 114 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`; 106 115 const data = await safeFetchJson(url); 107 - return data.value as WispFsRecord; 116 + 117 + // Return both the record and its CID for verification 118 + return { 119 + record: data.value as WispFsRecord, 120 + cid: data.cid || '' 121 + }; 108 122 } catch (err) { 109 123 console.error('Failed to fetch site record', did, rkey, err); 110 124 return null; ··· 140 154 return null; 141 155 } 142 156 143 - export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string): Promise<void> { 157 + export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> { 144 158 console.log('Caching site', did, rkey); 145 159 146 160 // Validate record structure ··· 155 169 } 156 170 157 171 await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, ''); 172 + 173 + // Save cache metadata with CID for verification 174 + await saveCacheMetadata(did, rkey, recordCid); 158 175 } 159 176 160 177 async function cacheFiles( ··· 236 253 export function isCached(did: string, site: string): boolean { 237 254 return existsSync(`${CACHE_DIR}/${did}/${site}`); 238 255 } 256 + 257 + async function saveCacheMetadata(did: string, rkey: string, recordCid: string): Promise<void> { 258 + const metadata: CacheMetadata = { 259 + recordCid, 260 + cachedAt: Date.now(), 261 + did, 262 + rkey 263 + }; 264 + 265 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`; 266 + const metadataDir = metadataPath.substring(0, metadataPath.lastIndexOf('/')); 267 + 268 + if (!existsSync(metadataDir)) { 269 + mkdirSync(metadataDir, { recursive: true }); 270 + } 271 + 272 + await writeFile(metadataPath, JSON.stringify(metadata, null, 2)); 273 + } 274 + 275 + async function getCacheMetadata(did: string, rkey: string): Promise<CacheMetadata | null> { 276 + try { 277 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`; 278 + if (!existsSync(metadataPath)) return null; 279 + 280 + const content = await readFile(metadataPath, 'utf-8'); 281 + return JSON.parse(content) as CacheMetadata; 282 + } catch (err) { 283 + console.error('Failed to read cache metadata', err); 284 + return null; 285 + } 286 + } 287 + 288 + export async function isCacheValid(did: string, rkey: string, currentRecordCid?: string): Promise<boolean> { 289 + const metadata = await getCacheMetadata(did, rkey); 290 + if (!metadata) return false; 291 + 292 + // Check if cache has expired (14 days TTL) 293 + const cacheAge = Date.now() - metadata.cachedAt; 294 + if (cacheAge > CACHE_TTL) { 295 + console.log('[Cache] Cache expired for', did, rkey); 296 + return false; 297 + } 298 + 299 + // If current CID is provided, verify it matches 300 + if (currentRecordCid && metadata.recordCid !== currentRecordCid) { 301 + console.log('[Cache] CID mismatch for', did, rkey, 'cached:', metadata.recordCid, 'current:', currentRecordCid); 302 + return false; 303 + } 304 + 305 + return true; 306 + }
+42 -1
src/index.ts
··· 8 8 import { 9 9 createClientMetadata, 10 10 getOAuthClient, 11 - getCurrentKeys 11 + getCurrentKeys, 12 + cleanupExpiredSessions, 13 + rotateKeysIfNeeded 12 14 } from './lib/oauth-client' 13 15 import { authRoutes } from './routes/auth' 14 16 import { wispRoutes } from './routes/wisp' ··· 22 24 23 25 const client = await getOAuthClient(config) 24 26 27 + // Periodic maintenance: cleanup expired sessions and rotate keys 28 + // Run every hour 29 + const runMaintenance = async () => { 30 + console.log('[Maintenance] Running periodic maintenance...') 31 + await cleanupExpiredSessions() 32 + await rotateKeysIfNeeded() 33 + } 34 + 35 + // Run maintenance on startup 36 + runMaintenance() 37 + 38 + // Schedule maintenance to run every hour 39 + setInterval(runMaintenance, 60 * 60 * 1000) 40 + 25 41 export const app = new Elysia() 42 + // Security headers middleware 43 + .onAfterHandle(({ set }) => { 44 + // Prevent clickjacking attacks 45 + set.headers['X-Frame-Options'] = 'DENY' 46 + // Prevent MIME type sniffing 47 + set.headers['X-Content-Type-Options'] = 'nosniff' 48 + // Strict Transport Security (HSTS) - enforce HTTPS 49 + set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' 50 + // Referrer policy - limit referrer information 51 + set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' 52 + // Content Security Policy 53 + set.headers['Content-Security-Policy'] = 54 + "default-src 'self'; " + 55 + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + 56 + "style-src 'self' 'unsafe-inline'; " + 57 + "img-src 'self' data: https:; " + 58 + "font-src 'self' data:; " + 59 + "connect-src 'self' https:; " + 60 + "frame-ancestors 'none'; " + 61 + "base-uri 'self'; " + 62 + "form-action 'self'" 63 + // Additional security headers 64 + set.headers['X-XSS-Protection'] = '1; mode=block' 65 + set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 66 + }) 26 67 .use( 27 68 openapi({ 28 69 references: fromTypes()
+136 -16
src/lib/db.ts
··· 23 23 CREATE TABLE IF NOT EXISTS oauth_sessions ( 24 24 sub TEXT PRIMARY KEY, 25 25 data TEXT NOT NULL, 26 - updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 26 + updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 27 + expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000 27 28 ) 28 29 `; 29 30 30 31 await db` 31 32 CREATE TABLE IF NOT EXISTS oauth_keys ( 32 33 kid TEXT PRIMARY KEY, 33 - jwk TEXT NOT NULL 34 + jwk TEXT NOT NULL, 35 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 34 36 ) 35 37 `; 36 38 ··· 44 46 ) 45 47 `; 46 48 47 - // Add rkey column if it doesn't exist (for existing databases) 49 + // Add columns if they don't exist (for existing databases) 48 50 try { 49 51 await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`; 52 + } catch (err) { 53 + // Column might already exist, ignore 54 + } 55 + 56 + try { 57 + await db`ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000`; 58 + } catch (err) { 59 + // Column might already exist, ignore 60 + } 61 + 62 + try { 63 + await db`ALTER TABLE oauth_keys ADD COLUMN IF NOT EXISTS created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())`; 64 + } catch (err) { 65 + // Column might already exist, ignore 66 + } 67 + 68 + try { 69 + await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`; 50 70 } catch (err) { 51 71 // Column might already exist, ignore 52 72 } ··· 205 225 return rows[0]?.rkey ?? null; 206 226 }; 207 227 228 + // Session timeout configuration (30 days in seconds) 229 + const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 230 + // OAuth state timeout (1 hour in seconds) 231 + const STATE_TIMEOUT = 60 * 60; // 3600 seconds 232 + 208 233 const stateStore = { 209 234 async set(key: string, data: any) { 210 235 console.debug('[stateStore] set', key) 236 + const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 211 237 await db` 212 - INSERT INTO oauth_states (key, data) 213 - VALUES (${key}, ${JSON.stringify(data)}) 214 - ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 238 + INSERT INTO oauth_states (key, data, created_at, expires_at) 239 + VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 240 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 215 241 `; 216 242 }, 217 243 async get(key: string) { 218 244 console.debug('[stateStore] get', key) 219 - const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 220 - return result[0] ? JSON.parse(result[0].data) : undefined; 245 + const now = Math.floor(Date.now() / 1000); 246 + const result = await db` 247 + SELECT data, expires_at 248 + FROM oauth_states 249 + WHERE key = ${key} 250 + `; 251 + if (!result[0]) return undefined; 252 + 253 + // Check if expired 254 + const expiresAt = Number(result[0].expires_at); 255 + if (expiresAt && now > expiresAt) { 256 + console.debug('[stateStore] State expired, deleting', key); 257 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 258 + return undefined; 259 + } 260 + 261 + return JSON.parse(result[0].data); 221 262 }, 222 263 async del(key: string) { 223 264 console.debug('[stateStore] del', key) ··· 228 269 const sessionStore = { 229 270 async set(sub: string, data: any) { 230 271 console.debug('[sessionStore] set', sub) 272 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 231 273 await db` 232 - INSERT INTO oauth_sessions (sub, data) 233 - VALUES (${sub}, ${JSON.stringify(data)}) 234 - ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 274 + INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 275 + VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 276 + ON CONFLICT (sub) DO UPDATE SET 277 + data = EXCLUDED.data, 278 + updated_at = EXTRACT(EPOCH FROM NOW()), 279 + expires_at = ${expiresAt} 235 280 `; 236 281 }, 237 282 async get(sub: string) { 238 283 console.debug('[sessionStore] get', sub) 239 - const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 240 - return result[0] ? JSON.parse(result[0].data) : undefined; 284 + const now = Math.floor(Date.now() / 1000); 285 + const result = await db` 286 + SELECT data, expires_at 287 + FROM oauth_sessions 288 + WHERE sub = ${sub} 289 + `; 290 + if (!result[0]) return undefined; 291 + 292 + // Check if expired 293 + const expiresAt = Number(result[0].expires_at); 294 + if (expiresAt && now > expiresAt) { 295 + console.log('[sessionStore] Session expired, deleting', sub); 296 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 297 + return undefined; 298 + } 299 + 300 + return JSON.parse(result[0].data); 241 301 }, 242 302 async del(sub: string) { 243 303 console.debug('[sessionStore] del', sub) ··· 247 307 248 308 export { sessionStore }; 249 309 310 + // Cleanup expired sessions and states 311 + export const cleanupExpiredSessions = async () => { 312 + const now = Math.floor(Date.now() / 1000); 313 + try { 314 + const sessionsDeleted = await db` 315 + DELETE FROM oauth_sessions WHERE expires_at < ${now} 316 + `; 317 + const statesDeleted = await db` 318 + DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 319 + `; 320 + console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 321 + return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 322 + } catch (err) { 323 + console.error('[Cleanup] Failed to cleanup expired data:', err); 324 + return { sessions: 0, states: 0 }; 325 + } 326 + }; 327 + 250 328 export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({ 251 329 client_id: `${config.domain}/client-metadata.json`, 252 330 client_name: config.clientName, ··· 272 350 if (!priv) return; 273 351 const kid = key.kid ?? crypto.randomUUID(); 274 352 await db` 275 - INSERT INTO oauth_keys (kid, jwk) 276 - VALUES (${kid}, ${JSON.stringify(priv)}) 353 + INSERT INTO oauth_keys (kid, jwk, created_at) 354 + VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 277 355 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 278 356 `; 279 357 }; 280 358 281 359 const loadPersistedKeys = async (): Promise<JoseKey[]> => { 282 - const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 360 + const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 283 361 const keys: JoseKey[] = []; 284 362 for (const row of rows) { 285 363 try { ··· 312 390 let currentKeys: JoseKey[] = []; 313 391 314 392 export const getCurrentKeys = () => currentKeys; 393 + 394 + // Key rotation - rotate keys older than 30 days (monthly rotation) 395 + const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 396 + 397 + export const rotateKeysIfNeeded = async (): Promise<boolean> => { 398 + const now = Math.floor(Date.now() / 1000); 399 + const cutoffTime = now - KEY_MAX_AGE; 400 + 401 + try { 402 + // Find keys older than 30 days 403 + const oldKeys = await db` 404 + SELECT kid, created_at FROM oauth_keys 405 + WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 406 + ORDER BY created_at ASC 407 + `; 408 + 409 + if (oldKeys.length === 0) { 410 + console.log('[KeyRotation] No keys need rotation'); 411 + return false; 412 + } 413 + 414 + console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 415 + 416 + // Rotate the oldest key 417 + const oldestKey = oldKeys[0]; 418 + const oldKid = oldestKey.kid; 419 + 420 + // Generate new key with same kid 421 + const newKey = await JoseKey.generate(['ES256'], oldKid); 422 + await persistKey(newKey); 423 + 424 + console.log(`[KeyRotation] Rotated key ${oldKid}`); 425 + 426 + // Reload keys into memory 427 + currentKeys = await ensureKeys(); 428 + 429 + return true; 430 + } catch (err) { 431 + console.error('[KeyRotation] Failed to rotate keys:', err); 432 + return false; 433 + } 434 + }; 315 435 316 436 export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 317 437 if (currentKeys.length === 0) {
+37
src/lib/logger.ts
··· 1 + // Secure logging utility - only verbose in development mode 2 + const isDev = process.env.NODE_ENV !== 'production'; 3 + 4 + export const logger = { 5 + // Always log these (safe for production) 6 + info: (...args: any[]) => { 7 + console.log(...args); 8 + }, 9 + 10 + // Only log in development (may contain sensitive info) 11 + debug: (...args: any[]) => { 12 + if (isDev) { 13 + console.debug(...args); 14 + } 15 + }, 16 + 17 + // Safe error logging - sanitizes in production 18 + error: (message: string, error?: any) => { 19 + if (isDev) { 20 + // Development: log full error details 21 + console.error(message, error); 22 + } else { 23 + // Production: log only the message, not error details 24 + console.error(message); 25 + } 26 + }, 27 + 28 + // Log error with context but sanitize sensitive data in production 29 + errorWithContext: (message: string, context?: Record<string, any>, error?: any) => { 30 + if (isDev) { 31 + console.error(message, context, error); 32 + } else { 33 + // In production, only log the message 34 + console.error(message); 35 + } 36 + } 37 + };
+115 -14
src/lib/oauth-client.ts
··· 1 1 import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 2 2 import { JoseKey } from "@atproto/jwk-jose"; 3 3 import { db } from "./db"; 4 + import { logger } from "./logger"; 5 + 6 + // Session timeout configuration (30 days in seconds) 7 + const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 8 + // OAuth state timeout (1 hour in seconds) 9 + const STATE_TIMEOUT = 60 * 60; // 3600 seconds 4 10 5 11 const stateStore = { 6 12 async set(key: string, data: any) { 7 13 console.debug('[stateStore] set', key) 14 + const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 8 15 await db` 9 - INSERT INTO oauth_states (key, data) 10 - VALUES (${key}, ${JSON.stringify(data)}) 11 - ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 16 + INSERT INTO oauth_states (key, data, created_at, expires_at) 17 + VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 18 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 12 19 `; 13 20 }, 14 21 async get(key: string) { 15 22 console.debug('[stateStore] get', key) 16 - const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 17 - return result[0] ? JSON.parse(result[0].data) : undefined; 23 + const now = Math.floor(Date.now() / 1000); 24 + const result = await db` 25 + SELECT data, expires_at 26 + FROM oauth_states 27 + WHERE key = ${key} 28 + `; 29 + if (!result[0]) return undefined; 30 + 31 + // Check if expired 32 + const expiresAt = Number(result[0].expires_at); 33 + if (expiresAt && now > expiresAt) { 34 + console.debug('[stateStore] State expired, deleting', key); 35 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 36 + return undefined; 37 + } 38 + 39 + return JSON.parse(result[0].data); 18 40 }, 19 41 async del(key: string) { 20 42 console.debug('[stateStore] del', key) ··· 25 47 const sessionStore = { 26 48 async set(sub: string, data: any) { 27 49 console.debug('[sessionStore] set', sub) 50 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 28 51 await db` 29 - INSERT INTO oauth_sessions (sub, data) 30 - VALUES (${sub}, ${JSON.stringify(data)}) 31 - ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 52 + INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 53 + VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 54 + ON CONFLICT (sub) DO UPDATE SET 55 + data = EXCLUDED.data, 56 + updated_at = EXTRACT(EPOCH FROM NOW()), 57 + expires_at = ${expiresAt} 32 58 `; 33 59 }, 34 60 async get(sub: string) { 35 61 console.debug('[sessionStore] get', sub) 36 - const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 37 - return result[0] ? JSON.parse(result[0].data) : undefined; 62 + const now = Math.floor(Date.now() / 1000); 63 + const result = await db` 64 + SELECT data, expires_at 65 + FROM oauth_sessions 66 + WHERE sub = ${sub} 67 + `; 68 + if (!result[0]) return undefined; 69 + 70 + // Check if expired 71 + const expiresAt = Number(result[0].expires_at); 72 + if (expiresAt && now > expiresAt) { 73 + logger.debug('[sessionStore] Session expired, deleting', sub); 74 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 75 + return undefined; 76 + } 77 + 78 + return JSON.parse(result[0].data); 38 79 }, 39 80 async del(sub: string) { 40 81 console.debug('[sessionStore] del', sub) ··· 44 85 45 86 export { sessionStore }; 46 87 88 + // Cleanup expired sessions and states 89 + export const cleanupExpiredSessions = async () => { 90 + const now = Math.floor(Date.now() / 1000); 91 + try { 92 + const sessionsDeleted = await db` 93 + DELETE FROM oauth_sessions WHERE expires_at < ${now} 94 + `; 95 + const statesDeleted = await db` 96 + DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 97 + `; 98 + logger.info(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 99 + return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 100 + } catch (err) { 101 + logger.error('[Cleanup] Failed to cleanup expired data', err); 102 + return { sessions: 0, states: 0 }; 103 + } 104 + }; 105 + 47 106 export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => { 48 107 // Use editor.wisp.place for OAuth endpoints since that's where the API routes live 49 108 return { ··· 72 131 if (!priv) return; 73 132 const kid = key.kid ?? crypto.randomUUID(); 74 133 await db` 75 - INSERT INTO oauth_keys (kid, jwk) 76 - VALUES (${kid}, ${JSON.stringify(priv)}) 134 + INSERT INTO oauth_keys (kid, jwk, created_at) 135 + VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 77 136 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 78 137 `; 79 138 }; 80 139 81 140 const loadPersistedKeys = async (): Promise<JoseKey[]> => { 82 - const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 141 + const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 83 142 const keys: JoseKey[] = []; 84 143 for (const row of rows) { 85 144 try { ··· 87 146 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 88 147 keys.push(key); 89 148 } catch (err) { 90 - console.error('Could not parse stored JWK', err); 149 + logger.error('[OAuth] Could not parse stored JWK', err); 91 150 } 92 151 } 93 152 return keys; ··· 112 171 let currentKeys: JoseKey[] = []; 113 172 114 173 export const getCurrentKeys = () => currentKeys; 174 + 175 + // Key rotation - rotate keys older than 30 days (monthly rotation) 176 + const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 177 + 178 + export const rotateKeysIfNeeded = async (): Promise<boolean> => { 179 + const now = Math.floor(Date.now() / 1000); 180 + const cutoffTime = now - KEY_MAX_AGE; 181 + 182 + try { 183 + // Find keys older than 30 days 184 + const oldKeys = await db` 185 + SELECT kid, created_at FROM oauth_keys 186 + WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 187 + ORDER BY created_at ASC 188 + `; 189 + 190 + if (oldKeys.length === 0) { 191 + logger.debug('[KeyRotation] No keys need rotation'); 192 + return false; 193 + } 194 + 195 + logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 196 + 197 + // Rotate the oldest key 198 + const oldestKey = oldKeys[0]; 199 + const oldKid = oldestKey.kid; 200 + 201 + // Generate new key with same kid 202 + const newKey = await JoseKey.generate(['ES256'], oldKid); 203 + await persistKey(newKey); 204 + 205 + logger.info(`[KeyRotation] Rotated key ${oldKid}`); 206 + 207 + // Reload keys into memory 208 + currentKeys = await ensureKeys(); 209 + 210 + return true; 211 + } catch (err) { 212 + logger.error('[KeyRotation] Failed to rotate keys', err); 213 + return false; 214 + } 215 + }; 115 216 116 217 export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 117 218 if (currentKeys.length === 0) {
+2 -1
src/lib/wisp-auth.ts
··· 2 2 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 3 import type { OAuthSession } from "@atproto/oauth-client-node"; 4 4 import { Cookie } from "elysia"; 5 + import { logger } from "./logger"; 5 6 6 7 7 8 export interface AuthenticatedContext { ··· 20 21 const session = await client.restore(did, "auto"); 21 22 return session ? { did, session } : null; 22 23 } catch (err) { 23 - console.error('Authentication error:', err); 24 + logger.error('[Auth] Authentication error', err); 24 25 return null; 25 26 } 26 27 };
+12 -11
src/routes/auth.ts
··· 3 3 import { getSitesByDid, getDomainByDid } from '../lib/db' 4 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 5 import { authenticateRequest } from '../lib/wisp-auth' 6 + import { logger } from '../lib/logger' 6 7 7 8 export const authRoutes = (client: NodeOAuthClient) => new Elysia() 8 9 .post('/api/auth/signin', async (c) => { ··· 12 13 const url = await client.authorize(handle, { state }) 13 14 return { url: url.toString() } 14 15 } catch (err) { 15 - console.error('Signin error', err) 16 + logger.error('[Auth] Signin error', err) 16 17 return { error: 'Authentication failed' } 17 18 } 18 19 }) ··· 25 26 const { session } = await client.callback(params) 26 27 27 28 if (!session) { 28 - console.error('[Auth] OAuth callback failed: no session returned') 29 + logger.error('[Auth] OAuth callback failed: no session returned') 29 30 return c.redirect('/?error=auth_failed') 30 31 } 31 32 ··· 33 34 cookieSession.did.value = session.did 34 35 35 36 // Sync sites from PDS to database cache 36 - console.log('[Auth] Syncing sites from PDS for', session.did) 37 + logger.debug('[Auth] Syncing sites from PDS for', session.did) 37 38 try { 38 39 const syncResult = await syncSitesFromPDS(session.did, session) 39 - console.log(`[Auth] Sync complete: ${syncResult.synced} sites synced`) 40 + logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`) 40 41 if (syncResult.errors.length > 0) { 41 - console.warn('[Auth] Sync errors:', syncResult.errors) 42 + logger.debug('[Auth] Sync errors:', syncResult.errors) 42 43 } 43 44 } catch (err) { 44 - console.error('[Auth] Failed to sync sites:', err) 45 + logger.error('[Auth] Failed to sync sites', err) 45 46 // Don't fail auth if sync fails, just log it 46 47 } 47 48 ··· 57 58 return c.redirect('/editor') 58 59 } catch (err) { 59 60 // This catches state validation failures and other OAuth errors 60 - console.error('[Auth] OAuth callback error:', err) 61 + logger.error('[Auth] OAuth callback error', err) 61 62 return c.redirect('/?error=auth_failed') 62 63 } 63 64 }) ··· 74 75 if (did && typeof did === 'string') { 75 76 try { 76 77 await client.revoke(did) 77 - console.log('[Auth] Revoked OAuth session for', did) 78 + logger.debug('[Auth] Revoked OAuth session for', did) 78 79 } catch (err) { 79 - console.error('[Auth] Failed to revoke session:', err) 80 + logger.error('[Auth] Failed to revoke session', err) 80 81 // Continue with logout even if revoke fails 81 82 } 82 83 } 83 84 84 85 return { success: true } 85 86 } catch (err) { 86 - console.error('[Auth] Logout error:', err) 87 + logger.error('[Auth] Logout error', err) 87 88 return { error: 'Logout failed' } 88 89 } 89 90 }) ··· 100 101 did: auth.did 101 102 } 102 103 } catch (err) { 103 - console.error('[Auth] Status check error:', err) 104 + logger.error('[Auth] Status check error', err) 104 105 return { authenticated: false } 105 106 } 106 107 })
+11 -10
src/routes/domain.ts
··· 20 20 } from '../lib/db' 21 21 import { createHash } from 'crypto' 22 22 import { verifyCustomDomain } from '../lib/dns-verify' 23 + import { logger } from '../lib/logger' 23 24 24 25 export const domainRoutes = (client: NodeOAuthClient) => 25 26 new Elysia({ prefix: '/api/domain' }) ··· 43 44 domain: toDomain(handle) 44 45 }; 45 46 } catch (err) { 46 - console.error("domain/check error", err); 47 + logger.error('[Domain] Check error', err); 47 48 return { 48 49 available: false 49 50 }; ··· 69 70 return { registered: false }; 70 71 } 71 72 } catch (err) { 72 - console.error("domain/registered error", err); 73 + logger.error('[Domain] Registered check error', err); 73 74 set.status = 500; 74 75 return { error: 'Failed to check domain' }; 75 76 } ··· 118 119 119 120 return { success: true, domain }; 120 121 } catch (err) { 121 - console.error("domain/claim error", err); 122 + logger.error('[Domain] Claim error', err); 122 123 throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`); 123 124 } 124 125 }) ··· 160 161 161 162 return { success: true, domain }; 162 163 } catch (err) { 163 - console.error("domain/update error", err); 164 + logger.error('[Domain] Update error', err); 164 165 throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); 165 166 } 166 167 }) ··· 193 194 verified: false 194 195 }; 195 196 } catch (err) { 196 - console.error('custom domain add error', err); 197 + logger.error('[Domain] Custom domain add error', err); 197 198 throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 198 199 } 199 200 }) ··· 208 209 } 209 210 210 211 // Verify DNS records (TXT + CNAME) 211 - console.log(`Verifying custom domain: ${domainInfo.domain}`); 212 + logger.debug(`[Domain] Verifying custom domain: ${domainInfo.domain}`); 212 213 const result = await verifyCustomDomain(domainInfo.domain, auth.did, id); 213 214 214 215 // Update verification status in database ··· 221 222 found: result.found 222 223 }; 223 224 } catch (err) { 224 - console.error('custom domain verify error', err); 225 + logger.error('[Domain] Custom domain verify error', err); 225 226 throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 226 227 } 227 228 }) ··· 244 245 245 246 return { success: true }; 246 247 } catch (err) { 247 - console.error('custom domain delete error', err); 248 + logger.error('[Domain] Custom domain delete error', err); 248 249 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 249 250 } 250 251 }) ··· 257 258 258 259 return { success: true }; 259 260 } catch (err) { 260 - console.error('wisp domain map error', err); 261 + logger.error('[Domain] Wisp domain map error', err); 261 262 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 262 263 } 263 264 }) ··· 281 282 282 283 return { success: true }; 283 284 } catch (err) { 284 - console.error('custom domain map error', err); 285 + logger.error('[Domain] Custom domain map error', err); 285 286 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 286 287 } 287 288 });
+8 -7
src/routes/user.ts
··· 4 4 import { Agent } from '@atproto/api' 5 5 import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db' 6 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 + import { logger } from '../lib/logger' 7 8 8 9 export const userRoutes = (client: NodeOAuthClient) => 9 10 new Elysia({ prefix: '/api/user' }) ··· 27 28 sitesCount: sites.length 28 29 } 29 30 } catch (err) { 30 - console.error('user/status error', err) 31 + logger.error('[User] Status error', err) 31 32 throw new Error('Failed to get user status') 32 33 } 33 34 }) ··· 41 42 const profile = await agent.getProfile({ actor: auth.did }) 42 43 handle = profile.data.handle 43 44 } catch (err) { 44 - console.error('Failed to fetch profile:', err) 45 + logger.error('[User] Failed to fetch profile', err) 45 46 } 46 47 47 48 return { ··· 49 50 handle 50 51 } 51 52 } catch (err) { 52 - console.error('user/info error', err) 53 + logger.error('[User] Info error', err) 53 54 throw new Error('Failed to get user info') 54 55 } 55 56 }) ··· 58 59 const sites = await getSitesByDid(auth.did) 59 60 return { sites } 60 61 } catch (err) { 61 - console.error('user/sites error', err) 62 + logger.error('[User] Sites error', err) 62 63 throw new Error('Failed to get sites') 63 64 } 64 65 }) ··· 78 79 customDomains 79 80 } 80 81 } catch (err) { 81 - console.error('user/domains error', err) 82 + logger.error('[User] Domains error', err) 82 83 throw new Error('Failed to get domains') 83 84 } 84 85 }) 85 86 .post('/sync', async ({ auth }) => { 86 87 try { 87 - console.log('[User] Manual sync requested for', auth.did) 88 + logger.debug('[User] Manual sync requested for', auth.did) 88 89 const result = await syncSitesFromPDS(auth.did, auth.session) 89 90 90 91 return { ··· 93 94 errors: result.errors 94 95 } 95 96 } catch (err) { 96 - console.error('user/sync error', err) 97 + logger.error('[User] Sync error', err) 97 98 throw new Error('Failed to sync sites') 98 99 } 99 100 })
+8 -15
src/routes/wisp.ts
··· 10 10 updateFileBlobs 11 11 } from '../lib/wisp-utils' 12 12 import { upsertSite } from '../lib/db' 13 + import { logger } from '../lib/logger' 13 14 14 - /** 15 - * Validate site name (rkey) according to AT Protocol specifications 16 - * - Must be 1-512 characters 17 - * - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons 18 - * - Cannot be just "." or ".." 19 - * - Cannot contain path traversal sequences 20 - */ 21 15 function isValidSiteName(siteName: string): boolean { 22 16 if (!siteName || typeof siteName !== 'string') return false; 23 17 ··· 235 229 returnedMimeType: returnedBlobRef.mimeType 236 230 }; 237 231 } catch (uploadError) { 238 - console.error(`❌ Upload failed for ${file.name}:`, uploadError); 232 + logger.error('[Wisp] Upload failed for file', uploadError); 239 233 throw uploadError; 240 234 } 241 235 }); ··· 265 259 record: manifest 266 260 }); 267 261 } catch (putRecordError: any) { 268 - console.error('\n❌ Failed to create record on PDS'); 269 - console.error('Error:', putRecordError.message); 262 + logger.error('[Wisp] Failed to create record on PDS'); 263 + logger.error('[Wisp] Record creation error', putRecordError); 270 264 271 265 throw putRecordError; 272 266 } ··· 284 278 285 279 return result; 286 280 } catch (error) { 287 - console.error('❌ Upload error:', error); 288 - console.error('Error details:', { 281 + logger.error('[Wisp] Upload error', error); 282 + logger.errorWithContext('[Wisp] Upload error details', { 289 283 message: error instanceof Error ? error.message : 'Unknown error', 290 - stack: error instanceof Error ? error.stack : undefined, 291 284 name: error instanceof Error ? error.name : undefined 292 - }); 285 + }, error); 293 286 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 294 287 } 295 288 } 296 - ) 289 + )