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

copy and fix ts linting

+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 };