2
0
Files
log/src/web/lib/auth.ts
2026-03-27 04:40:11 +09:00

461 lines
11 KiB
TypeScript

import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
import { getNetworks } from './api'
let oauthClient: BrowserOAuthClient | null = null
let agent: Agent | null = null
let sessionDid: string | null = null
let sessionHandle: string | null = null
let currentNetworkConfig: { bsky: string; plc: string } | null = null
// Get client ID based on environment
function getClientId(): string {
const host = window.location.host
if (host.includes('localhost') || host.includes('127.0.0.1')) {
const port = window.location.port || '5173'
const redirectUri = `http://127.0.0.1:${port}/`
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
}
return `${window.location.origin}/oauth-client-metadata.json`
}
// Set network config (call before login)
export async function setNetworkConfig(handle: string): Promise<void> {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
if (handle.endsWith(`.${domain}`)) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
oauthClient = null
return
}
}
// Check syui.ai -> syu.is
if (handle.endsWith('.syui.ai')) {
const network = networks['syu.is']
if (network) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
oauthClient = null
return
}
}
// Default to first network
const first = Object.values(networks)[0]
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
oauthClient = null
}
// Initialize OAuth client
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
}
// Primary OAuth domain
const OAUTH_ORIGIN = 'https://syui.ai'
// Login with handle
export async function login(handle: string): Promise<void> {
// Redirect to primary OAuth domain if on a different domain
if (window.location.origin !== OAUTH_ORIGIN) {
window.location.href = `${OAUTH_ORIGIN}${window.location.pathname}?login=${encodeURIComponent(handle)}`
return
}
await setNetworkConfig(handle)
try {
const client = await initOAuthClient()
await client.signIn(handle, {
scope: 'atproto transition:generic'
})
} catch (e) {
console.error('Login failed:', e)
throw e
}
}
// Handle OAuth callback
export async function handleCallback(): Promise<string | null> {
// Check query params first, then hash fragment
let params = new URLSearchParams(window.location.search)
if (!params.has('code') && !params.has('state')) {
// Try hash fragment
if (window.location.hash && window.location.hash.length > 1) {
params = new URLSearchParams(window.location.hash.slice(1))
}
}
if (!params.has('code') && !params.has('state')) {
return null
}
try {
// Detect network from issuer (iss param) and set config before init
const iss = params.get('iss') || ''
if (iss && !currentNetworkConfig) {
const networks = await getNetworks()
for (const [domain, network] of Object.entries(networks)) {
if (iss.includes(domain)) {
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
break
}
}
}
const client = await initOAuthClient()
// Initialize client to restore state from storage
await client.init()
const result = await client.callback(params)
sessionDid = result.session.did
// Create agent and get handle
agent = new Agent(result.session)
try {
const profile = await agent.getProfile({ actor: sessionDid })
sessionHandle = profile.data.handle
} catch {
// Could not get handle
}
// Clear URL params and hash
window.history.replaceState({}, '', window.location.pathname)
return sessionDid
} catch (e) {
console.error('OAuth callback error:', e)
return null
}
}
// Logout
export async function logout(): Promise<void> {
// Clear module state
sessionDid = null
sessionHandle = null
agent = null
oauthClient = null
currentNetworkConfig = null
// Clear all storage
sessionStorage.clear()
localStorage.clear()
// Clear IndexedDB (used by OAuth client)
try {
const databases = await indexedDB.databases()
for (const db of databases) {
if (db.name) {
indexedDB.deleteDatabase(db.name)
}
}
} catch (e) {
// IndexedDB.databases() not supported in some browsers
console.warn('Could not clear IndexedDB:', e)
}
window.location.reload()
}
// Restore session from storage
export async function restoreSession(): Promise<string | null> {
try {
// Try to initialize with default network first
const networks = await getNetworks()
const first = Object.values(networks)[0]
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
const client = await initOAuthClient()
const result = await client.init()
if (result?.session) {
sessionDid = result.session.did
// Create agent and get handle
agent = new Agent(result.session)
try {
const profile = await agent.getProfile({ actor: sessionDid })
sessionHandle = profile.data.handle
} catch {
// Could not get handle
}
return sessionDid
}
} catch (e) {
// Silently fail - no session to restore
}
return null
}
// Check if logged in
export function isLoggedIn(): boolean {
return sessionDid !== null
}
// Get logged in DID
export function getLoggedInDid(): string | null {
return sessionDid
}
// Get logged in handle
export function getLoggedInHandle(): string | null {
return sessionHandle
}
// Get agent
export function getAgent(): Agent | null {
return agent
}
// Create post
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,
site: window.location.origin,
title,
content: {
$type: `${collection}#markdown`,
text: content,
},
publishedAt: new Date().toISOString(),
},
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Create post error:', err)
throw err
}
}
// Update post
export async function updatePost(
collection: string,
rkey: string,
title: string,
content: string
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
try {
// Fetch existing record to preserve translations
let existingTranslations: unknown = undefined
let existingPublishedAt: unknown = new Date().toISOString()
try {
const existing = await agent.com.atproto.repo.getRecord({
repo: agent.assertDid,
collection,
rkey,
})
if (existing.data.value) {
const value = existing.data.value as Record<string, unknown>
existingTranslations = value.translations
if (value.publishedAt) {
existingPublishedAt = value.publishedAt
}
}
} catch {
// Record doesn't exist, that's ok
}
const record: Record<string, unknown> = {
$type: collection,
site: window.location.origin,
title,
content: {
$type: `${collection}#markdown`,
text: content,
},
publishedAt: existingPublishedAt,
}
if (existingTranslations) {
record.translations = existingTranslations
}
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection,
rkey,
record,
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Update post error:', err)
throw err
}
}
// Update chat message
export async function updateChat(
collection: string,
rkey: string,
content: string
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
try {
// Fetch existing record to preserve translations and other fields
let existingRecord: Record<string, unknown> = {}
try {
const existing = await agent.com.atproto.repo.getRecord({
repo: agent.assertDid,
collection,
rkey,
})
if (existing.data.value) {
existingRecord = existing.data.value as Record<string, unknown>
}
} catch {
// Record doesn't exist
throw new Error('Record not found')
}
const record: Record<string, unknown> = {
...existingRecord,
$type: collection,
content,
}
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection,
rkey,
record,
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Update chat error:', err)
throw err
}
}
// Update links (ai.syui.at.link)
export async function updateLinks(
links: { service: string; username: string }[]
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
const collection = 'ai.syui.at.link'
try {
const record = {
$type: collection,
links,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection,
rkey: 'self',
record,
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Update links error:', err)
throw err
}
}
// Save migrated card data to ai.syui.card.old
export async function saveMigratedCardData(
user: {
username: string
did: string
aiten: number
planet: number
fav: number
coin: number
createdAt: string
updatedAt: string
},
card: {
cid: string
id: number
cp: number
rare: number
unique: boolean
}[],
checksum: string
): Promise<{ uri: string; cid: string } | null> {
if (!agent) return null
const collection = 'ai.syui.card.old'
const rkey = 'self'
try {
const record = {
$type: collection,
user,
card,
checksum,
migratedAt: new Date().toISOString(),
}
const result = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection,
rkey,
record,
})
return { uri: result.data.uri, cid: result.data.cid }
} catch (err) {
console.error('Save migrated card data error:', err)
throw err
}
}
// Delete record
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
}
}