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

better modualizaition of identity

+168 -140
+1
.gitignore
··· 1 1 .research/ 2 + cache/ 2 3 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 4 .env 4 5 # dependencies
+2 -61
apps/hosting-service/src/lib/utils.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 1 import type { Record as WispFsRecord, Directory, Entry, File } from '@wisp/lexicons/types/place/wisp/fs'; 3 2 import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs'; 4 3 import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings'; ··· 7 6 import { Readable } from 'stream'; 8 7 import { safeFetchJson, safeFetchBlob } from '@wisp/safe-fetch'; 9 8 import { CID } from 'multiformats'; 10 - import { extractBlobCid } from '@wisp/atproto-utils'; 9 + import { extractBlobCid, resolveDid, getPdsForDid, didWebToHttps } from '@wisp/atproto-utils'; 11 10 import { sanitizePath, collectFileCidsFromEntries, countFilesInDirectory } from '@wisp/fs-utils'; 12 11 import { shouldCompressMimeType } from '@wisp/atproto-utils/compression'; 13 12 import { MAX_BLOB_SIZE, MAX_FILE_COUNT, MAX_SITE_SIZE } from '@wisp/constants'; 14 13 import { storage } from './storage'; 15 14 16 15 // Re-export shared utilities for local usage and tests 17 - export { extractBlobCid, sanitizePath }; 16 + export { extractBlobCid, sanitizePath, resolveDid, getPdsForDid, didWebToHttps }; 18 17 19 18 const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 20 19 const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL ··· 30 29 settings?: WispSettings | null; 31 30 } 32 31 33 - 34 - export async function resolveDid(identifier: string): Promise<string | null> { 35 - try { 36 - // If it's already a DID, return it 37 - if (identifier.startsWith('did:')) { 38 - return identifier; 39 - } 40 - 41 - // Otherwise, resolve the handle using agent's built-in method 42 - const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 43 - const response = await agent.resolveHandle({ handle: identifier }); 44 - return response.data.did; 45 - } catch (err) { 46 - console.error('Failed to resolve identifier', identifier, err); 47 - return null; 48 - } 49 - } 50 - 51 - export async function getPdsForDid(did: string): Promise<string | null> { 52 - try { 53 - let doc; 54 - 55 - if (did.startsWith('did:plc:')) { 56 - doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`); 57 - } else if (did.startsWith('did:web:')) { 58 - const didUrl = didWebToHttps(did); 59 - doc = await safeFetchJson(didUrl); 60 - } else { 61 - console.error('Unsupported DID method', did); 62 - return null; 63 - } 64 - 65 - const services = doc.service || []; 66 - const pdsService = services.find((s: any) => s.id === '#atproto_pds'); 67 - 68 - return pdsService?.serviceEndpoint || null; 69 - } catch (err) { 70 - console.error('Failed to get PDS for DID', did, err); 71 - return null; 72 - } 73 - } 74 - 75 - function didWebToHttps(did: string): string { 76 - const didParts = did.split(':'); 77 - if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') { 78 - throw new Error('Invalid did:web format'); 79 - } 80 - 81 - const domain = didParts[2]; 82 - const pathParts = didParts.slice(3); 83 - 84 - if (pathParts.length === 0) { 85 - return `https://${domain}/.well-known/did.json`; 86 - } else { 87 - const path = pathParts.join('/'); 88 - return `https://${domain}/${path}/did.json`; 89 - } 90 - } 91 32 92 33 export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> { 93 34 try {
+4 -8
apps/main-app/src/routes/user.ts
··· 4 4 import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db' 5 5 import { syncSitesFromPDS } from '../lib/sync-sites' 6 6 import { createLogger } from '@wisp/observability' 7 - import { createDidResolver, extractAtprotoData } from '@atproto-labs/did-resolver' 7 + import { getHandleForDid } from '@wisp/atproto-utils' 8 8 9 9 const logger = createLogger('main-app') 10 - const didResolver = createDidResolver({}) 11 10 12 11 export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) => 13 12 new Elysia({ ··· 53 52 try { 54 53 let handle = 'unknown' 55 54 try { 56 - const didDoc = await didResolver.resolve(auth.did) 57 - const atprotoData = extractAtprotoData(didDoc) 58 - 59 - if (atprotoData.aka) { 60 - handle = atprotoData.aka 55 + const resolvedHandle = await getHandleForDid(auth.did) 56 + if (resolvedHandle) { 57 + handle = resolvedHandle 61 58 } 62 59 } catch (err) { 63 - 64 60 logger.error('[User] Failed to resolve DID', err) 65 61 } 66 62
+1 -36
cli/commands/pull.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 1 import type { Directory, Entry, File, Record as FsRecord } from '@wisp/lexicons/types/place/wisp/fs'; 3 2 import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs'; 4 - import { extractBlobCid } from '@wisp/atproto-utils'; 3 + import { extractBlobCid, resolveDid, getPdsForDid } from '@wisp/atproto-utils'; 5 4 import { sanitizePath } from '@wisp/fs-utils'; 6 5 import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readFileSync } from 'fs'; 7 6 import { dirname, join } from 'path'; ··· 14 13 export interface PullOptions { 15 14 site: string; 16 15 path: string; 17 - } 18 - 19 - async function resolveDid(identifier: string): Promise<string | null> { 20 - if (identifier.startsWith('did:')) { 21 - return identifier; 22 - } 23 - 24 - const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 25 - const response = await agent.resolveHandle({ handle: identifier }); 26 - return response.data.did; 27 - } 28 - 29 - async function getPdsForDid(did: string): Promise<string | null> { 30 - let doc: { service?: Array<{ id: string; serviceEndpoint?: string }> }; 31 - 32 - if (did.startsWith('did:plc:')) { 33 - const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`); 34 - doc = await res.json() as typeof doc; 35 - } else if (did.startsWith('did:web:')) { 36 - const didParts = did.split(':'); 37 - const domain = didParts[2]; 38 - const pathParts = didParts.slice(3); 39 - const url = pathParts.length === 0 40 - ? `https://${domain}/.well-known/did.json` 41 - : `https://${domain}/${pathParts.join('/')}/did.json`; 42 - const res = await fetch(url); 43 - doc = await res.json() as typeof doc; 44 - } else { 45 - return null; 46 - } 47 - 48 - const services = doc.service || []; 49 - const pdsService = services.find((s) => s.id === '#atproto_pds'); 50 - return pdsService?.serviceEndpoint || null; 51 16 } 52 17 53 18 async function fetchRecord(pdsEndpoint: string, did: string, collection: string, rkey: string): Promise<any> {
+1 -34
cli/commands/serve.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 1 import { IdResolver } from '@atproto/identity'; 3 2 import { Firehose } from '@atproto/sync'; 4 3 import { Hono } from 'hono'; 5 4 import { serve as honoNodeServe } from '@hono/node-server'; 6 5 import type { Record as SettingsRecord } from '@wisp/lexicons/types/place/wisp/settings'; 6 + import { resolveDid, getPdsForDid } from '@wisp/atproto-utils'; 7 7 import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; 8 8 import { join, extname } from 'path'; 9 9 import { lookup } from 'mime-types'; ··· 26 26 siteDir: string; 27 27 settings: SettingsRecord | null; 28 28 redirectRules: RedirectRule[]; 29 - } 30 - 31 - async function resolveDid(identifier: string): Promise<string | null> { 32 - if (identifier.startsWith('did:')) { 33 - return identifier; 34 - } 35 - const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }); 36 - const response = await agent.resolveHandle({ handle: identifier }); 37 - return response.data.did; 38 - } 39 - 40 - async function getPdsForDid(did: string): Promise<string | null> { 41 - let doc: { service?: Array<{ id: string; serviceEndpoint?: string }> }; 42 - 43 - if (did.startsWith('did:plc:')) { 44 - const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`); 45 - doc = await res.json() as typeof doc; 46 - } else if (did.startsWith('did:web:')) { 47 - const didParts = did.split(':'); 48 - const domain = didParts[2]; 49 - const pathParts = didParts.slice(3); 50 - const url = pathParts.length === 0 51 - ? `https://${domain}/.well-known/did.json` 52 - : `https://${domain}/${pathParts.join('/')}/did.json`; 53 - const res = await fetch(url); 54 - doc = await res.json() as typeof doc; 55 - } else { 56 - return null; 57 - } 58 - 59 - const services = doc.service || []; 60 - const pdsService = services.find((s) => s.id === '#atproto_pds'); 61 - return pdsService?.serviceEndpoint || null; 62 29 } 63 30 64 31 async function fetchSettings(pdsEndpoint: string, did: string, rkey: string): Promise<SettingsRecord | null> {
+9 -1
cli/lib/auth.ts
··· 1 1 import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node"; 2 2 import { Agent, CredentialSession } from "@atproto/api"; 3 + import { resolvePdsFromHandle } from "@wisp/atproto-utils"; 3 4 import { Hono } from "hono"; 4 5 import { serve as honoNodeServe } from "@hono/node-server"; 5 6 import open from "open"; ··· 236 237 password: string, 237 238 pdsUrl?: string 238 239 ): Promise<{ agent: Agent; did: string }> { 239 - const serviceUrl = pdsUrl || 'https://bsky.social'; 240 + let serviceUrl = pdsUrl; 241 + 242 + if (!serviceUrl) { 243 + // Resolve the handle to find the correct PDS 244 + console.log(`Resolving PDS for ${identifier}...`); 245 + serviceUrl = await resolvePdsFromHandle(identifier); 246 + console.log(`Found PDS: ${serviceUrl}`); 247 + } 240 248 241 249 const credSession = new CredentialSession(new URL(serviceUrl)); 242 250 await credSession.login({ identifier, password });
+140
packages/@wisp/atproto-utils/src/identity.ts
··· 1 + /** 2 + * AT Protocol identity utilities for resolving handles and DIDs 3 + */ 4 + 5 + interface DidDocument { 6 + service?: Array<{ id: string; serviceEndpoint?: string }>; 7 + alsoKnownAs?: string[]; 8 + } 9 + 10 + /** 11 + * Convert a did:web to an HTTPS URL for fetching the DID document 12 + */ 13 + export function didWebToHttps(did: string): string { 14 + const didParts = did.split(':'); 15 + if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') { 16 + throw new Error('Invalid did:web format'); 17 + } 18 + 19 + const domain = didParts[2]; 20 + const pathParts = didParts.slice(3); 21 + 22 + if (pathParts.length === 0) { 23 + return `https://${domain}/.well-known/did.json`; 24 + } else { 25 + const path = pathParts.join('/'); 26 + return `https://${domain}/${path}/did.json`; 27 + } 28 + } 29 + 30 + /** 31 + * Resolve a handle or DID to a DID 32 + * If the identifier is already a DID, returns it as-is 33 + * If it's a handle, resolves it to a DID using the public API 34 + */ 35 + export async function resolveDid(identifier: string): Promise<string | null> { 36 + try { 37 + // If it's already a DID, return it 38 + if (identifier.startsWith('did:')) { 39 + return identifier; 40 + } 41 + 42 + // Otherwise, resolve the handle using the public API 43 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(identifier)}`; 44 + const response = await fetch(url); 45 + 46 + if (!response.ok) { 47 + console.error('Failed to resolve handle', identifier, response.status); 48 + return null; 49 + } 50 + 51 + const data = await response.json() as { did: string }; 52 + return data.did; 53 + } catch (err) { 54 + console.error('Failed to resolve identifier', identifier, err); 55 + return null; 56 + } 57 + } 58 + 59 + /** 60 + * Fetch the DID document for a DID 61 + */ 62 + export async function getDidDocument(did: string): Promise<DidDocument | null> { 63 + try { 64 + if (did.startsWith('did:plc:')) { 65 + const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`); 66 + if (!res.ok) return null; 67 + return await res.json() as DidDocument; 68 + } else if (did.startsWith('did:web:')) { 69 + const didUrl = didWebToHttps(did); 70 + const res = await fetch(didUrl); 71 + if (!res.ok) return null; 72 + return await res.json() as DidDocument; 73 + } else { 74 + console.error('Unsupported DID method', did); 75 + return null; 76 + } 77 + } catch (err) { 78 + console.error('Failed to fetch DID document', did, err); 79 + return null; 80 + } 81 + } 82 + 83 + /** 84 + * Get the PDS endpoint for a DID from its DID document 85 + */ 86 + export async function getPdsForDid(did: string): Promise<string | null> { 87 + try { 88 + const doc = await getDidDocument(did); 89 + if (!doc) return null; 90 + 91 + const services = doc.service || []; 92 + const pdsService = services.find((s) => s.id === '#atproto_pds'); 93 + 94 + return pdsService?.serviceEndpoint || null; 95 + } catch (err) { 96 + console.error('Failed to get PDS for DID', did, err); 97 + return null; 98 + } 99 + } 100 + 101 + /** 102 + * Get the handle (alsoKnownAs) for a DID from its DID document 103 + */ 104 + export async function getHandleForDid(did: string): Promise<string | null> { 105 + try { 106 + const doc = await getDidDocument(did); 107 + if (!doc) return null; 108 + 109 + const aka = doc.alsoKnownAs || []; 110 + // Find the at:// handle 111 + const atHandle = aka.find((h) => h.startsWith('at://')); 112 + if (atHandle) { 113 + // Remove 'at://' prefix 114 + return atHandle.replace('at://', ''); 115 + } 116 + 117 + return null; 118 + } catch (err) { 119 + console.error('Failed to get handle for DID', did, err); 120 + return null; 121 + } 122 + } 123 + 124 + /** 125 + * Resolve a handle to find its PDS service endpoint 126 + * Combines resolveDid and getPdsForDid into a single operation 127 + */ 128 + export async function resolvePdsFromHandle(handle: string): Promise<string> { 129 + const did = await resolveDid(handle); 130 + if (!did) { 131 + throw new Error(`Failed to resolve handle: ${handle}`); 132 + } 133 + 134 + const pdsUrl = await getPdsForDid(did); 135 + if (!pdsUrl) { 136 + throw new Error(`Could not find PDS for ${handle} (${did})`); 137 + } 138 + 139 + return pdsUrl; 140 + }
+10
packages/@wisp/atproto-utils/src/index.ts
··· 6 6 7 7 // Subfs utilities 8 8 export { extractSubfsUris } from './subfs'; 9 + 10 + // Identity utilities 11 + export { 12 + resolveDid, 13 + getPdsForDid, 14 + getDidDocument, 15 + getHandleForDid, 16 + didWebToHttps, 17 + resolvePdsFromHandle 18 + } from './identity';