@oneentry/mcp-platform-server 0.1.4 → 0.1.6

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.
@@ -0,0 +1,229 @@
1
+ import { z } from 'zod';
2
+ import { AuditLog } from '../api/audit.js';
3
+ import { buildUrl, RequestBuildError } from '../api/client.js';
4
+ import { checkLevel, decide } from '../api/policy.js';
5
+ import { shapeResponse } from '../api/shape.js';
6
+ import { fetchRemoteUpload, readLocalUpload, resolveUploadOperation, UploadSourceError, } from '../api/upload.js';
7
+ import { errorResult, jsonResult } from './result.js';
8
+ const targetingSchema = {
9
+ type: z
10
+ .string()
11
+ .optional()
12
+ .describe('What kind of file this is, e.g. "image" or "file". Copy the values cms_api_describe lists.'),
13
+ entity: z
14
+ .string()
15
+ .optional()
16
+ .describe('The entity kind the file belongs to, e.g. "product", "page", "block".'),
17
+ id: z.number().int().optional().describe('Id of the entity the file belongs to.'),
18
+ template: z
19
+ .number()
20
+ .int()
21
+ .optional()
22
+ .describe('NUMERIC id of a /template-previews record. Without a valid one no previewLink is ever generated and nothing reports it.'),
23
+ compress: z.boolean().optional().describe('Ask the instance to compress the image.'),
24
+ edit: z.boolean().optional().describe('Replace an existing file rather than adding one.'),
25
+ dryRun: z
26
+ .boolean()
27
+ .optional()
28
+ .describe('Do not send: return the resolved request, the resolved source and the policy decision.'),
29
+ confirm: z.string().optional().describe('Confirm token from a dryRun of this exact call.'),
30
+ };
31
+ const queryOf = (args) => {
32
+ const query = {};
33
+ for (const key of ['type', 'entity', 'id', 'template', 'compress', 'edit']) {
34
+ const value = args[key];
35
+ if (value !== undefined) {
36
+ query[key] = value;
37
+ }
38
+ }
39
+ return query;
40
+ };
41
+ const runUpload = async (params) => {
42
+ const { session, toolName, args, load, source } = params;
43
+ const { config, catalog, audit } = session.shared;
44
+ const operation = resolveUploadOperation(catalog);
45
+ if (!operation) {
46
+ return errorResult('This instance does not expose a file upload operation, so there is nothing to send to. ' +
47
+ 'Report it rather than trying another path.', { tool: toolName });
48
+ }
49
+ const query = queryOf(args);
50
+ const callArgs = { ...(Object.keys(query).length > 0 ? { query } : {}) };
51
+ const argsHash = AuditLog.hashArgs({ ...callArgs, body: source });
52
+ const auditBase = {
53
+ opId: operation.opId,
54
+ method: operation.method.toUpperCase(),
55
+ path: operation.path,
56
+ argsHash,
57
+ };
58
+ const levelDenial = checkLevel(operation, config.allow);
59
+ if (levelDenial) {
60
+ audit.record({ ...auditBase, outcome: 'denied' });
61
+ return errorResult(levelDenial.reason, {
62
+ tool: toolName,
63
+ policy: config.allow,
64
+ risk: operation.risk,
65
+ });
66
+ }
67
+ const identity = operation.permission ? await session.identity() : undefined;
68
+ if (identity) {
69
+ Object.assign(auditBase, { adminId: identity.id });
70
+ }
71
+ const confirmValid = args.confirm !== undefined && session.confirms.verify(args.confirm, operation.opId, callArgs);
72
+ if (args.confirm !== undefined && !confirmValid) {
73
+ return errorResult('Confirm token is expired, already used, or does not match these arguments. Re-run with dryRun: true to get a fresh one.', { tool: toolName });
74
+ }
75
+ const decision = decide({
76
+ operation,
77
+ allow: config.allow,
78
+ ...(identity ? { identity } : {}),
79
+ confirmValid,
80
+ });
81
+ if (decision.kind === 'deny') {
82
+ audit.record({ ...auditBase, outcome: 'denied' });
83
+ return errorResult(decision.reason, {
84
+ tool: toolName,
85
+ policy: config.allow,
86
+ risk: operation.risk,
87
+ });
88
+ }
89
+ let url;
90
+ try {
91
+ url = buildUrl(config.baseUrl, operation, callArgs);
92
+ }
93
+ catch (error) {
94
+ if (error instanceof RequestBuildError) {
95
+ return errorResult(error.message, { tool: toolName, params: operation.params });
96
+ }
97
+ throw error;
98
+ }
99
+ if (decision.kind === 'needsConfirm') {
100
+ const token = session.confirms.issue(operation.opId, callArgs);
101
+ audit.record({ ...auditBase, outcome: 'needs-confirm' });
102
+ return jsonResult({
103
+ needsConfirm: true,
104
+ reason: decision.reason,
105
+ request: { method: operation.method.toUpperCase(), url },
106
+ source,
107
+ confirm: token,
108
+ expiresInSeconds: 300,
109
+ next: 'Show the source and the target to the human, then repeat this exact call with the confirm token added.',
110
+ });
111
+ }
112
+ if (args.dryRun === true) {
113
+ return jsonResult({
114
+ dryRun: true,
115
+ request: { method: operation.method.toUpperCase(), url },
116
+ source,
117
+ policy: { allow: config.allow, risk: operation.risk, decision: 'would be sent' },
118
+ ...(args.template === undefined
119
+ ? {
120
+ warning: 'No "template" given. The file will be stored without a previewLink, no error will ' +
121
+ 'be reported, and the only repair is uploading it again. Read /template-previews ' +
122
+ 'first and pass the numeric id.',
123
+ }
124
+ : {}),
125
+ });
126
+ }
127
+ if (args.confirm !== undefined && !session.confirms.consume(args.confirm, operation.opId, callArgs)) {
128
+ return errorResult('Confirm token was consumed concurrently. Re-run with dryRun: true to get a fresh one.', { tool: toolName });
129
+ }
130
+ let payload;
131
+ try {
132
+ payload = await load();
133
+ }
134
+ catch (error) {
135
+ if (error instanceof UploadSourceError) {
136
+ return errorResult(error.message, { tool: toolName, sent: false });
137
+ }
138
+ throw error;
139
+ }
140
+ const result = await session.client.upload(operation, callArgs, payload);
141
+ audit.record({
142
+ ...auditBase,
143
+ outcome: 'sent',
144
+ status: result.ok ? result.status : result.error.status,
145
+ });
146
+ if (!result.ok) {
147
+ return errorResult(result.error.message, {
148
+ tool: toolName,
149
+ status: result.error.status,
150
+ ...(result.error.hint ? { hint: result.error.hint } : {}),
151
+ });
152
+ }
153
+ const shaped = shapeResponse(result.body, config.maxResponseBytes);
154
+ return jsonResult({
155
+ tool: toolName,
156
+ opId: operation.opId,
157
+ status: result.status,
158
+ uploaded: { filename: payload.filename, contentType: payload.contentType, bytes: payload.bytes.byteLength },
159
+ truncated: shaped.truncated,
160
+ body: shaped.body,
161
+ next: 'Keep the WHOLE record as the attribute value, and check "previewLink": an upload with no ' +
162
+ 'valid template id stores the file without one and reports no error. The record carries no ' +
163
+ '"alt" — alternative text needs a sibling attribute.',
164
+ ...(operation.note ? { note: operation.note } : {}),
165
+ });
166
+ };
167
+ export const registerUpload = (server, getSession) => {
168
+ server.registerTool('cms_upload_file', {
169
+ title: 'Upload a local file',
170
+ description: 'Upload one file from the machine running this server to the instance, as multipart — the one thing cms_api_call cannot send. Requires --allow=write, is audited, and supports dryRun. Local mode only: in remote mode there is no shared filesystem, so use cms_import_file_from_url. Pass "template" (the numeric id of a /template-previews record) or the file is stored with no preview and nothing reports it.',
171
+ inputSchema: {
172
+ path: z
173
+ .string()
174
+ .min(1)
175
+ .describe('Path to the file, absolute or relative to the server\'s upload root.'),
176
+ ...targetingSchema,
177
+ },
178
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
179
+ }, async ({ path, ...args }) => {
180
+ const session = getSession();
181
+ const { config } = session.shared;
182
+ if (config.mode === 'remote') {
183
+ return errorResult('cms_upload_file reads a file from the machine running this server, and in remote mode ' +
184
+ 'that machine is not yours: nothing was read and nothing was sent. Use ' +
185
+ 'cms_import_file_from_url with a URL the operator allowed.', { tool: 'cms_upload_file', mode: config.mode });
186
+ }
187
+ return runUpload({
188
+ session,
189
+ toolName: 'cms_upload_file',
190
+ args,
191
+ source: { path, root: config.upload.root },
192
+ load: () => readLocalUpload({ path, root: config.upload.root, maxBytes: config.upload.maxBytes }),
193
+ });
194
+ });
195
+ server.registerTool('cms_import_file_from_url', {
196
+ title: 'Import a file from a URL',
197
+ description: 'Fetch one file over http(s) and upload it to the instance in a single step — the usual "take the image from the customer\'s site into the CMS" move. Requires --allow=write, is audited, and supports dryRun. Addresses resolving to private or loopback networks are refused, and in remote mode an operator allowlist is required. Pass "template" (the numeric id of a /template-previews record) or the file is stored with no preview.',
198
+ inputSchema: {
199
+ url: z.string().min(1).describe('Absolute http or https address of the file.'),
200
+ filename: z
201
+ .string()
202
+ .optional()
203
+ .describe('Override the stored file name; by default it comes from the URL.'),
204
+ ...targetingSchema,
205
+ },
206
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
207
+ }, async ({ url, filename, ...args }) => {
208
+ const session = getSession();
209
+ const { config } = session.shared;
210
+ if (config.mode === 'remote' && config.upload.allowedHosts.length === 0) {
211
+ return errorResult('cms_import_file_from_url is disabled in remote mode until the operator sets ' +
212
+ '--upload-allowed-hosts: a URL supplied over a session would otherwise make this ' +
213
+ 'server fetch anything reachable from where it runs. Nothing was fetched.', { tool: 'cms_import_file_from_url', mode: config.mode });
214
+ }
215
+ return runUpload({
216
+ session,
217
+ toolName: 'cms_import_file_from_url',
218
+ args,
219
+ source: { url, ...(filename !== undefined ? { filename } : {}) },
220
+ load: () => fetchRemoteUpload({
221
+ url,
222
+ allowedHosts: config.upload.allowedHosts,
223
+ maxBytes: config.upload.maxBytes,
224
+ timeoutMs: config.requestTimeoutMs,
225
+ ...(filename !== undefined ? { filename } : {}),
226
+ }),
227
+ });
228
+ });
229
+ };
@@ -26,6 +26,11 @@ export const registerWhoami = (server, getSession) => {
26
26
  swaggerHash: catalog.catalog.swaggerHash,
27
27
  builtAt: catalog.catalog.builtAt,
28
28
  knownPermissions: catalog.catalog.permissions.length,
29
+ coverage: {
30
+ unexposedOperations: catalog.catalog.coverage.unexposedOpIds.length,
31
+ permissionsWithoutOperation: catalog.catalog.coverage.permissionsWithoutOperation.length,
32
+ hint: 'This catalog is what the instance serves, not everything the platform has. When cms_api_search finds nothing, it reports whether the name exists but is unexposed — trust that over constructing a path.',
33
+ },
29
34
  warnings: catalog.catalog.warnings,
30
35
  },
