diff --git a/src/dev.html b/src/dev.html
index d9b1ffa..5665495 100644
--- a/src/dev.html
+++ b/src/dev.html
@@ -63,6 +63,152 @@
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
const { AppShell } = await import('./js/sw/shell/app-shell.js');
+ // ── SDK Boot ────────────────────────────
+ const { boot } = await import('./js/sw/sdk/index.js');
+ await boot().catch(e => console.warn('SDK boot (no backend):', e));
+ const sw = window.sw;
+
+ // ── SDK Demo Page ───────────────────────
+ function SDKPage() {
+ const [authState, setAuthState] = useState(null);
+ const [apiResult, setApiResult] = useState(null);
+ const [permInput, setPermInput] = useState('model.use');
+ const [canResult, setCanResult] = useState(null);
+ const [eventLog, setEventLog] = useState([]);
+ const [emitLabel, setEmitLabel] = useState('test.event');
+ const logRef = useRef([]);
+
+ // Subscribe to all events for live log
+ useEffect(() => {
+ const unsub = sw.on('*', (payload, meta) => {
+ const entry = { ts: new Date().toLocaleTimeString(), label: meta.event, local: meta.local };
+ logRef.current = [entry, ...logRef.current].slice(0, 20);
+ setEventLog([...logRef.current]);
+ });
+ return unsub;
+ }, []);
+
+ function refreshAuth() {
+ setAuthState({
+ isAuthenticated: sw.auth.isAuthenticated,
+ user: sw.auth.user,
+ permissions: [...sw.auth.permissions],
+ teams: sw.auth.teams,
+ policies: sw.auth.policies,
+ });
+ }
+
+ useEffect(() => { refreshAuth(); }, []);
+
+ async function testApi() {
+ try {
+ const res = await sw.api.channels.list();
+ setApiResult({ ok: true, data: res });
+ } catch (e) {
+ setApiResult({ ok: false, error: e.message });
+ }
+ }
+
+ function testCan() {
+ setCanResult(sw.can(permInput));
+ }
+
+ const monoStyle = 'font-family: var(--font-mono, monospace); font-size: 0.8rem; background: var(--bg-1); padding: 0.75rem; border-radius: 6px; overflow: auto; max-height: 200px; white-space: pre-wrap; word-break: break-all; color: var(--text-2);';
+
+ return html`
+
+
SDK Demo
+
Layer 1 — SDK modules (v0.37.3)
+
+
+
+
sw.auth
+
+ <${Button} variant="secondary" size="sm" onClick=${refreshAuth}>Refresh/>
+
+
${JSON.stringify(authState, null, 2)}
+
+
+
+
+
sw.api
+
+ <${Button} variant="secondary" size="sm" onClick=${testApi}>sw.api.channels.list()/>
+
+ ${apiResult && html`
+
+ ${apiResult.ok
+ ? JSON.stringify(apiResult.data, null, 2)
+ : 'Error: ' + apiResult.error}
+
+ `}
+
+
+
+
+
sw.can()
+
+ setPermInput(e.target.value)} />
+ <${Button} variant="secondary" size="sm" onClick=${testCan}>Check/>
+ ${canResult !== null && html`
+
+ ${canResult ? 'ALLOWED' : 'DENIED'}
+
+ `}
+
+
+
+ sw.isAdmin: ${String(sw.isAdmin)}
+
+
+
+
+
+
+
sw.theme
+
+
+ mode: ${sw.theme.mode} · resolved: ${sw.theme.current}
+
+
+
+ <${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('light')}>Light/>
+ <${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('dark')}>Dark/>
+ <${Button} variant="secondary" size="sm" onClick=${() => sw.theme.set('system')}>System/>
+
+
+
+
+
+
sw.on / sw.emit
+
+ setEmitLabel(e.target.value)} />
+ <${Button} variant="secondary" size="sm"
+ onClick=${() => sw.emit(emitLabel, { demo: true }, { localOnly: true })}>Emit/>
+
+
+ ${eventLog.length === 0 ? '(listening for events...)' :
+ eventLog.map(e => `${e.ts} ${e.local ? 'L' : 'R'} ${e.label}`).join('\n')}
+
+
+
+
+
+
sw.pipe
+
${JSON.stringify(sw.pipe.list(), null, 2)}
+
+
+
+
+
sw.api namespaces
+
${Object.keys(sw.api).filter(k => typeof sw.api[k] === 'object').join(', ')}
+
+
+ `;
+ }
+
function PrimitivesPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -336,6 +482,7 @@
${page === 'primitives' && html`<${PrimitivesPage} />`}
+ ${page === 'sdk' && html`<${SDKPage} />`}
/>
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js
new file mode 100644
index 0000000..a430880
--- /dev/null
+++ b/src/js/sw/sdk/api-domains.js
@@ -0,0 +1,446 @@
+// ==========================================
+// Chat Switchboard — SDK: API Domain Namespaces
+// ==========================================
+// 18 namespaced domain clients wrapping restClient.
+// Each domain is a plain object with typed methods.
+//
+// Factory: createDomains(restClient)
+// ==========================================
+
+/**
+ * Build query string from an options object. Skips null/undefined values.
+ * @param {object} [opts]
+ * @returns {string} — '' or '?key=val&...'
+ */
+function _qs(opts) {
+ if (!opts) return '';
+ const p = new URLSearchParams();
+ for (const [k, v] of Object.entries(opts)) {
+ if (v != null) p.set(k, String(v));
+ }
+ const s = p.toString();
+ return s ? '?' + s : '';
+}
+
+/**
+ * Standard CRUD factory.
+ * @param {object} rc — restClient
+ * @param {string} base — e.g. '/api/v1/channels'
+ * @returns {object} — { list, get, create, update, del }
+ */
+function crud(rc, base) {
+ return {
+ list: (opts) => rc.get(base + _qs(opts)),
+ get: (id) => rc.get(`${base}/${id}`),
+ create: (data) => rc.post(base, data),
+ update: (id, data) => rc.put(`${base}/${id}`, data),
+ del: (id) => rc.del(`${base}/${id}`),
+ };
+}
+
+/**
+ * Create all 18 domain namespaces.
+ *
+ * @param {object} restClient
+ * @returns {object} api — the full sw.api object
+ */
+export function createDomains(restClient) {
+ const rc = restClient;
+
+ return {
+ // ── Escape hatches (raw restClient) ─────
+ get: rc.get.bind(rc),
+ post: rc.post.bind(rc),
+ put: rc.put.bind(rc),
+ patch: rc.patch.bind(rc),
+ del: rc.del.bind(rc),
+ stream: rc.stream.bind(rc),
+ upload: rc.upload.bind(rc),
+
+ // ── 1. Auth ─────────────────────────────
+ auth: {
+ login: (login, password) => rc.post('/api/v1/auth/login', { login, password }),
+ register: (username, email, password) => rc.post('/api/v1/auth/register', { username, email, password }),
+ refresh: (refreshToken) => rc.post('/api/v1/auth/refresh', { refresh_token: refreshToken }),
+ logout: (refreshToken) => rc.post('/api/v1/auth/logout', { refresh_token: refreshToken }),
+ },
+
+ // ── 2. Channels ────────────────────────
+ channels: {
+ ...crud(rc, '/api/v1/channels'),
+ messages: (id, opts) => rc.get(`/api/v1/channels/${id}/messages` + _qs(opts)),
+ send: (id, data) => rc.post(`/api/v1/channels/${id}/messages`, data),
+ complete: (data, signal) => rc.stream('/api/v1/chat/completions', data, signal),
+ regenerate: (id, msgId, data, signal) => rc.stream(`/api/v1/channels/${id}/messages/${msgId}/regenerate`, data, signal),
+ editMessage: (id, msgId, content) => rc.post(`/api/v1/channels/${id}/messages/${msgId}/edit`, { content }),
+ siblings: (id, msgId) => rc.get(`/api/v1/channels/${id}/messages/${msgId}/siblings`),
+ path: (id) => rc.get(`/api/v1/channels/${id}/path`),
+ cursor: (id, leafId) => rc.put(`/api/v1/channels/${id}/cursor`, { active_leaf_id: leafId }),
+ markRead: (id) => rc.post(`/api/v1/channels/${id}/mark-read`, {}),
+ generateTitle: (id) => rc.post(`/api/v1/channels/${id}/generate-title`, {}),
+ summarize: (id) => rc.post(`/api/v1/channels/${id}/summarize`, {}),
+ // Participants
+ participants: (id) => rc.get(`/api/v1/channels/${id}/participants`),
+ addParticipant: (id, data) => rc.post(`/api/v1/channels/${id}/participants`, data),
+ updateParticipant: (id, pid, data) => rc.patch(`/api/v1/channels/${id}/participants/${pid}`, data),
+ removeParticipant: (id, pid) => rc.del(`/api/v1/channels/${id}/participants/${pid}`),
+ // Channel models
+ models: (id) => rc.get(`/api/v1/channels/${id}/models`),
+ addModel: (id, data) => rc.post(`/api/v1/channels/${id}/models`, data),
+ updateModel: (id, modelId, data) => rc.patch(`/api/v1/channels/${id}/models/${modelId}`, data),
+ removeModel: (id, modelId) => rc.del(`/api/v1/channels/${id}/models/${modelId}`),
+ // Knowledge bases
+ kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
+ setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
+ // Files
+ files: (id) => rc.get(`/api/v1/channels/${id}/files`),
+ uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
+ },
+
+ // ── 3. Personas ────────────────────────
+ personas: {
+ ...crud(rc, '/api/v1/personas'),
+ kbs: (id) => rc.get(`/api/v1/personas/${id}/knowledge-bases`),
+ setKbs: (id, data) => rc.put(`/api/v1/personas/${id}/knowledge-bases`, data),
+ },
+
+ // ── 4. Knowledge Bases ─────────────────
+ knowledge: {
+ ...crud(rc, '/api/v1/knowledge-bases'),
+ discoverable: () => rc.get('/api/v1/knowledge-bases-discoverable'),
+ search: (id, query, limit) => rc.post(`/api/v1/knowledge-bases/${id}/search`, { query, limit }),
+ documents: (id) => rc.get(`/api/v1/knowledge-bases/${id}/documents`),
+ upload: (id, file) => rc.upload(`/api/v1/knowledge-bases/${id}/documents`, file),
+ docStatus: (id, docId) => rc.get(`/api/v1/knowledge-bases/${id}/documents/${docId}/status`),
+ delDoc: (id, docId) => rc.del(`/api/v1/knowledge-bases/${id}/documents/${docId}`),
+ },
+
+ // ── 5. Notes ───────────────────────────
+ notes: {
+ ...crud(rc, '/api/v1/notes'),
+ search: (query, limit) => rc.get(`/api/v1/notes/search` + _qs({ q: query, limit })),
+ searchTitles: (query, limit) => rc.get(`/api/v1/notes/search-titles` + _qs({ q: query, limit })),
+ folders: () => rc.get('/api/v1/notes/folders'),
+ backlinks: (id) => rc.get(`/api/v1/notes/${id}/backlinks`),
+ graph: () => rc.get('/api/v1/notes/graph'),
+ bulkDelete: (ids) => rc.post('/api/v1/notes/bulk-delete', { ids }),
+ },
+
+ // ── 6. Projects ────────────────────────
+ projects: {
+ ...crud(rc, '/api/v1/projects'),
+ channels: (id) => rc.get(`/api/v1/projects/${id}/channels`),
+ addChannel: (id, channelId, pos) => rc.post(`/api/v1/projects/${id}/channels`, { channel_id: channelId, position: pos || 0 }),
+ removeChannel: (id, channelId) => rc.del(`/api/v1/projects/${id}/channels/${channelId}`),
+ reorderChannels:(id, channelIds) => rc.put(`/api/v1/projects/${id}/channels/reorder`, { channel_ids: channelIds }),
+ kbs: (id) => rc.get(`/api/v1/projects/${id}/knowledge-bases`),
+ addKb: (id, kbId, autoSearch) => rc.post(`/api/v1/projects/${id}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false }),
+ removeKb: (id, kbId) => rc.del(`/api/v1/projects/${id}/knowledge-bases/${kbId}`),
+ notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
+ addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
+ removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
+ files: (id) => rc.get(`/api/v1/projects/${id}/files`),
+ uploadFile: (id, file) => rc.upload(`/api/v1/projects/${id}/files`, file),
+ },
+
+ // ── 7. Workspaces ──────────────────────
+ workspaces: {
+ ...crud(rc, '/api/v1/workspaces'),
+ files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
+ readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
+ writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
+ deleteFile: (id, path) => rc.del(`/api/v1/workspaces/${id}/files/delete` + _qs({ path })),
+ mkdir: (id, path) => rc.post(`/api/v1/workspaces/${id}/files/mkdir`, { path }),
+ gitStatus: (id) => rc.get(`/api/v1/workspaces/${id}/git/status`),
+ gitBranches: (id) => rc.get(`/api/v1/workspaces/${id}/git/branches`),
+ credentials: () => rc.get('/api/v1/git-credentials'),
+ },
+
+ // ── 8. Memory ──────────────────────────
+ memory: {
+ list: (opts) => rc.get('/api/v1/memories' + _qs(opts)),
+ get: (id) => rc.get(`/api/v1/memories/${id}`),
+ update: (id, data) => rc.put(`/api/v1/memories/${id}`, data),
+ del: (id) => rc.del(`/api/v1/memories/${id}`),
+ count: () => rc.get('/api/v1/memories/count'),
+ approve: (id) => rc.post(`/api/v1/memories/${id}/approve`, {}),
+ reject: (id) => rc.post(`/api/v1/memories/${id}/reject`, {}),
+ },
+
+ // ── 9. Models ──────────────────────────
+ models: {
+ enabled: () => rc.get('/api/v1/models/enabled'),
+ preferences: () => rc.get('/api/v1/models/preferences'),
+ setPref: (modelId, provId, hidden) => rc.put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: provId, hidden }),
+ bulkSetPref: (entries, hidden) => rc.post('/api/v1/models/preferences/bulk', { entries, hidden }),
+ },
+
+ // ── 10. Providers (user BYOK) ──────────
+ providers: {
+ ...crud(rc, '/api/v1/api-configs'),
+ models: (id) => rc.get(`/api/v1/api-configs/${id}/models`),
+ fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
+ },
+
+ // ── 11. Notifications ──────────────────
+ notifications: {
+ list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
+ markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
+ prefs: () => rc.get('/api/v1/notifications/preferences'),
+ setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
+ delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
+ },
+
+ // ── 12. Extensions ─────────────────────
+ extensions: {
+ list: (opts) => rc.get('/api/v1/extensions' + _qs(opts)),
+ get: (id) => rc.get(`/api/v1/extensions/${id}`),
+ settings: (id) => rc.get(`/api/v1/extensions/${id}/settings`),
+ },
+
+ // ── 13. Profile ────────────────────────
+ profile: {
+ get: () => rc.get('/api/v1/profile'),
+ update: (data) => rc.put('/api/v1/profile', data),
+ avatar: (base64) => rc.post('/api/v1/profile/avatar', { image: base64 }),
+ deleteAvatar:() => rc.del('/api/v1/profile/avatar'),
+ password: (current, pw) => rc.post('/api/v1/profile/password', { current_password: current, new_password: pw }),
+ settings: () => rc.get('/api/v1/settings'),
+ updateSettings: (data) => rc.put('/api/v1/settings', data),
+ permissions: () => rc.get('/api/v1/profile/permissions'),
+ },
+
+ // ── 14. Teams ──────────────────────────
+ teams: {
+ mine: () => rc.get('/api/v1/teams/mine'),
+ get: (id) => rc.get(`/api/v1/teams/${id}`),
+ members: (id) => rc.get(`/api/v1/teams/${id}/members`),
+ addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
+ updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
+ removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
+ personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
+ createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
+ deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
+ personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
+ setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
+ models: (id) => rc.get(`/api/v1/teams/${id}/models`),
+ groups: (id) => rc.get(`/api/v1/teams/${id}/groups`),
+ providers: (id) => rc.get(`/api/v1/teams/${id}/providers`),
+ createProvider:(id, data) => rc.post(`/api/v1/teams/${id}/providers`, data),
+ updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
+ deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
+ providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
+ roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
+ updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
+ deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
+ audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
+ auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
+ usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
+ },
+
+ // ── 15. Workflows ──────────────────────
+ workflows: {
+ ...crud(rc, '/api/v1/workflows'),
+ instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
+ advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
+ reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
+ },
+
+ // ── 16. Tasks ──────────────────────────
+ tasks: {
+ ...crud(rc, '/api/v1/tasks'),
+ runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
+ start: (id, data) => rc.post(`/api/v1/tasks/${id}/start`, data || {}),
+ stop: (id) => rc.post(`/api/v1/tasks/${id}/stop`, {}),
+ },
+
+ // ── 17. Surfaces ───────────────────────
+ surfaces: {
+ list: (opts) => rc.get('/api/v1/surfaces' + _qs(opts)),
+ get: (id) => rc.get(`/api/v1/surfaces/${id}`),
+ install: (id) => rc.post(`/api/v1/surfaces/${id}/install`, {}),
+ },
+
+ // ── 18. Admin ──────────────────────────
+ admin: {
+ stats: () => rc.get('/api/v1/admin/stats'),
+ dashboard: () => rc.get('/api/v1/admin/dashboard'),
+
+ users: {
+ list: (opts) => rc.get('/api/v1/admin/users' + _qs(opts)),
+ create: (data) => rc.post('/api/v1/admin/users', data),
+ del: (id) => rc.del(`/api/v1/admin/users/${id}`),
+ resetPassword: (id, pw) => rc.post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }),
+ updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }),
+ toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }),
+ permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
+ },
+
+ settings: {
+ get: () => rc.get('/api/v1/admin/settings'),
+ update: (key, val) => rc.put(`/api/v1/admin/settings/${key}`, val),
+ public: () => rc.get('/api/v1/settings/public'),
+ },
+
+ configs: {
+ list: () => rc.get('/api/v1/admin/configs'),
+ create: (data) => rc.post('/api/v1/admin/configs', data),
+ update: (id, data) => rc.put(`/api/v1/admin/configs/${id}`, data),
+ del: (id) => rc.del(`/api/v1/admin/configs/${id}`),
+ },
+
+ models: {
+ list: () => rc.get('/api/v1/admin/models'),
+ fetch: () => rc.post('/api/v1/admin/models/fetch', {}),
+ update: (id, data) => rc.put(`/api/v1/admin/models/${id}`, data),
+ bulkUpdate: (visibility) => rc.put('/api/v1/admin/models/bulk', { visibility, is_enabled: visibility === 'enabled' }),
+ del: (id) => rc.del(`/api/v1/admin/models/${id}`),
+ capabilities: (modelId) => rc.get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`),
+ setCapability: (modelId, d) => rc.put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, d),
+ delCapability: (modelId, oId) => rc.del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${oId}`),
+ overrides: () => rc.get('/api/v1/admin/capability-overrides'),
+ },
+
+ personas: {
+ list: () => rc.get('/api/v1/admin/personas'),
+ create: (data) => rc.post('/api/v1/admin/personas', data),
+ update: (id, data) => rc.put(`/api/v1/admin/personas/${id}`, data),
+ del: (id) => rc.del(`/api/v1/admin/personas/${id}`),
+ avatar: (id, base64) => rc.post(`/api/v1/admin/personas/${id}/avatar`, { image: base64 }),
+ delAvatar: (id) => rc.del(`/api/v1/admin/personas/${id}/avatar`),
+ kbs: (id) => rc.get(`/api/v1/admin/personas/${id}/knowledge-bases`),
+ setKbs: (id, data) => rc.put(`/api/v1/admin/personas/${id}/knowledge-bases`, data),
+ },
+
+ teams: {
+ list: () => rc.get('/api/v1/admin/teams'),
+ get: (id) => rc.get(`/api/v1/admin/teams/${id}`),
+ create: (data) => rc.post('/api/v1/admin/teams', data),
+ update: (id, data) => rc.put(`/api/v1/admin/teams/${id}`, data),
+ del: (id) => rc.del(`/api/v1/admin/teams/${id}`),
+ members: (id) => rc.get(`/api/v1/admin/teams/${id}/members`),
+ addMember: (id, userId, role) => rc.post(`/api/v1/admin/teams/${id}/members`, { user_id: userId, role }),
+ updateMember: (id, mid, role) => rc.put(`/api/v1/admin/teams/${id}/members/${mid}`, { role }),
+ removeMember: (id, mid) => rc.del(`/api/v1/admin/teams/${id}/members/${mid}`),
+ },
+
+ groups: {
+ list: () => rc.get('/api/v1/admin/groups'),
+ get: (id) => rc.get(`/api/v1/admin/groups/${id}`),
+ create: (data) => rc.post('/api/v1/admin/groups', data),
+ update: (id, data) => rc.put(`/api/v1/admin/groups/${id}`, data),
+ del: (id) => rc.del(`/api/v1/admin/groups/${id}`),
+ members: (id) => rc.get(`/api/v1/admin/groups/${id}/members`),
+ addMember: (id, userId) => rc.post(`/api/v1/admin/groups/${id}/members`, { user_id: userId }),
+ removeMember: (id, userId) => rc.del(`/api/v1/admin/groups/${id}/members/${userId}`),
+ },
+
+ grants: {
+ get: (type, id) => rc.get(`/api/v1/admin/grants/${type}/${id}`),
+ set: (type, id, scope, groups) => rc.put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: scope, granted_groups: groups || [] }),
+ del: (type, id) => rc.del(`/api/v1/admin/grants/${type}/${id}`),
+ },
+
+ permissions: {
+ list: () => rc.get('/api/v1/admin/permissions'),
+ },
+
+ audit: {
+ list: (opts) => rc.get('/api/v1/admin/audit' + _qs(opts)),
+ actions: () => rc.get('/api/v1/admin/audit/actions'),
+ },
+
+ roles: {
+ list: () => rc.get('/api/v1/admin/roles'),
+ get: (role) => rc.get(`/api/v1/admin/roles/${role}`),
+ update: (role, config) => rc.put(`/api/v1/admin/roles/${role}`, config),
+ test: (role) => rc.post(`/api/v1/admin/roles/${role}/test`, {}),
+ },
+
+ usage: {
+ get: (opts) => rc.get('/api/v1/admin/usage' + _qs(opts)),
+ user: (id, opts) => rc.get(`/api/v1/admin/usage/users/${id}` + _qs(opts)),
+ team: (id, opts) => rc.get(`/api/v1/admin/usage/teams/${id}` + _qs(opts)),
+ },
+
+ pricing: {
+ list: () => rc.get('/api/v1/admin/pricing'),
+ upsert: (entry) => rc.put('/api/v1/admin/pricing', entry),
+ del: (provId, modelId) => rc.del(`/api/v1/admin/pricing/${provId}/${encodeURIComponent(modelId)}`),
+ },
+
+ providers: {
+ health: () => rc.get('/api/v1/admin/providers/health'),
+ healthOne: (id) => rc.get(`/api/v1/admin/providers/${id}/health`),
+ types: () => rc.get('/api/v1/admin/provider-types'),
+ },
+
+ routing: {
+ policies: () => rc.get('/api/v1/admin/routing/policies'),
+ getPolicy: (id) => rc.get(`/api/v1/admin/routing/policies/${id}`),
+ createPolicy:(data) => rc.post('/api/v1/admin/routing/policies', data),
+ updatePolicy:(id, data) => rc.put(`/api/v1/admin/routing/policies/${id}`, data),
+ deletePolicy:(id) => rc.del(`/api/v1/admin/routing/policies/${id}`),
+ test: (data) => rc.post('/api/v1/admin/routing/test', data),
+ },
+
+ storage: {
+ status: () => rc.get('/api/v1/admin/storage/status'),
+ orphans: () => rc.get('/api/v1/admin/storage/orphans'),
+ cleanup: () => rc.post('/api/v1/admin/storage/cleanup', {}),
+ extraction: () => rc.get('/api/v1/admin/storage/extraction'),
+ },
+
+ vault: {
+ status: () => rc.get('/api/v1/admin/vault/status'),
+ },
+
+ projects: {
+ list: (opts) => rc.get('/api/v1/admin/projects' + _qs(opts)),
+ del: (id) => rc.del(`/api/v1/admin/projects/${id}`),
+ },
+
+ memories: {
+ pending: () => rc.get('/api/v1/admin/memories/pending'),
+ bulkApprove: (ids) => rc.post('/api/v1/admin/memories/bulk-approve', { ids }),
+ },
+
+ notifications: {
+ testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
+ },
+ },
+
+ // ── Misc (not domain-specific) ─────────
+ folders: {
+ list: () => rc.get('/api/v1/folders'),
+ create: (name, sort) => rc.post('/api/v1/folders', { name, sort_order: sort || 0 }),
+ update: (id, data) => rc.put(`/api/v1/folders/${id}`, data),
+ del: (id) => rc.del(`/api/v1/folders/${id}`),
+ },
+
+ files: {
+ get: (id) => rc.get(`/api/v1/files/${id}`),
+ del: (id) => rc.del(`/api/v1/files/${id}`),
+ },
+
+ tools: {
+ list: () => rc.get('/api/v1/tools'),
+ },
+
+ presence: {
+ heartbeat: () => rc.post('/api/v1/presence/heartbeat', {}),
+ },
+
+ usage: {
+ mine: (opts) => rc.get('/api/v1/usage' + _qs(opts)),
+ },
+
+ groups: {
+ mine: () => rc.get('/api/v1/groups/mine'),
+ },
+
+ health: () => rc.get('/api/v1/health'),
+
+ export: (content, format, filename) => rc.post('/api/v1/export', { content, format, filename }),
+ };
+}
diff --git a/src/js/sw/sdk/auth.js b/src/js/sw/sdk/auth.js
new file mode 100644
index 0000000..978aecf
--- /dev/null
+++ b/src/js/sw/sdk/auth.js
@@ -0,0 +1,245 @@
+// ==========================================
+// Chat Switchboard — SDK: Auth Module
+// ==========================================
+// Owns token storage, login/logout/refresh lifecycle,
+// and the permissions/teams/policies cache.
+//
+// Factory: createAuth()
+// Wire after creation: _setRestClient(client), _setEmit(fn)
+// ==========================================
+
+const BASE = window.__BASE__ || '';
+const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
+
+/**
+ * Create the auth module.
+ * Must call _setRestClient() and _setEmit() before use.
+ *
+ * @returns {object} auth
+ */
+export function createAuth() {
+
+ // ── Private state ───────────────────────────
+
+ let _accessToken = null;
+ let _refreshToken = null;
+ let _user = null;
+ let _permissions = new Set();
+ let _teams = [];
+ let _groups = [];
+ let _policies = {};
+ let _refreshTimer = null;
+ let _refreshPromise = null;
+ let _restClient = null;
+ let _emit = () => {};
+
+ // ── Token persistence ───────────────────────
+
+ function _saveTokens() {
+ localStorage.setItem(_storageKey, JSON.stringify({
+ accessToken: _accessToken,
+ refreshToken: _refreshToken,
+ user: _user,
+ }));
+ // Cookie sync for Go template page auth
+ if (_accessToken) {
+ document.cookie = `sb_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
+ } else {
+ document.cookie = 'sb_token=; path=/; max-age=0';
+ }
+ }
+
+ function _loadTokens() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
+ if (saved) {
+ _accessToken = saved.accessToken || null;
+ _refreshToken = saved.refreshToken || null;
+ _user = saved.user || null;
+ }
+ } catch (_) { /* corrupt storage */ }
+ }
+
+ function _clearTokens() {
+ _accessToken = null;
+ _refreshToken = null;
+ _user = null;
+ _permissions = new Set();
+ _teams = [];
+ _groups = [];
+ _policies = {};
+ localStorage.removeItem(_storageKey);
+ document.cookie = 'sb_token=; path=/; max-age=0';
+ if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
+ }
+
+ // ── Token lifecycle ─────────────────────────
+
+ function _setAuth(data) {
+ _accessToken = data.access_token;
+ _refreshToken = data.refresh_token;
+ _user = data.user;
+ _saveTokens();
+ _scheduleRefresh(data.expires_in || 900);
+ }
+
+ function _scheduleRefresh(expiresIn) {
+ if (_refreshTimer) clearTimeout(_refreshTimer);
+ // Refresh at 80% of expiry (e.g., 12min for 15min token)
+ const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
+ _refreshTimer = setTimeout(async () => {
+ if (!_refreshToken) return;
+ console.debug('[sw.auth] Proactive token refresh');
+ try {
+ const data = await _restClient.post('/api/v1/auth/refresh', {
+ refresh_token: _refreshToken,
+ });
+ _setAuth(data);
+ _emit('auth.refresh', {}, { localOnly: true });
+ } catch (e) {
+ console.warn('[sw.auth] Proactive refresh failed, token still valid');
+ }
+ }, ms);
+ }
+
+ // ── Permissions fetch ───────────────────────
+
+ async function _fetchPermissions() {
+ try {
+ const data = await _restClient.get('/api/v1/profile/permissions');
+ _permissions = new Set(data.permissions || []);
+ _teams = Object.freeze(data.teams || []);
+ _groups = Object.freeze(data.groups || []);
+ _policies = Object.freeze(data.policies || {});
+ _emit('auth.permissions.changed', {}, { localOnly: true });
+ } catch (e) {
+ console.warn('[sw.auth] Failed to fetch permissions:', e.message);
+ throw e;
+ }
+ }
+
+ // ── Public API ──────────────────────────────
+
+ const auth = {
+ // ── Getters ─────────────────────────────
+
+ get isAuthenticated() { return !!_accessToken; },
+
+ get user() {
+ if (!_user) return null;
+ return Object.freeze({
+ id: _user.id,
+ username: _user.username,
+ display_name: _user.display_name || _user.username,
+ email: _user.email || null,
+ role: _user.role || 'user',
+ avatar: _user.avatar_url || _user.avatar || null,
+ });
+ },
+
+ get permissions() { return _permissions; },
+ get teams() { return _teams; },
+ get groups() { return _groups; },
+ get policies() { return _policies; },
+
+ // ── Lifecycle ───────────────────────────
+
+ /**
+ * Login with credentials. Stores tokens, fetches permissions, emits event.
+ */
+ async login(login, password) {
+ const data = await _restClient.post('/api/v1/auth/login', { login, password });
+ _setAuth(data);
+ await _fetchPermissions();
+ _emit('auth.login', { user: auth.user }, { localOnly: true });
+ return auth.user;
+ },
+
+ /**
+ * Logout. Clears tokens, emits event. Server call is best-effort.
+ */
+ async logout() {
+ try {
+ await _restClient.post('/api/v1/auth/logout', { refresh_token: _refreshToken });
+ } catch (_) { /* best effort */ }
+ _clearTokens();
+ _emit('auth.logout', {}, { localOnly: true });
+ },
+
+ /**
+ * Manual token refresh. Returns true on success, false on failure.
+ */
+ async refresh() {
+ if (_refreshPromise) return _refreshPromise;
+ _refreshPromise = (async () => {
+ try {
+ const data = await _restClient.post('/api/v1/auth/refresh', {
+ refresh_token: _refreshToken,
+ });
+ _setAuth(data);
+ return true;
+ } catch (_) {
+ _clearTokens();
+ return false;
+ } finally {
+ _refreshPromise = null;
+ }
+ })();
+ return _refreshPromise;
+ },
+
+ /**
+ * Boot sequence. Called by index.js on startup.
+ * Loads tokens from localStorage, validates, fetches permissions.
+ */
+ async boot() {
+ _loadTokens();
+ if (!_refreshToken) return;
+
+ // Unknown token age — schedule refresh soon
+ _scheduleRefresh(60);
+
+ // Fetch permissions (validates the access token)
+ try {
+ await _fetchPermissions();
+ } catch (e) {
+ if (e.status === 401) {
+ // Token expired — try refresh
+ const ok = await auth.refresh();
+ if (ok) {
+ try { await _fetchPermissions(); }
+ catch (_) { _clearTokens(); }
+ }
+ // If refresh failed, _clearTokens already called
+ } else {
+ // Network error or other — keep tokens, user may be offline
+ console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
+ }
+ }
+
+ if (_accessToken) {
+ _emit('auth.boot', { user: auth.user }, { localOnly: true });
+ }
+ },
+
+ // ── Internal (called by rest-client / index.js) ──
+
+ _getToken() { return _accessToken; },
+
+ async _onRefresh() {
+ if (_refreshToken) return auth.refresh();
+ return false;
+ },
+
+ _on401Failure() {
+ _clearTokens();
+ const base = window.__BASE__ || '';
+ window.location.href = base + '/login';
+ },
+
+ _setRestClient(client) { _restClient = client; },
+ _setEmit(fn) { _emit = fn; },
+ };
+
+ return auth;
+}
diff --git a/src/js/sw/sdk/can.js b/src/js/sw/sdk/can.js
new file mode 100644
index 0000000..a04bebf
--- /dev/null
+++ b/src/js/sw/sdk/can.js
@@ -0,0 +1,49 @@
+// ==========================================
+// Chat Switchboard — SDK: RBAC Gate
+// ==========================================
+// Synchronous permission checking for render paths.
+//
+// Factory: createCan(authRef)
+// ==========================================
+
+/**
+ * Create the RBAC gate.
+ *
+ * @param {object} authRef — the auth module (reads .permissions, .user, .teams)
+ * @returns {{ can, isAdmin, isTeamAdmin }}
+ */
+export function createCan(authRef) {
+
+ /**
+ * Check if the current user has a permission.
+ * Synchronous — reads from cached permission set.
+ *
+ * @param {string} permission — e.g. 'model.use', 'kb.read', 'channel.create'
+ * @returns {boolean}
+ */
+ function can(permission) {
+ return authRef.permissions.has(permission);
+ }
+
+ /**
+ * Is the current user a platform admin?
+ * @returns {boolean}
+ */
+ function isAdmin() {
+ return authRef.user?.role === 'admin';
+ }
+
+ /**
+ * Is the current user an admin of the given team?
+ * @param {string} teamId
+ * @returns {boolean}
+ */
+ function isTeamAdmin(teamId) {
+ const teams = authRef.teams;
+ if (!teams || !teams.length) return false;
+ const t = teams.find(t => t.id === teamId);
+ return t?.my_role === 'admin';
+ }
+
+ return { can, isAdmin, isTeamAdmin };
+}
diff --git a/src/js/sw/sdk/events.js b/src/js/sw/sdk/events.js
new file mode 100644
index 0000000..9f4e0b7
--- /dev/null
+++ b/src/js/sw/sdk/events.js
@@ -0,0 +1,277 @@
+// ==========================================
+// Chat Switchboard — SDK: Event Bus
+// ==========================================
+// Labeled event bus with WebSocket bridge.
+// Port of events.js as ES module with factory pattern.
+//
+// Factory: createEvents(getWsAuth)
+// ==========================================
+
+/**
+ * Create the event bus.
+ *
+ * @param {() => Promise} getWsAuth — returns WS auth query param
+ * @returns {object} events
+ */
+export function createEvents(getWsAuth) {
+
+ const _handlers = new Map(); // label → Set<{fn, once}>
+ let _ws = null;
+ let _wsUrl = null;
+ let _wsReconnectMs = 1000;
+ const _wsMaxReconnectMs = 30000;
+ let _wsReconnectTimer = null;
+ let _wsConnected = false;
+ let _wsRetries = 0;
+ const _wsMaxRetries = 5;
+ const _wsQueue = []; // buffered while disconnected
+ let _heartbeatInterval = null;
+
+ // Labels that should NOT cross the wire
+ const _localOnly = new Set([
+ 'ui.modal.open', 'ui.modal.close',
+ 'ui.sidebar.toggle', 'ui.sidebar.collapse',
+ 'ui.toast',
+ 'auth.login', 'auth.logout', 'auth.refresh',
+ 'auth.boot', 'auth.permissions.changed',
+ 'sdk.ready', 'theme.changed',
+ ]);
+
+ // Labels the FE should never receive from BE
+ const _serverOnly = new Set([
+ 'plugin.hook.pre_completion',
+ 'plugin.hook.post_completion',
+ 'internal.',
+ 'db.',
+ ]);
+
+ // ── Pattern matching ────────────────────────
+
+ function _match(label, pattern) {
+ if (pattern === '*') return true;
+ if (pattern === label) return true;
+ if (!pattern.endsWith('*')) return false;
+ const prefix = pattern.slice(0, -1);
+ return label.startsWith(prefix);
+ }
+
+ function _isLocalOnly(label) {
+ if (_localOnly.has(label)) return true;
+ for (const prefix of _localOnly) {
+ if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
+ }
+ return false;
+ }
+
+ // ── Internal dispatch ───────────────────────
+
+ function _dispatch(label, payload, meta) {
+ for (const [pattern, entries] of _handlers) {
+ if (_match(label, pattern)) {
+ for (const entry of entries) {
+ try {
+ entry.fn(payload, meta);
+ } catch (e) {
+ console.error(`[sw.events] Handler error for ${pattern}:`, e);
+ }
+ if (entry.once) entries.delete(entry);
+ }
+ }
+ }
+ }
+
+ // ── WebSocket ───────────────────────────────
+
+ async function _doConnect() {
+ if (!_wsUrl) return;
+
+ let url = _wsUrl;
+ if (url.startsWith('/')) {
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ url = `${proto}//${location.host}${url}`;
+ }
+
+ const authParam = await getWsAuth();
+ if (!authParam) {
+ console.warn('[sw.events] No auth available — skipping WebSocket');
+ return;
+ }
+ url += (url.includes('?') ? '&' : '?') + authParam;
+
+ try {
+ _ws = new WebSocket(url);
+ } catch (e) {
+ console.warn('[sw.events] WebSocket creation failed:', e);
+ _scheduleReconnect();
+ return;
+ }
+
+ _ws.onopen = () => {
+ console.log('[sw.events] WebSocket connected');
+ _wsConnected = true;
+ _wsReconnectMs = 1000;
+ _wsRetries = 0;
+
+ // Flush queued events
+ while (_wsQueue.length > 0) {
+ _ws.send(JSON.stringify(_wsQueue.shift()));
+ }
+
+ _dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
+ };
+
+ _ws.onmessage = (evt) => {
+ try {
+ const msg = JSON.parse(evt.data);
+ if (msg.event === 'pong') return;
+
+ for (const prefix of _serverOnly) {
+ if (msg.event.startsWith(prefix)) return;
+ }
+
+ const meta = {
+ event: msg.event,
+ room: msg.room || null,
+ ts: msg.ts || Date.now(),
+ local: false,
+ };
+ _dispatch(msg.event, msg.payload, meta);
+ } catch (_) {
+ console.warn('[sw.events] Bad message:', evt.data);
+ }
+ };
+
+ _ws.onclose = (evt) => {
+ _wsConnected = false;
+ _ws = null;
+ if (evt.code !== 1000) {
+ console.log(`[sw.events] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
+ }
+ _dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
+ _scheduleReconnect();
+ };
+
+ _ws.onerror = () => {};
+
+ _startHeartbeat();
+ }
+
+ function _scheduleReconnect() {
+ if (!_wsUrl) return;
+ if (_wsReconnectTimer) return;
+
+ _wsRetries++;
+ if (_wsRetries > _wsMaxRetries) {
+ console.warn(`[sw.events] WebSocket failed after ${_wsMaxRetries} attempts`);
+ _dispatch('ws.failed', { retries: _wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
+ return;
+ }
+
+ console.log(`[sw.events] Reconnecting in ${_wsReconnectMs}ms (attempt ${_wsRetries}/${_wsMaxRetries})`);
+ _wsReconnectTimer = setTimeout(() => {
+ _wsReconnectTimer = null;
+ _doConnect();
+ }, _wsReconnectMs);
+
+ _wsReconnectMs = Math.min(_wsReconnectMs * 2, _wsMaxReconnectMs);
+ }
+
+ function _send(msg) {
+ if (_wsConnected && _ws?.readyState === WebSocket.OPEN) {
+ _ws.send(JSON.stringify(msg));
+ } else {
+ if (_wsQueue.length < 100) _wsQueue.push(msg);
+ }
+ }
+
+ function _startHeartbeat() {
+ if (_heartbeatInterval) clearInterval(_heartbeatInterval);
+ _heartbeatInterval = setInterval(() => {
+ if (_wsConnected) {
+ _send({ event: 'ping', payload: {}, ts: Date.now() });
+ }
+ }, 30000);
+ }
+
+ // ── Public API ──────────────────────────────
+
+ const events = {
+ on(label, fn) {
+ if (!_handlers.has(label)) _handlers.set(label, new Set());
+ const entry = { fn, once: false };
+ _handlers.get(label).add(entry);
+ return () => _handlers.get(label)?.delete(entry);
+ },
+
+ once(label, fn) {
+ if (!_handlers.has(label)) _handlers.set(label, new Set());
+ const entry = { fn, once: true };
+ _handlers.get(label).add(entry);
+ return () => _handlers.get(label)?.delete(entry);
+ },
+
+ off(label, fn) {
+ if (!fn) {
+ _handlers.delete(label);
+ } else {
+ const set = _handlers.get(label);
+ if (set) {
+ for (const entry of set) {
+ if (entry.fn === fn) { set.delete(entry); break; }
+ }
+ }
+ }
+ },
+
+ emit(label, payload, opts) {
+ const _opts = opts || {};
+ const meta = {
+ event: label,
+ room: _opts.room || null,
+ ts: Date.now(),
+ local: true,
+ };
+ _dispatch(label, payload, meta);
+
+ if (!_opts.localOnly && !_isLocalOnly(label)) {
+ _send({ event: label, room: meta.room, payload, ts: meta.ts });
+ }
+ },
+
+ connect(url) {
+ _wsUrl = url;
+ _wsReconnectMs = 1000;
+ _wsRetries = 0;
+ _doConnect();
+ },
+
+ disconnect() {
+ if (_wsReconnectTimer) clearTimeout(_wsReconnectTimer);
+ _wsReconnectTimer = null;
+ _wsRetries = 0;
+ if (_ws) {
+ _ws.onclose = null;
+ _ws.close();
+ _ws = null;
+ }
+ _wsConnected = false;
+ if (_heartbeatInterval) clearInterval(_heartbeatInterval);
+ _heartbeatInterval = null;
+ _dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
+ },
+
+ get connected() { return _wsConnected; },
+
+ clear() { _handlers.clear(); },
+
+ debug() {
+ const subs = {};
+ for (const [label, entries] of _handlers) {
+ subs[label] = entries.size;
+ }
+ return subs;
+ },
+ };
+
+ return events;
+}
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
new file mode 100644
index 0000000..b7623df
--- /dev/null
+++ b/src/js/sw/sdk/index.js
@@ -0,0 +1,120 @@
+// ==========================================
+// Chat Switchboard — SDK Entry Point
+// ==========================================
+// Assembles all SDK modules into the `sw` object
+// and runs the boot sequence.
+//
+// Usage:
+// import { boot } from './js/sw/sdk/index.js';
+// const sw = await boot();
+//
+// Exports: boot() → window.sw
+// ==========================================
+
+import { createRestClient } from './rest-client.js';
+import { createAuth } from './auth.js';
+import { createCan } from './can.js';
+import { createDomains } from './api-domains.js';
+import { createEvents } from './events.js';
+import { createPipe } from './pipe.js';
+import { createTheme } from './theme.js';
+
+/**
+ * Boot the SDK. Assembles modules, loads auth state, connects WebSocket.
+ * Idempotent — returns the same sw object on subsequent calls.
+ *
+ * @returns {Promise