Files
Fabric.OpenclawPlugin/dist/fabric/src/fabric-client.js
hzhang ab126825ef feat(security): commandsSyncKey is a required channel-config field (Guild C-2)
The slash-command sync secret now comes from
channels.fabric.commandsSyncKey (configSchema marks it required) and
is no longer read from FABRIC_COMMANDS_SYNC_KEY env. command-sync
resolves it from config and threads it into client.syncCommands;
when absent, sync is skipped with a clear warning. README updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:44:25 +01:00

109 lines
5.0 KiB
JavaScript

// 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 class FabricClient {
centerApiBase;
constructor(centerApiBase) {
this.centerApiBase = centerApiBase;
}
async post(url, body, auth) {
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());
}
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
// (Fabric returns an empty body when a channel has no canvas).
async req(method, url, auth, body, extraHeaders) {
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);
}
// Exchange an agent API key for a Fabric user session (+ guild tokens).
agentLogin(apiKey) {
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) {
return this.post(`${this.centerApiBase}/auth/refresh`, { refreshToken });
}
async meGuilds(accessToken) {
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());
}
// ---- guild-scoped (use the per-guild access token) ----
postMessage(guildEndpoint, guildToken, channelId, content, authorUserId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/messages`, { content, authorUserId }, guildToken);
}
createChannel(guildEndpoint, guildToken, body) {
return this.post(`${guildEndpoint}/api/channels`, body, guildToken);
}
closeChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/close`, {}, guildToken);
}
joinChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
}
leaveChannel(guildEndpoint, guildToken, channelId) {
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 /<cmd> message into OpenClaw's command system.
syncCommands(guildEndpoint, guildToken, commands, syncKey) {
// 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, guildToken, channelId) {
return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken);
}
// ---- channel canvas (one pinned doc per channel) ----
canvasUrl(endpoint, channelId) {
return `${endpoint}/api/channels/${channelId}/canvas`;
}
// null when the channel has no canvas
getCanvas(endpoint, token, channelId) {
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
}
// share / replace (caller becomes the sharer)
shareCanvas(endpoint, token, channelId, body) {
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
}
// update in place (original sharer only — else the guild returns 403)
updateCanvas(endpoint, token, channelId, body) {
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
}
// remove ("close") the canvas (original sharer only)
removeCanvas(endpoint, token, channelId) {
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
}
}