// Thin Fabric REST client. One auth concept: an agent's Center API key is // exchanged for a normal user session (POST /auth/agent/login); the returned // guild access tokens are used to post messages and call channel APIs. export type FabricSession = { accessToken: string; refreshToken: string; user: { id: string; email: string; name: string }; guilds: Array<{ nodeId: string; name: string; endpoint: string; status: string }>; guildAccessTokens: Array<{ guildNodeId: string; token: string }>; }; export class FabricClient { constructor(private readonly centerApiBase: string) {} private async post(url: string, body: unknown, auth?: string): Promise { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(auth ? { authorization: `Bearer ${auth}` } : {}), }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`POST ${url} -> ${res.status} ${text}`); } return (await res.json()) as T; } // Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null // (Fabric returns an empty body when a channel has no canvas). private async req( method: string, url: string, auth?: string, body?: unknown, extraHeaders?: Record, ): Promise { const res = await fetch(url, { method, headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(extraHeaders ?? {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`${method} ${url} -> ${res.status} ${text}`); } const text = await res.text(); return (text ? JSON.parse(text) : null) as T; } // Exchange an agent API key for a Fabric user session (+ guild tokens). agentLogin(apiKey: string): Promise { return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey }); } // Refresh the center access token (guild tokens are re-fetched via /auth/me/guilds). async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken }); } async meGuilds(accessToken: string): Promise> { const res = await fetch(`${this.centerApiBase}/auth/me/guilds`, { headers: { authorization: `Bearer ${accessToken}` }, }); if (!res.ok) throw new Error(`me/guilds -> ${res.status}`); return (await res.json()) as Pick; } // ---- guild-scoped (use the per-guild access token) ---- postMessage( guildEndpoint: string, guildToken: string, channelId: string, content: string, authorUserId: string, ): Promise { return this.post( `${guildEndpoint}/api/channels/${channelId}/messages`, { content, authorUserId }, guildToken, ); } createChannel( guildEndpoint: string, guildToken: string, body: { guildId: string; name: string; xType: string; isPublic?: boolean; memberUserIds?: string[]; onDuty?: string; listeners?: string[]; }, ): Promise<{ id: string }> { return this.post(`${guildEndpoint}/api/channels`, body, guildToken); } closeChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise { return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken); } joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise { return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); } leaveChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise { return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken); } // Register the OpenClaw slash-command catalog with this guild (idempotent // full replace). The frontend GETs it for `/` autocomplete; execution // still flows as a normal / message into OpenClaw's command system. syncCommands( guildEndpoint: string, guildToken: string, commands: unknown[], syncKey: string, ): Promise { // Guild C-2: the shared key is sourced from the channel config // (channels.fabric.commandsSyncKey) and must equal the guild's // FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY for the catalog write. return this.req( 'PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }, syncKey ? { 'x-commands-sync-key': syncKey } : undefined, ); } // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list channelMembers( guildEndpoint: string, guildToken: string, channelId: string, ): Promise> { return this.req( 'GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken, ); } // ---- channel canvas (one pinned doc per channel) ---- private canvasUrl(endpoint: string, channelId: string): string { return `${endpoint}/api/channels/${channelId}/canvas`; } // null when the channel has no canvas getCanvas( endpoint: string, token: string, channelId: string, ): Promise { return this.req('GET', this.canvasUrl(endpoint, channelId), token); } // share / replace (caller becomes the sharer) shareCanvas( endpoint: string, token: string, channelId: string, body: CanvasInput, ): Promise { return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body); } // update in place (original sharer only — else the guild returns 403) updateCanvas( endpoint: string, token: string, channelId: string, body: Partial, ): Promise { return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body); } // remove ("close") the canvas (original sharer only) removeCanvas(endpoint: string, token: string, channelId: string): Promise { return this.req('DELETE', this.canvasUrl(endpoint, channelId), token); } } export type CanvasFormat = 'md' | 'html' | 'text'; export type CanvasInput = { title: string; format: CanvasFormat; source: string }; export type FabricCanvas = { channelId: string; sharerUserId: string; title: string; format: CanvasFormat; source: string; version: number; createdAt: string; updatedAt: string; };