@moxn/kb-migrate 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,3 +12,10 @@ export declare function formatApiError(status: number, body: {
12
12
  details?: unknown;
13
13
  [key: string]: unknown;
14
14
  } | null | undefined): string;
15
+ /**
16
+ * Parse a response body that should be JSON. When it isn't — typically the
17
+ * Vercel Auth wall's HTML page on a protected deployment — say what arrived
18
+ * (status, content-type) instead of surfacing a raw `JSON.parse` error like
19
+ * `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`.
20
+ */
21
+ export declare function readJson<T = any>(response: Response): Promise<T>;
package/dist/api-error.js CHANGED
@@ -17,3 +17,24 @@ export function formatApiError(status, body) {
17
17
  : '';
18
18
  return detail ? `API error ${status}: ${detail}` : `API error: ${status}`;
19
19
  }
20
+ /**
21
+ * Parse a response body that should be JSON. When it isn't — typically the
22
+ * Vercel Auth wall's HTML page on a protected deployment — say what arrived
23
+ * (status, content-type) instead of surfacing a raw `JSON.parse` error like
24
+ * `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`.
25
+ */
26
+ export async function readJson(response) {
27
+ try {
28
+ return (await response.json());
29
+ }
30
+ catch (cause) {
31
+ if (!(cause instanceof SyntaxError))
32
+ throw cause;
33
+ const contentType = response.headers?.get?.('content-type') ?? 'no content-type';
34
+ const where = response.url ? ` from ${response.url}` : '';
35
+ const hint = /html/i.test(contentType)
36
+ ? ' — an HTML page usually means the Vercel protection wall: set MOXN_VERCEL_BYPASS, with MOXN_BASE_URL on this origin'
37
+ : '';
38
+ throw new Error(`Expected JSON${where} but got a non-JSON response (HTTP ${response.status}, ${contentType})${hint}`);
39
+ }
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * kb-migrate's HTTP client against a Vercel-protected deployment (staging).
3
+ *
4
+ * `export-local` against https://staging.moxn.dev failed with
5
+ * `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`: kb-migrate never
6
+ * sent the protection-bypass header, and the auth wall's HTML page reached a
7
+ * bare `response.json()`. The client now attaches the SAME
8
+ * `vercelBypassHeaders` kb-cli uses (@moxn/auth — env-gated, scoped to
9
+ * MOXN_BASE_URL's origin, so signed storage URLs never see the secret), and a
10
+ * non-JSON body is reported as what it is.
11
+ */
12
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import { MoxnClient } from './client.js';
17
+ const STAGING = 'https://staging.moxn.dev';
18
+ const HEADER = 'x-vercel-protection-bypass';
19
+ function jsonResponse(body) {
20
+ return new Response(JSON.stringify(body), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ function headersOf(init) {
26
+ return new Headers(init?.headers).has(HEADER)
27
+ ? { [HEADER]: new Headers(init?.headers).get(HEADER) }
28
+ : {};
29
+ }
30
+ describe('MoxnClient — Vercel protection bypass', () => {
31
+ const saved = { ...process.env };
32
+ let fetchSpy;
33
+ beforeEach(() => {
34
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
35
+ });
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ process.env = { ...saved };
39
+ });
40
+ function client() {
41
+ return new MoxnClient({
42
+ apiUrl: STAGING,
43
+ apiKey: 'k',
44
+ basePath: '/',
45
+ onConflict: 'skip',
46
+ dryRun: false,
47
+ });
48
+ }
49
+ it('attaches the bypass header to requests for the MOXN_BASE_URL origin only', async () => {
50
+ process.env.MOXN_VERCEL_BYPASS = 'bypass-secret';
51
+ process.env.MOXN_BASE_URL = STAGING;
52
+ fetchSpy.mockImplementation(async (url) => String(url).startsWith(STAGING)
53
+ ? jsonResponse({ documents: [], pagination: { hasMore: false } })
54
+ : new Response('blob', { status: 200 }));
55
+ await client().listDocuments('/qa');
56
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-bypass-'));
57
+ await client().downloadFile('https://storage.example/signed/f.png', path.join(dir, 'f.png'));
58
+ const [apiCall, storageCall] = fetchSpy.mock.calls;
59
+ expect(String(apiCall[0])).toContain(`${STAGING}/api/v1/kb/documents`);
60
+ expect(headersOf(apiCall[1])).toEqual({ [HEADER]: 'bypass-secret' });
61
+ // The API key still rides alongside.
62
+ expect(new Headers(apiCall[1].headers).get('x-api-key')).toBe('k');
63
+ // A different origin (signed storage URL) never receives the secret.
64
+ expect(headersOf(storageCall[1])).toEqual({});
65
+ fs.rmSync(dir, { recursive: true, force: true });
66
+ });
67
+ it('sends no bypass header when MOXN_VERCEL_BYPASS is unset', async () => {
68
+ delete process.env.MOXN_VERCEL_BYPASS;
69
+ process.env.MOXN_BASE_URL = STAGING;
70
+ fetchSpy.mockResolvedValue(jsonResponse({ documents: [], pagination: { hasMore: false } }));
71
+ await client().listDocuments('/qa');
72
+ expect(headersOf(fetchSpy.mock.calls[0][1])).toEqual({});
73
+ });
74
+ it('reports a non-JSON (auth-wall HTML) body by status + content-type, not a raw JSON.parse error', async () => {
75
+ fetchSpy.mockResolvedValue(new Response('<!DOCTYPE html><html>Vercel Authentication</html>', {
76
+ status: 200,
77
+ headers: { 'content-type': 'text/html; charset=utf-8' },
78
+ }));
79
+ const err = await client()
80
+ .listDocuments('/qa')
81
+ .then(() => null, (e) => e);
82
+ expect(err).toBeInstanceOf(Error);
83
+ expect(err.message).not.toMatch(/Unexpected token/);
84
+ expect(err.message).toMatch(/non-JSON/i);
85
+ expect(err.message).toContain('200');
86
+ expect(err.message).toContain('text/html');
87
+ expect(err.message).toMatch(/MOXN_VERCEL_BYPASS/);
88
+ });
89
+ });
package/dist/client.d.ts CHANGED
@@ -36,12 +36,59 @@ export interface ImportMarkdownResult {
36
36
  sectionIds?: string[];
37
37
  /** The branch the upsert landed on (for branch-scoped follow-ups like tags). */
38
38
  branchId?: string;
39
+ /**
40
+ * The permissions a CREATED document got. Absent on update/skip (an import
41
+ * never re-permissions an existing document) and from servers that predate
42
+ * the importer permission fields.
43
+ */
44
+ permissions?: {
45
+ defaultPermission: 'edit' | 'read' | 'none';
46
+ aiAccess: 'edit' | 'read' | 'none';
47
+ };
48
+ }
49
+ /**
50
+ * The warning (if any) for an import whose `--default-permission` /
51
+ * `--ai-access` did not take effect: an existing document is never
52
+ * re-permissioned by a re-import, and a server that predates the fields
53
+ * creates documents with its defaults without echoing `permissions`.
54
+ */
55
+ export declare function importPermissionWarning(requested: {
56
+ defaultPermission?: string;
57
+ aiAccess?: string;
58
+ }, result: Pick<ImportMarkdownResult, 'outcome' | 'permissions'>): string | undefined;
59
+ /**
60
+ * One item of a `read` tool response, narrowed to the fields the export uses.
61
+ * `kind` is absent on an error item and on older md reads.
62
+ */
63
+ export interface ReadDocumentItem {
64
+ type: string;
65
+ documentId: string;
66
+ error?: string;
67
+ kind?: string;
68
+ /** report: the HTML; slides: the deck-marker HTML. */
69
+ content?: Array<{
70
+ type: string;
71
+ text?: string;
72
+ }>;
73
+ /** kind='file' only. */
74
+ file?: {
75
+ filename: string;
76
+ download: {
77
+ url: string;
78
+ };
79
+ };
80
+ /** kind='slides' only. */
81
+ deck?: {
82
+ themeCss?: string | null;
83
+ };
39
84
  }
40
85
  export declare class MoxnClient {
41
86
  private apiUrl;
42
87
  private apiKey;
43
88
  private defaultPermission?;
44
89
  private aiAccess?;
90
+ /** Every request goes through {@link moxnFetch} (bypass-header aware). */
91
+ private fetch;
45
92
  constructor(options: MigrationOptions | ExportOptions);
46
93
  /**
47
94
  * Migrate a single document
@@ -58,6 +105,13 @@ export declare class MoxnClient {
58
105
  * update by the server.
59
106
  */
60
107
  migrateDocument(doc: ExtractedDocument, basePath: string, onConflict: 'skip' | 'update', dryRun: boolean): Promise<MigrationResult>;
108
+ /**
109
+ * {@link importPermissionWarning} for this client's requested permissions,
110
+ * logged once and returned as a spreadable `{ warning }`.
111
+ */
112
+ permissionWarningFor(result: Pick<ImportMarkdownResult, 'outcome' | 'permissions'>, sourcePath: string): {
113
+ warning?: string;
114
+ };
61
115
  /**
62
116
  * List all documents, optionally filtered by path prefix.
63
117
  * Handles pagination automatically.
@@ -79,6 +133,13 @@ export declare class MoxnClient {
79
133
  getDocumentMarkdown(documentId: string, opts?: {
80
134
  forEdit?: boolean;
81
135
  }): Promise<DocumentMarkdownResponse | null>;
136
+ /**
137
+ * Read one document through the `read` tool (`/api/v1/kb/tools`) — the same
138
+ * serialization agents and the CLI see. The export uses it for the kinds the
139
+ * grammar export has no body for (report / file / slides). Returns the single
140
+ * read item: a document shape carrying `kind`, or an `{ error }` item.
141
+ */
142
+ readDocument(documentId: string): Promise<ReadDocumentItem>;
82
143
  /**
83
144
  * Build a `storageKey → signedDownloadUrl` map for a document's media, by
84
145
  * reusing the `get_document_content` export action (the blocks read, which
package/dist/client.js CHANGED
@@ -2,13 +2,40 @@
2
2
  * API client for Moxn KB
3
3
  */
4
4
  import * as fs from 'fs/promises';
5
- import { formatApiError } from './api-error.js';
5
+ import { formatApiError, readJson } from './api-error.js';
6
+ import { moxnFetch } from './http.js';
6
7
  import { sectionsToGrammarMarkdown, buildImportFrontMatter, } from './blocks-to-grammar.js';
8
+ /**
9
+ * The warning (if any) for an import whose `--default-permission` /
10
+ * `--ai-access` did not take effect: an existing document is never
11
+ * re-permissioned by a re-import, and a server that predates the fields
12
+ * creates documents with its defaults without echoing `permissions`.
13
+ */
14
+ export function importPermissionWarning(requested, result) {
15
+ const asked = [
16
+ requested.defaultPermission &&
17
+ `--default-permission ${requested.defaultPermission}`,
18
+ requested.aiAccess && `--ai-access ${requested.aiAccess}`,
19
+ ].filter(Boolean);
20
+ if (asked.length === 0 || result.outcome === 'skipped')
21
+ return undefined;
22
+ if (result.outcome === 'updated') {
23
+ return `${asked.join(', ')} not applied: this updated an existing document, whose permissions are left as they were (change them in the app)`;
24
+ }
25
+ if (!result.permissions) {
26
+ return `${asked.join(', ')}: the server did not apply them (it predates importer permissions) — the document was created with the server defaults`;
27
+ }
28
+ return undefined;
29
+ }
7
30
  export class MoxnClient {
8
31
  apiUrl;
9
32
  apiKey;
10
33
  defaultPermission;
11
34
  aiAccess;
35
+ /** Every request goes through {@link moxnFetch} (bypass-header aware). */
36
+ fetch(url, init = {}) {
37
+ return moxnFetch(url, init);
38
+ }
12
39
  constructor(options) {
13
40
  this.apiUrl = options.apiUrl.replace(/\/$/, '');
14
41
  this.apiKey = options.apiKey;
@@ -72,6 +99,7 @@ export class MoxnClient {
72
99
  sectionIds: result.sectionIds,
73
100
  references: doc.references,
74
101
  sourcePageId: doc.metadata?.notionPageId,
102
+ ...this.permissionWarningFor(result, doc.sourcePath),
75
103
  duration: Date.now() - startTime,
76
104
  };
77
105
  }
@@ -85,6 +113,17 @@ export class MoxnClient {
85
113
  };
86
114
  }
87
115
  }
116
+ /**
117
+ * {@link importPermissionWarning} for this client's requested permissions,
118
+ * logged once and returned as a spreadable `{ warning }`.
119
+ */
120
+ permissionWarningFor(result, sourcePath) {
121
+ const warning = importPermissionWarning({ defaultPermission: this.defaultPermission, aiAccess: this.aiAccess }, result);
122
+ if (!warning)
123
+ return {};
124
+ console.warn(` ⚠ ${warning}: ${sourcePath}`);
125
+ return { warning };
126
+ }
88
127
  // ──────────────────────────────────────────────
89
128
  // Export methods
90
129
  // ──────────────────────────────────────────────
@@ -112,14 +151,14 @@ export class MoxnClient {
112
151
  params.set('modifiedAfter', dateFilter.modifiedAfter);
113
152
  if (dateFilter?.modifiedBefore)
114
153
  params.set('modifiedBefore', dateFilter.modifiedBefore);
115
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
154
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
116
155
  headers: { 'x-api-key': this.apiKey },
117
156
  });
118
157
  if (!response.ok) {
119
158
  const error = await response.text();
120
159
  throw new Error(`Failed to list documents: ${response.status} ${error}`);
121
160
  }
122
- const data = await response.json();
161
+ const data = await readJson(response);
123
162
  allDocs.push(...data.documents);
124
163
  if (!data.pagination.hasMore)
125
164
  break;
@@ -131,14 +170,14 @@ export class MoxnClient {
131
170
  * Get full document detail with sections and content.
132
171
  */
133
172
  async getDocument(documentId) {
134
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
173
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
135
174
  headers: { 'x-api-key': this.apiKey },
136
175
  });
137
176
  if (!response.ok) {
138
177
  const error = await response.text();
139
178
  throw new Error(`Failed to get document ${documentId}: ${response.status} ${error}`);
140
179
  }
141
- return response.json();
180
+ return readJson(response);
142
181
  }
