@prenta/mcp-server 0.23.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.
package/dist/client.js ADDED
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * Thrown by `PrentaClient` on non-2xx responses. Carries the HTTP
3
+ * `status` so callers (e.g. `list_collections`'s OpenAPI fallback) can
4
+ * branch on the status code rather than pattern-matching the error
5
+ * message — pattern matching was rejected by review on PR #155 because
6
+ * a real 404 might omit the literal substring "not found" and an
7
+ * unrelated 5xx response might include it.
8
+ */
9
+ export class PrentaClientError extends Error {
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.name = 'PrentaClientError';
14
+ this.status = status;
15
+ }
16
+ }
17
+ export class PrentaClient {
18
+ baseUrl;
19
+ apiKey;
20
+ fetchImpl;
21
+ constructor(opts) {
22
+ if (!opts.baseUrl)
23
+ throw new Error('baseUrl is required');
24
+ if (!opts.apiKey)
25
+ throw new Error('apiKey is required');
26
+ // Trim trailing slash so we can concatenate `/api/cms/...` predictably.
27
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
28
+ this.apiKey = opts.apiKey;
29
+ this.fetchImpl = opts.fetchImpl ?? fetch;
30
+ }
31
+ headers(extra) {
32
+ return {
33
+ Authorization: `Bearer ${this.apiKey}`,
34
+ Accept: 'application/json',
35
+ ...extra,
36
+ };
37
+ }
38
+ async request(method, path, body, query) {
39
+ let url = `${this.baseUrl}/api/cms${path}`;
40
+ if (query) {
41
+ const params = new URLSearchParams();
42
+ for (const [k, v] of Object.entries(query)) {
43
+ if (v !== undefined)
44
+ params.set(k, String(v));
45
+ }
46
+ const q = params.toString();
47
+ if (q)
48
+ url += `?${q}`;
49
+ }
50
+ const init = { method, headers: this.headers() };
51
+ if (body !== undefined) {
52
+ init.body = JSON.stringify(body);
53
+ init.headers['Content-Type'] = 'application/json';
54
+ }
55
+ const res = await this.fetchImpl(url, init);
56
+ const text = await res.text();
57
+ let parsed = null;
58
+ if (text) {
59
+ try {
60
+ parsed = JSON.parse(text);
61
+ }
62
+ catch {
63
+ // non-JSON response body — surface as message for the tool caller.
64
+ }
65
+ }
66
+ if (!res.ok) {
67
+ const msg = parsed?.error ?? `Prenta API ${method} ${path} failed: ${res.status}`;
68
+ throw new PrentaClientError(msg, res.status);
69
+ }
70
+ return parsed;
71
+ }
72
+ /**
73
+ * POST a `multipart/form-data` body. Distinct from `request()` because:
74
+ *
75
+ * - we MUST NOT set `Content-Type` (the fetch implementation appends the
76
+ * multipart boundary; if we override it the server can't parse the body),
77
+ * - we MUST NOT `JSON.stringify(form)` — a `FormData` is sent verbatim,
78
+ * - the upload handler in `cms-core` rejects requests without a valid
79
+ * `Content-Length` header, and Node 20+ / undici populate that header
80
+ * automatically when the body is a `FormData`.
81
+ *
82
+ * Used exclusively by `uploadMedia` today. Kept as a thin private helper so
83
+ * the failure mode (multipart parse error vs JSON parse error vs HTTP
84
+ * error) stays observable to the caller.
85
+ */
86
+ async requestMultipart(path, form) {
87
+ const url = `${this.baseUrl}/api/cms${path}`;
88
+ const init = {
89
+ method: 'POST',
90
+ headers: this.headers(),
91
+ body: form,
92
+ };
93
+ const res = await this.fetchImpl(url, init);
94
+ const text = await res.text();
95
+ let parsed = null;
96
+ if (text) {
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ }
100
+ catch {
101
+ /* non-JSON body */
102
+ }
103
+ }
104
+ if (!res.ok) {
105
+ const msg = parsed?.error ?? `Prenta API POST ${path} failed: ${res.status}`;
106
+ throw new PrentaClientError(msg, res.status);
107
+ }
108
+ return parsed;
109
+ }
110
+ // ---- Collections ----
111
+ listDocuments(collection, query) {
112
+ return this.request('GET', `/collections/${encodeURIComponent(collection)}`, undefined, query);
113
+ }
114
+ getDocument(collection, id) {
115
+ return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
116
+ }
117
+ createDocument(collection, data) {
118
+ return this.request('POST', `/collections/${encodeURIComponent(collection)}`, data);
119
+ }
120
+ updateDocument(collection, id, data) {
121
+ return this.request('PUT', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, data);
122
+ }
123
+ deleteDocument(collection, id) {
124
+ return this.request('DELETE', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
125
+ }
126
+ // ---- Globals ----
127
+ getGlobal(slug) {
128
+ return this.request('GET', `/globals/${encodeURIComponent(slug)}`);
129
+ }
130
+ updateGlobal(slug, data) {
131
+ return this.request('PUT', `/globals/${encodeURIComponent(slug)}`, data);
132
+ }
133
+ // ---- Media ----
134
+ /**
135
+ * List media assets. The `GET /media` endpoint returns the asset array under
136
+ * `items`/`data` (and pagination fields), never `docs`. We normalize it into
137
+ * the same `{ data: { docs, total, page, pageSize, totalPages } }` envelope
138
+ * as `listDocuments` so the `list_media` MCP tool output is consistent with
139
+ * `list_documents` and an agent can read `docs` uniformly.
140
+ */
141
+ async listMedia(query) {
142
+ const res = await this.request('GET', '/media', undefined, query);
143
+ const envelope = res?.data ?? {};
144
+ const docs = envelope.items ?? envelope.data ?? [];
145
+ const total = envelope.total ?? docs.length;
146
+ const page = envelope.page ?? query?.page ?? 1;
147
+ const pageSize = envelope.pageSize ?? query?.pageSize ?? docs.length;
148
+ const totalPages = envelope.totalPages ?? (pageSize > 0 ? Math.ceil(total / pageSize) : 1);
149
+ return { data: { docs, total, page, pageSize, totalPages } };
150
+ }
151
+ /**
152
+ * Upload a binary asset to the CMS media library. Bytes are accepted as a
153
+ * `Uint8Array` / `Buffer` (the MCP tool wrapper handles base64 decoding
154
+ * from the JSON-RPC layer, but the client surface stays binary).
155
+ *
156
+ * The server enforces a 50 MB hard cap and validates magic bytes against
157
+ * the declared `mimeType` — uploading a `.exe` with `image/png` will be
158
+ * rejected with HTTP 415. SVGs are sanitised server-side. Pass
159
+ * `skipOptimize: true` to keep the original bytes; by default JPEG / PNG
160
+ * are recompressed to WebP.
161
+ */
162
+ uploadMedia(input) {
163
+ const form = new FormData();
164
+ // Copy bytes into a freshly-allocated ArrayBuffer. `Buffer` (and a
165
+ // `Uint8Array` constructed from an externally-provided ArrayBufferLike)
166
+ // can be backed by a SharedArrayBuffer in some runtimes, which the
167
+ // strict `Blob` constructor signature refuses (`Uint8Array<ArrayBuffer>`
168
+ // only). The copy is O(n) and negligible compared to the network upload.
169
+ const ab = new ArrayBuffer(input.bytes.byteLength);
170
+ new Uint8Array(ab).set(input.bytes);
171
+ const blob = new Blob([ab], { type: input.mimeType });
172
+ form.append('file', blob, input.filename);
173
+ if (input.skipOptimize)
174
+ form.append('skipOptimize', 'true');
175
+ if (input.forceUpload)
176
+ form.append('forceUpload', 'true');
177
+ return this.requestMultipart('/media/upload', form);
178
+ }
179
+ /**
180
+ * Update media metadata. Only the supplied fields are touched. `alt` maps
181
+ * to the DB column `altText`; `focalX` / `focalY` (range 0..1) drive
182
+ * smart-crop on the front end. `caption`, `description`, and `focusKeyword`
183
+ * round out the fields `calculateMediaSeoScore` reads, so an agent can take
184
+ * an asset all the way to a passing media SEO grade.
185
+ */
186
+ updateMedia(id, body) {
187
+ return this.request('PUT', `/media/${encodeURIComponent(id)}`, body);
188
+ }
189
+ /**
190
+ * Vision-backed alt text for a media library image. Requires the AI plugin,
191
+ * a vision-capable provider, and `ai.features.mediaAltText` enabled.
192
+ * Returns a suggestion — call `update_media({ alt })` to publish it.
193
+ */
194
+ generateMediaAlt(body) {
195
+ return this.request('POST', '/ai/media-metadata', body);
196
+ }
197
+ /** Delete a media item AND its underlying blob storage object (best-effort). */
198
+ deleteMedia(id) {
199
+ return this.request('DELETE', `/media/${encodeURIComponent(id)}`);
200
+ }
201
+ /**
202
+ * Bulk-update media metadata. Per-item patches (not a shared patch). Returns
203
+ * per-id success/failure so a large backfill can land partial results (#717).
204
+ */
205
+ bulkUpdateMedia(items) {
206
+ return this.request('POST', '/media/bulk-update', { items });
207
+ }
208
+ /**
209
+ * Bulk-delete media items and their blobs (best-effort). Requires
210
+ * `confirm: true` at the MCP tool layer; the API also requires it.
211
+ */
212
+ bulkDeleteMedia(ids, confirm) {
213
+ return this.request('POST', '/media/bulk-delete', { ids, confirm });
214
+ }
215
+ /**
216
+ * Recompress an existing image to WebP. Returns the updated media row plus
217
+ * an `optimization` summary (`{ originalSize, optimizedSize, savings, ... }`).
218
+ * Already-WebP files and non-images short-circuit with `alreadyOptimized: true`.
219
+ */
220
+ optimizeMedia(id) {
221
+ return this.request('POST', `/media/${encodeURIComponent(id)}/optimize`);
222
+ }
223
+ /**
224
+ * Move a media item into a folder, or out of every folder by passing
225
+ * `folderId: null`. The server validates that the target folder lives in
226
+ * the `media` scope.
227
+ */
228
+ moveMediaToFolder(id, folderId) {
229
+ return this.request('PUT', `/media/${encodeURIComponent(id)}/folder`, { folderId });
230
+ }
231
+ // ---- Folders ----
232
+ /**
233
+ * List folders in a given scope as a nested tree (`{ ...folder, children: [...] }`).
234
+ * Scopes today: `'media'` for the media library, `'documents'` / `'collections'`
235
+ * for document folders. The `scope` query param is required by the server.
236
+ */
237
+ listFolders(scope) {
238
+ return this.request('GET', '/folders', undefined, { scope });
239
+ }
240
+ /**
241
+ * Create a folder. If `parentId` is supplied, it must already exist AND live
242
+ * in the same scope — the server refuses cross-scope reparenting.
243
+ */
244
+ createFolder(body) {
245
+ return this.request('POST', '/folders', body);
246
+ }
247
+ /**
248
+ * Update a folder: rename (`name`), reparent (`parentId`; pass `null` to
249
+ * promote to root), or reorder (`position`). The server prevents self-
250
+ * parenting and cross-scope reparenting.
251
+ */
252
+ updateFolder(id, body) {
253
+ return this.request('PUT', `/folders/${encodeURIComponent(id)}`, body);
254
+ }
255
+ /**
256
+ * Delete a folder. Refuses with HTTP 409 if the folder still contains
257
+ * documents, media, or sub-folders — the caller must move or delete the
258
+ * contents first.
259
+ */
260
+ deleteFolder(id) {
261
+ return this.request('DELETE', `/folders/${encodeURIComponent(id)}`);
262
+ }
263
+ /**
264
+ * Move a document into a folder, or remove it from every folder by passing
265
+ * `folderId: null`. Server validates that the target folder is in the
266
+ * documents / collections scope.
267
+ */
268
+ moveDocumentToFolder(id, folderId) {
269
+ return this.request('PUT', `/documents/${encodeURIComponent(id)}/folder`, { folderId });
270
+ }
271
+ // ---- Version history ----
272
+ /**
273
+ * List immutable snapshots of a document, newest first. Each `Version`
274
+ * carries the full `data` payload at the time of write plus the user who
275
+ * made the change (joined via `relationLoadStrategy: 'join'` server-side).
276
+ */
277
+ listVersions(collection, documentId, query) {
278
+ return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/versions`, undefined, query);
279
+ }
280
+ /**
281
+ * Roll a document back to a prior version. The restore goes through the
282
+ * normal `updateDocument` path so it: (a) runs validation hooks, (b)
283
+ * creates a NEW version row capturing the restore (i.e. it never rewrites
284
+ * history), and (c) emits a `version_restored` event to `prenta_audit_logs`.
285
+ */
286
+ restoreVersion(collection, documentId, versionId) {
287
+ return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/versions/${encodeURIComponent(versionId)}/restore`);
288
+ }
289
+ // ---- Scheduling ----
290
+ /**
291
+ * Schedule a document to publish and/or unpublish at a future timestamp.
292
+ * `scheduledAt` (publish) forces `status` → `SCHEDULED` so the cron picks
293
+ * it up; `scheduledUnpublishAt` leaves the current status alone. The
294
+ * server requires at least one of the two, both must be ISO 8601 strings
295
+ * AND in the future, and `scheduledUnpublishAt` must be after
296
+ * `scheduledAt` when both are provided.
297
+ */
298
+ schedulePublish(collection, documentId, body) {
299
+ return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/schedule`, body);
300
+ }
301
+ /**
302
+ * Cancel any pending publish + unpublish schedule for the document. If
303
+ * the document was in `SCHEDULED` (not yet published) the server drops
304
+ * its status back to `DRAFT`. Already-published documents stay live.
305
+ */
306
+ unschedulePublish(collection, documentId) {
307
+ return this.request('DELETE', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/schedule`);
308
+ }
309
+ // ---- Editorial workflow ----
310
+ /**
311
+ * Read the workflow state of a document. Returns the current stage
312
+ * (`DRAFT` | `IN_REVIEW` | `APPROVED` | `PUBLISHED` | `ARCHIVED`), the
313
+ * stages the *current* user is allowed to transition to next (derived
314
+ * from their role), plus the reviewer ID + note if set.
315
+ */
316
+ getWorkflowState(collection, documentId) {
317
+ return this.request('GET', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/workflow`);
318
+ }
319
+ /**
320
+ * Move a document to a new workflow stage. The server validates that the
321
+ * transition is allowed from the current stage AND that the caller's
322
+ * role permits it; if either check fails it returns HTTP 400 with the
323
+ * reason in the error body.
324
+ */
325
+ transitionWorkflow(collection, documentId, body) {
326
+ return this.request('POST', `/collections/${encodeURIComponent(collection)}/${encodeURIComponent(documentId)}/workflow`, body);
327
+ }
328
+ // ---- Comments ----
329
+ /**
330
+ * List the comment thread attached to a document. By default returns only
331
+ * unresolved, non-deleted comments. `includeResolved` widens that to
332
+ * include resolved threads; `includeDeleted` is admin-only and surfaces
333
+ * soft-deleted moderation history (returns HTTP 403 for non-admin keys).
334
+ */
335
+ listComments(documentId, query) {
336
+ // Server returns `{ data: CommentDTO[] }` — a plain array, NOT an
337
+ // envelope like `{ data: { comments, total, hasMore } }`. Encoding
338
+ // the array shape in the TS return type protects against future
339
+ // engineers / AI assistants accidentally re-wrapping it on either
340
+ // side of the wire.
341
+ return this.request('GET', `/documents/${encodeURIComponent(documentId)}/comments`, undefined,
342
+ // The server reads these as URL query strings. Booleans become
343
+ // 'true'/'false' via String(); the handler matches exactly
344
+ // `=== 'true'` so other truthy values are ignored.
345
+ query);
346
+ }
347
+ /**
348
+ * Create a comment on a document. `anchor` is optional and lets the
349
+ * caller pin the comment to a specific text range (used by the
350
+ * Prosemirror selection UI in the admin). `parentId` is set for thread
351
+ * replies. The server emits a `comment_created` event to
352
+ * `prenta_audit_logs` and fires a fire-and-forget notification dispatch.
353
+ */
354
+ createComment(documentId, body) {
355
+ return this.request('POST', `/documents/${encodeURIComponent(documentId)}/comments`, body);
356
+ }
357
+ // ---- Page builder accessibility ----
358
+ /**
359
+ * Run a deterministic accessibility audit over a page-builder tree.
360
+ * Detects missing alt text, heading-level skips, missing form labels,
361
+ * missing ARIA attributes, and undersized touch targets. Returns an
362
+ * array of `A11yIssue` entries — each carries `autoFixable: boolean`
363
+ * so the caller can pair this with `fixPageA11y` for one-click cleanup.
364
+ *
365
+ * Pass the tree exactly as returned by a document's `data.tree` field;
366
+ * no normalisation needed.
367
+ */
368
+ auditPageA11y(tree) {
369
+ return this.request('POST', '/page-builder/audit-a11y', { tree });
370
+ }
371
+ /**
372
+ * Auto-fix the accessibility issues marked `autoFixable: true` from
373
+ * `audit_page_a11y` and return the mutated tree plus a report
374
+ * (`fixedCount` / `remainingCount`). Non-auto-fixable issues are
375
+ * returned in `report.issues` for human follow-up. The handler emits a
376
+ * `settings_changed` audit event with the fix counts.
377
+ */
378
+ fixPageA11y(tree) {
379
+ return this.request('POST', '/page-builder/fix-a11y', { tree });
380
+ }
381
+ // ---- Comment thread management (PR E) ----
382
+ /**
383
+ * Edit a comment body. Only the author can edit; the server returns a
384
+ * `forbidden` error otherwise. Resolved threads remain editable so a
385
+ * moderator can append context.
386
+ */
387
+ editComment(id, body) {
388
+ return this.request('PATCH', `/comments/${encodeURIComponent(id)}`, {
389
+ body,
390
+ });
391
+ }
392
+ /** Mark a comment thread resolved. Requires write-role; emits `comment_resolved` audit event. */
393
+ resolveComment(id) {
394
+ return this.request('POST', `/comments/${encodeURIComponent(id)}/resolve`);
395
+ }
396
+ /** Re-open a previously resolved comment thread. Requires write-role. */
397
+ reopenComment(id) {
398
+ return this.request('POST', `/comments/${encodeURIComponent(id)}/reopen`);
399
+ }
400
+ /**
401
+ * Soft-delete a comment. Server returns `forbidden` unless the caller is
402
+ * the author OR an admin. Soft-deleted comments are admin-only viewable
403
+ * via `list_comments({ includeDeleted: true })`.
404
+ */
405
+ deleteComment(id) {
406
+ return this.request('DELETE', `/comments/${encodeURIComponent(id)}`);
407
+ }
408
+ // ---- Redirects (PR E) ----
409
+ /** List all redirects, newest first. */
410
+ listRedirects() {
411
+ return this.request('GET', '/redirects');
412
+ }
413
+ /**
414
+ * Create a redirect. Admin-only. The server enforces an open-redirect
415
+ * defence — absolute destinations must point at a host that's either
416
+ * the configured `NEXT_PUBLIC_SITE_URL` or listed in
417
+ * `redirects.allowedExternalHosts`. Relative destinations (`/foo`) are
418
+ * always allowed. `statusCode` accepts 301 / 302 / 307 / 308 only and
419
+ * defaults to 301 on the server.
420
+ */
421
+ createRedirect(body) {
422
+ return this.request('POST', '/redirects', body);
423
+ }
424
+ /** Delete a redirect by ID. Admin-only. */
425
+ deleteRedirect(id) {
426
+ return this.request('DELETE', `/redirects/${encodeURIComponent(id)}`);
427
+ }
428
+ // ---- Page templates + saved sections (PR E) ----
429
+ /**
430
+ * List page templates. Admin-only. Optional `category` filter narrows
431
+ * to a single template category. `collection` reorders the result so
432
+ * the suggested templates for that collection bubble to the top
433
+ * (driven by collection presets) and annotates each row with
434
+ * `suggested` / `suggestionRank`. When `collection` is supplied the
435
+ * response also carries a top-level `suggestion` summary.
436
+ */
437
+ listPageTemplates(query) {
438
+ return this.request('GET', '/page-templates', undefined, query);
439
+ }
440
+ /** Fetch a single page template by ID. Admin-only. */
441
+ getPageTemplate(id) {
442
+ return this.request('GET', `/page-templates/${encodeURIComponent(id)}`);
443
+ }
444
+ /** List saved page-builder sections. Admin-only. Optional `category` filter. */
445
+ listSavedSections(query) {
446
+ return this.request('GET', '/saved-sections', undefined, query);
447
+ }
448
+ // ---- SEO aggregate (PR E) ----
449
+ /**
450
+ * Run a site-wide SEO scan over every published document. **Expensive**
451
+ * — touches every published row. Returns
452
+ * `{ total, pagesWithIssues, totalProblems, issues[] }` where each
453
+ * issue carries `{ documentId, title, slug, problems[] }`. The audit
454
+ * recommended gating this behind an explicit confirmation arg; the
455
+ * tool wrapper enforces that, the client is intentionally a thin
456
+ * pass-through.
457
+ */
458
+ seoScanRun() {
459
+ return this.request('POST', '/seo/scan');
460
+ }
461
+ /**
462
+ * Aggregate SEO health for the dashboard — counts of missing meta
463
+ * descriptions / alt text plus a `topContent` array of recent
464
+ * published docs with light SEO scores. Cheap; safe to call on
465
+ * dashboard refresh.
466
+ */
467
+ seoSummary() {
468
+ return this.request('GET', '/seo/summary');
469
+ }
470
+ // ---- SEO Operations Center (audit runs + issues + redirects) ----
471
+ /**
472
+ * Aggregated Overview-tab payload: site score + grade + delta, four
473
+ * headline metrics, open-issue summary, priority-ranked quick wins, and
474
+ * (gated) search-performance rows. Cheap read; derived from the last
475
+ * completed audit run.
476
+ */
477
+ seoOverview() {
478
+ return this.request('GET', '/seo/overview');
479
+ }
480
+ /**
481
+ * Kick off a persistent SEO audit run. `scope` controls breadth:
482
+ * `full` (everything), `content`, `technical`, or `redirect`. Returns the
483
+ * created `SEOAuditRun` (status, issue counts, before/after site score).
484
+ * Heavier than `seoSummary` — persists issues for the Audit tab.
485
+ */
486
+ runSeoAudit(scope = 'full') {
487
+ return this.request('POST', '/seo/audit', { scope });
488
+ }
489
+ /** List persisted SEO audit runs, newest first. */
490
+ listSeoAuditRuns() {
491
+ return this.request('GET', '/seo/audits');
492
+ }
493
+ /**
494
+ * List SEO issues with optional filters. `severity` (critical/warning/info),
495
+ * `category` (content/technical/redirects/...), `status`
496
+ * (open/ignored/resolved/recurring), and `auditRunId` narrow the result.
497
+ */
498
+ listSeoIssues(query) {
499
+ return this.request('GET', '/seo/issues', undefined, query);
500
+ }
501
+ /** Fetch a single SEO issue by ID. */
502
+ getSeoIssue(id) {
503
+ return this.request('GET', `/seo/issues/${encodeURIComponent(id)}`);
504
+ }
505
+ /** Mark an SEO issue resolved / ignored / reopened. */
506
+ setSeoIssueStatus(id, action) {
507
+ return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/${action}`);
508
+ }
509
+ /**
510
+ * Export a markdown SEO fix brief for dev-team handoff. Embeds cached AI
511
+ * content briefs and topic research when available.
512
+ */
513
+ exportSeoFixBrief(query) {
514
+ const params = {};
515
+ if (query?.severity)
516
+ params.severity = query.severity;
517
+ if (query?.status)
518
+ params.status = query.status;
519
+ if (query?.auditRunId)
520
+ params.auditRunId = query.auditRunId;
521
+ if (query?.manualOnly === false)
522
+ params.manualOnly = 'false';
523
+ if (query?.includeContentBriefs === false)
524
+ params.includeContentBriefs = 'false';
525
+ if (query?.includeContentResearch === false)
526
+ params.includeContentResearch = 'false';
527
+ return this.request('GET', '/seo/issues/export-brief', undefined, params);
528
+ }
529
+ /** Preview a deterministic or AI-assisted inline metadata/schema fix. */
530
+ suggestSeoIssueFix(id) {
531
+ return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/suggest-fix`);
532
+ }
533
+ /** Apply a previously suggested inline fix (requires matching fingerprint). */
534
+ applySeoIssueFix(id, fingerprint) {
535
+ return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/apply-fix`, {
536
+ fingerprint,
537
+ });
538
+ }
539
+ /** Load a cached AI content brief for a content/heading issue. */
540
+ getSeoIssueContentBrief(id) {
541
+ return this.request('GET', `/seo/issues/${encodeURIComponent(id)}/content-brief`);
542
+ }
543
+ /** Generate (or regenerate) an AI content brief for manual content fixes. */
544
+ generateSeoIssueContentBrief(id) {
545
+ return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/content-brief`);
546
+ }
547
+ /**
548
+ * Run topic research (thin-content) then generate a content brief in one step.
549
+ * Optional competitor URLs are fetched SSRF-safe (max 3).
550
+ */
551
+ researchAndBriefSeoIssue(id, competitorUrls) {
552
+ return this.request('POST', `/seo/issues/${encodeURIComponent(id)}/research-and-brief`, { competitorUrls: competitorUrls?.filter(Boolean) });
553
+ }
554
+ /** Batch-generate content briefs for open issues (skips fresh cached briefs). */
555
+ batchGenerateSeoContentBriefs(body) {
556
+ return this.request('POST', '/seo/issues/batch-content-briefs', body ?? {});
557
+ }
558
+ /** List redirect rules with hit counts + stale flags. */
559
+ listRedirectRules() {
560
+ return this.request('GET', '/seo/redirects');
561
+ }
562
+ /** Redirect overview counts (active / stale / 404s / chains). */
563
+ redirectOverview() {
564
+ return this.request('GET', '/seo/redirects/overview');
565
+ }
566
+ /** Detected redirect chains and loops with safe-flatten suggestions. */
567
+ redirectChains() {
568
+ return this.request('GET', '/seo/redirects/chains');
569
+ }
570
+ /** Flatten a redirect chain to a single hop (updates the first rule's destination). */
571
+ flattenRedirectChain(chainId) {
572
+ return this.request('POST', '/seo/redirects/flatten', { chainId });
573
+ }
574
+ /** AI-generated 404 recovery suggestions awaiting review. */
575
+ listRedirectSuggestions() {
576
+ return this.request('GET', '/seo/redirects/suggestions');
577
+ }
578
+ getRedirectRecoverySettings() {
579
+ return this.request('GET', '/seo/redirects/recovery-settings');
580
+ }
581
+ updateRedirectRecoverySettings(body) {
582
+ return this.request('PUT', '/seo/redirects/recovery-settings', body);
583
+ }
584
+ /** Scan tracked 404s and generate recovery redirect suggestions. */
585
+ generateRedirectRecoverySuggestions() {
586
+ return this.request('POST', '/seo/redirects/suggest');
587
+ }
588
+ acceptRedirectSuggestion(id) {
589
+ return this.request('POST', `/seo/redirects/suggestions/${encodeURIComponent(id)}/accept`);
590
+ }
591
+ dismissRedirectSuggestion(id) {
592
+ return this.request('POST', `/seo/redirects/suggestions/${encodeURIComponent(id)}/dismiss`);
593
+ }
594
+ exportRedirectsCsv() {
595
+ return this.request('GET', '/seo/redirects/export');
596
+ }
597
+ importRedirectsCsv(csv) {
598
+ return this.request('POST', '/seo/redirects/import', { csv });
599
+ }
600
+ updateRedirectRule(id, body) {
601
+ return this.request('PUT', `/seo/redirects/${encodeURIComponent(id)}`, body);
602
+ }
603
+ /** Site-wide internal/external link graph snapshot (Links tab). */
604
+ seoLinkSnapshot() {
605
+ return this.request('GET', '/seo/links/snapshot');
606
+ }
607
+ /** Broken / suspicious outbound link scan (bounded batch). */
608
+ seoLinkHealth() {
609
+ return this.request('GET', '/seo/link-health');
610
+ }
611
+ seoLinkSuggest(body) {
612
+ return this.request('POST', '/seo/links/suggest', body);
613
+ }
614
+ /** Build or refresh the AI link index used for contextual suggestions. */
615
+ seoLinkAiIndex() {
616
+ return this.request('POST', '/seo/links/ai-index');
617
+ }
618
+ getCrawlSettings() {
619
+ return this.request('GET', '/seo/crawl-settings');
620
+ }
621
+ updateCrawlSettings(body) {
622
+ return this.request('PUT', '/seo/crawl-settings', body);
623
+ }
624
+ getRobotsTxt() {
625
+ return this.request('GET', '/seo/robots');
626
+ }
627
+ updateRobotsTxt(body) {
628
+ return this.request('PUT', '/seo/robots', { body });
629
+ }
630
+ getSitemapStatus() {
631
+ return this.request('GET', '/seo/sitemap/status');
632
+ }
633
+ regenerateSitemap() {
634
+ return this.request('POST', '/seo/sitemap/regenerate');
635
+ }
636
+ listSeoContentRecords() {
637
+ return this.request('GET', '/seo/content');
638
+ }
639
+ getSeoContentRecord(entityType, entityId) {
640
+ return this.request('GET', `/seo/content/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`);
641
+ }
642
+ updateSeoContentFields(entityType, entityId, body) {
643
+ return this.request('PUT', `/seo/content/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`, body);
644
+ }
645
+ aiSeoGenerateField(body) {
646
+ return this.request('POST', '/ai/seo/generate-field', body);
647
+ }
648
+ aiSeoExplainIssue(issueId) {
649
+ return this.request('POST', '/ai/seo/explain-issue', { issueId });
650
+ }
651
+ aiSeoGenerateSchema(body) {
652
+ return this.request('POST', '/ai/seo/generate-schema', body);
653
+ }
654
+ aiSeoSummarize() {
655
+ return this.request('POST', '/ai/seo/summarize');
656
+ }
657
+ // ---- Forms + health (PR E) ----
658
+ /**
659
+ * List submissions for a form. **PII-sensitive** — form submissions
660
+ * typically contain names, emails, free-form messages, and any other
661
+ * fields the form collects. Requires write-role on the API key AND
662
+ * an explicit `formId` (no enumeration). Paged.
663
+ */
664
+ listFormSubmissions(formId, query) {
665
+ return this.request('GET', `/forms/${encodeURIComponent(formId)}/submissions`, undefined, query);
666
+ }
667
+ /**
668
+ * List entries from the submissions inbox (decrypted PII, attribution,
669
+ * read/star/archive state). `formId` is required at the tool boundary to
670
+ * prevent accidental cross-form enumeration. Returns
671
+ * `{ data: { entries, total, page, pageSize } }`.
672
+ */
673
+ listFormEntries(query) {
674
+ const params = {
675
+ formId: query.formId,
676
+ page: query.page,
677
+ pageSize: query.pageSize,
678
+ spamStatus: query.spamStatus,
679
+ unread: query.unread === undefined ? undefined : String(query.unread),
680
+ starred: query.starred === undefined ? undefined : String(query.starred),
681
+ archived: query.archived === undefined ? undefined : String(query.archived),
682
+ };
683
+ return this.request('GET', '/forms/entries', undefined, params);
684
+ }
685
+ /**
686
+ * Read a single entry plus the schema version it was captured against.
687
+ * Returns `{ data: { entry, fields } }`. The server audits this read as
688
+ * `form.entry.viewed` because it exposes decrypted PII.
689
+ */
690
+ getFormEntry(entryId) {
691
+ return this.request('GET', `/forms/entries/${encodeURIComponent(entryId)}`);
692
+ }
693
+ // ---- Form authoring (WS-C C1) ----
694
+ //
695
+ // The read-only form tools above (submissions / entries) let an agent
696
+ // INSPECT a form's data. These let it AUTHOR the form itself: create the
697
+ // definition, edit its metadata, version its field schema, manage the
698
+ // inbox state, and export. All map to the `/forms/*` admin endpoints,
699
+ // which require a write-role API key server-side.
700
+ /**
701
+ * List form definitions (NOT submissions). Returns
702
+ * `{ data: Form[], total, page, pageSize }` where each form carries its
703
+ * field schema plus the legacy `fieldCount` / `submissions` aliases. Use
704
+ * this to discover form IDs before reading entries or editing a schema.
705
+ */
706
+ listForms(query) {
707
+ return this.request('GET', '/forms', undefined, query);
708
+ }
709
+ /** Fetch a single form definition by ID (metadata + field schema). */
710
+ getForm(id) {
711
+ return this.request('GET', `/forms/${encodeURIComponent(id)}`);
712
+ }
713
+ /**
714
+ * Create a form. Only `name` is required server-side; the slug is derived
715
+ * from `name` when omitted. `fields` is the initial field schema (see
716
+ * `saveFormSchema` for the field shape) — omit to start with an empty
717
+ * form and add the schema in a second call.
718
+ */
719
+ createForm(body) {
720
+ return this.request('POST', '/forms', body);
721
+ }
722
+ /**
723
+ * Update a form's metadata. Only the supplied fields change. Note the
724
+ * field SCHEMA is NOT editable here — use `saveFormSchema` so each schema
725
+ * change is versioned.
726
+ */
727
+ updateForm(id, body) {
728
+ return this.request('PATCH', `/forms/${encodeURIComponent(id)}`, body);
729
+ }
730
+ /**
731
+ * Delete a form. By default the server refuses (HTTP 409) when the form
732
+ * has submissions; pass `force: true` to delete it and its entries.
733
+ */
734
+ deleteForm(id, force) {
735
+ return this.request('DELETE', `/forms/${encodeURIComponent(id)}`, undefined, force ? { force: 'true' } : undefined);
736
+ }
737
+ /** Duplicate a form (new draft copy with a fresh slug). Returns the new form. */
738
+ duplicateForm(id) {
739
+ return this.request('POST', `/forms/${encodeURIComponent(id)}/duplicate`);
740
+ }
741
+ /**
742
+ * Read a form's field schema plus its version history:
743
+ * `{ fields, activeVersionId, versions }`. Call before `saveFormSchema`
744
+ * to avoid clobbering the live schema.
745
+ */
746
+ getFormSchema(id) {
747
+ return this.request('GET', `/forms/${encodeURIComponent(id)}/schema`);
748
+ }
749
+ /**
750
+ * Replace a form's field schema. Creates a NEW schema version (history is
751
+ * never rewritten) and points the form at it. `fields` is the full
752
+ * ordered field list — partial patches are not supported (read with
753
+ * `getFormSchema`, mutate, write back the whole array).
754
+ */
755
+ saveFormSchema(id, fields, notes) {
756
+ const body = { fields };
757
+ if (notes !== undefined)
758
+ body.notes = notes;
759
+ return this.request('PUT', `/forms/${encodeURIComponent(id)}/schema`, body);
760
+ }
761
+ /**
762
+ * Update inbox state on a single submission entry (mark read/unread,
763
+ * star, archive). Does not touch the captured field values.
764
+ */
765
+ updateFormEntry(entryId, patch) {
766
+ return this.request('PATCH', `/forms/entries/${encodeURIComponent(entryId)}`, patch);
767
+ }
768
+ /** Apply the same inbox-state patch to many entries at once. Returns `{ updated }`. */
769
+ bulkUpdateFormEntries(ids, patch) {
770
+ return this.request('POST', '/forms/entries/bulk', {
771
+ ids,
772
+ patch,
773
+ });
774
+ }
775
+ /** Permanently delete a submission entry (and its decrypted PII). Destructive. */
776
+ deleteFormEntry(entryId) {
777
+ return this.request('DELETE', `/forms/entries/${encodeURIComponent(entryId)}`);
778
+ }
779
+ /**
780
+ * Export every submission for a form as CSV. The endpoint returns a raw
781
+ * `text/csv` body (NOT a JSON envelope), so we read it as text rather
782
+ * than via `request()`. **Bulk PII egress** — the server records a
783
+ * `form.entries.exported` audit event. Returns the CSV text plus the
784
+ * server-suggested filename.
785
+ */
786
+ async exportFormEntries(formId) {
787
+ const url = `${this.baseUrl}/api/cms/forms/entries/export?formId=${encodeURIComponent(formId)}`;
788
+ const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers() });
789
+ const text = await res.text();
790
+ if (!res.ok) {
791
+ // Error bodies ARE JSON even though the success body is CSV.
792
+ let msg = `Prenta API GET /forms/entries/export failed: ${res.status}`;
793
+ try {
794
+ const parsed = JSON.parse(text);
795
+ if (parsed?.error)
796
+ msg = parsed.error;
797
+ }
798
+ catch {
799
+ /* non-JSON error body — keep the status-based message */
800
+ }
801
+ throw new PrentaClientError(msg, res.status);
802
+ }
803
+ const disposition = res.headers.get('content-disposition') ?? '';
804
+ const match = /filename="?([^"]+)"?/i.exec(disposition);
805
+ return { filename: match?.[1] ?? `${formId}-entries.csv`, csv: text };
806
+ }
807
+ /**
808
+ * Read the deployment health of the CMS. Unauthenticated on the server
809
+ * side (still safe to call — exposes only operational status, no
810
+ * tenant data). Returns `{ status, version, env, models,
811
+ * databaseConnected, secretConfigured }`.
812
+ *
813
+ * Recommended as an agent's first call: a `degraded` / `unhealthy`
814
+ * status surfaces operator-level config problems early instead of
815
+ * surfacing as cryptic errors deep in a workflow.
816
+ */
817
+ healthCheck() {
818
+ return this.request('GET', '/health');
819
+ }
820
+ // ---- Page builder ----
821
+ generatePage(input) {
822
+ return this.request('POST', '/page-builder/generate', input);
823
+ }
824
+ generateBlock(input) {
825
+ return this.request('POST', '/page-builder/generate-block', input);
826
+ }
827
+ /** One-shot: generate AI page AND persist as a draft (or published) document. */
828
+ createPageFromPrompt(input) {
829
+ return this.request('POST', '/page-builder/create', input);
830
+ }
831
+ /**
832
+ * Generic AI content authoring for any non-page collection. Used by the
833
+ * per-type MCP tools (create_blog_post, create_case_study, etc.) and by the
834
+ * generic create_in_collection tool.
835
+ */
836
+ createDocumentFromPrompt(collection, input) {
837
+ return this.request('POST', `/collections/${encodeURIComponent(collection)}/ai-create`, input);
838
+ }
839
+ // ---- AI quality (Slice F) ----
840
+ /**
841
+ * Run the composite content audit (SEO + readability + a11y + freshness)
842
+ * against a stored document or ad-hoc content. Deterministic — no LLM call,
843
+ * safe to invoke on every save.
844
+ */
845
+ auditDocument(input) {
846
+ return this.request('POST', '/ai/audit-document', input);
847
+ }
848
+ /**
849
+ * Inline AI co-author. Wraps the plugin-ai writing assistant
850
+ * (rewrite/expand/compress/proofread) behind a single HTTP surface.
851
+ */
852
+ coauthor(input) {
853
+ return this.request('POST', '/ai/coauthor', input);
854
+ }
855
+ /**
856
+ * Suggest internal links from a body of text against the published CMS
857
+ * documents the API key can read. Optionally scope to a single collection.
858
+ */
859
+ suggestInternalLinks(input) {
860
+ return this.request('POST', '/ai/suggest-internal-links', input);
861
+ }
862
+ // ---- Schema discovery ----
863
+ /** Fetch the auto-generated OpenAPI spec, useful for tools listing collections + fields. */
864
+ getOpenApi() {
865
+ return this.request('GET', '/openapi.json');
866
+ }
867
+ /**
868
+ * List configured collections via the dedicated `/collections`
869
+ * discovery endpoint. Returns `{ data: CollectionMeta[] }` with one
870
+ * entry per configured collection (`slug`, `labels`, `type`, `fields`,
871
+ * `fieldCount`, `hidden`). Sourced directly from `getPrentaConfig()`
872
+ * server-side, so it stays accurate even when OpenAPI spec generation
873
+ * is missing routes. Newer endpoint — older CMS servers may return
874
+ * 404; `list_collections` falls back to OpenAPI in that case.
875
+ */
876
+ listCollections() {
877
+ return this.request('GET', '/collections');
878
+ }
879
+ /**
880
+ * Lightweight user directory lookup used by the comment mention
881
+ * picker. Returns up to 10 active users whose name OR email matches
882
+ * `query` (case-insensitive `contains`). Empty / whitespace
883
+ * queries return an empty array — the endpoint deliberately won't
884
+ * dump the whole user table.
885
+ *
886
+ * Exposed via the `search_users` MCP tool so AI agents can resolve
887
+ * `@<userId>` mentions when drafting comments.
888
+ */
889
+ searchUsers(query) {
890
+ return this.request('GET', '/users/search', undefined, { q: query });
891
+ }
892
+ /** Resolve a public URL path to its document. */
893
+ resolveUrl(path) {
894
+ return this.request('GET', '/resolve', undefined, { path });
895
+ }
896
+ /**
897
+ * Soft-check navigation item URLs against published documents + redirects.
898
+ * Returns warnings only — never blocks save. Requires cms-core with
899
+ * `POST /navigations/validate-targets`.
900
+ */
901
+ validateNavigationTargets(items) {
902
+ return this.request('POST', '/navigations/validate-targets', { items });
903
+ }
904
+ // ---- SEO analysis (per-document) ----
905
+ //
906
+ // These mirror the five GET /seo/<scope>/:documentId endpoints in
907
+ // `packages/cms-core/src/api/handlers.ts`. Unlike most other CMS endpoints,
908
+ // these return the raw analysis object directly (no `{ data: ... }`
909
+ // envelope) — we keep that shape so MCP tools forward the API response as-is.
910
+ /** Run the deterministic SEO content analysis against a stored document. */
911
+ seoAnalyzeDocument(documentId) {
912
+ return this.request('GET', `/seo/analysis/${encodeURIComponent(documentId)}`);
913
+ }
914
+ /** Compute a Flesch-Kincaid / grade-level readability score for a document body. */
915
+ seoReadability(documentId) {
916
+ return this.request('GET', `/seo/readability/${encodeURIComponent(documentId)}`);
917
+ }
918
+ /**
919
+ * Suggest internal-link targets for a document. Title-keyword-based (extracts
920
+ * up to 5 keywords > 3 chars from the doc title, matches them against
921
+ * other PUBLISHED documents). Returns up to 10 suggestions. Distinct from
922
+ * `suggestInternalLinks` above, which is content-based and freeform.
923
+ *
924
+ * `title` and `slug` are typed nullable because the underlying Prisma
925
+ * `Document` schema makes them optional (`String?`) — the API forwards
926
+ * the raw column values, so callers must handle nulls before rendering
927
+ * anchor text or building target URLs.
928
+ */
929
+ seoInternalLinks(documentId) {
930
+ return this.request('GET', `/seo/internal-links/${encodeURIComponent(documentId)}`);
931
+ }
932
+ /** Build the OG / Twitter / canonical meta-tag preview for a document. */
933
+ seoMetaPreview(documentId) {
934
+ return this.request('GET', `/seo/meta/${encodeURIComponent(documentId)}`);
935
+ }
936
+ /** Build the Schema.org JSON-LD graph for a document. */
937
+ seoSchemaGraph(documentId) {
938
+ return this.request('GET', `/seo/schema/${encodeURIComponent(documentId)}`);
939
+ }
940
+ // ---- Page sections (flat Page Editor model) ----
941
+ //
942
+ // These wrap the `/page-sections/*` endpoints. A page is a document in a
943
+ // collection that declares a `sections` JSON field; sections are an
944
+ // ordered list the visual Page Editor and these endpoints both read/write.
945
+ // Mutations go through the audited document update path server-side.
946
+ /**
947
+ * Discover the registered section types + their content/field schemas.
948
+ * Each entry carries `id`, `name`, `description`, `category`, `scope`,
949
+ * `fields` (the inspector schema), and `defaultContent` / `defaultSettings`.
950
+ *
951
+ * Pass `scope: 'page'` or `scope: 'post'` to list only the types usable on
952
+ * that surface (so an agent building a post template doesn't pick a
953
+ * page-only type, and vice versa).
954
+ */
955
+ listSectionTypes(scope) {
956
+ return this.request('GET', '/page-sections/types', undefined, scope ? { scope } : undefined);
957
+ }
958
+ /**
959
+ * Validate section content against its type schema. Deterministic (no
960
+ * LLM). Returns `{ valid, errors, warnings }` where each issue is
961
+ * `{ sectionType, field?, message }`. An unknown `sectionType` is a hard
962
+ * error (no registered renderer).
963
+ */
964
+ validateSection(input) {
965
+ return this.request('POST', '/page-sections/validate', input);
966
+ }
967
+ /** List a page's ordered sections (`data.sections`). */
968
+ listPageSections(collection, id) {
969
+ return this.request('GET', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
970
+ }
971
+ /**
972
+ * Add a section to a page. The server seeds registry defaults for
973
+ * `sectionType`, shallow-merges the supplied `content` / `settings`,
974
+ * generates an id, inserts at `position` (default end), and persists.
975
+ * Returns `{ section, sections, validation }`.
976
+ */
977
+ addPageSection(collection, id, body) {
978
+ return this.request('POST', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, body);
979
+ }
980
+ /**
981
+ * Update a section in place. Only supplied fields change; `content` /
982
+ * `settings` are shallow-merged. Returns `{ section, sections, validation }`.
983
+ */
984
+ updatePageSection(collection, id, sectionId, body) {
985
+ return this.request('PATCH', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/${encodeURIComponent(sectionId)}`, body);
986
+ }
987
+ /** Reorder a page's sections to an explicit id order. Returns the new array. */
988
+ reorderPageSections(collection, id, orderedIds) {
989
+ return this.request('POST', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/reorder`, { orderedIds });
990
+ }
991
+ /** Remove a section from a page. Returns the remaining sections. */
992
+ removePageSection(collection, id, sectionId) {
993
+ return this.request('DELETE', `/page-sections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/${encodeURIComponent(sectionId)}`);
994
+ }
995
+ // ---- Post templates ----
996
+ //
997
+ // A post type's template (one per type) stores the design-driven header
998
+ // layout + the default section list new posts inherit. Build a template
999
+ // from `scope: 'post'` section types, then authors/agents create posts
1000
+ // that start from it.
1001
+ /**
1002
+ * Read a post type's template: `{ postType, docId, header, sections }`.
1003
+ * Returns an unsaved default (centered header, empty section list) when
1004
+ * the type has no saved template yet (`docId: null`).
1005
+ */
1006
+ getPostTemplate(postType) {
1007
+ return this.request('GET', `/post-templates/${encodeURIComponent(postType)}`);
1008
+ }
1009
+ /**
1010
+ * Create or replace a post type's template. `header` is coerced to a full
1011
+ * header config (missing keys filled with defaults) and `sections` are
1012
+ * coerced (unknown section types dropped) so the stored template always
1013
+ * renders. Use only `scope: 'post'` section types in `sections`.
1014
+ */
1015
+ savePostTemplate(postType, body) {
1016
+ return this.request('PUT', `/post-templates/${encodeURIComponent(postType)}`, body);
1017
+ }
1018
+ }
1019
+ //# sourceMappingURL=client.js.map