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

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