143
182
  /**
144
183
  * Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
@@ -150,7 +189,7 @@ export class MoxnClient {
150
189
  * address sections stably.
151
190
  */
152
191
  async getDocumentMarkdown(documentId, opts) {
153
- const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
192
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/export`, {
154
193
  method: 'POST',
155
194
  headers: {
156
195
  'Content-Type': 'application/json',
@@ -166,9 +205,38 @@ export class MoxnClient {
166
205
  const body = await response.text();
167
206
  throw new Error(`Failed to get document markdown ${documentId}: ${response.status} ${body}`);
168
207
  }
169
- const data = await response.json();
208
+ const data = await readJson(response);
170
209
  return (data.result?.document ?? null);
171
210
  }
211
+ /**
212
+ * Read one document through the `read` tool (`/api/v1/kb/tools`) — the same
213
+ * serialization agents and the CLI see. The export uses it for the kinds the
214
+ * grammar export has no body for (report / file / slides). Returns the single
215
+ * read item: a document shape carrying `kind`, or an `{ error }` item.
216
+ */
217
+ async readDocument(documentId) {
218
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/tools`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'x-api-key': this.apiKey,
223
+ },
224
+ body: JSON.stringify({
225
+ tool: 'read',
226
+ params: { items: [{ type: 'document', documentId }] },
227
+ }),
228
+ });
229
+ if (!response.ok) {
230
+ const body = await response.json().catch(() => ({}));
231
+ throw new Error(formatApiError(response.status, body));
232
+ }
233
+ const data = await readJson(response);
234
+ const item = data.result?.items?.[0];
235
+ if (!item) {
236
+ throw new Error(`read returned no item for document ${documentId}`);
237
+ }
238
+ return item;
239
+ }
172
240
  /**
173
241
  * Build a `storageKey → signedDownloadUrl` map for a document's media, by
174
242
  * reusing the `get_document_content` export action (the blocks read, which
@@ -178,7 +246,7 @@ export class MoxnClient {
178
246
  * Returns an empty map when the document has no resolvable media.
179
247
  */
