init
This commit is contained in:
217
src/lib/api.ts
Normal file
217
src/lib/api.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { AtpAgent } from '@atproto/api'
|
||||
import type { Profile, BlogPost, NetworkConfig } from '../types.js'
|
||||
|
||||
const agents: Map<string, AtpAgent> = new Map()
|
||||
|
||||
let networkConfig: NetworkConfig | null = null
|
||||
|
||||
export function setNetworkConfig(config: NetworkConfig): void {
|
||||
networkConfig = config
|
||||
}
|
||||
|
||||
function getPlc(): string {
|
||||
return networkConfig?.plc || 'https://plc.directory'
|
||||
}
|
||||
|
||||
function getBsky(): string {
|
||||
return networkConfig?.bsky || 'https://public.api.bsky.app'
|
||||
}
|
||||
|
||||
export function getAgent(service: string): AtpAgent {
|
||||
if (!agents.has(service)) {
|
||||
agents.set(service, new AtpAgent({ service }))
|
||||
}
|
||||
return agents.get(service)!
|
||||
}
|
||||
|
||||
export async function resolvePds(did: string): Promise<string> {
|
||||
const res = await fetch(`${getPlc()}/${did}`)
|
||||
const doc = await res.json()
|
||||
const service = doc.service?.find((s: any) => s.type === 'AtprotoPersonalDataServer')
|
||||
return service?.serviceEndpoint || getBsky()
|
||||
}
|
||||
|
||||
export async function resolveHandle(handle: string): Promise<string> {
|
||||
const agent = getAgent(getBsky())
|
||||
const res = await agent.resolveHandle({ handle })
|
||||
return res.data.did
|
||||
}
|
||||
|
||||
export async function getProfile(actor: string): Promise<Profile> {
|
||||
const agent = getAgent(getBsky())
|
||||
const res = await agent.getProfile({ actor })
|
||||
return {
|
||||
did: res.data.did,
|
||||
handle: res.data.handle,
|
||||
displayName: res.data.displayName,
|
||||
description: res.data.description,
|
||||
avatar: res.data.avatar,
|
||||
banner: res.data.banner,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRecords(
|
||||
did: string,
|
||||
collection: string,
|
||||
limit = 50
|
||||
): Promise<BlogPost[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.listRecords({
|
||||
repo: did,
|
||||
collection,
|
||||
limit,
|
||||
})
|
||||
|
||||
return res.data.records.map((record: any) => ({
|
||||
uri: record.uri,
|
||||
cid: record.cid,
|
||||
title: record.value.title || '',
|
||||
content: record.value.content || '',
|
||||
createdAt: record.value.createdAt || '',
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getRecord(
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<BlogPost | null> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
try {
|
||||
const res = await agent.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return {
|
||||
uri: res.data.uri,
|
||||
cid: res.data.cid || '',
|
||||
title: (res.data.value as any).title || '',
|
||||
content: (res.data.value as any).content || '',
|
||||
createdAt: (res.data.value as any).createdAt || '',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeRepo(did: string): Promise<string[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.describeRepo({ repo: did })
|
||||
return res.data.collections || []
|
||||
}
|
||||
|
||||
export async function listRecordsRaw(
|
||||
did: string,
|
||||
collection: string,
|
||||
limit = 100
|
||||
): Promise<any[]> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
const res = await agent.com.atproto.repo.listRecords({
|
||||
repo: did,
|
||||
collection,
|
||||
limit,
|
||||
})
|
||||
return res.data.records
|
||||
}
|
||||
|
||||
export async function getRecordRaw(
|
||||
did: string,
|
||||
collection: string,
|
||||
rkey: string
|
||||
): Promise<any | null> {
|
||||
const pds = await resolvePds(did)
|
||||
const agent = getAgent(pds)
|
||||
try {
|
||||
const res = await agent.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Known lexicon prefixes that have schemas
|
||||
const KNOWN_LEXICON_PREFIXES = [
|
||||
'app.bsky.',
|
||||
'chat.bsky.',
|
||||
'com.atproto.',
|
||||
'sh.tangled.',
|
||||
'pub.leaflet.',
|
||||
'blue.linkat.',
|
||||
'fyi.unravel.frontpage.',
|
||||
'com.whtwnd.',
|
||||
'com.shinolabs.pinksea.',
|
||||
]
|
||||
|
||||
export function hasKnownSchema(nsid: string): boolean {
|
||||
return KNOWN_LEXICON_PREFIXES.some(prefix => nsid.startsWith(prefix))
|
||||
}
|
||||
|
||||
export async function fetchLexicon(nsid: string): Promise<any | null> {
|
||||
// Check if it's a known lexicon first
|
||||
if (hasKnownSchema(nsid)) {
|
||||
return { id: nsid, known: true }
|
||||
}
|
||||
|
||||
// Extract authority from NSID (e.g., "ai.syui.log.post" -> "syui.ai")
|
||||
const parts = nsid.split('.')
|
||||
if (parts.length < 3) return null
|
||||
|
||||
const authority = parts.slice(0, 2).reverse().join('.')
|
||||
const url = `https://${authority}/.well-known/lexicon/${nsid}.json`
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Known service mappings for collections
|
||||
const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
||||
'app.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'chat.bsky': { domain: 'bsky.app', icon: 'https://bsky.app/static/favicon-32x32.png' },
|
||||
'ai.syui': { domain: 'syui.ai' },
|
||||
'com.whtwnd': { domain: 'whtwnd.com' },
|
||||
'fyi.unravel.frontpage': { domain: 'frontpage.fyi' },
|
||||
'com.shinolabs.pinksea': { domain: 'pinksea.art' },
|
||||
'blue.linkat': { domain: 'linkat.blue' },
|
||||
'sh.tangled': { domain: 'tangled.sh' },
|
||||
'pub.leaflet': { domain: 'leaflet.pub' },
|
||||
}
|
||||
|
||||
export function getServiceInfo(collection: string): { name: string; domain: string; favicon: string } | null {
|
||||
// Try to find matching service prefix
|
||||
for (const [prefix, info] of Object.entries(SERVICE_MAP)) {
|
||||
if (collection.startsWith(prefix)) {
|
||||
return {
|
||||
name: info.domain,
|
||||
domain: info.domain,
|
||||
favicon: info.icon || `https://www.google.com/s2/favicons?domain=${info.domain}&sz=32`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract domain from first 2 parts of NSID
|
||||
const parts = collection.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const domain = parts.slice(0, 2).reverse().join('.')
|
||||
return {
|
||||
name: domain,
|
||||
domain: domain,
|
||||
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
187
src/lib/auth.ts
Normal file
187
src/lib/auth.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||
import { Agent } from '@atproto/api'
|
||||
import type { NetworkConfig } from '../types.js'
|
||||
|
||||
let oauthClient: BrowserOAuthClient | null = null
|
||||
let agent: Agent | null = null
|
||||
let currentNetworkConfig: NetworkConfig | null = null
|
||||
|
||||
export interface AuthSession {
|
||||
did: string
|
||||
handle: string
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
export function setAuthNetworkConfig(config: NetworkConfig): void {
|
||||
currentNetworkConfig = config
|
||||
// Reset client when network changes
|
||||
oauthClient = null
|
||||
}
|
||||
|
||||
export async function initOAuthClient(): Promise<BrowserOAuthClient> {
|
||||
if (oauthClient) return oauthClient
|
||||
|
||||
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
|
||||
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
|
||||
|
||||
oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: getClientId(),
|
||||
handleResolver,
|
||||
plcDirectoryUrl,
|
||||
})
|
||||
|
||||
return oauthClient
|
||||
}
|
||||
|
||||
function getClientId(): string {
|
||||
const host = window.location.host
|
||||
// For localhost development
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
// client_id must start with http://localhost, redirect_uri must use 127.0.0.1
|
||||
const port = window.location.port || '3000'
|
||||
const redirectUri = `http://127.0.0.1:${port}/`
|
||||
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
|
||||
}
|
||||
// For production, use the client-metadata.json
|
||||
return `${window.location.origin}/client-metadata.json`
|
||||
}
|
||||
|
||||
export async function login(handle: string): Promise<void> {
|
||||
const client = await initOAuthClient()
|
||||
await client.signIn(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(): Promise<AuthSession | null> {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (!params.has('code') && !params.has('state')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await initOAuthClient()
|
||||
const result = await client.callback(params)
|
||||
|
||||
agent = new Agent(result.session)
|
||||
|
||||
// Get profile to get handle
|
||||
const profile = await agent.getProfile({ actor: result.session.did })
|
||||
|
||||
// Clear URL params
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
|
||||
return {
|
||||
did: result.session.did,
|
||||
handle: profile.data.handle,
|
||||
agent,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OAuth callback error:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSession(): Promise<AuthSession | null> {
|
||||
try {
|
||||
const client = await initOAuthClient()
|
||||
const result = await client.init()
|
||||
|
||||
if (result?.session) {
|
||||
agent = new Agent(result.session)
|
||||
const profile = await agent.getProfile({ actor: result.session.did })
|
||||
|
||||
return {
|
||||
did: result.session.did,
|
||||
handle: profile.data.handle,
|
||||
agent,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Session restore error:', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// Clear all storage
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
|
||||
// Clear IndexedDB (used by OAuth client)
|
||||
const databases = await indexedDB.databases()
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
}
|
||||
|
||||
agent = null
|
||||
oauthClient = null
|
||||
}
|
||||
|
||||
export function getAgent(): Agent | null {
|
||||
return agent
|
||||
}
|
||||
|
||||
export async function createPost(collection: string, title: string, content: string): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.createRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
record: {
|
||||
$type: collection,
|
||||
title,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Create post error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecord(collection: string, rkey: string): Promise<boolean> {
|
||||
if (!agent) return false
|
||||
|
||||
try {
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Delete record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function putRecord(
|
||||
collection: string,
|
||||
rkey: string,
|
||||
record: Record<string, unknown>
|
||||
): Promise<{ uri: string; cid: string } | null> {
|
||||
if (!agent) return null
|
||||
|
||||
try {
|
||||
const result = await agent.com.atproto.repo.putRecord({
|
||||
repo: agent.assertDid,
|
||||
collection,
|
||||
rkey,
|
||||
record: {
|
||||
$type: collection,
|
||||
...record,
|
||||
},
|
||||
})
|
||||
return { uri: result.data.uri, cid: result.data.cid }
|
||||
} catch (err) {
|
||||
console.error('Put record error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user