31
36
  knowledge: {
@@ -1,25 +1,21 @@
1
1
  # Operating rules for the OneEntry Admin API
2
2
 
3
- Read this before your first write. Every rule here has broken a real payload, and each one links to the document that explains it in full.
4
-
5
- These rules are short on purpose. When one of them applies to what you are about to do, follow the pointer before you build the body.
3
+ Read this before your first write. Every rule here has broken a real payload, and each links to the document explaining it. When one applies, follow the pointer before you build the body.
6
4
 
7
5
  → `mcp/docs/server/doc-map` · `mcp/docs/server/payload-conventions`
8
6
 
9
7
  ## The loop you must follow
10
8
 
11
9
  1. `cms_guide` once, at the start.
12
- 2. `cms_docs_search` for the entity you are about to touch — **before** you write a payload, not after a 400.
13
- 3. `cms_api_search` to find the operation, then `cms_api_describe` for its exact shape.
14
- 4. `cms_api_call` with `dryRun: true` for anything that mutates, then again with the confirm token if one was issued.
10
+ 2. `cms_docs_search` for the entity you are about to touch — **before** the payload, not after a 400.
11
+ 3. `cms_api_search` for the operation, then `cms_api_describe` for its shape.
12
+ 4. `cms_api_call` with `dryRun: true` for anything that mutates, then again with the confirm token. Files go through `cms_upload_file` or `cms_import_file_from_url`.
15
13
 
16
- Never invent a path, an operation id or a body key. `cms_api_search` is the only authority on what exists; the documents are the authority on what the values mean.
14
+ Never invent a path, an operation id or a body key. `cms_api_search` is the only authority on what exists.
17
15
 
18
16
  ## Trust the example not the type
19
17
 
20
- The OpenAPI document this catalog is built from contains field types that are not JSON Schema types. `cms_api_describe` normalises what it can and marks the rest `"x-loose": true`, with the original under `x-source-type`.
21
-
22
- For a loose field, **the `example` is the contract**. Copy its shape. Client-side validation of your body is advisory only — the instance is the real validator, so a call is never blocked because a loose field could not be checked.
18
+ The API document carries field types that are not JSON Schema types. `cms_api_describe` normalises what it can and marks the rest `"x-loose": true`, and for those **the `example` is the contract**. A field flagged `x-example-mismatch` contradicts its own type, and the example wins there too; a `curatedBody` beats both.
23
19
 
24
20
  → `mcp/docs/server/cms-api-describe#loose-fields`
25
21
 
@@ -31,7 +27,7 @@ Titles and descriptive content live under `localizeInfos`, keyed by locale code:
31
27
  { "localizeInfos": { "en_US": { "title": "Summer sale" } } }
32
28
  ```
33
29
 
34
- Required when creating a product, and effectively required on pages. Do not hardcode `en_US`: read the active locale codes from the instance first, and write every locale the instance has active if the content is meant to be visible in all of them.
30
+ Required on a product, effectively required on a page. Never hardcode `en_US`: read the active locales and write every one the content is meant to appear in. The one structure that is **not** locale keyed is an option's extra value.
35
31
 
36
32
  → `mcp/docs/api/locales`
37
33
 
@@ -43,79 +39,90 @@ Required when creating a product, and effectively required on pages. Do not hard
43
39
  { "attributesSets": { "en_US": { "string_id42": "SKU-1" } } }
44
40
  ```
45
41
 
46
- The inner key is `<attribute type>_id<attribute id>`, taken from the attribute set the entity belongs to. Read the set before you build the body.
47
-
48
- A flat single-level map is accepted and stored empty. The call answers 201 and the attributes are silently missing, so always read the entity back by id after creating it.
42
+ The inner key is `<attribute type>_id<attribute id>`, from the entity's attribute set. A flat one-level map is accepted, answers 201 and stores nothing — read the entity back by id.
49
43
 
50
44
  → `mcp/docs/api/attribute-sets`
51
45
 
52
46
  ## Positions are lexorank or numeric depending on the endpoint
53
47
 
54
- Ordering is a lexorank **string** on parent-scoped Admin operations, and a **number** on flat lists and the Content API. Never sort a lexorank value numerically, and never reorder by patching the field directly use the dedicated position operations of that entity.
48
+ Ordering is a lexorank **string** on parent-scoped Admin operations and a **number** on flat lists and public reads. Never sort a lexorank numerically, never reorder by patching the field, and never send a string one back to an update.
55
49
 
56
50
  → `mcp/docs/server/payload-conventions#position-is-a-lexorank-string-or-a-number`
57
51
 
58
52
  ## A read straight after a write can lag
59
53
 
60
- Reading an entity **by id** shows your write immediately. List and search responses may not, for a few seconds.
54
+ Reading an entity **by id** shows your write immediately; lists and searches may lag by seconds. So re-read by id and **never repeat the write** that makes a duplicate somebody cleans up by hand. Never swallow a failed read into an empty result either: the next run then recreates everything.
61
55
 
62
- If a list does not yet show what you just created, re-read by id to confirm it exists. **Never repeat the write** — you will create a duplicate that consumes instance quota and has to be cleaned up by hand.
56
+ ## A 200 means accepted not applied
63
57
 
64
- ## Prefer marker over id
58
+ Several endpoints take the body as one opaque value, so a wrong **shape** is stored as happily as a right one and the answer is still `200`. Confirm a write by its effect, **through the read its consumer uses** — the raw record echoes your input back, wrong shape included.
59
+
60
+ → `mcp/docs/api/silent-no-ops`
65
61
 
66
- Blocks, forms, menus, templates, general types and modules are addressed by a `marker` or `identifier` that is stable across instances. A numeric `id` is not: an id taken from one instance is meaningless on another, and `404 Not found` on an id you were given is usually that mistake.
62
+ ## An omitted field can mean clear it
63
+
64
+ Most updates merge. A few apply an omitted field as **"set it to nothing"** and still answer `200`: a page loses `parentId` to the root, a block loses every page attachment, a menu item flattens, a form loses its bindings and their submissions. Products merge, so "PUT always replaces" is the wrong lesson.
65
+
66
+ Read, change what you meant to, send it back whole — then check the fields that were **not** in your body.
67
+
68
+ → `mcp/docs/server/payload-conventions#an-omitted-field-can-mean-clear-it`
69
+
70
+ ## Prefer marker over id
67
71
 
68
- Where an operation accepts either, use the marker.
72
+ Blocks, forms, menus, templates, general types and modules carry a `marker` or `identifier` stable across instances. A numeric `id` is not, and a `404` on an id you were handed is usually that. Where both are accepted, use the marker.
69
73
 
70
74
  ## Baseline data already exists do not recreate it
71
75
 
72
- Every instance arrives populated: a guest user group with its content-API permissions, the admin modules, the general types, the attribute set types and field types, the locales, the dynamic block types and their default templates, and the singleton settings records.
76
+ Every instance arrives populated: user groups, modules, general types, attribute set and field types, locales, block types, the singleton settings. **List first, create second** — the dangerous duplicates (user groups, modules, attribute set types, settings) succeed silently.
73
77
 
74
- **List first, create second.** Some duplicates fail loudly, which is harmless. The dangerous ones — user groups, modules, attribute set types, settings records **succeed silently** and leave a shadow record that nothing references.
78
+ Two lists start **empty**: product statuses and template previews. Nothing reports their absence, yet without them no product is sellable and no upload gets a preview. There, create.
75
79
 
76
80
  → `mcp/docs/api/baseline-data`
77
81
 
78
82
  ## Never touch these without a human saying so
79
83
 
80
- Mutations under `immutable-settings`, `admins`, `backups`, `modules`, `payments/webhook`, `settings-general`, `system/captcha-keys` and `auth/logout/all-users` are permanently confirm-gated by this server, at every allow level.
81
-
82
- The gate is not a suggestion. State what you intend to change, show the human the `target` the dry run returned, and wait for them to say yes in this conversation.
84
+ Mutations on the instance's own configuration — admins, modules, backups, settings — are confirm-gated at every allow level, and `cms_guide` prints the list. State what you intend to change, show the dry run's `target`, wait for a yes.
83
85
 
84
86
  → `mcp/docs/server/allow-levels#paths-that-are-always-confirm-gated`
85
87
 
86
88
  ## Permissions are checked before the request is sent
87
89
 
88
- Each operation declares the permission it requires, and this server refuses locally when the authenticated admin does not hold it. Nothing is sent, so nothing changed.
89
-
90
- A permission refusal means **ask for the grant** and stop. Retrying cannot succeed, and neither can a different operation that needs the same permission.
90
+ Each operation declares the permission it needs, and this server refuses locally when the admin does not hold it nothing is sent. **Ask for the grant** and stop: no retry and no sibling operation gets past it.
91
91
 
92
92
  → `mcp/docs/api/admins-and-permissions`
93
93
 
94
94
  ## Truncated responses are deliberate
95
95
 
96
- Large responses come back with a `_truncated` envelope reporting what was shown and what the total was. That is this server capping what it hands you, not the API limiting itself.
97
-
98
- Do not retry hoping for more. Narrow the request with the operation's own `limit`, `offset` and filter parameters.
96
+ A large response comes back with a `_truncated` envelope saying what was shown and what the total was this server capping the answer, not the API. Do not retry for more; narrow the request with the operation's `limit`, `offset` and filters.
99
97
 
100
98
  → `mcp/docs/server/response-shaping`
101
99
 
102
100
  ## Operations with a single supported path
103
101
 
104
- For the calls below, one route works and the obvious alternative does not. Use the supported one directly — a different body or a retry on the other route will not help.
102
+ One route works and the obvious alternative does not.
105
103
 
106
- - **Create a form** — send the payload wrapped in `newForm`. The OpenAPI document shows the fields unwrapped; the wrapped form is the one the endpoint accepts.
107
- - **Update a product** — always include `blocks` (send `blocks: []` when you have nothing to set), and never include `forms`.
108
- - **Read locale codes** — use `AdminLocalesController_findAllActive`.
109
- - **List products** — the list operation is `POST /products/all`; there is no `GET /products`.
110
- - **Export operations** — `orders.export`, `payments.export` and `users.export` are not grantable on current instances. Treat them as unavailable and tell the human rather than retrying.
104
+ - **Create a form** — wrapped in `newForm`, with `type`, which the schema omits.
105
+ - **Replace an attribute set schema** — the schema object itself. Wrapped as `{ "schema": }` it answers 200 and destroys it.
106
+ - **Update a product** — include `blocks` (`[]` if nothing to set), never `forms`.
107
+ - **Create a menu** — with `pagesIds: []`; non-empty answers 500. Nesting and labels come later.
108
+ - **Set a product status** — `statusId` in the product update, not bulk `set-status`.
109
+ - **Upload a file** — `cms_upload_file` or `cms_import_file_from_url`; `cms_api_call` sends JSON only.
111
110
 
112
- If a call outside this list answers 5xx, stop and report it with the operation id and the request you sent. Do not retry it with a modified body.
111
+ ## List products and other calls whose input is split
113
112
 
114
- ## Where to look next
113
+ `POST /products/all` is the only way to list products, and its input is split: **paging and `langCode` in the query, the body an array of filters** — `[]` for none. Sent in the body they are ignored, and the 400 blames `langCode` for a value you never sent. Copy `example` from `cms_api_describe` whole, and prefer `curatedBody` where it appears.
114
+
115
+ A 5xx outside these two lists means stop and report it, with the operation id and the request.
115
116
 
116
- With the operation hints removed from `cms_api_describe`, `cms_docs_search` and the map below are how you find anything.
117
+ ## Reading it back is not always verifying
118
+
119
+ Two cases where the habit is not enough:
120
+
121
+ - **A batch write** can miss one entity while every response reports success. Re-read **all** of them — for products, by ids in one call — and retry the mismatches. Calculated values such as ratings arrive after a delay: wait, then check again.
122
+ - **A field that exists for the admin panel** — an option's extra value, a flag like `multiselect` — comes back from every read exactly as sent, while the panel still shows it empty. Get a human to look, or report the check as incomplete and say what is unverified.
123
+
124
+ → `mcp/docs/api/bulk-content-migration#panel-facing-fields-cannot-be-verified-by-reading`
125
+
126
+ ## Where to look next
117
127
 
118
- - `mcp/docs/server/doc-map` every document, with when to read it
119
- - `mcp/docs/server/payload-conventions` — the rules above, in full
120
- - `mcp/docs/api/baseline-data` — what already exists on your instance
121
- - `mcp/docs/server/errors-and-refusals` — what a specific error means and what to do next
128
+ `mcp/docs/server/doc-map` lists every document with a reason to read it, and `mcp/docs/api/content-modelling` covers where content should go. The corpus is **English** — search it in English whatever language you answer in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneentry/mcp-platform-server",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "MCP server that lets an AI agent operate the OneEntry Admin API, grounded in the project's own rules",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,6 +50,7 @@
50
50
  "start": "node dist/bin/cli.js",
51
51
  "dev": "tsx src/bin/cli.ts",
52
52
  "sync:permissions": "tsx build/sync-permissions.ts",
53
+ "docs:audit": "tsx build/docs-audit.ts",
53
54
  "publish:knowledge": "node -e \"console.error('REFUSED: the knowledge repository is hand-authored. Bulk-copying internal docs into it is prohibited.'); process.exit(1)\"",
54
55
  "lint": "eslint \"{src,build,__tests__}/**/*.ts\"",
55
56
  "test": "vitest run",