180
248
  async getDocumentMediaUrls(documentId) {
181
- const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
249
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/export`, {
182
250
  method: 'POST',
183
251
  headers: {
184
252
  'Content-Type': 'application/json',
@@ -190,7 +258,7 @@ export class MoxnClient {
190
258
  const body = await response.text();
191
259
  throw new Error(`Failed to get document content ${documentId}: ${response.status} ${body}`);
192
260
  }
193
- const data = await response.json();
261
+ const data = await readJson(response);
194
262
  const doc = data.result?.document;
195
263
  const map = new Map();
196
264
  if (!doc)
@@ -215,7 +283,7 @@ export class MoxnClient {
215
283
  * onConflict 'update' → replace_document (full-set PUT)
216
284
  */
217
285
  async importMarkdown(input) {
218
- const response = await fetch(`${this.apiUrl}/api/v1/kb/import`, {
286
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/import`, {
219
287
  method: 'POST',
220
288
  headers: {
221
289
  'Content-Type': 'application/json',
@@ -226,20 +294,25 @@ export class MoxnClient {
226
294
  markdown: input.markdown,
227
295
  path: input.path,
228
296
  onConflict: input.onConflict,
297
+ // Applied by the server to a document this import CREATES.
298
+ ...(this.defaultPermission
299
+ ? { defaultPermission: this.defaultPermission }
300
+ : {}),
301
+ ...(this.aiAccess ? { aiAccess: this.aiAccess } : {}),
229
302
  }),
230
303
  });
231
304
  if (!response.ok) {
232
305
  const body = await response.json().catch(() => ({}));
233
306
  throw new Error(formatApiError(response.status, body));
234
307
  }
235
- const data = await response.json();
308
+ const data = await readJson(response);
236
309
  return data.result;
237
310
  }
238
311
  /**
239
312
  * Download a file from a URL to a local path.
240
313
  */
241
314
  async downloadFile(url, destPath) {
242
- const response = await fetch(url);
315
+ const response = await this.fetch(url);
243
316
  if (!response.ok) {
244
317
  throw new Error(`Failed to download ${url}: ${response.status}`);
245
318
  }
@@ -338,7 +411,7 @@ export class MoxnClient {
338
411
  block.type === 'url' &&
339
412
  block.url) {
340
413
  try {
341
- const response = await fetch(block.url);
414
+ const response = await this.fetch(block.url);
342
415
  if (!response.ok)
343
416
  throw new Error(`HTTP ${response.status}`);
344
417
  const data = Buffer.from(await response.arrayBuffer());
@@ -364,7 +437,7 @@ export class MoxnClient {
364
437
  // 1. Get presigned upload URL
365
438
  const { key, uploadUrl } = await this.getUploadUrl(mimeType, filename);
366
439
  // 2. PUT file to presigned URL
367
- const response = await fetch(uploadUrl, {
440
+ const response = await this.fetch(uploadUrl, {
368
441
  method: 'PUT',
369
442
  headers: { 'Content-Type': mimeType },
370
443
  body: new Uint8Array(data),
@@ -375,7 +448,7 @@ export class MoxnClient {
375
448
  return { key };
376
449
  }
377
450
  async getUploadUrl(type, filename) {
378
- const response = await fetch(`${this.apiUrl}/api/v1/kb/upload`, {
451
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/upload`, {
379
452
  method: 'POST',
380
453
  headers: {
381
454
  'Content-Type': 'application/json',
@@ -387,7 +460,7 @@ export class MoxnClient {
387
460
  const body = await response.text();
388
461
  throw new Error(`Upload URL request failed: ${response.status} ${body}`);
389
462
  }
390
- const data = await response.json();
463
+ const data = await readJson(response);
391
464
  return { key: data.key, uploadUrl: data.uploadUrl };
392
465
  }
393
466
  // ──────────────────────────────────────────────
@@ -397,7 +470,7 @@ export class MoxnClient {
397
470
  * Create a KB database.
398
471
  */
399
472
  async createDatabase(input) {
400
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases`, {
473
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases`, {
401
474
  method: 'POST',
402
475
  headers: {
403
476
  'Content-Type': 'application/json',
@@ -409,13 +482,13 @@ export class MoxnClient {
409
482
  const body = await response.json().catch(() => ({}));
410
483
  throw new Error(body.error || `Failed to create database: ${response.status}`);
411
484
  }
412
- return response.json();
485
+ return readJson(response);
413
486
  }
414
487
  /**
415
488
  * Add a column to a KB database.
416
489
  */
417
490
  async addDatabaseColumn(databaseId, input) {
418
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/columns`, {
491
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/columns`, {
419
492
  method: 'POST',
420
493
  headers: {
421
494
  'Content-Type': 'application/json',
@@ -427,13 +500,13 @@ export class MoxnClient {
427
500
  const body = await response.json().catch(() => ({}));
428
501
  throw new Error(body.error || `Failed to add column: ${response.status}`);
429
502
  }
430
- return response.json();
503
+ return readJson(response);
431
504
  }
432
505
  /**
433
506
  * Add a document to a KB database.
434
507
  */
435
508
  async addDocumentToDatabase(databaseId, documentId) {
436
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/documents`, {
509
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/documents`, {
437
510
  method: 'POST',
438
511
  headers: {
439
512
  'Content-Type': 'application/json',
@@ -453,7 +526,7 @@ export class MoxnClient {
453
526
  * Create a tag (with automatic ancestor creation).
454
527
  */
455
528
  async createTag(input) {
456
- const response = await fetch(`${this.apiUrl}/api/v1/kb/tags`, {
529
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/tags`, {
457
530
  method: 'POST',
458
531
  headers: {
459
532
  'Content-Type': 'application/json',
@@ -473,13 +546,13 @@ export class MoxnClient {
473
546
  }
474
547
  throw new Error(body.error || `Failed to create tag: ${response.status}`);
475
548
  }
476
- return response.json();
549
+ return readJson(response);
477
550
  }
478
551
  /**
479
552
  * Assign a tag to a document.
480
553
  */
481
554
  async assignTag(documentId, tagId, branchId) {
482
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/tags`, {
555
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/tags`, {
483
556
  method: 'POST',
484
557
  headers: {
485
558
  'Content-Type': 'application/json',
@@ -497,7 +570,7 @@ export class MoxnClient {
497
570
  * Returns { created, skipped, errors }.
498
571
  */
499
572
  async createReferences(documentId, references) {
500
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/references`, {
573
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/references`, {
501
574
  method: 'POST',
502
575
  headers: {
503
576
  'Content-Type': 'application/json',
@@ -509,51 +582,51 @@ export class MoxnClient {
509
582
  const body = await response.json().catch(() => ({}));
510
583
  throw new Error(body.error || `Failed to create references: ${response.status}`);
511
584
  }
512
- return response.json();
585
+ return readJson(response);
513
586
  }
514
587
  /**
515
588
  * List all KB databases for the tenant.
516
589
  */
517
590
  async listDatabases() {
518
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases`, {
591
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases`, {
519
592
  headers: { 'x-api-key': this.apiKey },
520
593
  });
521
594
  if (!response.ok) {
522
595
  const body = await response.json().catch(() => ({}));
523
596
  throw new Error(body.error || `Failed to list databases: ${response.status}`);
524
597
  }
525
- const data = await response.json();
598
+ const data = await readJson(response);
526
599
  return data.databases;
527
600
  }
528
601
  /**
529
602
  * Get fully resolved database with columns, options, and document property values.
530
603
  */
531
604
  async resolveDatabase(databaseId) {
532
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/resolve`, {
605
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/resolve`, {
533
606
  headers: { 'x-api-key': this.apiKey },
534
607
  });
535
608
  if (!response.ok) {
536
609
  const body = await response.json().catch(() => ({}));
537
610
  throw new Error(body.error || `Failed to resolve database: ${response.status}`);
538
611
  }
539
- return response.json();
612
+ return readJson(response);
540
613
  }
541
614
  /**
542
615
  * Get Notion database mapping for a KB database.
543
616
  */
544
617
  async getNotionDatabaseMapping(kbDatabaseId) {
545
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases/by-kb-database/${kbDatabaseId}`, { headers: { 'x-api-key': this.apiKey } });
618
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases/by-kb-database/${kbDatabaseId}`, { headers: { 'x-api-key': this.apiKey } });
546
619
  if (!response.ok) {
547
620
  const body = await response.json().catch(() => ({}));
548
621
  throw new Error(body.error || `Failed to get database mapping: ${response.status}`);
549
622
  }
550
- return response.json();
623
+ return readJson(response);
551
624
  }
552
625
  /**
553
626
  * Create or upsert a Notion database mapping.
554
627
  */
555
628
  async createNotionDatabaseMapping(input) {
556
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
629
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
557
630
  method: 'POST',
558
631
  headers: {
559
632
  'Content-Type': 'application/json',
@@ -565,13 +638,13 @@ export class MoxnClient {
565
638
  const body = await response.json().catch(() => ({}));
566
639
  throw new Error(body.error || `Failed to create database mapping: ${response.status}`);
567
640
  }
568
- return response.json();
641
+ return readJson(response);
569
642
  }
570
643
  /**
571
644
  * Set a scalar property value on a document in a database.
572
645
  */
573
646
  async setPropertyValue(databaseId, documentId, columnName, value) {
574
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
647
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
575
648
  method: 'POST',
576
649
  headers: {
577
650
  'Content-Type': 'application/json',
@@ -591,7 +664,7 @@ export class MoxnClient {
591
664
  * Create or upsert a Notion page → KB document mapping.
592
665
  */
593
666
  async createNotionPageMapping(input) {
594
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
667
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
595
668
  method: 'POST',
596
669
  headers: {
597
670
  'Content-Type': 'application/json',
@@ -609,14 +682,14 @@ export class MoxnClient {
609
682
  * Returns a Map of notionDatabaseId → kbDatabaseId.
610
683
  */
611
684
  async getAllDatabaseMappings() {
612
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
685
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
613
686
  headers: { 'x-api-key': this.apiKey },
614
687
  });
615
688
  if (!response.ok) {
616
689
  const body = await response.json().catch(() => ({}));
617
690
  throw new Error(body.error || `Failed to get database mappings: ${response.status}`);
618
691
  }
619
- const data = await response.json();
692
+ const data = await readJson(response);
620
693
  const map = new Map();
621
694
  for (const mapping of data.mappings) {
622
695
  map.set(mapping.notionDatabaseId, mapping.kbDatabaseId);