hub-client.mjs
175 lines 5.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * Knowtation hosted Hub HTTP client used by Paperclip skills.
3 *
4 * This is a thin, dependency-free wrapper around globalThis.fetch that:
5 * - injects Authorization, X-Vault-Id, and X-User-Id headers on every call
6 * - normalizes error responses (always throws { code, status, message, body })
7 * - applies an exponential backoff (3 attempts) on 5xx and ECONN errors
8 * - never logs tokens; tokens come from process.env at call time
9 *
10 * Why not the MCP SDK directly?
11 * Paperclip already speaks MCP at the agent layer. These skills are the
12 * *implementation* layer one level below — they call the Hub's REST API
13 * directly so unit tests can assert exact request shape without spinning
14 * up an MCP transport.
15 *
16 * @typedef {object} HubClientOptions
17 * @property {string} baseUrl Knowtation Hub base URL (no trailing slash).
18 * @property {string} jwt Hub JWT (Authorization: Bearer <jwt>).
19 * @property {string} vaultId X-Vault-Id header value.
20 * @property {string} [userId] Optional X-User-Id (defaults to 'paperclip').
21 * @property {number} [maxAttempts] Retry cap. Default 3. Set to 1 to disable retry.
22 * @property {number} [retryBaseMs] Base backoff. Default 250.
23 * @property {typeof fetch} [fetch] Inject for testing. Defaults to globalThis.fetch.
24 */
25
26 /**
27 * @param {HubClientOptions} opts
28 * @returns {{
29 * search: (body: object) => Promise<any>,
30 * getNote: (path: string) => Promise<any>,
31 * putNote: (path: string, body: object) => Promise<any>,
32 * listNotes: (query: object) => Promise<any>,
33 * }}
34 */
35 export function createHubClient(opts) {
36 const {
37 baseUrl,
38 jwt,
39 vaultId,
40 userId = 'paperclip',
41 maxAttempts = 3,
42 retryBaseMs = 250,
43 fetch: fetchImpl = globalThis.fetch,
44 } = opts;
45
46 if (!baseUrl || typeof baseUrl !== 'string') {
47 throw new Error('createHubClient: baseUrl is required (string)');
48 }
49 if (!jwt || typeof jwt !== 'string') {
50 throw new Error('createHubClient: jwt is required (string)');
51 }
52 if (!vaultId || typeof vaultId !== 'string') {
53 throw new Error('createHubClient: vaultId is required (string)');
54 }
55 if (typeof fetchImpl !== 'function') {
56 throw new Error('createHubClient: fetch implementation missing');
57 }
58
59 const trimmedBase = baseUrl.replace(/\/+$/, '');
60
61 function headers() {
62 return {
63 Authorization: `Bearer ${jwt}`,
64 'X-Vault-Id': vaultId,
65 'X-User-Id': userId,
66 'Content-Type': 'application/json',
67 Accept: 'application/json',
68 };
69 }
70
71 /**
72 * Call the Hub with retries. Throws a structured error on non-2xx.
73 * @param {string} path absolute path (e.g. '/api/v1/search')
74 * @param {object} init { method, body? }
75 * @returns {Promise<any>}
76 */
77 async function request(path, init) {
78 const url = `${trimmedBase}${path}`;
79 const body = init.body != null ? JSON.stringify(init.body) : undefined;
80 let lastErr;
81
82 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
83 let res;
84 try {
85 res = await fetchImpl(url, {
86 method: init.method,
87 headers: headers(),
88 body,
89 });
90 } catch (e) {
91 lastErr = Object.assign(new Error(`hub_fetch_failed: ${e.message ?? e}`), {
92 code: 'HUB_FETCH_FAILED',
93 cause: e,
94 });
95 if (attempt < maxAttempts) {
96 await sleep(retryBaseMs * 2 ** (attempt - 1));
97 continue;
98 }
99 throw lastErr;
100 }
101
102 if (res.status >= 500 && attempt < maxAttempts) {
103 await sleep(retryBaseMs * 2 ** (attempt - 1));
104 continue;
105 }
106
107 let parsed;
108 try {
109 parsed = await res.json();
110 } catch (_e) {
111 parsed = null;
112 }
113
114 if (!res.ok) {
115 const err = new Error(
116 `hub_${res.status}: ${parsed?.error || parsed?.message || res.statusText || 'request failed'}`
117 );
118 Object.assign(err, {
119 code: parsed?.code || `HUB_${res.status}`,
120 status: res.status,
121 body: parsed,
122 });
123 throw err;
124 }
125
126 return parsed;
127 }
128
129 throw lastErr;
130 }
131
132 return {
133 search(body) {
134 return request('/api/v1/search', { method: 'POST', body });
135 },
136 getNote(path) {
137 const safe = encodeURIComponent(path);
138 return request(`/api/v1/notes/${safe}`, { method: 'GET' });
139 },
140 putNote(path, body) {
141 const safe = encodeURIComponent(path);
142 return request(`/api/v1/notes/${safe}`, { method: 'PUT', body });
143 },
144 listNotes(query) {
145 const qs = new URLSearchParams(
146 Object.fromEntries(
147 Object.entries(query ?? {}).filter(([_k, v]) => v != null && v !== '')
148 )
149 ).toString();
150 return request(`/api/v1/notes${qs ? `?${qs}` : ''}`, { method: 'GET' });
151 },
152 };
153 }
154
155 function sleep(ms) {
156 return new Promise((r) => setTimeout(r, ms));
157 }
158
159 /**
160 * Validate that a project slug is one of the three Knowtation projects.
161 * Paperclip has a company-per-project model; skills validate the project at the boundary
162 * so a misconfigured agent can't write Born Free content into the Knowtation vault.
163 * @param {string} project
164 * @returns {'born-free' | 'store-free' | 'knowtation'}
165 */
166 export function assertProject(project) {
167 const allowed = ['born-free', 'store-free', 'knowtation'];
168 if (!allowed.includes(project)) {
169 throw Object.assign(
170 new Error(`unknown_project: '${project}' is not in [${allowed.join(', ')}]`),
171 { code: 'UNKNOWN_PROJECT' }
172 );
173 }
174 return /** @type {'born-free' | 'store-free' | 'knowtation'} */ (project);
175 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago