@com55/node-red-ai-access 0.0.0-stage → 0.1.1

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,2589 @@
1
+ openapi: 3.1.0
2
+
3
+ info:
4
+ title: Node-RED AI Access Plugin API
5
+ version: 0.1.1
6
+ summary: Temporary, owner-approved Node-RED access for AI clients
7
+ description: |
8
+ REST surface exposed by the `@com55/node-red-ai-access` Node-RED plugin
9
+ under the editor admin root (respects `httpAdminRoot`).
10
+
11
+ **Git mental model:** a proposal is like a pull request. Human approve =
12
+ merge; reject = close; withdraw = the author closes their own PR. If `rev` /
13
+ `expectedRev` does not match, treat it as a conflict: re-read the flow,
14
+ rebase the edit, and resubmit. Every access link can see all proposals
15
+ (`mine: true|false`).
16
+
17
+ ## Base path
18
+
19
+ All routes are under `{httpAdminRoot}/mcp/api/v1`
20
+ (for example `/mcp/api/v1` when `httpAdminRoot` is `/`).
21
+
22
+ ## Quick start for access-link clients
23
+
24
+ ### Self-contained GET link (no headers)
25
+
26
+ Prefer this when the AI can only issue HTTP GET (no `Authorization` header,
27
+ no MCP). An admin creates a **get_link** token via `POST /links` (this
28
+ OpenAPI document); the create response returns `url` like
29
+ `http(s)://{host}{httpAdminRoot}/mcp/a/{token}` (default TTL **4h**).
30
+
31
+ **GET `/mcp/a/<token>/…` never mints tokens.** There is no link-create
32
+ operation under the get-link surface (no `POST …/links`). Attempted mint
33
+ paths return **403** `link_create_not_supported`. Only admins with `*`
34
+ may create tokens on `POST /mcp/api/v1/links`.
35
+
36
+ 1. `GET {url}` — markdown docs (workflow, exact paths, errors, limits),
37
+ served by default as `text/html; charset=utf-8` (the same text escaped in
38
+ a `<pre>`), because some AI web fetchers only read HTML. Every response
39
+ under `/mcp/a/<token>` follows the same rule: `Accept: text/markdown`
40
+ or `application/json`, or `?format=md` / `?format=json`, return the
41
+ raw representation; `Accept: text/plain` returns the raw text as
42
+ `text/plain` (`Vary: Accept`).
43
+ 2. `GET {url}/status`, `/flows`, …
44
+ 3. Layered read (prefer before loading whole tabs):
45
+ `GET {url}/flows/{id}?summary=1` → `GET {url}/search?q=&flow=` →
46
+ `GET {url}/nodes/{id}` / `…/nodes/{id}/field/{name}?lines=A-B`.
47
+ 4. Small string edits (preferred): `GET {url}/propose?flow=&rev=&node=&field=&find=&replace=`
48
+ (`occurrence` 1-based if find is not unique). Same as patch
49
+ `{op:'replace_text',...}` / REST·MCP `payload.nodes`.
50
+ 5. Whole property: `GET {url}/propose?flow=&rev=&node=&set.<prop>=&summary=`
51
+ 6. Multi-node / add / remove / replace_text: `GET {url}/propose?flow=&rev=&patch=<base64url JSON>`
52
+ where the JSON is `nodes:[{op:'set'|'add'|'remove'|'replace_text',...}]` or a
53
+ full `replace_flow` document (URL soft-cap ~8KB → use chunked drafts;
54
+ prefer ~4–5 KB raw chunks; base64url adds ~33%).
55
+ 7. Chunked drafts: `…/draft/new` → `…/draft/{id}/append?seq=&data=` →
56
+ `…/draft/{id}/submit?sha256=` (30 min expiry, 1.5MB **decoded** bytes;
57
+ field mode validates node/field at `draft/new`; `mode=json` may add nodes).
58
+ Last resort if still too large: placeholder propose → human deploy → user pastes
59
+ full content in the editor (not reviewed via diff).
60
+ 8. Human approves on `/mcp`. JSON uses `Cache-Control: no-store`.
61
+ Unknown paths under `/mcp/a/*` return JSON `not_found` (never echo the token).
62
+
63
+ Recommended edit loop: summary → search → read a line range →
64
+ `replace_text` → propose.
65
+
66
+ ### Bearer REST (clients that can send headers)
67
+
68
+ Plain HTTP AI clients (no MCP required) can also use an access-link token
69
+ and this REST API:
70
+
71
+ 1. Ask the user for an access link from the `/mcp` admin page (**Access
72
+ links** → Create). Prefer the copy-link output (full GET `url` + Bearer
73
+ token + docs hint).
74
+ 2. Discover endpoints (no auth): `GET {httpAdminRoot}/mcp/api/v1`.
75
+ 3. Send `Authorization: Bearer <token>` on every authenticated call.
76
+ 4. Read flows lightly: `GET /flows`, then `GET /flows/{id}?summary=1`,
77
+ `GET /search?q=`, `GET /nodes/{id}`, `GET /nodes/{id}/field/{name}?lines=A-B`
78
+ (full `GET /flows/{id}` still available; response always includes
79
+ `flow.type`, defaulting to `tab`).
80
+ 5. Submit a proposal. Prefer targeted `replace_text` for small string edits:
81
+
82
+ ```json
83
+ {
84
+ "type": "replace_flow",
85
+ "expectedRev": "<rev from GET /flows or /flows/{id}>",
86
+ "payload": {
87
+ "flowId": "tab1",
88
+ "nodes": [
89
+ {
90
+ "op": "replace_text",
91
+ "node": "n1",
92
+ "field": "template",
93
+ "find": "Hello",
94
+ "replace": "Hi"
95
+ }
96
+ ]
97
+ }
98
+ }
99
+ ```
100
+
101
+ Or a full tab via `payload.flow` (from `GET /flows/{id}`).
102
+
103
+ Missing tokens return **401** `access_link_missing`; invalid/expired/revoked
104
+ tokens return **401** `access_link_invalid`. Both tell you to ask the user
105
+ for a new link. OpenAPI: `GET /openapi.yaml` (or `GET /openapi.json`). MCP (optional):
106
+ `POST {httpAdminRoot}/mcp/rpc` with the same Bearer token.
107
+
108
+ ## MCP JSON-RPC
109
+
110
+ AI clients may also use MCP Streamable HTTP at `{httpAdminRoot}/mcp/rpc`
111
+ with the same access-link Bearer token. Tools mirror the AI-facing REST
112
+ operations (`get_status`, `list_flows`, `get_flow` with optional `summary`,
113
+ `get_node`, `get_node_field`, `search_flows`, `list_node_types`,
114
+ `get_node_type`, `search_palette`, `submit_proposal`, `list_proposals`
115
+ (alias `list_my_proposals`),
116
+ `get_proposal`, `get_proposal_diff`). Approve/reject/link management stay on
117
+ this REST API and the `/mcp` admin page. This OpenAPI document covers the
118
+ REST surface only.
119
+
120
+ ## Authentication
121
+
122
+ - **Admin session** — Node-RED `adminAuth` cookie/session. Users with
123
+ `read` may view status, flows, proposals, and logs. Users with `*` may
124
+ also manage access links and approve/reject proposals.
125
+ - **Access-link Bearer token** — temporary token minted by an admin with
126
+ `*` via `POST /links` (never under `/mcp/a`). Each token has a
127
+ `kind` (`get_link` | `rest` | `mcp`) and only works on that surface.
128
+ AI clients send `Authorization: Bearer <token>` for **rest** (this API)
129
+ or **mcp** (`/mcp/rpc`). Tokens are in-memory only (lost on restart).
130
+
131
+ When an access-link token is **missing**, AI-facing routes return **401**
132
+ with `code: access_link_missing`. When the token is **invalid**, expired,
133
+ revoked, or cleared by a restart, they return **401** with
134
+ `code: access_link_invalid`. Both include `WWW-Authenticate: Bearer` and a
135
+ message that tells the AI to ask the user for a new access link.
136
+ When the token kind does not match the surface, routes return **403**
137
+ with `code: wrong_token_kind` (`expectedKind` / `actualKind`).
138
+
139
+ ## Admin page (`GET /mcp`)
140
+
141
+ The HTML shell is served **without** adminAuth so the browser can read the
142
+ Node-RED editor token from `localStorage` (editors do not use cookies) and
143
+ call APIs with `Authorization: Bearer`. All data and mutating actions stay
144
+ behind `needsPermission` / access-link checks.
145
+
146
+ ## Size limits
147
+
148
+ Proposal JSON is capped at **1_500_000 bytes** (`MAX_PROPOSAL_JSON_BYTES` /
149
+ ~1.5MB) on **every** channel: GET propose/drafts, REST `POST /proposals`,
150
+ and MCP `submit_proposal`. Oversized proposal JSON or audit append payloads
151
+ return **413** with `code: payload_too_large` (never silently truncate).
152
+ Malformed JSON bodies return **400** JSON (`code: invalid_json` on REST;
153
+ JSON-RPC `-32700` Parse error on `/mcp/rpc`) — never HTML stack traces.
154
+ Unauthenticated admin API calls return JSON `{ code: auth_required }`
155
+ (not plain-text `Unauthorized` from `needsPermission`).
156
+ The MCP endpoint shares the same limits, rate limiting, and audit logging
157
+ as REST.
158
+
159
+ ## Proposal visibility and conflicts
160
+
161
+ Every access-link token can **list and read all proposals**. Each item
162
+ includes `mine: true|false` for the caller. Filter with `?mine=true`,
163
+ `?status=`, and/or `?flowId=` (REST/GET), or MCP `list_proposals` with the
164
+ same fields. Paginate with `limit`/`offset`; every channel returns the
165
+ same pagination fields: `total` (matches before paging), `offset`, `limit`
166
+ (when given), `hasMore`, and `nextOffset` (only when `hasMore`). Pagination
167
+ is offset-based (no cursor). MCP keeps `list_my_proposals` as an alias.
168
+
169
+ Invalid filters are rejected (never clamped or ignored) with
170
+ `400 invalid_filter`, naming `parameter` and `allowed`: `status` must be a
171
+ proposal status, `limit` an integer 1–200, `offset` an integer ≥ 0, and
172
+ `mine` one of true/false/1/0. MCP argument errors use the same JSON body
173
+ (`invalid_filter` for list filters, `invalid_arguments` otherwise).
174
+
175
+ On submit and on **get** of a **pending** proposal, responses may include
176
+ `conflictingProposals` (same-`expectedRev` peers or true node/field overlaps)
177
+ with optional `overlappingNodeIds` / `overlappingFields`. List endpoints
178
+ omit `conflictingProposals`. Submit, get, and list responses include
179
+ `autoClosedCount` when that request auto-closed pending proposals as
180
+ `expired` (omitted when zero).
181
+
182
+ Validation: a single error returns its own code (e.g. `404 node_not_found`);
183
+ several errors return `400 validation_failed` with message
184
+ `N validation errors` and every entry in `errors[]`. Duplicate node ids in a
185
+ full tab or batch return `duplicate_node_id`.
186
+
187
+ Pending proposals whose `expectedRev` no longer matches the live flow rev
188
+ are auto-closed as `expired`. When a proposal is approved, other pending
189
+ proposals on the same flow become `superseded`. Closed proposals are never
190
+ conflicts.
191
+
192
+ ## Withdraw
193
+
194
+ The creating access link may withdraw its own pending proposal
195
+ (`POST /proposals/{id}/withdraw`, GET `…/proposals/{id}/withdraw?confirm=1`,
196
+ or MCP `withdraw_proposal`). GET withdraw requires `confirm=1` so prefetchers
197
+ cannot trigger it. Status becomes `withdrawn` (separate from admin
198
+ `rejected`).
199
+
200
+ ## Known issues
201
+
202
+ - **Rate limits are per token (and per IP).** Each access-link token has its
203
+ own token bucket. Several tokens can each use the full per-token limit at
204
+ the same time; the shared IP bucket still applies across them.
205
+ - **`RateLimit-*` headers describe the per-token bucket** on requests that
206
+ carry a Bearer or path token (`RateLimit-Policy: 300;w=60` by default):
207
+ that is the bucket being consumed and normally the stricter one. Requests
208
+ without a token report the per-IP bucket (`600;w=60`). Both buckets are
209
+ enforced; either can return `429 rate_limited`.
210
+
211
+ contact:
212
+ name: com55
213
+ url: https://github.com/com55/node-red-ai-access
214
+ license:
215
+ name: MIT
216
+ url: https://opensource.org/licenses/MIT
217
+
218
+ servers:
219
+ - url: /mcp/api/v1
220
+ description: Relative to Node-RED httpAdminRoot (default `/`)
221
+
222
+ tags:
223
+ - name: Status
224
+ - name: Access links
225
+ - name: Flows
226
+ - name: Node types
227
+ - name: Proposals
228
+ - name: Audit log
229
+ - name: Spec
230
+
231
+ paths:
232
+ /:
233
+ get:
234
+ tags: [Spec]
235
+ summary: Discovery index (no auth)
236
+ description: |
237
+ Unauthenticated JSON index listing auth scheme, OpenAPI URL, MCP
238
+ endpoint, and available REST paths. Intended for plain HTTP AI clients.
239
+ Advertises that token minting is admin `POST /links` only — not under
240
+ GET `/mcp/a/<token>`.
241
+ operationId: getDiscoveryIndex
242
+ security: []
243
+ responses:
244
+ '200':
245
+ description: Discovery payload
246
+ content:
247
+ application/json:
248
+ schema:
249
+ type: object
250
+ required: [ok, auth, openapi, mcp, endpoints]
251
+ properties:
252
+ ok:
253
+ type: boolean
254
+ name:
255
+ type: string
256
+ version:
257
+ type: string
258
+ auth:
259
+ type: object
260
+ required: [scheme, header]
261
+ properties:
262
+ scheme:
263
+ type: string
264
+ const: Bearer
265
+ header:
266
+ type: string
267
+ example: 'Authorization: Bearer <token>'
268
+ description:
269
+ type: string
270
+ tokenKinds:
271
+ type: array
272
+ items:
273
+ $ref: '#/components/schemas/AccessLinkKind'
274
+ mint:
275
+ type: object
276
+ description: Where admins create tokens (not under get-link)
277
+ properties:
278
+ path:
279
+ type: string
280
+ const: /links
281
+ method:
282
+ type: string
283
+ const: POST
284
+ auth:
285
+ type: string
286
+ const: admin
287
+ note:
288
+ type: string
289
+ openapi:
290
+ type: string
291
+ openapiJson:
292
+ type: string
293
+ mcp:
294
+ type: string
295
+ apiBase:
296
+ type: string
297
+ getLink:
298
+ type: object
299
+ description: |
300
+ Self-contained GET surface metadata. `canMintTokens` is
301
+ always false — no link-create under `/mcp/a`.
302
+ properties:
303
+ base:
304
+ type: string
305
+ canMintTokens:
306
+ type: boolean
307
+ const: false
308
+ note:
309
+ type: string
310
+ endpoints:
311
+ type: array
312
+ items:
313
+ type: object
314
+ required: [method, path, auth, summary]
315
+ properties:
316
+ method:
317
+ type: string
318
+ path:
319
+ type: string
320
+ auth:
321
+ type: string
322
+ summary:
323
+ type: string
324
+
325
+ /openapi.yaml:
326
+ get:
327
+ tags: [Spec]
328
+ summary: OpenAPI document (YAML)
329
+ operationId: getOpenApiYaml
330
+ security: []
331
+ responses:
332
+ '200':
333
+ description: OpenAPI 3.1 YAML for this API
334
+ content:
335
+ application/yaml:
336
+ schema:
337
+ type: string
338
+
339
+ /openapi.json:
340
+ get:
341
+ tags: [Spec]
342
+ summary: OpenAPI document (JSON)
343
+ description: The same specification as `/openapi.yaml`, converted to JSON.
344
+ operationId: getOpenApiJson
345
+ security: []
346
+ responses:
347
+ '200':
348
+ description: OpenAPI 3.1 JSON for this API
349
+ content:
350
+ application/json:
351
+ schema:
352
+ type: object
353
+
354
+ /status:
355
+ get:
356
+ tags: [Status]
357
+ summary: Plugin status
358
+ description: |
359
+ Accepts an **admin session** or a valid **access-link Bearer**.
360
+ Access-link callers receive `permission: null` and a `caller` object
361
+ with link label, expiry, permissions, and base URLs.
362
+ operationId: getStatus
363
+ security:
364
+ - AdminSession: []
365
+ - AccessLinkBearer: []
366
+ responses:
367
+ '200':
368
+ description: Status payload
369
+ content:
370
+ application/json:
371
+ schema:
372
+ $ref: '#/components/schemas/Status'
373
+ '401':
374
+ $ref: '#/components/responses/Unauthorized'
375
+
376
+ /settings:
377
+ get:
378
+ tags: [Status]
379
+ summary: Get effective AI Access settings
380
+ description: |
381
+ Returns effective settings with precedence metadata
382
+ (`disk` → `settings.js` → built-in defaults). Admin `read` or `*`.
383
+ `read` users get `canEdit: false`; `*` can PUT.
384
+ operationId: getAiAccessSettings
385
+ security:
386
+ - AdminSession: []
387
+ responses:
388
+ '200':
389
+ description: Effective settings
390
+ content:
391
+ application/json:
392
+ schema:
393
+ $ref: '#/components/schemas/AiAccessSettingsResponse'
394
+ '401':
395
+ $ref: '#/components/responses/Unauthorized'
396
+ put:
397
+ tags: [Status]
398
+ summary: Update AI Access settings (persist to disk)
399
+ description: |
400
+ Requires `*`. Writes `<userDir>/.ai-access/settings.json` and applies
401
+ immediately (no restart). Partial body merges into existing disk
402
+ overrides.
403
+ operationId: putAiAccessSettings
404
+ security:
405
+ - AdminSession: []
406
+ requestBody:
407
+ required: true
408
+ content:
409
+ application/json:
410
+ schema:
411
+ $ref: '#/components/schemas/AiAccessSettingsPatch'
412
+ responses:
413
+ '200':
414
+ description: Updated effective settings
415
+ content:
416
+ application/json:
417
+ schema:
418
+ $ref: '#/components/schemas/AiAccessSettingsResponse'
419
+ '400':
420
+ $ref: '#/components/responses/BadRequest'
421
+ '401':
422
+ $ref: '#/components/responses/Unauthorized'
423
+ '403':
424
+ $ref: '#/components/responses/Forbidden'
425
+ '413':
426
+ $ref: '#/components/responses/PayloadTooLarge'
427
+
428
+ /links:
429
+ get:
430
+ tags: [Access links]
431
+ summary: List active access links
432
+ description: Requires full admin permission (`*`).
433
+ operationId: listLinks
434
+ security:
435
+ - AdminSession: []
436
+ responses:
437
+ '200':
438
+ description: Active links (no raw tokens)
439
+ content:
440
+ application/json:
441
+ schema:
442
+ type: object
443
+ required: [links]
444
+ properties:
445
+ links:
446
+ type: array
447
+ items:
448
+ $ref: '#/components/schemas/AccessLinkPublic'
449
+ '401':
450
+ $ref: '#/components/responses/Unauthorized'
451
+ '403':
452
+ $ref: '#/components/responses/Forbidden'
453
+ post:
454
+ tags: [Access links]
455
+ summary: Create an access link
456
+ description: |
457
+ Requires full admin permission (`*`). This is the **only** link-create
458
+ operation — GET `/mcp/a/<token>/…` has no mint/create-link route.
459
+ The raw `token` is returned **once** in this response. For
460
+ `kind=get_link`, a self-contained `url` is included for GET-only
461
+ clients at `/mcp/a/<token>`. For `rest` / `mcp`, `url` is omitted
462
+ (use the Bearer token on the matching surface). Default TTL is
463
+ 14400 seconds (4h). Caps: `get_link` ≤ 24h; `rest` / `mcp` ≤ 30d
464
+ (over-cap → **400**). Max active links defaults to 10
465
+ (`aiAccess.maxActiveLinks`).
466
+ operationId: createLink
467
+ security:
468
+ - AdminSession: []
469
+ requestBody:
470
+ required: false
471
+ content:
472
+ application/json:
473
+ schema:
474
+ $ref: '#/components/schemas/CreateLinkRequest'
475
+ responses:
476
+ '201':
477
+ description: Link created (includes token)
478
+ content:
479
+ application/json:
480
+ schema:
481
+ $ref: '#/components/schemas/AccessLinkCreated'
482
+ '400':
483
+ description: |
484
+ Invalid body — including unknown `kind`
485
+ (`code: invalid_link_kind`).
486
+ content:
487
+ application/json:
488
+ schema:
489
+ oneOf:
490
+ - $ref: '#/components/schemas/InvalidLinkKindError'
491
+ - $ref: '#/components/schemas/Error'
492
+ '401':
493
+ $ref: '#/components/responses/Unauthorized'
494
+ '403':
495
+ $ref: '#/components/responses/Forbidden'
496
+ '409':
497
+ description: Active link limit reached
498
+ content:
499
+ application/json:
500
+ schema:
501
+ $ref: '#/components/schemas/Error'
502
+
503
+ /links/{id}:
504
+ delete:
505
+ tags: [Access links]
506
+ summary: Revoke an access link
507
+ operationId: revokeLink
508
+ security:
509
+ - AdminSession: []
510
+ parameters:
511
+ - $ref: '#/components/parameters/LinkId'
512
+ responses:
513
+ '200':
514
+ description: Link revoked
515
+ content:
516
+ application/json:
517
+ schema:
518
+ type: object
519
+ required: [revoked, link]
520
+ properties:
521
+ revoked:
522
+ type: boolean
523
+ const: true
524
+ link:
525
+ $ref: '#/components/schemas/AccessLinkPublic'
526
+ '401':
527
+ $ref: '#/components/responses/Unauthorized'
528
+ '403':
529
+ $ref: '#/components/responses/Forbidden'
530
+ '404':
531
+ $ref: '#/components/responses/NotFound'
532
+
533
+ /flows:
534
+ get:
535
+ tags: [Flows]
536
+ summary: List flows with revision
537
+ description: |
538
+ AI clients and admins with at least `read` may call this.
539
+ Use `rev` as `expectedRev` when submitting a `replace_flow` proposal.
540
+ operationId: listFlows
541
+ security:
542
+ - AccessLinkBearer: []
543
+ - AdminSession: []
544
+ responses:
545
+ '200':
546
+ description: Flat flows array plus global revision
547
+ content:
548
+ application/json:
549
+ schema:
550
+ $ref: '#/components/schemas/FlowsSnapshot'
551
+ '401':
552
+ $ref: '#/components/responses/AccessLinkUnauthorized'
553
+ '403':
554
+ $ref: '#/components/responses/Forbidden'
555
+ '502':
556
+ $ref: '#/components/responses/BadGateway'
557
+
558
+ /flows/{id}:
559
+ get:
560
+ tags: [Flows]
561
+ summary: Get one flow (tab) with revision
562
+ description: |
563
+ Returns the nested tab shape used as the basis for a `replace_flow`
564
+ proposal payload. Pass `summary=1` for a lightweight node list
565
+ (`id`/`type`/`name`/`wires` + per-node `bytes`, no field contents).
566
+ Always re-read before proposing so `expectedRev` matches the current
567
+ revision.
568
+ operationId: getFlow
569
+ security:
570
+ - AccessLinkBearer: []
571
+ - AdminSession: []
572
+ parameters:
573
+ - $ref: '#/components/parameters/FlowId'
574
+ - name: summary
575
+ in: query
576
+ required: false
577
+ description: When `1`/`true`/`yes`, return a node summary without field bodies
578
+ schema:
579
+ type: string
580
+ enum: ['1', 'true', 'yes']
581
+ responses:
582
+ '200':
583
+ description: Nested flow tab plus revision, or summary shape when `summary=1`
584
+ content:
585
+ application/json:
586
+ schema:
587
+ oneOf:
588
+ - $ref: '#/components/schemas/FlowSnapshot'
589
+ - $ref: '#/components/schemas/FlowSummary'
590
+ '400':
591
+ $ref: '#/components/responses/BadRequest'
592
+ '401':
593
+ $ref: '#/components/responses/AccessLinkUnauthorized'
594
+ '403':
595
+ $ref: '#/components/responses/Forbidden'
596
+ '404':
597
+ $ref: '#/components/responses/NotFound'
598
+ '502':
599
+ $ref: '#/components/responses/BadGateway'
600
+
601
+ /nodes/{id}:
602
+ get:
603
+ tags: [Flows]
604
+ summary: Get one full node
605
+ description: |
606
+ Returns one node plus owning `flowId` when known. Optional `flow`
607
+ scopes the lookup to a tab.
608
+ operationId: getNode
609
+ security:
610
+ - AccessLinkBearer: []
611
+ - AdminSession: []
612
+ parameters:
613
+ - name: id
614
+ in: path
615
+ required: true
616
+ schema:
617
+ type: string
618
+ - name: flow
619
+ in: query
620
+ required: false
621
+ description: Optional tab id to scope the lookup
622
+ schema:
623
+ type: string
624
+ responses:
625
+ '200':
626
+ description: One node with revision
627
+ content:
628
+ application/json:
629
+ schema:
630
+ $ref: '#/components/schemas/NodeSnapshot'
631
+ '400':
632
+ $ref: '#/components/responses/BadRequest'
633
+ '401':
634
+ $ref: '#/components/responses/AccessLinkUnauthorized'
635
+ '403':
636
+ $ref: '#/components/responses/Forbidden'
637
+ '502':
638
+ $ref: '#/components/responses/BadGateway'
639
+
640
+ /nodes/{id}/field/{name}:
641
+ get:
642
+ tags: [Flows]
643
+ summary: Get one node string field
644
+ description: |
645
+ Returns one string field. Optional `lines=A-B` (1-based inclusive)
646
+ returns a slice; response always includes `totalLines` and `bytes`.
647
+ operationId: getNodeField
648
+ security:
649
+ - AccessLinkBearer: []
650
+ - AdminSession: []
651
+ parameters:
652
+ - name: id
653
+ in: path
654
+ required: true
655
+ schema:
656
+ type: string
657
+ - name: name
658
+ in: path
659
+ required: true
660
+ schema:
661
+ type: string
662
+ - name: lines
663
+ in: query
664
+ required: false
665
+ description: Optional 1-based line range `A` or `A-B`
666
+ schema:
667
+ type: string
668
+ pattern: '^[0-9]+(-[0-9]+)?$'
669
+ - name: flow
670
+ in: query
671
+ required: false
672
+ schema:
673
+ type: string
674
+ responses:
675
+ '200':
676
+ description: Field value (possibly sliced) with metadata
677
+ content:
678
+ application/json:
679
+ schema:
680
+ $ref: '#/components/schemas/NodeFieldSnapshot'
681
+ '400':
682
+ $ref: '#/components/responses/BadRequest'
683
+ '401':
684
+ $ref: '#/components/responses/AccessLinkUnauthorized'
685
+ '403':
686
+ $ref: '#/components/responses/Forbidden'
687
+ '502':
688
+ $ref: '#/components/responses/BadGateway'
689
+
690
+ /search:
691
+ get:
692
+ tags: [Flows]
693
+ summary: Text search across string fields
694
+ description: |
695
+ Case-sensitive substring search. Hits include node id, field, line
696
+ numbers, and a short snippet. Results are capped (default 50);
697
+ `truncated:true` when the cap is hit. Optional `flow` scopes to one tab.
698
+ operationId: searchFlows
699
+ security:
700
+ - AccessLinkBearer: []
701
+ - AdminSession: []
702
+ parameters:
703
+ - name: q
704
+ in: query
705
+ required: true
706
+ schema:
707
+ type: string
708
+ minLength: 1
709
+ - name: flow
710
+ in: query
711
+ required: false
712
+ schema:
713
+ type: string
714
+ responses:
715
+ '200':
716
+ description: Search hits
717
+ content:
718
+ application/json:
719
+ schema:
720
+ $ref: '#/components/schemas/SearchResult'
721
+ '400':
722
+ $ref: '#/components/responses/BadRequest'
723
+ '401':
724
+ $ref: '#/components/responses/AccessLinkUnauthorized'
725
+ '403':
726
+ $ref: '#/components/responses/Forbidden'
727
+ '502':
728
+ $ref: '#/components/responses/BadGateway'
729
+
730
+ /node-types:
731
+ get:
732
+ tags: [Node types]
733
+ summary: List installed palette node types
734
+ description: |
735
+ Returns installed Node-RED palette types (`type`, `module`, optional
736
+ `category`, `description`, `enabled`, `version`, `setId`). Optional
737
+ `q` filters type/module/category/description (case-insensitive).
738
+ Results are capped; response includes `total` and `truncated`.
739
+ operationId: listNodeTypes
740
+ security:
741
+ - AccessLinkBearer: []
742
+ - AdminSession: []
743
+ parameters:
744
+ - name: q
745
+ in: query
746
+ required: false
747
+ schema:
748
+ type: string
749
+ responses:
750
+ '200':
751
+ description: Installed node types
752
+ content:
753
+ application/json:
754
+ schema:
755
+ $ref: '#/components/schemas/NodeTypeListResult'
756
+ '401':
757
+ $ref: '#/components/responses/AccessLinkUnauthorized'
758
+ '403':
759
+ $ref: '#/components/responses/Forbidden'
760
+ '502':
761
+ $ref: '#/components/responses/BadGateway'
762
+
763
+ /node-types/{type}:
764
+ get:
765
+ tags: [Node types]
766
+ summary: Get one installed node type
767
+ description: |
768
+ Detail for one type: `properties`/`defaults`, `inputs`/`outputs`,
769
+ `requiredConfigNodeTypes`, short `help`, plus `category`/`module`/`color`
770
+ when available. **404** `node_type_not_found` when unknown (may include
771
+ `nearest` suggestions).
772
+ operationId: getNodeType
773
+ security:
774
+ - AccessLinkBearer: []
775
+ - AdminSession: []
776
+ parameters:
777
+ - name: type
778
+ in: path
779
+ required: true
780
+ schema:
781
+ type: string
782
+ responses:
783
+ '200':
784
+ description: Node type detail
785
+ content:
786
+ application/json:
787
+ schema:
788
+ $ref: '#/components/schemas/NodeTypeDetail'
789
+ '401':
790
+ $ref: '#/components/responses/AccessLinkUnauthorized'
791
+ '403':
792
+ $ref: '#/components/responses/Forbidden'
793
+ '404':
794
+ $ref: '#/components/responses/NotFound'
795
+ '502':
796
+ $ref: '#/components/responses/BadGateway'
797
+
798
+ /palette/search:
799
+ get:
800
+ tags: [Node types]
801
+ summary: Search Node-RED catalogue (recommend only)
802
+ description: |
803
+ Searches `catalogue.nodered.org` (cached ~1h). Returns modules with
804
+ `installed` boolean. **Recommend only — never installs.** Ask the user
805
+ to install via Manage palette, then recheck `/node-types`. Response
806
+ always includes a `hint` stating this.
807
+ operationId: searchPalette
808
+ security:
809
+ - AccessLinkBearer: []
810
+ - AdminSession: []
811
+ parameters:
812
+ - name: q
813
+ in: query
814
+ required: true
815
+ schema:
816
+ type: string
817
+ minLength: 1
818
+ responses:
819
+ '200':
820
+ description: Catalogue search hits
821
+ content:
822
+ application/json:
823
+ schema:
824
+ $ref: '#/components/schemas/PaletteSearchResult'
825
+ '400':
826
+ $ref: '#/components/responses/BadRequest'
827
+ '401':
828
+ $ref: '#/components/responses/AccessLinkUnauthorized'
829
+ '403':
830
+ $ref: '#/components/responses/Forbidden'
831
+ '502':
832
+ $ref: '#/components/responses/BadGateway'
833
+
834
+ /proposals:
835
+ get:
836
+ tags: [Proposals]
837
+ summary: List proposals
838
+ description: |
839
+ Admins see all proposals. Access-link clients also see **all** proposals,
840
+ each marked `mine: true|false`. Filter with `?mine=true` (own only) or
841
+ `?mine=false` (only other links' proposals), `?status=` (one or more
842
+ statuses, comma-separated `?status=pending,superseded` or repeated
843
+ `?status=pending&status=expired`; any unknown value is `invalid_filter`),
844
+ and/or `?flowId=`. Other filter parameters may appear at most once. Paginate with `?limit=` (1–200) and `?offset=`; responses
845
+ always include `total` (count after filters, before pagination). Pending
846
+ items may include `conflictingProposals` on get/submit (not on list).
847
+ operationId: listProposals
848
+ security:
849
+ - AccessLinkBearer: []
850
+ - AdminSession: []
851
+ parameters:
852
+ - name: mine
853
+ in: query
854
+ required: false
855
+ schema:
856
+ type: boolean
857
+ description: >-
858
+ Access-link only. true (or 1) returns only this token's proposals;
859
+ false (or 0) returns only proposals created by other links.
860
+ - name: status
861
+ in: query
862
+ required: false
863
+ style: form
864
+ explode: true
865
+ schema:
866
+ type: array
867
+ items:
868
+ $ref: '#/components/schemas/ProposalStatus'
869
+ description: >-
870
+ One or more statuses. Repeat the parameter or pass a comma-separated
871
+ list. Any invalid value returns 400 invalid_filter.
872
+ - name: flowId
873
+ in: query
874
+ required: false
875
+ schema:
876
+ type: string
877
+ description: Filter by payload.flowId
878
+ - name: limit
879
+ in: query
880
+ required: false
881
+ schema:
882
+ type: integer
883
+ minimum: 1
884
+ maximum: 200
885
+ description: Page size. Omit to return all matching proposals.
886
+ - name: offset
887
+ in: query
888
+ required: false
889
+ schema:
890
+ type: integer
891
+ minimum: 0
892
+ default: 0
893
+ description: Skip this many filtered results
894
+ - $ref: '#/components/parameters/ProposalInclude'
895
+ - $ref: '#/components/parameters/ProposalIncludePayload'
896
+ responses:
897
+ '200':
898
+ description: Proposal list
899
+ content:
900
+ application/json:
901
+ schema:
902
+ type: object
903
+ required: [proposals, total]
904
+ properties:
905
+ proposals:
906
+ type: array
907
+ items:
908
+ $ref: '#/components/schemas/Proposal'
909
+ total:
910
+ type: integer
911
+ description: Count after filters, before limit/offset
912
+ limit:
913
+ type: integer
914
+ description: Echo of requested limit when pagination is used
915
+ offset:
916
+ type: integer
917
+ description: Echo of offset when pagination is used
918
+ '401':
919
+ $ref: '#/components/responses/AccessLinkUnauthorized'
920
+ '403':
921
+ $ref: '#/components/responses/Forbidden'
922
+ post:
923
+ tags: [Proposals]
924
+ summary: Create a replace_flow proposal
925
+ description: |
926
+ **Access-link Bearer only.** Submits a proposed replacement for one
927
+ flow tab. Does not mutate Node-RED until an admin with `*` approves.
928
+
929
+ Include `expectedRev` (preferred; `expected_rev` accepted as alias)
930
+ from the latest `GET /flows` or `GET /flows/{id}` response. The live
931
+ flow revision is checked **at submit time** (**409**
932
+ `version_mismatch` if stale). On approve, a stale revision marks the
933
+ proposal `rejected` with a resubmit message (no deploy).
934
+
935
+ Optional `title` (≤80 chars) is stored as a human-friendly label; when
936
+ omitted it is auto-generated from the change set. Responses always
937
+ include `title`, `displayName` (`title ·` server-local submitted time
938
+ with offset), absolute `reviewUrl`, and relative `reviewPath`. AI clients
939
+ should cite `displayName` + `reviewUrl` to users (not the raw id).
940
+
941
+ `payload.flow` must be a tab object (`id`, `type`, `label?`, `nodes[]`,
942
+ optional `configs[]`). Payloads over 1_500_000 bytes return **413**.
943
+ Unknown palette `node.type` values return **400** `unknown_node_type`
944
+ with `nearest` suggestions — call `/node-types` or `/palette/search`
945
+ and ask the user to install via Manage palette (this API never installs).
946
+
947
+ Responses are **lean** by default (no `payload` / `baseFlow`). Pass
948
+ `?include=payload,baseFlow` or `?include_payload=true` to include them
949
+ (same as MCP `include_payload`).
950
+ operationId: createProposal
951
+ security:
952
+ - AccessLinkBearer: []
953
+ parameters:
954
+ - $ref: '#/components/parameters/ProposalInclude'
955
+ - $ref: '#/components/parameters/ProposalIncludePayload'
956
+ requestBody:
957
+ required: true
958
+ content:
959
+ application/json:
960
+ schema:
961
+ $ref: '#/components/schemas/CreateProposalRequest'
962
+ responses:
963
+ '201':
964
+ description: Proposal created (`pending`)
965
+ content:
966
+ application/json:
967
+ schema:
968
+ $ref: '#/components/schemas/Proposal'
969
+ '400':
970
+ $ref: '#/components/responses/BadRequest'
971
+ '401':
972
+ $ref: '#/components/responses/AccessLinkUnauthorized'
973
+ '403':
974
+ $ref: '#/components/responses/Forbidden'
975
+ '409':
976
+ $ref: '#/components/responses/VersionConflict'
977
+ '413':
978
+ $ref: '#/components/responses/PayloadTooLarge'
979
+ '415':
980
+ $ref: '#/components/responses/UnsupportedMediaType'
981
+
982
+ /proposals/{id}:
983
+ get:
984
+ tags: [Proposals]
985
+ summary: Get one proposal
986
+ operationId: getProposal
987
+ security:
988
+ - AccessLinkBearer: []
989
+ - AdminSession: []
990
+ parameters:
991
+ - $ref: '#/components/parameters/ProposalId'
992
+ - $ref: '#/components/parameters/ProposalInclude'
993
+ - $ref: '#/components/parameters/ProposalIncludePayload'
994
+ responses:
995
+ '200':
996
+ description: Proposal
997
+ content:
998
+ application/json:
999
+ schema:
1000
+ $ref: '#/components/schemas/Proposal'
1001
+ '400':
1002
+ $ref: '#/components/responses/BadRequest'
1003
+ '401':
1004
+ $ref: '#/components/responses/AccessLinkUnauthorized'
1005
+ '403':
1006
+ $ref: '#/components/responses/Forbidden'
1007
+ '404':
1008
+ $ref: '#/components/responses/NotFound'
1009
+
1010
+ /proposals/{id}/diff:
1011
+ get:
1012
+ tags: [Proposals]
1013
+ summary: Diff a proposal against the current flow
1014
+ description: |
1015
+ Compares the proposal payload to the **base flow snapshot** stored at
1016
+ submit (`baseFlow` at `expectedRev`) — what the AI actually changed —
1017
+ not the live editor flow. Returns counts of added / removed / modified
1018
+ nodes. Added/removed nodes are single presence blocks with properties
1019
+ (long text fields listed for expandable UI). Modified nodes include
1020
+ line-level diffs for text fields such as `func` and `template`.
1021
+
1022
+ `baseRevStale` is true only for **pending** proposals when
1023
+ `expectedRev` does not match the live revision (approve would fail).
1024
+ Approved/rejected/failed proposals never set `baseRevStale`. When
1025
+ pending and stale, `vsCurrent` optionally includes proposal-vs-live
1026
+ sections for review.
1027
+
1028
+ Admins with `read` can diff any proposal. Access-link clients can only
1029
+ diff proposals they created.
1030
+ operationId: getProposalDiff
1031
+ security:
1032
+ - AccessLinkBearer: []
1033
+ - AdminSession: []
1034
+ parameters:
1035
+ - $ref: '#/components/parameters/ProposalId'
1036
+ responses:
1037
+ '200':
1038
+ description: Proposal diff
1039
+ content:
1040
+ application/json:
1041
+ schema:
1042
+ $ref: '#/components/schemas/ProposalDiff'
1043
+ '400':
1044
+ $ref: '#/components/responses/BadRequest'
1045
+ '401':
1046
+ $ref: '#/components/responses/AccessLinkUnauthorized'
1047
+ '403':
1048
+ $ref: '#/components/responses/Forbidden'
1049
+ '404':
1050
+ $ref: '#/components/responses/NotFound'
1051
+
1052
+ /proposals/{id}/approve:
1053
+ post:
1054
+ tags: [Proposals]
1055
+ summary: Approve and deploy a proposal
1056
+ description: |
1057
+ Requires full admin permission (`*`). Deploys via the FlowAdapter.
1058
+ Optional body `deploymentType`: `nodes` (default from settings),
1059
+ `flows`, or `full`.
1060
+
1061
+ Revision conflict: proposal is marked `rejected` with a resubmit
1062
+ message (HTTP 200 with `status: rejected`). Other deploy errors mark
1063
+ it `failed`. Concurrent approve of the same id returns 409.
1064
+ operationId: approveProposal
1065
+ security:
1066
+ - AdminSession: []
1067
+ parameters:
1068
+ - $ref: '#/components/parameters/ProposalId'
1069
+ requestBody:
1070
+ required: false
1071
+ content:
1072
+ application/json:
1073
+ schema:
1074
+ $ref: '#/components/schemas/ApproveProposalRequest'
1075
+ responses:
1076
+ '200':
1077
+ description: Updated proposal (`approved`, `rejected`, or `failed`)
1078
+ content:
1079
+ application/json:
1080
+ schema:
1081
+ $ref: '#/components/schemas/Proposal'
1082
+ '400':
1083
+ $ref: '#/components/responses/BadRequest'
1084
+ '401':
1085
+ $ref: '#/components/responses/Unauthorized'
1086
+ '403':
1087
+ $ref: '#/components/responses/Forbidden'
1088
+ '404':
1089
+ $ref: '#/components/responses/NotFound'
1090
+ '409':
1091
+ description: Not pending, or approve already in progress
1092
+ content:
1093
+ application/json:
1094
+ schema:
1095
+ $ref: '#/components/schemas/Error'
1096
+ '413':
1097
+ $ref: '#/components/responses/PayloadTooLarge'
1098
+ '415':
1099
+ $ref: '#/components/responses/UnsupportedMediaType'
1100
+
1101
+ /proposals/{id}/reject:
1102
+ post:
1103
+ tags: [Proposals]
1104
+ summary: Reject a proposal
1105
+ description: Requires full admin permission (`*`). Never mutates flows.
1106
+ operationId: rejectProposal
1107
+ security:
1108
+ - AdminSession: []
1109
+ parameters:
1110
+ - $ref: '#/components/parameters/ProposalId'
1111
+ requestBody:
1112
+ required: false
1113
+ content:
1114
+ application/json:
1115
+ schema:
1116
+ $ref: '#/components/schemas/RejectProposalRequest'
1117
+ responses:
1118
+ '200':
1119
+ description: Proposal rejected
1120
+ content:
1121
+ application/json:
1122
+ schema:
1123
+ $ref: '#/components/schemas/Proposal'
1124
+ '400':
1125
+ $ref: '#/components/responses/BadRequest'
1126
+ '401':
1127
+ $ref: '#/components/responses/Unauthorized'
1128
+ '403':
1129
+ $ref: '#/components/responses/Forbidden'
1130
+ '404':
1131
+ $ref: '#/components/responses/NotFound'
1132
+ '409':
1133
+ description: Proposal is not pending
1134
+ content:
1135
+ application/json:
1136
+ schema:
1137
+ $ref: '#/components/schemas/Error'
1138
+ '413':
1139
+ $ref: '#/components/responses/PayloadTooLarge'
1140
+ '415':
1141
+ $ref: '#/components/responses/UnsupportedMediaType'
1142
+
1143
+ /proposals/{id}/withdraw:
1144
+ post:
1145
+ tags: [Proposals]
1146
+ summary: Withdraw own pending proposal
1147
+ description: |
1148
+ Access-link Bearer only. Withdraws a **pending** proposal created by
1149
+ this token. Status becomes `withdrawn` (not `rejected`). Optional
1150
+ JSON body `{ "reason": "…" }`.
1151
+ operationId: withdrawProposal
1152
+ security:
1153
+ - AccessLinkBearer: []
1154
+ parameters:
1155
+ - $ref: '#/components/parameters/ProposalId'
1156
+ requestBody:
1157
+ required: false
1158
+ content:
1159
+ application/json:
1160
+ schema:
1161
+ type: object
1162
+ properties:
1163
+ reason:
1164
+ type: string
1165
+ responses:
1166
+ '200':
1167
+ description: Proposal withdrawn
1168
+ content:
1169
+ application/json:
1170
+ schema:
1171
+ $ref: '#/components/schemas/Proposal'
1172
+ '401':
1173
+ $ref: '#/components/responses/Unauthorized'
1174
+ '403':
1175
+ description: Not owned by this access link
1176
+ content:
1177
+ application/json:
1178
+ schema:
1179
+ $ref: '#/components/schemas/Error'
1180
+ '404':
1181
+ $ref: '#/components/responses/NotFound'
1182
+ '409':
1183
+ description: Proposal is not pending
1184
+ content:
1185
+ application/json:
1186
+ schema:
1187
+ $ref: '#/components/schemas/Error'
1188
+ '415':
1189
+ $ref: '#/components/responses/UnsupportedMediaType'
1190
+
1191
+ /logs:
1192
+ get:
1193
+ tags: [Audit log]
1194
+ summary: List recent audit log entries
1195
+ description: Admin session with at least `read`. Newest entries first.
1196
+ operationId: listLogs
1197
+ security:
1198
+ - AdminSession: []
1199
+ parameters:
1200
+ - name: limit
1201
+ in: query
1202
+ schema:
1203
+ type: integer
1204
+ minimum: 1
1205
+ maximum: 500
1206
+ default: 100
1207
+ responses:
1208
+ '200':
1209
+ description: Audit entries
1210
+ content:
1211
+ application/json:
1212
+ schema:
1213
+ type: object
1214
+ required: [logs]
1215
+ properties:
1216
+ logs:
1217
+ type: array
1218
+ items:
1219
+ $ref: '#/components/schemas/AuditEntry'
1220
+ '401':
1221
+ $ref: '#/components/responses/Unauthorized'
1222
+ '403':
1223
+ $ref: '#/components/responses/Forbidden'
1224
+
1225
+ components:
1226
+ securitySchemes:
1227
+ AdminSession:
1228
+ type: apiKey
1229
+ in: cookie
1230
+ name: node-red-session
1231
+ description: |
1232
+ Node-RED editor admin session established via `adminAuth`.
1233
+ Equivalent browser cookie/session auth used by the `/mcp` admin UI.
1234
+ AccessLinkBearer:
1235
+ type: http
1236
+ scheme: bearer
1237
+ bearerFormat: access-link
1238
+ description: |
1239
+ Temporary access-link token from `POST /links` (`token` field).
1240
+ On 401, ask the user for a new access link.
1241
+
1242
+ parameters:
1243
+ LinkId:
1244
+ name: id
1245
+ in: path
1246
+ required: true
1247
+ schema:
1248
+ type: string
1249
+ FlowId:
1250
+ name: id
1251
+ in: path
1252
+ required: true
1253
+ schema:
1254
+ type: string
1255
+ ProposalId:
1256
+ name: id
1257
+ in: path
1258
+ required: true
1259
+ schema:
1260
+ type: string
1261
+ pattern: '^prop-'
1262
+ ProposalInclude:
1263
+ name: include
1264
+ in: query
1265
+ required: false
1266
+ schema:
1267
+ type: string
1268
+ description: |
1269
+ Comma-separated fields to include on lean proposal responses.
1270
+ Supported keys: `payload`, `baseFlow`. Default is lean (omit both).
1271
+ Example: `include=payload,baseFlow`.
1272
+ ProposalIncludePayload:
1273
+ name: include_payload
1274
+ in: query
1275
+ required: false
1276
+ schema:
1277
+ type: boolean
1278
+ description: |
1279
+ Alias for MCP clients: when `true` / `1` / `yes`, includes both
1280
+ `payload` and `baseFlow` (same as `include=payload,baseFlow`).
1281
+
1282
+ responses:
1283
+ BadRequest:
1284
+ description: Invalid request
1285
+ content:
1286
+ application/json:
1287
+ schema:
1288
+ $ref: '#/components/schemas/Error'
1289
+ Unauthorized:
1290
+ description: Admin authentication required or insufficient permissions
1291
+ content:
1292
+ application/json:
1293
+ schema:
1294
+ $ref: '#/components/schemas/Error'
1295
+ AccessLinkUnauthorized:
1296
+ description: |
1297
+ Missing (`code: access_link_missing`), wrong scheme such as Basic
1298
+ (`code: bearer_scheme_required`), known-expired
1299
+ (`code: token_expired`), or invalid/revoked/pre-restart
1300
+ (`code: access_link_invalid`) access link. Includes
1301
+ `WWW-Authenticate: Bearer` (on every channel, including GET links).
1302
+ `token_expired` also includes `expiredAt` and `accessLinksUrl` (the
1303
+ `/mcp` page's Access links section, where the user creates a new link). Message instructs the AI to ask the user
1304
+ for a new access link (or to use Bearer, not Basic).
1305
+ headers:
1306
+ WWW-Authenticate:
1307
+ schema:
1308
+ type: string
1309
+ description: 'Bearer realm="node-red-ai-access", charset="UTF-8"'
1310
+ content:
1311
+ application/json:
1312
+ schema:
1313
+ $ref: '#/components/schemas/Error'
1314
+ examples:
1315
+ missing:
1316
+ value:
1317
+ error: 'Access link Bearer token is missing. Ask the user for a new access link and send Authorization: Bearer <token>.'
1318
+ code: access_link_missing
1319
+ basicScheme:
1320
+ value:
1321
+ error: 'Authorization must use the Bearer scheme (Authorization: Bearer <access-link-token>), not Basic. Ask the user for a new access link.'
1322
+ code: bearer_scheme_required
1323
+ expired:
1324
+ value:
1325
+ error: 'Access link token has expired. Ask the user for a new access link.'
1326
+ code: token_expired
1327
+ expiredAt: '2026-01-01T12:00:00.000Z'
1328
+ accessLinksUrl: 'https://node-red.example/mcp#links'
1329
+ hint: 'Ask the user to create a new access link in Node-RED on the /mcp page → Access links (https://node-red.example/mcp#links).'
1330
+ askForNewLink:
1331
+ value:
1332
+ error: 'Access link is invalid, revoked, or no longer available after a restart. Ask the user for a new access link.'
1333
+ code: access_link_invalid
1334
+ VersionConflict:
1335
+ description: |
1336
+ `expectedRev` does not match the current flow revision
1337
+ (`code: version_mismatch`). Re-read flows and resubmit.
1338
+ content:
1339
+ application/json:
1340
+ schema:
1341
+ $ref: '#/components/schemas/VersionConflictError'
1342
+ Forbidden:
1343
+ description: |
1344
+ AI features disabled, full (`*`) permission required, or access-link
1345
+ token kind mismatch (`code: wrong_token_kind` with `expectedKind` /
1346
+ `actualKind`).
1347
+ content:
1348
+ application/json:
1349
+ schema:
1350
+ oneOf:
1351
+ - $ref: '#/components/schemas/WrongTokenKindError'
1352
+ - $ref: '#/components/schemas/Error'
1353
+ examples:
1354
+ wrongTokenKind:
1355
+ value:
1356
+ error: 'Access link kind mismatch: expected rest, got get_link'
1357
+ code: wrong_token_kind
1358
+ expectedKind: rest
1359
+ actualKind: get_link
1360
+ hint: 'This REST endpoint requires a rest token. This token is kind=get_link — ask the user for a REST Bearer token (kind=rest).'
1361
+ NotFound:
1362
+ description: Resource not found
1363
+ content:
1364
+ application/json:
1365
+ schema:
1366
+ $ref: '#/components/schemas/Error'
1367
+ LinkCreateNotSupported:
1368
+ description: |
1369
+ Attempted to create/list/revoke access links under the GET
1370
+ `/mcp/a/<token>` surface (`code: link_create_not_supported`).
1371
+ GET links cannot mint tokens — use admin `POST /links` instead.
1372
+ content:
1373
+ application/json:
1374
+ schema:
1375
+ $ref: '#/components/schemas/LinkCreateNotSupportedError'
1376
+ PayloadTooLarge:
1377
+ description: Proposal JSON or audit append exceeds size cap
1378
+ content:
1379
+ application/json:
1380
+ schema:
1381
+ $ref: '#/components/schemas/PayloadTooLargeError'
1382
+ UnsupportedMediaType:
1383
+ description: |
1384
+ Request body Content-Type is not `application/json`
1385
+ (`code: unsupported_media_type`). Send
1386
+ `Content-Type: application/json` with a JSON body.
1387
+ content:
1388
+ application/json:
1389
+ schema:
1390
+ $ref: '#/components/schemas/Error'
1391
+ examples:
1392
+ notJson:
1393
+ value:
1394
+ error: 'Content-Type must be application/json'
1395
+ code: unsupported_media_type
1396
+ hint: 'Send Content-Type: application/json with a JSON body.'
1397
+ BadGateway:
1398
+ description: Upstream Node-RED runtime/Admin API failure
1399
+ content:
1400
+ application/json:
1401
+ schema:
1402
+ $ref: '#/components/schemas/Error'
1403
+
1404
+ schemas:
1405
+ Error:
1406
+ type: object
1407
+ required: [error, code]
1408
+ properties:
1409
+ error:
1410
+ type: string
1411
+ code:
1412
+ type: string
1413
+ description: Machine-readable error code — see `ErrorCode` and docs/errors.md.
1414
+ hint:
1415
+ type: string
1416
+
1417
+ # BEGIN GENERATED ERROR CODES (src/plugin/error-registry.ts)
1418
+ ErrorCode:
1419
+ type: string
1420
+ description: |
1421
+ Machine-readable `code` on JSON error bodies. See docs/errors.md.
1422
+
1423
+ - `access_link_missing` (401): No Bearer / path token
1424
+ - `bearer_scheme_required` (401): Auth used Basic (or non-Bearer)
1425
+ - `token_expired` (401): Known token past `expiresAt` (body includes `expiredAt` and `accessLinksUrl`)
1426
+ - `access_link_invalid` (401): Unknown, revoked, or cleared after restart
1427
+ - `wrong_token_kind` (403): Token kind does not match this surface
1428
+ - `ai_features_disabled` (403): Plugin AI features off
1429
+ - `auth_required` (401): Admin session required
1430
+ - `insufficient_permissions` (403): Admin lacks required scope
1431
+ - `invalid_link_kind` (400): Unknown `kind` on create
1432
+ - `invalid_ttl` (400): `ttlSeconds` is zero, negative, not a number, or over the cap for the kind
1433
+ - `access_link_limit` (409): Too many active links
1434
+ - `access_link_not_found` (404): Unknown link id on revoke
1435
+ - `link_create_not_supported` (403): Attempted mint under a GET link
1436
+ - `rate_limited` (429): Per-IP or per-token limit
1437
+ - `payload_too_large` (413): Body / proposal over ~1.5MB
1438
+ - `invalid_json` (400): Malformed JSON body
1439
+ - `unsupported_media_type` (415): POST body is not `application/json`
1440
+ - `url_too_long` (414): GET URL over soft cap (~8KB)
1441
+ - `not_found` (404): Unknown path or resource
1442
+ - `missing_query` (400): Required search `q` missing
1443
+ - `invalid_filter` (400): Invalid list filter (`status`, `limit`, `offset`, `mine`); body names `parameter` and `allowed`
1444
+ - `invalid_arguments` (400): MCP tool arguments failed schema validation; body names `parameter`
1445
+ - `invalid_diff_format` (400): Unknown `format` on proposal diff
1446
+ - `confirm_required` (400): GET withdraw without `confirm=1`
1447
+ - `missing_expected_rev` (400): Proposal submitted without `expectedRev` / `rev`
1448
+ - `version_mismatch` (409): Stale `rev` / `expectedRev`
1449
+ - `proposal_not_found` (404): Unknown proposal id
1450
+ - `proposal_not_pending` (409): Action needs a pending proposal
1451
+ - `proposal_not_owned` (403): Withdraw of another token’s proposal
1452
+ - `validation_failed` (400): Several validation errors at once (message `N validation errors`)
1453
+ - `duplicate_node_id` (400): The same node id appears more than once in a full tab or batch
1454
+ - `missing_node_id` (400): Node / op without an id
1455
+ - `missing_node_type` (400): Node without a `type`
1456
+ - `invalid_node_type` (400): `type` is not a string
1457
+ - `invalid_nodes` (400): `flow.nodes` / `flow.configs` not an array
1458
+ - `invalid_node` (400): Node entry is not an object
1459
+ - `invalid_wires` (400): Bad wires shape, missing target, or more ports than outputs
1460
+ - `z_mismatch` (400): Node `z` differs from the flow id
1461
+ - `node_already_exists` (400): `add` op for an id already in the flow
1462
+ - `protected_property` (400): Tried to change id / type / z
1463
+ - `node_not_found` (404): Unknown node (single error only; several errors → `validation_failed`)
1464
+ - `field_not_found` (404): Unknown field on a node
1465
+ - `text_not_found` (400): `replace_text` find had no match
1466
+ - `ambiguous_match` (409): `replace_text` matched more than once
1467
+ - `unknown_node_type` (400): Proposal uses a type that is not installed
1468
+ - `node_type_not_found` (404): Unknown type on node-types detail
1469
+ - `chunk_conflict` (409): Same draft seq re-sent with different data
1470
+ - `missing_chunks` (400): Draft submit with gaps in seq
1471
+ - `draft_hash_mismatch` (400): Draft sha256 does not match assembled bytes
1472
+ - `upstream_error` (502): Node-RED runtime/flows error
1473
+ - `internal_error` (500): Unexpected server error
1474
+ enum:
1475
+ - access_link_missing
1476
+ - bearer_scheme_required
1477
+ - token_expired
1478
+ - access_link_invalid
1479
+ - wrong_token_kind
1480
+ - ai_features_disabled
1481
+ - auth_required
1482
+ - insufficient_permissions
1483
+ - invalid_link_kind
1484
+ - invalid_ttl
1485
+ - access_link_limit
1486
+ - access_link_not_found
1487
+ - link_create_not_supported
1488
+ - rate_limited
1489
+ - payload_too_large
1490
+ - invalid_json
1491
+ - unsupported_media_type
1492
+ - url_too_long
1493
+ - not_found
1494
+ - missing_query
1495
+ - invalid_filter
1496
+ - invalid_arguments
1497
+ - invalid_diff_format
1498
+ - confirm_required
1499
+ - missing_expected_rev
1500
+ - version_mismatch
1501
+ - proposal_not_found
1502
+ - proposal_not_pending
1503
+ - proposal_not_owned
1504
+ - validation_failed
1505
+ - duplicate_node_id
1506
+ - missing_node_id
1507
+ - missing_node_type
1508
+ - invalid_node_type
1509
+ - invalid_nodes
1510
+ - invalid_node
1511
+ - invalid_wires
1512
+ - z_mismatch
1513
+ - node_already_exists
1514
+ - protected_property
1515
+ - node_not_found
1516
+ - field_not_found
1517
+ - text_not_found
1518
+ - ambiguous_match
1519
+ - unknown_node_type
1520
+ - node_type_not_found
1521
+ - chunk_conflict
1522
+ - missing_chunks
1523
+ - draft_hash_mismatch
1524
+ - upstream_error
1525
+ - internal_error
1526
+ # END GENERATED ERROR CODES
1527
+
1528
+ WrongTokenKindError:
1529
+ allOf:
1530
+ - $ref: '#/components/schemas/Error'
1531
+ - type: object
1532
+ required: [code, expectedKind, actualKind]
1533
+ properties:
1534
+ code:
1535
+ type: string
1536
+ const: wrong_token_kind
1537
+ expectedKind:
1538
+ $ref: '#/components/schemas/AccessLinkKind'
1539
+ actualKind:
1540
+ $ref: '#/components/schemas/AccessLinkKind'
1541
+ hint:
1542
+ type: string
1543
+
1544
+ InvalidLinkKindError:
1545
+ allOf:
1546
+ - $ref: '#/components/schemas/Error'
1547
+ - type: object
1548
+ required: [code]
1549
+ properties:
1550
+ code:
1551
+ type: string
1552
+ const: invalid_link_kind
1553
+ error:
1554
+ type: string
1555
+ example: "kind must be one of: 'get_link', 'rest', 'mcp'"
1556
+
1557
+ LinkCreateNotSupportedError:
1558
+ allOf:
1559
+ - $ref: '#/components/schemas/Error'
1560
+ - type: object
1561
+ required: [code]
1562
+ properties:
1563
+ code:
1564
+ type: string
1565
+ const: link_create_not_supported
1566
+ error:
1567
+ type: string
1568
+ example: GET access links cannot create tokens
1569
+ hint:
1570
+ type: string
1571
+
1572
+ NodeTypeListItem:
1573
+ type: object
1574
+ required: [type, module]
1575
+ properties:
1576
+ type:
1577
+ type: string
1578
+ module:
1579
+ type: string
1580
+ category:
1581
+ type: string
1582
+ description:
1583
+ type: string
1584
+ enabled:
1585
+ type: boolean
1586
+ version:
1587
+ type: string
1588
+ setId:
1589
+ type: string
1590
+
1591
+ NodeTypeListResult:
1592
+ type: object
1593
+ required: [types, total, truncated]
1594
+ properties:
1595
+ types:
1596
+ type: array
1597
+ items:
1598
+ $ref: '#/components/schemas/NodeTypeListItem'
1599
+ total:
1600
+ type: integer
1601
+ truncated:
1602
+ type: boolean
1603
+ q:
1604
+ type: string
1605
+
1606
+ NodeTypeDetail:
1607
+ type: object
1608
+ required: [type, module]
1609
+ properties:
1610
+ type:
1611
+ type: string
1612
+ module:
1613
+ type: string
1614
+ setId:
1615
+ type: string
1616
+ version:
1617
+ type: string
1618
+ enabled:
1619
+ type: boolean
1620
+ category:
1621
+ type: string
1622
+ color:
1623
+ type: string
1624
+ inputs:
1625
+ type: number
1626
+ outputs:
1627
+ type: number
1628
+ properties:
1629
+ type: object
1630
+ additionalProperties: true
1631
+ defaults:
1632
+ type: object
1633
+ additionalProperties: true
1634
+ requiredConfigNodeTypes:
1635
+ type: array
1636
+ items:
1637
+ type: string
1638
+ help:
1639
+ type: string
1640
+
1641
+ PaletteSearchItem:
1642
+ type: object
1643
+ required: [module, installed]
1644
+ properties:
1645
+ module:
1646
+ type: string
1647
+ description:
1648
+ type: string
1649
+ version:
1650
+ type: string
1651
+ updated:
1652
+ type: string
1653
+ downloads:
1654
+ type: number
1655
+ link:
1656
+ type: string
1657
+ installed:
1658
+ type: boolean
1659
+
1660
+ PaletteSearchResult:
1661
+ type: object
1662
+ required: [q, modules, total, truncated, hint]
1663
+ properties:
1664
+ q:
1665
+ type: string
1666
+ modules:
1667
+ type: array
1668
+ items:
1669
+ $ref: '#/components/schemas/PaletteSearchItem'
1670
+ total:
1671
+ type: integer
1672
+ truncated:
1673
+ type: boolean
1674
+ hint:
1675
+ type: string
1676
+
1677
+ PayloadTooLargeError:
1678
+ allOf:
1679
+ - $ref: '#/components/schemas/Error'
1680
+ - type: object
1681
+ properties:
1682
+ code:
1683
+ type: string
1684
+ const: payload_too_large
1685
+
1686
+ VersionConflictError:
1687
+ allOf:
1688
+ - $ref: '#/components/schemas/Error'
1689
+ - type: object
1690
+ properties:
1691
+ code:
1692
+ type: string
1693
+ const: version_mismatch
1694
+ expectedRev:
1695
+ type: string
1696
+ currentRev:
1697
+ type: string
1698
+
1699
+ AiAccessSettingsPatch:
1700
+ type: object
1701
+ properties:
1702
+ enabled:
1703
+ type: boolean
1704
+ allowWithoutAdminAuth:
1705
+ type: boolean
1706
+ defaultLinkTtlSeconds:
1707
+ type: integer
1708
+ minimum: 1
1709
+ defaultDeploymentType:
1710
+ $ref: '#/components/schemas/DeploymentType'
1711
+ maxActiveLinks:
1712
+ type: integer
1713
+ minimum: 1
1714
+ additionalProperties: false
1715
+
1716
+ AiAccessSettingsResponse:
1717
+ type: object
1718
+ required:
1719
+ - ok
1720
+ - adminAuthEnabled
1721
+ - canEdit
1722
+ - mcpPageUrl
1723
+ - settings
1724
+ - sources
1725
+ - precedence
1726
+ properties:
1727
+ ok:
1728
+ type: boolean
1729
+ adminAuthEnabled:
1730
+ type: boolean
1731
+ canEdit:
1732
+ type: boolean
1733
+ permission:
1734
+ oneOf:
1735
+ - type: string
1736
+ enum: [read, '*']
1737
+ - type: 'null'
1738
+ mcpPageUrl:
1739
+ type: string
1740
+ apiBase:
1741
+ type: string
1742
+ settings:
1743
+ type: object
1744
+ required:
1745
+ - enabled
1746
+ - allowWithoutAdminAuth
1747
+ - defaultLinkTtlSeconds
1748
+ - defaultDeploymentType
1749
+ - maxActiveLinks
1750
+ properties:
1751
+ enabled:
1752
+ type: boolean
1753
+ allowWithoutAdminAuth:
1754
+ type: boolean
1755
+ defaultLinkTtlSeconds:
1756
+ type: integer
1757
+ defaultDeploymentType:
1758
+ $ref: '#/components/schemas/DeploymentType'
1759
+ maxActiveLinks:
1760
+ type: integer
1761
+ sources:
1762
+ type: object
1763
+ additionalProperties:
1764
+ type: string
1765
+ enum: [default, settings.js, disk]
1766
+ disk:
1767
+ type: object
1768
+ additionalProperties: true
1769
+ precedence:
1770
+ type: array
1771
+ items:
1772
+ type: string
1773
+ description: Highest-wins order for effective values
1774
+
1775
+ Status:
1776
+ type: object
1777
+ required:
1778
+ - ok
1779
+ - version
1780
+ - adminAuthEnabled
1781
+ - aiAccessEnabled
1782
+ - aiFeaturesEnabled
1783
+ - allowWithoutAdminAuth
1784
+ - permission
1785
+ - capabilities
1786
+ - defaultLinkTtlSeconds
1787
+ - maxActiveLinks
1788
+ - defaultDeploymentType
1789
+ properties:
1790
+ ok:
1791
+ type: boolean
1792
+ version:
1793
+ type: string
1794
+ adminAuthEnabled:
1795
+ type: boolean
1796
+ aiAccessEnabled:
1797
+ type: boolean
1798
+ description: Master switch from resolved settings (disk / settings.js)
1799
+ aiFeaturesEnabled:
1800
+ type: boolean
1801
+ allowWithoutAdminAuth:
1802
+ type: boolean
1803
+ permission:
1804
+ oneOf:
1805
+ - type: string
1806
+ enum: [read, '*']
1807
+ - type: 'null'
1808
+ description: |
1809
+ Admin session permission, or `null` for access-link callers
1810
+ (AI tokens are not admin sessions — see `caller`).
1811
+ capabilities:
1812
+ $ref: '#/components/schemas/Capabilities'
1813
+ storageDir:
1814
+ type: string
1815
+ description: |
1816
+ Server-side plugin storage directory. Admin sessions only; omitted
1817
+ for access-link (AI token) callers.
1818
+ defaultLinkTtlSeconds:
1819
+ type: integer
1820
+ maxActiveLinks:
1821
+ type: integer
1822
+ defaultDeploymentType:
1823
+ $ref: '#/components/schemas/DeploymentType'
1824
+ caller:
1825
+ description: Who is calling — admin session or access-link Bearer.
1826
+ oneOf:
1827
+ - type: object
1828
+ required: [kind]
1829
+ properties:
1830
+ kind:
1831
+ type: string
1832
+ const: admin
1833
+ permission:
1834
+ type: string
1835
+ enum: [read, '*']
1836
+ - type: object
1837
+ required: [kind, linkId]
1838
+ properties:
1839
+ kind:
1840
+ type: string
1841
+ const: access-link
1842
+ linkId:
1843
+ type: string
1844
+ label:
1845
+ type: string
1846
+ expiresAt:
1847
+ type: string
1848
+ format: date-time
1849
+ description: ISO expiry time of this access-link token.
1850
+ minutesRemaining:
1851
+ type: integer
1852
+ minimum: 0
1853
+ description: >
1854
+ Whole minutes until `expiresAt`, rounded up so it is never 0 while
1855
+ the token is still valid (never negative).
1856
+ secondsRemaining:
1857
+ type: integer
1858
+ minimum: 0
1859
+ description: >
1860
+ Whole seconds until `expiresAt` (rounded up; never negative).
1861
+ permissions:
1862
+ type: array
1863
+ items:
1864
+ type: string
1865
+ description: >
1866
+ Access-link capabilities (e.g. status:read, flows:read,
1867
+ proposals:create, proposals:read-own)
1868
+ baseUrls:
1869
+ type: object
1870
+ required: [api, rpc, openapi, openapiJson, discovery]
1871
+ properties:
1872
+ api:
1873
+ type: string
1874
+ rpc:
1875
+ type: string
1876
+ openapi:
1877
+ type: string
1878
+ openapiJson:
1879
+ type: string
1880
+ discovery:
1881
+ type: string
1882
+
1883
+ Capabilities:
1884
+ type: object
1885
+ required: [view, manageLinks, approveProposals]
1886
+ properties:
1887
+ view:
1888
+ type: boolean
1889
+ manageLinks:
1890
+ type: boolean
1891
+ approveProposals:
1892
+ type: boolean
1893
+
1894
+ DeploymentType:
1895
+ type: string
1896
+ enum: [nodes, flows, full]
1897
+ default: nodes
1898
+
1899
+ AccessLinkKind:
1900
+ type: string
1901
+ enum: [get_link, rest, mcp]
1902
+ description: |
1903
+ Surface this token is minted for (admin `POST /links` only):
1904
+ - `get_link` → path token on `/mcp/a/<token>/…` (GET-only; **cannot**
1905
+ create further tokens)
1906
+ - `rest` → `Authorization: Bearer` on this REST API
1907
+ - `mcp` → `Authorization: Bearer` on `/mcp/rpc`
1908
+ Default on create when omitted: `get_link`. Wrong surface → **403**
1909
+ `wrong_token_kind`.
1910
+
1911
+ AccessLinkPublic:
1912
+ type: object
1913
+ required: [id, kind, createdAt, expiresAt]
1914
+ properties:
1915
+ id:
1916
+ type: string
1917
+ kind:
1918
+ $ref: '#/components/schemas/AccessLinkKind'
1919
+ label:
1920
+ type: string
1921
+ createdAt:
1922
+ type: string
1923
+ format: date-time
1924
+ expiresAt:
1925
+ type: string
1926
+ format: date-time
1927
+ createdBy:
1928
+ type: string
1929
+
1930
+ AccessLinkCreated:
1931
+ allOf:
1932
+ - $ref: '#/components/schemas/AccessLinkPublic'
1933
+ - type: object
1934
+ required: [token]
1935
+ properties:
1936
+ token:
1937
+ type: string
1938
+ description: Raw bearer token (returned only at creation)
1939
+ url:
1940
+ type: string
1941
+ nullable: true
1942
+ description: >
1943
+ Self-contained GET link (`http(s)://{host}{httpAdminRoot}/mcp/a/{token}`).
1944
+ Present for kind=get_link; omitted for rest/mcp. The get-link
1945
+ URL itself has no create-link operation.
1946
+
1947
+ CreateLinkRequest:
1948
+ type: object
1949
+ properties:
1950
+ ttlSeconds:
1951
+ type: number
1952
+ exclusiveMinimum: 0
1953
+ description: |
1954
+ Lifetime in seconds (default 14400 = 4h). Caps by `kind`:
1955
+ `get_link` ≤ 86400 (24h); `rest` / `mcp` ≤ 2592000 (30d).
1956
+ Over the cap → **400**.
1957
+ label:
1958
+ type: string
1959
+ kind:
1960
+ $ref: '#/components/schemas/AccessLinkKind'
1961
+ description: |
1962
+ Token surface (default `get_link`). Unknown values return
1963
+ **400** `invalid_link_kind`.
1964
+
1965
+ FlowsSnapshot:
1966
+ type: object
1967
+ required: [rev, flows]
1968
+ properties:
1969
+ rev:
1970
+ type: string
1971
+ flows:
1972
+ type: array
1973
+ items:
1974
+ type: object
1975
+ additionalProperties: true
1976
+
1977
+ FlowSnapshot:
1978
+ type: object
1979
+ required: [rev, flow]
1980
+ properties:
1981
+ rev:
1982
+ type: string
1983
+ flow:
1984
+ $ref: '#/components/schemas/FlowTab'
1985
+
1986
+ FlowSummary:
1987
+ type: object
1988
+ description: Lightweight flow view from `GET /flows/{id}?summary=1`
1989
+ required: [rev, flowId, nodes, nodeCount]
1990
+ properties:
1991
+ rev:
1992
+ type: string
1993
+ flowId:
1994
+ type: string
1995
+ label:
1996
+ type: string
1997
+ type:
1998
+ type: string
1999
+ nodeCount:
2000
+ type: integer
2001
+ nodes:
2002
+ type: array
2003
+ items:
2004
+ type: object
2005
+ required: [id, type, bytes]
2006
+ properties:
2007
+ id:
2008
+ type: string
2009
+ type:
2010
+ type: string
2011
+ name:
2012
+ type: string
2013
+ wires: {}
2014
+ bytes:
2015
+ type: integer
2016
+ description: UTF-8 byte size of the full node JSON
2017
+
2018
+ NodeSnapshot:
2019
+ type: object
2020
+ required: [rev, flowId, node]
2021
+ properties:
2022
+ rev:
2023
+ type: string
2024
+ flowId:
2025
+ oneOf:
2026
+ - type: string
2027
+ - type: 'null'
2028
+ node:
2029
+ type: object
2030
+ additionalProperties: true
2031
+
2032
+ NodeFieldSnapshot:
2033
+ type: object
2034
+ required: [rev, flowId, nodeId, field, value, totalLines, bytes]
2035
+ properties:
2036
+ rev:
2037
+ type: string
2038
+ flowId:
2039
+ oneOf:
2040
+ - type: string
2041
+ - type: 'null'
2042
+ nodeId:
2043
+ type: string
2044
+ field:
2045
+ type: string
2046
+ value:
2047
+ type: string
2048
+ totalLines:
2049
+ type: integer
2050
+ bytes:
2051
+ type: integer
2052
+ lineRange:
2053
+ type: object
2054
+ required: [start, end]
2055
+ properties:
2056
+ start:
2057
+ type: integer
2058
+ end:
2059
+ type: integer
2060
+
2061
+ SearchResult:
2062
+ type: object
2063
+ required: [rev, q, hits, hitCount, truncated, cap]
2064
+ properties:
2065
+ rev:
2066
+ type: string
2067
+ q:
2068
+ type: string
2069
+ flowId:
2070
+ type: string
2071
+ hitCount:
2072
+ type: integer
2073
+ truncated:
2074
+ type: boolean
2075
+ cap:
2076
+ type: integer
2077
+ hits:
2078
+ type: array
2079
+ items:
2080
+ type: object
2081
+ required: [flowId, nodeId, field, lines, snippet]
2082
+ properties:
2083
+ flowId:
2084
+ oneOf:
2085
+ - type: string
2086
+ - type: 'null'
2087
+ nodeId:
2088
+ type: string
2089
+ field:
2090
+ type: string
2091
+ lines:
2092
+ type: array
2093
+ items:
2094
+ type: integer
2095
+ snippet:
2096
+ type: string
2097
+
2098
+ FlowTab:
2099
+ type: object
2100
+ description: |
2101
+ Nested tab shape from `GET /flows/{id}` / `get_flow`. Reuse this object
2102
+ as `payload.flow` on `submit_proposal`. `get_flow` always includes
2103
+ `type` (defaults to `tab` when Node-RED omits it) and `nodes` (array,
2104
+ possibly empty). `configs` is optional.
2105
+ required: [id, type, nodes]
2106
+ properties:
2107
+ id:
2108
+ type: string
2109
+ type:
2110
+ type: string
2111
+ description: Tab/subflow type; `get_flow` defaults omitted values to `tab`
2112
+ default: tab
2113
+ label:
2114
+ type: string
2115
+ disabled:
2116
+ type: boolean
2117
+ info:
2118
+ type: string
2119
+ env:
2120
+ type: array
2121
+ items: {}
2122
+ nodes:
2123
+ type: array
2124
+ description: Child nodes for the tab (required on submit_proposal)
2125
+ items:
2126
+ type: object
2127
+ additionalProperties: true
2128
+ configs:
2129
+ type: array
2130
+ items:
2131
+ type: object
2132
+ additionalProperties: true
2133
+ additionalProperties: true
2134
+
2135
+ ProposalStatus:
2136
+ type: string
2137
+ enum: [pending, approved, rejected, failed, withdrawn, expired, superseded]
2138
+
2139
+ Proposal:
2140
+ type: object
2141
+ required:
2142
+ - id
2143
+ - type
2144
+ - status
2145
+ - expectedRev
2146
+ - payload
2147
+ - createdBy
2148
+ - createdAt
2149
+ - updatedAt
2150
+ - title
2151
+ - displayName
2152
+ - reviewUrl
2153
+ - reviewPath
2154
+ properties:
2155
+ id:
2156
+ type: string
2157
+ type:
2158
+ type: string
2159
+ const: replace_flow
2160
+ status:
2161
+ $ref: '#/components/schemas/ProposalStatus'
2162
+ expectedRev:
2163
+ type: string
2164
+ payload:
2165
+ $ref: '#/components/schemas/ReplaceFlowPayload'
2166
+ baseFlow:
2167
+ $ref: '#/components/schemas/FlowTab'
2168
+ description: |
2169
+ Flow tab snapshot at `expectedRev` when the proposal was submitted.
2170
+ Diffs compare `payload.flow` against this base. Omitted on legacy
2171
+ proposals created before this field existed.
2172
+ title:
2173
+ type: string
2174
+ maxLength: 80
2175
+ description: |
2176
+ Short human display title. Set at submit (AI-supplied or
2177
+ auto-generated); ` in <flowLabel>` is appended when the title does
2178
+ not already name the flow. Always present on API responses
2179
+ (resolved for legacy rows).
2180
+ storedTitle:
2181
+ type: string
2182
+ maxLength: 80
2183
+ description: |
2184
+ The title as saved. With an AI-supplied title this is that title
2185
+ (sanitized) without the ` in <flowLabel>` suffix that `title` may
2186
+ add. When no title was given it is the auto-generated title, which
2187
+ may already contain the flow name. Present on every proposal
2188
+ response, including submit.
2189
+ flowLabel:
2190
+ type: string
2191
+ description: |
2192
+ Human label of the target flow tab (stored snapshot, else the live
2193
+ flows). Empty string when unknown.
2194
+ displayName:
2195
+ type: string
2196
+ description: |
2197
+ `title · <submitted datetime>` in Node-RED **server local time**
2198
+ with an explicit UTC offset (e.g. `Edit fn1 (function) · 16 Oct 01:35 +00:00`).
2199
+ Prefer this when referring to the proposal with a human. UIs with a
2200
+ browser should render `createdAt` in the client's local timezone
2201
+ instead of parsing this embedded server clock.
2202
+ reviewUrl:
2203
+ type: string
2204
+ description: |
2205
+ Absolute deep link (`https://host/mcp#proposal=<id>`) built from the
2206
+ request Host/protocol. Uses `X-Forwarded-Host` / `X-Forwarded-Proto`
2207
+ only when Express `trust proxy` is enabled; otherwise falls back to
2208
+ the relative `reviewPath` when Host is unavailable.
2209
+ reviewPath:
2210
+ type: string
2211
+ description: |
2212
+ Relative deep link `/mcp#proposal=<id>` (under `httpAdminRoot` when
2213
+ set) that opens the admin page, switches to Proposals, and
2214
+ scrolls/highlights this item.
2215
+ createdBy:
2216
+ $ref: '#/components/schemas/ProposalCreator'
2217
+ createdAt:
2218
+ type: string
2219
+ format: date-time
2220
+ description: |
2221
+ ISO-8601 instant when the proposal was created. Prefer this for
2222
+ client-side local-time rendering.
2223
+ updatedAt:
2224
+ type: string
2225
+ format: date-time
2226
+ mine:
2227
+ type: boolean
2228
+ description: |
2229
+ Present on access-link responses: true when this token created the
2230
+ proposal.
2231
+ conflictingProposals:
2232
+ type: array
2233
+ description: |
2234
+ Pending proposals on the same flow (any token). Included on submit
2235
+ and on read of pending proposals when siblings exist.
2236
+ items:
2237
+ type: object
2238
+ required: [id, title, displayName, mine]
2239
+ properties:
2240
+ id:
2241
+ type: string
2242
+ title:
2243
+ type: string
2244
+ displayName:
2245
+ type: string
2246
+ mine:
2247
+ type: boolean
2248
+ overlappingNodeIds:
2249
+ type: array
2250
+ items:
2251
+ type: string
2252
+ overlappingFields:
2253
+ type: array
2254
+ items:
2255
+ type: object
2256
+ required: [nodeId, fields]
2257
+ properties:
2258
+ nodeId:
2259
+ type: string
2260
+ fields:
2261
+ type: array
2262
+ items:
2263
+ type: string
2264
+ decidedBy:
2265
+ $ref: '#/components/schemas/ProposalDecisionBy'
2266
+ reason:
2267
+ type: string
2268
+ description: |
2269
+ Present when rejected, failed, or withdrawn. On revision conflict
2270
+ after approve, explains that expectedRev is stale and the AI should
2271
+ re-read flows and submit a new proposal.
2272
+ resultRev:
2273
+ type: string
2274
+
2275
+ ReplaceFlowPayload:
2276
+ type: object
2277
+ required: [flowId]
2278
+ description: |
2279
+ **Canonical form (preferred):** `{ flowId, nodes: [op…] }` with no
2280
+ `flow`. Ops apply to the live tab at top-level `expectedRev`.
2281
+
2282
+ Supported `nodes[]` ops:
2283
+ - `replace_text` — `{ op:"replace_text", node, field, find, replace, occurrence? }`
2284
+ - `set` — `{ id, set:{…} }` or `{ op:"set", id, …props }`
2285
+ - `add` — `{ op:"add", node:{ id, type, … } }`
2286
+ - `remove` — `{ op:"remove", id }` (or `{ id }` remove shape)
2287
+
2288
+ **Alternative:** top-level `nodes` on `CreateProposalRequest` is an
2289
+ alias for `payload.nodes` (same shapes).
2290
+
2291
+ **Full tab:** `{ flowId, flow }` from `GET /flows/{id}` still works
2292
+ (optionally with `nodes` applied first when both are present).
2293
+ properties:
2294
+ flowId:
2295
+ type: string
2296
+ flow:
2297
+ $ref: '#/components/schemas/FlowTab'
2298
+ nodes:
2299
+ type: array
2300
+ description: |
2301
+ Canonical node ops (`replace_text` / `set` / `add` / `remove`)
2302
+ applied to the live tab when `flow` is omitted
2303
+ items:
2304
+ type: object
2305
+ additionalProperties: true
2306
+
2307
+ ProposalCreator:
2308
+ type: object
2309
+ required: [kind, linkId]
2310
+ properties:
2311
+ kind:
2312
+ type: string
2313
+ const: access-link
2314
+ linkId:
2315
+ type: string
2316
+ label:
2317
+ type: string
2318
+
2319
+ ProposalDecisionBy:
2320
+ type: object
2321
+ required: [kind]
2322
+ description: |
2323
+ Admin for approve/reject/fail; access-link for withdraw.
2324
+ properties:
2325
+ kind:
2326
+ type: string
2327
+ enum: [admin, access-link]
2328
+ username:
2329
+ type: string
2330
+ linkId:
2331
+ type: string
2332
+ label:
2333
+ type: string
2334
+
2335
+ CreateProposalRequest:
2336
+ type: object
2337
+ required: [type, expectedRev, payload]
2338
+ properties:
2339
+ type:
2340
+ type: string
2341
+ const: replace_flow
2342
+ expectedRev:
2343
+ type: string
2344
+ description: Preferred field name for the current flow revision (required)
2345
+ expected_rev:
2346
+ type: string
2347
+ description: Alias for expectedRev
2348
+ title:
2349
+ type: string
2350
+ maxLength: 80
2351
+ description: |
2352
+ Optional short human title (≤80 chars, sanitized). Auto-generated
2353
+ from the change set when omitted.
2354
+ payload:
2355
+ $ref: '#/components/schemas/ReplaceFlowPayload'
2356
+ nodes:
2357
+ type: array
2358
+ description: Optional top-level alias for payload.nodes
2359
+ items:
2360
+ type: object
2361
+ additionalProperties: true
2362
+ example:
2363
+ type: replace_flow
2364
+ expectedRev: 'abc123rev'
2365
+ title: Rename inject to Hello
2366
+ payload:
2367
+ flowId: tab1
2368
+ nodes:
2369
+ - op: replace_text
2370
+ node: n1
2371
+ field: template
2372
+ find: Hello
2373
+ replace: Hi
2374
+
2375
+ ApproveProposalRequest:
2376
+ type: object
2377
+ properties:
2378
+ deploymentType:
2379
+ $ref: '#/components/schemas/DeploymentType'
2380
+
2381
+ RejectProposalRequest:
2382
+ type: object
2383
+ properties:
2384
+ reason:
2385
+ type: string
2386
+ default: Rejected by admin
2387
+
2388
+ ProposalDiffLine:
2389
+ type: object
2390
+ required: [op, text]
2391
+ properties:
2392
+ op:
2393
+ type: string
2394
+ enum: [equal, add, remove]
2395
+ text:
2396
+ type: string
2397
+
2398
+ ProposalDiffPropertyChange:
2399
+ oneOf:
2400
+ - type: object
2401
+ required: [key, kind, before, after]
2402
+ properties:
2403
+ key:
2404
+ type: string
2405
+ kind:
2406
+ const: scalar
2407
+ before: {}
2408
+ after: {}
2409
+ - type: object
2410
+ required: [key, kind, before, after, lines]
2411
+ properties:
2412
+ key:
2413
+ type: string
2414
+ kind:
2415
+ const: text
2416
+ before:
2417
+ type: string
2418
+ after:
2419
+ type: string
2420
+ lines:
2421
+ type: array
2422
+ items:
2423
+ $ref: '#/components/schemas/ProposalDiffLine'
2424
+
2425
+ ProposalDiffModifiedNode:
2426
+ type: object
2427
+ required: [id, type, changes]
2428
+ properties:
2429
+ id:
2430
+ type: string
2431
+ type:
2432
+ type: string
2433
+ name:
2434
+ type: string
2435
+ changes:
2436
+ type: array
2437
+ description: Changed properties only (modified nodes).
2438
+ items:
2439
+ $ref: '#/components/schemas/ProposalDiffPropertyChange'
2440
+
2441
+ ProposalDiffPresenceProperty:
2442
+ type: object
2443
+ required: [key, kind, value]
2444
+ properties:
2445
+ key:
2446
+ type: string
2447
+ kind:
2448
+ type: string
2449
+ enum: [scalar, text]
2450
+ value: {}
2451
+ preview:
2452
+ type: string
2453
+ enum: [html]
2454
+ description: When set, the /mcp UI may offer a sandboxed HTML preview.
2455
+
2456
+ ProposalDiffPresenceNode:
2457
+ type: object
2458
+ required: [id, type, summary, keyFields, properties]
2459
+ description: |
2460
+ Added or removed node as one readable block: summary sentence, key
2461
+ fields (name, wires by node name, code/HTML), and full properties.
2462
+ properties:
2463
+ id:
2464
+ type: string
2465
+ type:
2466
+ type: string
2467
+ name:
2468
+ type: string
2469
+ summary:
2470
+ type: string
2471
+ description: |
2472
+ Human sentence, e.g. Adds template node "demo-html" to flow "Test Flow".
2473
+ keyFields:
2474
+ type: array
2475
+ items:
2476
+ $ref: '#/components/schemas/ProposalDiffPresenceProperty'
2477
+ properties:
2478
+ type: array
2479
+ description: 'Full property list (UI: under All properties).'
2480
+ items:
2481
+ $ref: '#/components/schemas/ProposalDiffPresenceProperty'
2482
+
2483
+ ProposalDiffSections:
2484
+ type: object
2485
+ required: [summary, added, removed, modified]
2486
+ properties:
2487
+ summary:
2488
+ type: object
2489
+ required: [added, removed, modified, tabChanges]
2490
+ properties:
2491
+ added:
2492
+ type: integer
2493
+ removed:
2494
+ type: integer
2495
+ modified:
2496
+ type: integer
2497
+ tabChanges:
2498
+ type: array
2499
+ items:
2500
+ type: string
2501
+ added:
2502
+ type: array
2503
+ items:
2504
+ $ref: '#/components/schemas/ProposalDiffPresenceNode'
2505
+ removed:
2506
+ type: array
2507
+ items:
2508
+ $ref: '#/components/schemas/ProposalDiffPresenceNode'
2509
+ modified:
2510
+ type: array
2511
+ items:
2512
+ $ref: '#/components/schemas/ProposalDiffModifiedNode'
2513
+
2514
+ ProposalDiff:
2515
+ allOf:
2516
+ - $ref: '#/components/schemas/ProposalDiffSections'
2517
+ - type: object
2518
+ required:
2519
+ - proposalId
2520
+ - flowId
2521
+ - expectedRev
2522
+ - currentRev
2523
+ - baseRevStale
2524
+ - compareAgainst
2525
+ properties:
2526
+ proposalId:
2527
+ type: string
2528
+ flowId:
2529
+ type: string
2530
+ expectedRev:
2531
+ type: string
2532
+ currentRev:
2533
+ type: string
2534
+ baseRevStale:
2535
+ type: boolean
2536
+ description: |
2537
+ True only for pending proposals when expectedRev ≠ live rev.
2538
+ Never true for approved/rejected/failed.
2539
+ compareAgainst:
2540
+ type: string
2541
+ enum: [base, current]
2542
+ baseSnapshotMissing:
2543
+ type: boolean
2544
+ description: True for legacy proposals without a stored baseFlow
2545
+ vsCurrent:
2546
+ $ref: '#/components/schemas/ProposalDiffSections'
2547
+ description: |
2548
+ Present when pending and baseRevStale — proposal vs live flow.
2549
+
2550
+ AuditActor:
2551
+ oneOf:
2552
+ - type: object
2553
+ required: [kind, linkId]
2554
+ properties:
2555
+ kind:
2556
+ const: access-link
2557
+ linkId:
2558
+ type: string
2559
+ label:
2560
+ type: string
2561
+ - type: object
2562
+ required: [kind]
2563
+ properties:
2564
+ kind:
2565
+ const: admin
2566
+ username:
2567
+ type: string
2568
+ - type: object
2569
+ required: [kind]
2570
+ properties:
2571
+ kind:
2572
+ const: system
2573
+
2574
+ AuditEntry:
2575
+ type: object
2576
+ required: [id, at, action, actor]
2577
+ properties:
2578
+ id:
2579
+ type: string
2580
+ at:
2581
+ type: string
2582
+ format: date-time
2583
+ action:
2584
+ type: string
2585
+ actor:
2586
+ $ref: '#/components/schemas/AuditActor'
2587
+ details:
2588
+ type: object
2589
+ additionalProperties: true