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

Configure Feed

Select the types of activity you want to include in your feed.

copy and fix ts linting

+26 -263
+2 -2
src/lib/db.test.ts
··· 66 66 // Now try to claim it with testDid1 - should fail 67 67 try { 68 68 await claimCustomDomain(testDid1, testDomain, hash3) 69 - expect.fail('Should have thrown an error when trying to claim a verified domain') 69 + expect('Should have thrown an error when trying to claim a verified domain').fail() 70 70 } catch (err) { 71 - expect(err.message).toBe('conflict') 71 + expect((err as Error).message).toBe('conflict') 72 72 } 73 73 74 74 // Verify the domain is still owned by testDid2 and verified
+1 -242
src/lib/db.ts
··· 1 - import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 2 1 import { SQL } from "bun"; 3 - import { JoseKey } from "@atproto/jwk-jose"; 4 2 import { BASE_HOST } from "./constants"; 5 3 6 4 export const db = new SQL( ··· 221 219 return rows[0] ?? null; 222 220 }; 223 221 224 - export const getAllWispDomains = async (did: string) => { 222 + export const getAllWispDomains = async (did: string): Promise<Array<{ domain: string; rkey: string | null }>> => { 225 223 const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`; 226 224 return rows; 227 225 }; ··· 338 336 339 337 export const deleteWispDomain = async (domain: string): Promise<void> => { 340 338 await db`DELETE FROM domains WHERE domain = ${domain}`; 341 - }; 342 - 343 - // Session timeout configuration (30 days in seconds) 344 - const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 345 - // OAuth state timeout (1 hour in seconds) 346 - const STATE_TIMEOUT = 60 * 60; // 3600 seconds 347 - 348 - const stateStore = { 349 - async set(key: string, data: any) { 350 - const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 351 - await db` 352 - INSERT INTO oauth_states (key, data, created_at, expires_at) 353 - VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 354 - ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 355 - `; 356 - }, 357 - async get(key: string) { 358 - const now = Math.floor(Date.now() / 1000); 359 - const result = await db` 360 - SELECT data, expires_at 361 - FROM oauth_states 362 - WHERE key = ${key} 363 - `; 364 - if (!result[0]) return undefined; 365 - 366 - // Check if expired 367 - const expiresAt = Number(result[0].expires_at); 368 - if (expiresAt && now > expiresAt) { 369 - await db`DELETE FROM oauth_states WHERE key = ${key}`; 370 - return undefined; 371 - } 372 - 373 - return JSON.parse(result[0].data); 374 - }, 375 - async del(key: string) { 376 - await db`DELETE FROM oauth_states WHERE key = ${key}`; 377 - } 378 - }; 379 - 380 - const sessionStore = { 381 - async set(sub: string, data: any) { 382 - const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 383 - await db` 384 - INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 385 - VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 386 - ON CONFLICT (sub) DO UPDATE SET 387 - data = EXCLUDED.data, 388 - updated_at = EXTRACT(EPOCH FROM NOW()), 389 - expires_at = ${expiresAt} 390 - `; 391 - }, 392 - async get(sub: string) { 393 - const now = Math.floor(Date.now() / 1000); 394 - const result = await db` 395 - SELECT data, expires_at 396 - FROM oauth_sessions 397 - WHERE sub = ${sub} 398 - `; 399 - if (!result[0]) return undefined; 400 - 401 - // Check if expired 402 - const expiresAt = Number(result[0].expires_at); 403 - if (expiresAt && now > expiresAt) { 404 - console.log('[sessionStore] Session expired, deleting', sub); 405 - await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 406 - return undefined; 407 - } 408 - 409 - return JSON.parse(result[0].data); 410 - }, 411 - async del(sub: string) { 412 - await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 413 - } 414 - }; 415 - 416 - export { sessionStore }; 417 - 418 - // Cleanup expired sessions and states 419 - export const cleanupExpiredSessions = async () => { 420 - const now = Math.floor(Date.now() / 1000); 421 - try { 422 - const sessionsDeleted = await db` 423 - DELETE FROM oauth_sessions WHERE expires_at < ${now} 424 - `; 425 - const statesDeleted = await db` 426 - DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 427 - `; 428 - console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 429 - return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 430 - } catch (err) { 431 - console.error('[Cleanup] Failed to cleanup expired data:', err); 432 - return { sessions: 0, states: 0 }; 433 - } 434 - }; 435 - 436 - export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => { 437 - const isLocalDev = process.env.LOCAL_DEV === 'true'; 438 - 439 - if (isLocalDev) { 440 - // Loopback client for local development 441 - // For loopback, scopes and redirect_uri must be in client_id query string 442 - const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 443 - const scope = 'atproto transition:generic'; 444 - const params = new URLSearchParams(); 445 - params.append('redirect_uri', redirectUri); 446 - params.append('scope', scope); 447 - 448 - return { 449 - client_id: `http://localhost?${params.toString()}`, 450 - client_name: config.clientName, 451 - client_uri: config.domain, 452 - redirect_uris: [redirectUri], 453 - grant_types: ['authorization_code', 'refresh_token'], 454 - response_types: ['code'], 455 - application_type: 'web', 456 - token_endpoint_auth_method: 'none', 457 - scope: scope, 458 - dpop_bound_access_tokens: false, 459 - subject_type: 'public' 460 - }; 461 - } 462 - 463 - // Production client with private_key_jwt 464 - return { 465 - client_id: `${config.domain}/client-metadata.json`, 466 - client_name: config.clientName, 467 - client_uri: config.domain, 468 - logo_uri: `${config.domain}/logo.png`, 469 - tos_uri: `${config.domain}/tos`, 470 - policy_uri: `${config.domain}/policy`, 471 - redirect_uris: [`${config.domain}/api/auth/callback`], 472 - grant_types: ['authorization_code', 'refresh_token'], 473 - response_types: ['code'], 474 - application_type: 'web', 475 - token_endpoint_auth_method: 'private_key_jwt', 476 - token_endpoint_auth_signing_alg: "ES256", 477 - scope: "atproto transition:generic", 478 - dpop_bound_access_tokens: true, 479 - jwks_uri: `${config.domain}/jwks.json`, 480 - subject_type: 'public', 481 - authorization_signed_response_alg: 'ES256' 482 - }; 483 - }; 484 - 485 - const persistKey = async (key: JoseKey) => { 486 - const priv = key.privateJwk; 487 - if (!priv) return; 488 - const kid = key.kid ?? crypto.randomUUID(); 489 - await db` 490 - INSERT INTO oauth_keys (kid, jwk, created_at) 491 - VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 492 - ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 493 - `; 494 - }; 495 - 496 - const loadPersistedKeys = async (): Promise<JoseKey[]> => { 497 - const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 498 - const keys: JoseKey[] = []; 499 - for (const row of rows) { 500 - try { 501 - const obj = JSON.parse(row.jwk); 502 - const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 503 - keys.push(key); 504 - } catch (err) { 505 - console.error('Could not parse stored JWK', err); 506 - } 507 - } 508 - return keys; 509 - }; 510 - 511 - const ensureKeys = async (): Promise<JoseKey[]> => { 512 - let keys = await loadPersistedKeys(); 513 - const needed: string[] = []; 514 - for (let i = 1; i <= 3; i++) { 515 - const kid = `key${i}`; 516 - if (!keys.some(k => k.kid === kid)) needed.push(kid); 517 - } 518 - for (const kid of needed) { 519 - const newKey = await JoseKey.generate(['ES256'], kid); 520 - await persistKey(newKey); 521 - keys.push(newKey); 522 - } 523 - keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 524 - return keys; 525 - }; 526 - 527 - // Load keys from database every time (stateless - safe for horizontal scaling) 528 - export const getCurrentKeys = async (): Promise<JoseKey[]> => { 529 - return await loadPersistedKeys(); 530 - }; 531 - 532 - // Key rotation - rotate keys older than 30 days (monthly rotation) 533 - const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 534 - 535 - export const rotateKeysIfNeeded = async (): Promise<boolean> => { 536 - const now = Math.floor(Date.now() / 1000); 537 - const cutoffTime = now - KEY_MAX_AGE; 538 - 539 - try { 540 - // Find keys older than 30 days 541 - const oldKeys = await db` 542 - SELECT kid, created_at FROM oauth_keys 543 - WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 544 - ORDER BY created_at ASC 545 - `; 546 - 547 - if (oldKeys.length === 0) { 548 - console.log('[KeyRotation] No keys need rotation'); 549 - return false; 550 - } 551 - 552 - console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 553 - 554 - // Rotate the oldest key 555 - const oldestKey = oldKeys[0]; 556 - const oldKid = oldestKey.kid; 557 - 558 - // Generate new key with same kid 559 - const newKey = await JoseKey.generate(['ES256'], oldKid); 560 - await persistKey(newKey); 561 - 562 - console.log(`[KeyRotation] Rotated key ${oldKid}`); 563 - 564 - return true; 565 - } catch (err) { 566 - console.error('[KeyRotation] Failed to rotate keys:', err); 567 - return false; 568 - } 569 - }; 570 - 571 - export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => { 572 - const keys = await ensureKeys(); 573 - 574 - return new NodeOAuthClient({ 575 - clientMetadata: createClientMetadata(config), 576 - keyset: keys, 577 - stateStore, 578 - sessionStore 579 - }); 580 339 }; 581 340 582 341 export const getCustomDomainsByDid = async (did: string) => {
+4 -3
src/lib/oauth-client.ts
··· 126 126 token_endpoint_auth_method: 'none', 127 127 scope: scope, 128 128 dpop_bound_access_tokens: false, 129 - subject_type: 'public' 130 - }; 129 + subject_type: 'public', 130 + authorization_signed_response_alg: 'ES256' 131 + } as ClientMetadata; 131 132 } 132 133 133 134 // Production client with private_key_jwt ··· 149 150 jwks_uri: `${config.domain}/jwks.json`, 150 151 subject_type: 'public', 151 152 authorization_signed_response_alg: 'ES256' 152 - }; 153 + } as ClientMetadata; 153 154 }; 154 155 155 156 const persistKey = async (key: JoseKey) => {
+12 -12
src/lib/wisp-utils.test.ts
··· 22 22 function createMockBlobRef(mimeType: string, size: number): BlobRef { 23 23 // Create a properly formatted CID 24 24 const cid = CID.parse(TEST_CID_STRING) 25 - return new BlobRef(cid, mimeType, size) 25 + return new BlobRef(cid as any, mimeType, size) 26 26 } 27 27 28 28 describe('shouldCompressFile', () => { ··· 727 727 describe('extractBlobMap', () => { 728 728 test('should extract blob map from flat directory structure', () => { 729 729 const mockCid = CID.parse(TEST_CID_STRING) 730 - const mockBlob = new BlobRef(mockCid, 'text/html', 100) 730 + const mockBlob = new BlobRef(mockCid as any, 'text/html', 100) 731 731 732 732 const directory: Directory = { 733 733 $type: 'place.wisp.fs#directory', ··· 758 758 const mockCid1 = CID.parse(TEST_CID_STRING) 759 759 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 760 760 761 - const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100) 762 - const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50) 761 + const mockBlob1 = new BlobRef(mockCid1 as any, 'text/html', 100) 762 + const mockBlob2 = new BlobRef(mockCid2 as any, 'text/css', 50) 763 763 764 764 const directory: Directory = { 765 765 $type: 'place.wisp.fs#directory', ··· 805 805 806 806 test('should handle deeply nested directory structures', () => { 807 807 const mockCid = CID.parse(TEST_CID_STRING) 808 - const mockBlob = new BlobRef(mockCid, 'text/javascript', 200) 808 + const mockBlob = new BlobRef(mockCid as any, 'text/javascript', 200) 809 809 810 810 const directory: Directory = { 811 811 $type: 'place.wisp.fs#directory', ··· 863 863 // This test verifies the fix: AT Protocol SDK returns BlobRef instances, 864 864 // not plain objects with $type and $link properties 865 865 const mockCid = CID.parse(TEST_CID_STRING) 866 - const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500) 866 + const mockBlob = new BlobRef(mockCid as any, 'application/octet-stream', 500) 867 867 868 868 const directory: Directory = { 869 869 $type: 'place.wisp.fs#directory', ··· 892 892 const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 893 893 const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 894 894 895 - const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000) 896 - const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000) 897 - const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000) 895 + const mockBlob1 = new BlobRef(mockCid1 as any, 'image/png', 1000) 896 + const mockBlob2 = new BlobRef(mockCid2 as any, 'image/png', 2000) 897 + const mockBlob3 = new BlobRef(mockCid3 as any, 'image/png', 3000) 898 898 899 899 const directory: Directory = { 900 900 $type: 'place.wisp.fs#directory', ··· 958 958 node: { 959 959 $type: 'place.wisp.fs#file', 960 960 type: 'file', 961 - blob: new BlobRef(mockCid1, 'text/html', 100), 961 + blob: new BlobRef(mockCid1 as any, 'text/html', 100), 962 962 }, 963 963 }, 964 964 { ··· 972 972 node: { 973 973 $type: 'place.wisp.fs#file', 974 974 type: 'file', 975 - blob: new BlobRef(mockCid2, 'text/css', 50), 975 + blob: new BlobRef(mockCid2 as any, 'text/css', 50), 976 976 }, 977 977 }, 978 978 ], ··· 983 983 node: { 984 984 $type: 'place.wisp.fs#file', 985 985 type: 'file', 986 - blob: new BlobRef(mockCid3, 'text/markdown', 200), 986 + blob: new BlobRef(mockCid3 as any, 'text/markdown', 200), 987 987 }, 988 988 }, 989 989 ],
+4 -1
src/lib/wisp-utils.ts
··· 446 446 const remainingPath = pathParts.slice(1).join('/'); 447 447 return { 448 448 name: entry.name, 449 - node: replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri) 449 + node: { 450 + ...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri), 451 + $type: 'place.wisp.fs#directory' as const 452 + } 450 453 }; 451 454 } 452 455 }
+2 -2
src/routes/auth.ts
··· 51 51 }) 52 52 53 53 // Sync sites from PDS to database cache 54 - logger.debug('[Auth] Syncing sites from PDS for', session.did) 54 + logger.debug('[Auth] Syncing sites from PDS for', session.did as any) 55 55 try { 56 56 const syncResult = await syncSitesFromPDS(session.did, session) 57 57 logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`) ··· 92 92 if (did && typeof did === 'string') { 93 93 try { 94 94 await client.revoke(did) 95 - logger.debug('[Auth] Revoked OAuth session for', did) 95 + logger.debug('[Auth] Revoked OAuth session for', did as any) 96 96 } catch (err) { 97 97 logger.error('[Auth] Failed to revoke session', err) 98 98 // Continue with logout even if revoke fails
+1 -1
src/routes/domain.ts
··· 362 362 }); 363 363 } catch (err) { 364 364 // Record might not exist in PDS, continue anyway 365 - logger.warn('[Domain] Could not delete wisp domain from PDS', err); 365 + logger.warn('[Domain] Could not delete wisp domain from PDS', err as any); 366 366 } 367 367 368 368 return { success: true };