Files
NotesAgentic/server sync/console/src/api.ts
T

156 lines
5.0 KiB
TypeScript

export interface ServiceStatus {
health: boolean
ready: boolean
protocol: number
maxObjectSize: number
}
export interface Session {
access_token: string
refresh_token: string
expires_in: number
device_id: string
must_change_credentials: boolean
}
export interface Vault {
id: string
name: string
sequence: number
used: number
quota: number
}
export interface Device {
id: string
name: string
revoked: number | boolean
}
interface ErrorPayload {
error?: { code?: string }
}
async function safeJson<T>(response: Response): Promise<T | null> {
try { return await response.json() as T }
catch { return null }
}
export class SyncApi {
private access = ''
private refresh = ''
deviceId = ''
mustChangeCredentials = false
get signedIn() { return Boolean(this.access) }
private async raw(path: string, init: RequestInit = {}, authenticate = true): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (authenticate && this.access) headers.set('Authorization', `Bearer ${this.access}`)
return fetch(path, { ...init, headers, cache: 'no-store', credentials: 'same-origin' })
}
private async rotate(): Promise<boolean> {
if (!this.refresh) return false
const response = await this.raw('/sync/v1/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: this.refresh }),
}, false)
if (!response.ok) return false
const session = await safeJson<Session>(response)
if (!session) return false
this.accept(session)
return true
}
private async request<T>(path: string, init: RequestInit = {}, allowRotate = true): Promise<T> {
let response = await this.raw(path, init)
if (response.status === 401 && allowRotate && await this.rotate()) response = await this.raw(path, init)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
if (response.status === 401) this.clear()
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
if (response.status === 204) return undefined as T
const payload = await safeJson<T>(response)
if (payload === null) throw new Error('INVALID_RESPONSE')
return payload
}
private accept(session: Session) {
this.access = session.access_token
this.refresh = session.refresh_token
this.deviceId = session.device_id
this.mustChangeCredentials = Boolean(session.must_change_credentials)
}
async status(): Promise<ServiceStatus> {
const [health, ready, handshake] = await Promise.allSettled([
this.raw('/health', {}, false),
this.raw('/ready', {}, false),
this.raw('/sync/v1/handshake?protocol=1', {}, false),
])
let protocol = 1
let maxObjectSize = 100 * 1024 * 1024
if (handshake.status === 'fulfilled' && handshake.value.ok) {
const payload = await safeJson<{ protocol: number; max_object_size: number }>(handshake.value)
protocol = payload?.protocol ?? protocol
maxObjectSize = payload?.max_object_size ?? maxObjectSize
}
return {
health: health.status === 'fulfilled' && health.value.ok,
ready: ready.status === 'fulfilled' && ready.value.ok,
protocol,
maxObjectSize,
}
}
async login(username: string, password: string, deviceName: string): Promise<boolean> {
const response = await this.raw('/sync/v1/auth/sessions', {
method: 'POST',
body: JSON.stringify({ username, password, device_name: deviceName }),
}, false)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
const session = await safeJson<Session>(response)
if (!session) throw new Error('INVALID_RESPONSE')
this.accept(session)
return this.mustChangeCredentials
}
async changeCredentials(currentPassword: string, username: string, password: string) {
const result = await this.request<{ username: string; credentials_fixed: boolean }>(
'/sync/v1/account/credentials', {
method: 'PUT', body: JSON.stringify({ current_password: currentPassword, username, password }),
}, false,
)
this.mustChangeCredentials = false
return result
}
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
devices() { return this.request<{ items: Device[] }>('/sync/v1/devices') }
createVault(name: string) {
return this.request<{ vault_id: string; name: string }>('/sync/v1/vaults', {
method: 'POST', body: JSON.stringify({ name }),
})
}
revokeDevice(id: string) {
return this.request<void>(`/sync/v1/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
}
async logout(): Promise<void> {
try {
if (this.access) await this.request<void>('/sync/v1/auth/sessions', { method: 'DELETE' }, false)
} finally { this.clear() }
}
clear() {
this.access = ''
this.refresh = ''
this.deviceId = ''
this.mustChangeCredentials = false
}
}