openapi: 3.1.0 info: title: Node-RED AI Access Plugin API version: 0.1.1 summary: Temporary, owner-approved Node-RED access for AI clients description: | REST surface exposed by the `@com55/node-red-ai-access` Node-RED plugin under the editor admin root (respects `httpAdminRoot`). **Git mental model:** a proposal is like a pull request. Human approve = merge; reject = close; withdraw = the author closes their own PR. If `rev` / `expectedRev` does not match, treat it as a conflict: re-read the flow, rebase the edit, and resubmit. Every access link can see all proposals (`mine: true|false`). ## Base path All routes are under `{httpAdminRoot}/mcp/api/v1` (for example `/mcp/api/v1` when `httpAdminRoot` is `/`). ## Quick start for access-link clients ### Self-contained GET link (no headers) Prefer this when the AI can only issue HTTP GET (no `Authorization` header, no MCP). An admin creates a **get_link** token via `POST /links` (this OpenAPI document); the create response returns `url` like `http(s)://{host}{httpAdminRoot}/mcp/a/{token}` (default TTL **4h**). **GET `/mcp/a//…` never mints tokens.** There is no link-create operation under the get-link surface (no `POST …/links`). Attempted mint paths return **403** `link_create_not_supported`. Only admins with `*` may create tokens on `POST /mcp/api/v1/links`. 1. `GET {url}` — markdown docs (workflow, exact paths, errors, limits), served by default as `text/html; charset=utf-8` (the same text escaped in a `
`), because some AI web fetchers only read HTML. Every response
       under `/mcp/a/` follows the same rule: `Accept: text/markdown`
       or `application/json`, or `?format=md` / `?format=json`, return the
       raw representation; `Accept: text/plain` returns the raw text as
       `text/plain` (`Vary: Accept`).
    2. `GET {url}/status`, `/flows`, …
    3. Layered read (prefer before loading whole tabs):
       `GET {url}/flows/{id}?summary=1` → `GET {url}/search?q=&flow=` →
       `GET {url}/nodes/{id}` / `…/nodes/{id}/field/{name}?lines=A-B`.
    4. Small string edits (preferred): `GET {url}/propose?flow=&rev=&node=&field=&find=&replace=`
       (`occurrence` 1-based if find is not unique). Same as patch
       `{op:'replace_text',...}` / REST·MCP `payload.nodes`.
    5. Whole property: `GET {url}/propose?flow=&rev=&node=&set.=&summary=`
    6. Multi-node / add / remove / replace_text: `GET {url}/propose?flow=&rev=&patch=`
       where the JSON is `nodes:[{op:'set'|'add'|'remove'|'replace_text',...}]` or a
       full `replace_flow` document (URL soft-cap ~8KB → use chunked drafts;
       prefer ~4–5 KB raw chunks; base64url adds ~33%).
    7. Chunked drafts: `…/draft/new` → `…/draft/{id}/append?seq=&data=` →
       `…/draft/{id}/submit?sha256=` (30 min expiry, 1.5MB **decoded** bytes;
       field mode validates node/field at `draft/new`; `mode=json` may add nodes).
       Last resort if still too large: placeholder propose → human deploy → user pastes
       full content in the editor (not reviewed via diff).
    8. Human approves on `/mcp`. JSON uses `Cache-Control: no-store`.
       Unknown paths under `/mcp/a/*` return JSON `not_found` (never echo the token).

    Recommended edit loop: summary → search → read a line range →
    `replace_text` → propose.

    ### Bearer REST (clients that can send headers)

    Plain HTTP AI clients (no MCP required) can also use an access-link token
    and this REST API:

    1. Ask the user for an access link from the `/mcp` admin page (**Access
       links** → Create). Prefer the copy-link output (full GET `url` + Bearer
       token + docs hint).
    2. Discover endpoints (no auth): `GET {httpAdminRoot}/mcp/api/v1`.
    3. Send `Authorization: Bearer ` on every authenticated call.
    4. Read flows lightly: `GET /flows`, then `GET /flows/{id}?summary=1`,
       `GET /search?q=`, `GET /nodes/{id}`, `GET /nodes/{id}/field/{name}?lines=A-B`
       (full `GET /flows/{id}` still available; response always includes
       `flow.type`, defaulting to `tab`).
    5. Submit a proposal. Prefer targeted `replace_text` for small string edits:

    ```json
    {
      "type": "replace_flow",
      "expectedRev": "",
      "payload": {
        "flowId": "tab1",
        "nodes": [
          {
            "op": "replace_text",
            "node": "n1",
            "field": "template",
            "find": "Hello",
            "replace": "Hi"
          }
        ]
      }
    }
    ```

    Or a full tab via `payload.flow` (from `GET /flows/{id}`).

    Missing tokens return **401** `access_link_missing`; invalid/expired/revoked
    tokens return **401** `access_link_invalid`. Both tell you to ask the user
    for a new link. OpenAPI: `GET /openapi.yaml` (or `GET /openapi.json`). MCP (optional):
    `POST {httpAdminRoot}/mcp/rpc` with the same Bearer token.

    ## MCP JSON-RPC

    AI clients may also use MCP Streamable HTTP at `{httpAdminRoot}/mcp/rpc`
    with the same access-link Bearer token. Tools mirror the AI-facing REST
    operations (`get_status`, `list_flows`, `get_flow` with optional `summary`,
    `get_node`, `get_node_field`, `search_flows`, `list_node_types`,
    `get_node_type`, `search_palette`, `submit_proposal`, `list_proposals`
    (alias `list_my_proposals`),
    `get_proposal`, `get_proposal_diff`). Approve/reject/link management stay on
    this REST API and the `/mcp` admin page. This OpenAPI document covers the
    REST surface only.

    ## Authentication

    - **Admin session** — Node-RED `adminAuth` cookie/session. Users with
      `read` may view status, flows, proposals, and logs. Users with `*` may
      also manage access links and approve/reject proposals.
    - **Access-link Bearer token** — temporary token minted by an admin with
      `*` via `POST /links` (never under `/mcp/a`). Each token has a
      `kind` (`get_link` | `rest` | `mcp`) and only works on that surface.
      AI clients send `Authorization: Bearer ` for **rest** (this API)
      or **mcp** (`/mcp/rpc`). Tokens are in-memory only (lost on restart).

    When an access-link token is **missing**, AI-facing routes return **401**
    with `code: access_link_missing`. When the token is **invalid**, expired,
    revoked, or cleared by a restart, they return **401** with
    `code: access_link_invalid`. Both include `WWW-Authenticate: Bearer` and a
    message that tells the AI to ask the user for a new access link.
    When the token kind does not match the surface, routes return **403**
    with `code: wrong_token_kind` (`expectedKind` / `actualKind`).

    ## Admin page (`GET /mcp`)

    The HTML shell is served **without** adminAuth so the browser can read the
    Node-RED editor token from `localStorage` (editors do not use cookies) and
    call APIs with `Authorization: Bearer`. All data and mutating actions stay
    behind `needsPermission` / access-link checks.

    ## Size limits

    Proposal JSON is capped at **1_500_000 bytes** (`MAX_PROPOSAL_JSON_BYTES` /
    ~1.5MB) on **every** channel: GET propose/drafts, REST `POST /proposals`,
    and MCP `submit_proposal`. Oversized proposal JSON or audit append payloads
    return **413** with `code: payload_too_large` (never silently truncate).
    Malformed JSON bodies return **400** JSON (`code: invalid_json` on REST;
    JSON-RPC `-32700` Parse error on `/mcp/rpc`) — never HTML stack traces.
    Unauthenticated admin API calls return JSON `{ code: auth_required }`
    (not plain-text `Unauthorized` from `needsPermission`).
    The MCP endpoint shares the same limits, rate limiting, and audit logging
    as REST.

    ## Proposal visibility and conflicts

    Every access-link token can **list and read all proposals**. Each item
    includes `mine: true|false` for the caller. Filter with `?mine=true`,
    `?status=`, and/or `?flowId=` (REST/GET), or MCP `list_proposals` with the
    same fields. Paginate with `limit`/`offset`; every channel returns the
    same pagination fields: `total` (matches before paging), `offset`, `limit`
    (when given), `hasMore`, and `nextOffset` (only when `hasMore`). Pagination
    is offset-based (no cursor). MCP keeps `list_my_proposals` as an alias.

    Invalid filters are rejected (never clamped or ignored) with
    `400 invalid_filter`, naming `parameter` and `allowed`: `status` must be a
    proposal status, `limit` an integer 1–200, `offset` an integer ≥ 0, and
    `mine` one of true/false/1/0. MCP argument errors use the same JSON body
    (`invalid_filter` for list filters, `invalid_arguments` otherwise).

    On submit and on **get** of a **pending** proposal, responses may include
    `conflictingProposals` (same-`expectedRev` peers or true node/field overlaps)
    with optional `overlappingNodeIds` / `overlappingFields`. List endpoints
    omit `conflictingProposals`. Submit, get, and list responses include
    `autoClosedCount` when that request auto-closed pending proposals as
    `expired` (omitted when zero).

    Validation: a single error returns its own code (e.g. `404 node_not_found`);
    several errors return `400 validation_failed` with message
    `N validation errors` and every entry in `errors[]`. Duplicate node ids in a
    full tab or batch return `duplicate_node_id`.

    Pending proposals whose `expectedRev` no longer matches the live flow rev
    are auto-closed as `expired`. When a proposal is approved, other pending
    proposals on the same flow become `superseded`. Closed proposals are never
    conflicts.

    ## Withdraw

    The creating access link may withdraw its own pending proposal
    (`POST /proposals/{id}/withdraw`, GET `…/proposals/{id}/withdraw?confirm=1`,
    or MCP `withdraw_proposal`). GET withdraw requires `confirm=1` so prefetchers
    cannot trigger it. Status becomes `withdrawn` (separate from admin
    `rejected`).

    ## Known issues

    - **Rate limits are per token (and per IP).** Each access-link token has its
      own token bucket. Several tokens can each use the full per-token limit at
      the same time; the shared IP bucket still applies across them.
    - **`RateLimit-*` headers describe the per-token bucket** on requests that
      carry a Bearer or path token (`RateLimit-Policy: 300;w=60` by default):
      that is the bucket being consumed and normally the stricter one. Requests
      without a token report the per-IP bucket (`600;w=60`). Both buckets are
      enforced; either can return `429 rate_limited`.

  contact:
    name: com55
    url: https://github.com/com55/node-red-ai-access
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: /mcp/api/v1
    description: Relative to Node-RED httpAdminRoot (default `/`)

tags:
  - name: Status
  - name: Access links
  - name: Flows
  - name: Node types
  - name: Proposals
  - name: Audit log
  - name: Spec

paths:
  /:
    get:
      tags: [Spec]
      summary: Discovery index (no auth)
      description: |
        Unauthenticated JSON index listing auth scheme, OpenAPI URL, MCP
        endpoint, and available REST paths. Intended for plain HTTP AI clients.
        Advertises that token minting is admin `POST /links` only — not under
        GET `/mcp/a/`.
      operationId: getDiscoveryIndex
      security: []
      responses:
        '200':
          description: Discovery payload
          content:
            application/json:
              schema:
                type: object
                required: [ok, auth, openapi, mcp, endpoints]
                properties:
                  ok:
                    type: boolean
                  name:
                    type: string
                  version:
                    type: string
                  auth:
                    type: object
                    required: [scheme, header]
                    properties:
                      scheme:
                        type: string
                        const: Bearer
                      header:
                        type: string
                        example: 'Authorization: Bearer '
                      description:
                        type: string
                      tokenKinds:
                        type: array
                        items:
                          $ref: '#/components/schemas/AccessLinkKind'
                      mint:
                        type: object
                        description: Where admins create tokens (not under get-link)
                        properties:
                          path:
                            type: string
                            const: /links
                          method:
                            type: string
                            const: POST
                          auth:
                            type: string
                            const: admin
                          note:
                            type: string
                  openapi:
                    type: string
                  openapiJson:
                    type: string
                  mcp:
                    type: string
                  apiBase:
                    type: string
                  getLink:
                    type: object
                    description: |
                      Self-contained GET surface metadata. `canMintTokens` is
                      always false — no link-create under `/mcp/a`.
                    properties:
                      base:
                        type: string
                      canMintTokens:
                        type: boolean
                        const: false
                      note:
                        type: string
                  endpoints:
                    type: array
                    items:
                      type: object
                      required: [method, path, auth, summary]
                      properties:
                        method:
                          type: string
                        path:
                          type: string
                        auth:
                          type: string
                        summary:
                          type: string

  /openapi.yaml:
    get:
      tags: [Spec]
      summary: OpenAPI document (YAML)
      operationId: getOpenApiYaml
      security: []
      responses:
        '200':
          description: OpenAPI 3.1 YAML for this API
          content:
            application/yaml:
              schema:
                type: string

  /openapi.json:
    get:
      tags: [Spec]
      summary: OpenAPI document (JSON)
      description: The same specification as `/openapi.yaml`, converted to JSON.
      operationId: getOpenApiJson
      security: []
      responses:
        '200':
          description: OpenAPI 3.1 JSON for this API
          content:
            application/json:
              schema:
                type: object

  /status:
    get:
      tags: [Status]
      summary: Plugin status
      description: |
        Accepts an **admin session** or a valid **access-link Bearer**.
        Access-link callers receive `permission: null` and a `caller` object
        with link label, expiry, permissions, and base URLs.
      operationId: getStatus
      security:
        - AdminSession: []
        - AccessLinkBearer: []
      responses:
        '200':
          description: Status payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /settings:
    get:
      tags: [Status]
      summary: Get effective AI Access settings
      description: |
        Returns effective settings with precedence metadata
        (`disk` → `settings.js` → built-in defaults). Admin `read` or `*`.
        `read` users get `canEdit: false`; `*` can PUT.
      operationId: getAiAccessSettings
      security:
        - AdminSession: []
      responses:
        '200':
          description: Effective settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AiAccessSettingsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    put:
      tags: [Status]
      summary: Update AI Access settings (persist to disk)
      description: |
        Requires `*`. Writes `/.ai-access/settings.json` and applies
        immediately (no restart). Partial body merges into existing disk
        overrides.
      operationId: putAiAccessSettings
      security:
        - AdminSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AiAccessSettingsPatch'
      responses:
        '200':
          description: Updated effective settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AiAccessSettingsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'

  /links:
    get:
      tags: [Access links]
      summary: List active access links
      description: Requires full admin permission (`*`).
      operationId: listLinks
      security:
        - AdminSession: []
      responses:
        '200':
          description: Active links (no raw tokens)
          content:
            application/json:
              schema:
                type: object
                required: [links]
                properties:
                  links:
                    type: array
                    items:
                      $ref: '#/components/schemas/AccessLinkPublic'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Access links]
      summary: Create an access link
      description: |
        Requires full admin permission (`*`). This is the **only** link-create
        operation — GET `/mcp/a//…` has no mint/create-link route.
        The raw `token` is returned **once** in this response. For
        `kind=get_link`, a self-contained `url` is included for GET-only
        clients at `/mcp/a/`. For `rest` / `mcp`, `url` is omitted
        (use the Bearer token on the matching surface). Default TTL is
        14400 seconds (4h). Caps: `get_link` ≤ 24h; `rest` / `mcp` ≤ 30d
        (over-cap → **400**). Max active links defaults to 10
        (`aiAccess.maxActiveLinks`).
      operationId: createLink
      security:
        - AdminSession: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLinkRequest'
      responses:
        '201':
          description: Link created (includes token)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessLinkCreated'
        '400':
          description: |
            Invalid body — including unknown `kind`
            (`code: invalid_link_kind`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/InvalidLinkKindError'
                  - $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Active link limit reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /links/{id}:
    delete:
      tags: [Access links]
      summary: Revoke an access link
      operationId: revokeLink
      security:
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/LinkId'
      responses:
        '200':
          description: Link revoked
          content:
            application/json:
              schema:
                type: object
                required: [revoked, link]
                properties:
                  revoked:
                    type: boolean
                    const: true
                  link:
                    $ref: '#/components/schemas/AccessLinkPublic'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /flows:
    get:
      tags: [Flows]
      summary: List flows with revision
      description: |
        AI clients and admins with at least `read` may call this.
        Use `rev` as `expectedRev` when submitting a `replace_flow` proposal.
      operationId: listFlows
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      responses:
        '200':
          description: Flat flows array plus global revision
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FlowsSnapshot'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /flows/{id}:
    get:
      tags: [Flows]
      summary: Get one flow (tab) with revision
      description: |
        Returns the nested tab shape used as the basis for a `replace_flow`
        proposal payload. Pass `summary=1` for a lightweight node list
        (`id`/`type`/`name`/`wires` + per-node `bytes`, no field contents).
        Always re-read before proposing so `expectedRev` matches the current
        revision.
      operationId: getFlow
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/FlowId'
        - name: summary
          in: query
          required: false
          description: When `1`/`true`/`yes`, return a node summary without field bodies
          schema:
            type: string
            enum: ['1', 'true', 'yes']
      responses:
        '200':
          description: Nested flow tab plus revision, or summary shape when `summary=1`
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FlowSnapshot'
                  - $ref: '#/components/schemas/FlowSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/BadGateway'

  /nodes/{id}:
    get:
      tags: [Flows]
      summary: Get one full node
      description: |
        Returns one node plus owning `flowId` when known. Optional `flow`
        scopes the lookup to a tab.
      operationId: getNode
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: flow
          in: query
          required: false
          description: Optional tab id to scope the lookup
          schema:
            type: string
      responses:
        '200':
          description: One node with revision
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeSnapshot'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /nodes/{id}/field/{name}:
    get:
      tags: [Flows]
      summary: Get one node string field
      description: |
        Returns one string field. Optional `lines=A-B` (1-based inclusive)
        returns a slice; response always includes `totalLines` and `bytes`.
      operationId: getNodeField
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: name
          in: path
          required: true
          schema:
            type: string
        - name: lines
          in: query
          required: false
          description: Optional 1-based line range `A` or `A-B`
          schema:
            type: string
            pattern: '^[0-9]+(-[0-9]+)?$'
        - name: flow
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Field value (possibly sliced) with metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeFieldSnapshot'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /search:
    get:
      tags: [Flows]
      summary: Text search across string fields
      description: |
        Case-sensitive substring search. Hits include node id, field, line
        numbers, and a short snippet. Results are capped (default 50);
        `truncated:true` when the cap is hit. Optional `flow` scopes to one tab.
      operationId: searchFlows
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
        - name: flow
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Search hits
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /node-types:
    get:
      tags: [Node types]
      summary: List installed palette node types
      description: |
        Returns installed Node-RED palette types (`type`, `module`, optional
        `category`, `description`, `enabled`, `version`, `setId`). Optional
        `q` filters type/module/category/description (case-insensitive).
        Results are capped; response includes `total` and `truncated`.
      operationId: listNodeTypes
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: q
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Installed node types
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeTypeListResult'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /node-types/{type}:
    get:
      tags: [Node types]
      summary: Get one installed node type
      description: |
        Detail for one type: `properties`/`defaults`, `inputs`/`outputs`,
        `requiredConfigNodeTypes`, short `help`, plus `category`/`module`/`color`
        when available. **404** `node_type_not_found` when unknown (may include
        `nearest` suggestions).
      operationId: getNodeType
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: type
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Node type detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeTypeDetail'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/BadGateway'

  /palette/search:
    get:
      tags: [Node types]
      summary: Search Node-RED catalogue (recommend only)
      description: |
        Searches `catalogue.nodered.org` (cached ~1h). Returns modules with
        `installed` boolean. **Recommend only — never installs.** Ask the user
        to install via Manage palette, then recheck `/node-types`. Response
        always includes a `hint` stating this.
      operationId: searchPalette
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
      responses:
        '200':
          description: Catalogue search hits
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaletteSearchResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          $ref: '#/components/responses/BadGateway'

  /proposals:
    get:
      tags: [Proposals]
      summary: List proposals
      description: |
        Admins see all proposals. Access-link clients also see **all** proposals,
        each marked `mine: true|false`. Filter with `?mine=true` (own only) or
        `?mine=false` (only other links' proposals), `?status=` (one or more
        statuses, comma-separated `?status=pending,superseded` or repeated
        `?status=pending&status=expired`; any unknown value is `invalid_filter`),
        and/or `?flowId=`. Other filter parameters may appear at most once. Paginate with `?limit=` (1–200) and `?offset=`; responses
        always include `total` (count after filters, before pagination). Pending
        items may include `conflictingProposals` on get/submit (not on list).
      operationId: listProposals
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - name: mine
          in: query
          required: false
          schema:
            type: boolean
          description: >-
            Access-link only. true (or 1) returns only this token's proposals;
            false (or 0) returns only proposals created by other links.
        - name: status
          in: query
          required: false
          style: form
          explode: true
          schema:
            type: array
            items:
              $ref: '#/components/schemas/ProposalStatus'
          description: >-
            One or more statuses. Repeat the parameter or pass a comma-separated
            list. Any invalid value returns 400 invalid_filter.
        - name: flowId
          in: query
          required: false
          schema:
            type: string
          description: Filter by payload.flowId
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
          description: Page size. Omit to return all matching proposals.
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Skip this many filtered results
        - $ref: '#/components/parameters/ProposalInclude'
        - $ref: '#/components/parameters/ProposalIncludePayload'
      responses:
        '200':
          description: Proposal list
          content:
            application/json:
              schema:
                type: object
                required: [proposals, total]
                properties:
                  proposals:
                    type: array
                    items:
                      $ref: '#/components/schemas/Proposal'
                  total:
                    type: integer
                    description: Count after filters, before limit/offset
                  limit:
                    type: integer
                    description: Echo of requested limit when pagination is used
                  offset:
                    type: integer
                    description: Echo of offset when pagination is used
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Proposals]
      summary: Create a replace_flow proposal
      description: |
        **Access-link Bearer only.** Submits a proposed replacement for one
        flow tab. Does not mutate Node-RED until an admin with `*` approves.

        Include `expectedRev` (preferred; `expected_rev` accepted as alias)
        from the latest `GET /flows` or `GET /flows/{id}` response. The live
        flow revision is checked **at submit time** (**409**
        `version_mismatch` if stale). On approve, a stale revision marks the
        proposal `rejected` with a resubmit message (no deploy).

        Optional `title` (≤80 chars) is stored as a human-friendly label; when
        omitted it is auto-generated from the change set. Responses always
        include `title`, `displayName` (`title ·` server-local submitted time
        with offset), absolute `reviewUrl`, and relative `reviewPath`. AI clients
        should cite `displayName` + `reviewUrl` to users (not the raw id).

        `payload.flow` must be a tab object (`id`, `type`, `label?`, `nodes[]`,
        optional `configs[]`). Payloads over 1_500_000 bytes return **413**.
        Unknown palette `node.type` values return **400** `unknown_node_type`
        with `nearest` suggestions — call `/node-types` or `/palette/search`
        and ask the user to install via Manage palette (this API never installs).

        Responses are **lean** by default (no `payload` / `baseFlow`). Pass
        `?include=payload,baseFlow` or `?include_payload=true` to include them
        (same as MCP `include_payload`).
      operationId: createProposal
      security:
        - AccessLinkBearer: []
      parameters:
        - $ref: '#/components/parameters/ProposalInclude'
        - $ref: '#/components/parameters/ProposalIncludePayload'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProposalRequest'
      responses:
        '201':
          description: Proposal created (`pending`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Proposal'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/VersionConflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'

  /proposals/{id}:
    get:
      tags: [Proposals]
      summary: Get one proposal
      operationId: getProposal
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/ProposalId'
        - $ref: '#/components/parameters/ProposalInclude'
        - $ref: '#/components/parameters/ProposalIncludePayload'
      responses:
        '200':
          description: Proposal
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Proposal'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /proposals/{id}/diff:
    get:
      tags: [Proposals]
      summary: Diff a proposal against the current flow
      description: |
        Compares the proposal payload to the **base flow snapshot** stored at
        submit (`baseFlow` at `expectedRev`) — what the AI actually changed —
        not the live editor flow. Returns counts of added / removed / modified
        nodes. Added/removed nodes are single presence blocks with properties
        (long text fields listed for expandable UI). Modified nodes include
        line-level diffs for text fields such as `func` and `template`.

        `baseRevStale` is true only for **pending** proposals when
        `expectedRev` does not match the live revision (approve would fail).
        Approved/rejected/failed proposals never set `baseRevStale`. When
        pending and stale, `vsCurrent` optionally includes proposal-vs-live
        sections for review.

        Admins with `read` can diff any proposal. Access-link clients can only
        diff proposals they created.
      operationId: getProposalDiff
      security:
        - AccessLinkBearer: []
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/ProposalId'
      responses:
        '200':
          description: Proposal diff
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProposalDiff'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/AccessLinkUnauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /proposals/{id}/approve:
    post:
      tags: [Proposals]
      summary: Approve and deploy a proposal
      description: |
        Requires full admin permission (`*`). Deploys via the FlowAdapter.
        Optional body `deploymentType`: `nodes` (default from settings),
        `flows`, or `full`.

        Revision conflict: proposal is marked `rejected` with a resubmit
        message (HTTP 200 with `status: rejected`). Other deploy errors mark
        it `failed`. Concurrent approve of the same id returns 409.
      operationId: approveProposal
      security:
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/ProposalId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApproveProposalRequest'
      responses:
        '200':
          description: Updated proposal (`approved`, `rejected`, or `failed`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Proposal'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not pending, or approve already in progress
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'

  /proposals/{id}/reject:
    post:
      tags: [Proposals]
      summary: Reject a proposal
      description: Requires full admin permission (`*`). Never mutates flows.
      operationId: rejectProposal
      security:
        - AdminSession: []
      parameters:
        - $ref: '#/components/parameters/ProposalId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RejectProposalRequest'
      responses:
        '200':
          description: Proposal rejected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Proposal'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Proposal is not pending
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'

  /proposals/{id}/withdraw:
    post:
      tags: [Proposals]
      summary: Withdraw own pending proposal
      description: |
        Access-link Bearer only. Withdraws a **pending** proposal created by
        this token. Status becomes `withdrawn` (not `rejected`). Optional
        JSON body `{ "reason": "…" }`.
      operationId: withdrawProposal
      security:
        - AccessLinkBearer: []
      parameters:
        - $ref: '#/components/parameters/ProposalId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Proposal withdrawn
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Proposal'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Not owned by this access link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Proposal is not pending
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'

  /logs:
    get:
      tags: [Audit log]
      summary: List recent audit log entries
      description: Admin session with at least `read`. Newest entries first.
      operationId: listLogs
      security:
        - AdminSession: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        '200':
          description: Audit entries
          content:
            application/json:
              schema:
                type: object
                required: [logs]
                properties:
                  logs:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuditEntry'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

components:
  securitySchemes:
    AdminSession:
      type: apiKey
      in: cookie
      name: node-red-session
      description: |
        Node-RED editor admin session established via `adminAuth`.
        Equivalent browser cookie/session auth used by the `/mcp` admin UI.
    AccessLinkBearer:
      type: http
      scheme: bearer
      bearerFormat: access-link
      description: |
        Temporary access-link token from `POST /links` (`token` field).
        On 401, ask the user for a new access link.

  parameters:
    LinkId:
      name: id
      in: path
      required: true
      schema:
        type: string
    FlowId:
      name: id
      in: path
      required: true
      schema:
        type: string
    ProposalId:
      name: id
      in: path
      required: true
      schema:
        type: string
        pattern: '^prop-'
    ProposalInclude:
      name: include
      in: query
      required: false
      schema:
        type: string
      description: |
        Comma-separated fields to include on lean proposal responses.
        Supported keys: `payload`, `baseFlow`. Default is lean (omit both).
        Example: `include=payload,baseFlow`.
    ProposalIncludePayload:
      name: include_payload
      in: query
      required: false
      schema:
        type: boolean
      description: |
        Alias for MCP clients: when `true` / `1` / `yes`, includes both
        `payload` and `baseFlow` (same as `include=payload,baseFlow`).

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Admin authentication required or insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    AccessLinkUnauthorized:
      description: |
        Missing (`code: access_link_missing`), wrong scheme such as Basic
        (`code: bearer_scheme_required`), known-expired
        (`code: token_expired`), or invalid/revoked/pre-restart
        (`code: access_link_invalid`) access link. Includes
        `WWW-Authenticate: Bearer` (on every channel, including GET links).
        `token_expired` also includes `expiredAt` and `accessLinksUrl` (the
        `/mcp` page's Access links section, where the user creates a new link). Message instructs the AI to ask the user
        for a new access link (or to use Bearer, not Basic).
      headers:
        WWW-Authenticate:
          schema:
            type: string
          description: 'Bearer realm="node-red-ai-access", charset="UTF-8"'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              value:
                error: 'Access link Bearer token is missing. Ask the user for a new access link and send Authorization: Bearer .'
                code: access_link_missing
            basicScheme:
              value:
                error: 'Authorization must use the Bearer scheme (Authorization: Bearer ), not Basic. Ask the user for a new access link.'
                code: bearer_scheme_required
            expired:
              value:
                error: 'Access link token has expired. Ask the user for a new access link.'
                code: token_expired
                expiredAt: '2026-01-01T12:00:00.000Z'
                accessLinksUrl: 'https://node-red.example/mcp#links'
                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).'
            askForNewLink:
              value:
                error: 'Access link is invalid, revoked, or no longer available after a restart. Ask the user for a new access link.'
                code: access_link_invalid
    VersionConflict:
      description: |
        `expectedRev` does not match the current flow revision
        (`code: version_mismatch`). Re-read flows and resubmit.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/VersionConflictError'
    Forbidden:
      description: |
        AI features disabled, full (`*`) permission required, or access-link
        token kind mismatch (`code: wrong_token_kind` with `expectedKind` /
        `actualKind`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/WrongTokenKindError'
              - $ref: '#/components/schemas/Error'
          examples:
            wrongTokenKind:
              value:
                error: 'Access link kind mismatch: expected rest, got get_link'
                code: wrong_token_kind
                expectedKind: rest
                actualKind: get_link
                hint: 'This REST endpoint requires a rest token. This token is kind=get_link — ask the user for a REST Bearer token (kind=rest).'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    LinkCreateNotSupported:
      description: |
        Attempted to create/list/revoke access links under the GET
        `/mcp/a/` surface (`code: link_create_not_supported`).
        GET links cannot mint tokens — use admin `POST /links` instead.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/LinkCreateNotSupportedError'
    PayloadTooLarge:
      description: Proposal JSON or audit append exceeds size cap
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PayloadTooLargeError'
    UnsupportedMediaType:
      description: |
        Request body Content-Type is not `application/json`
        (`code: unsupported_media_type`). Send
        `Content-Type: application/json` with a JSON body.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notJson:
              value:
                error: 'Content-Type must be application/json'
                code: unsupported_media_type
                hint: 'Send Content-Type: application/json with a JSON body.'
    BadGateway:
      description: Upstream Node-RED runtime/Admin API failure
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
          description: Machine-readable error code — see `ErrorCode` and docs/errors.md.
        hint:
          type: string

    # BEGIN GENERATED ERROR CODES (src/plugin/error-registry.ts)
    ErrorCode:
      type: string
      description: |
        Machine-readable `code` on JSON error bodies. See docs/errors.md.

        - `access_link_missing` (401): No Bearer / path token
        - `bearer_scheme_required` (401): Auth used Basic (or non-Bearer)
        - `token_expired` (401): Known token past `expiresAt` (body includes `expiredAt` and `accessLinksUrl`)
        - `access_link_invalid` (401): Unknown, revoked, or cleared after restart
        - `wrong_token_kind` (403): Token kind does not match this surface
        - `ai_features_disabled` (403): Plugin AI features off
        - `auth_required` (401): Admin session required
        - `insufficient_permissions` (403): Admin lacks required scope
        - `invalid_link_kind` (400): Unknown `kind` on create
        - `invalid_ttl` (400): `ttlSeconds` is zero, negative, not a number, or over the cap for the kind
        - `access_link_limit` (409): Too many active links
        - `access_link_not_found` (404): Unknown link id on revoke
        - `link_create_not_supported` (403): Attempted mint under a GET link
        - `rate_limited` (429): Per-IP or per-token limit
        - `payload_too_large` (413): Body / proposal over ~1.5MB
        - `invalid_json` (400): Malformed JSON body
        - `unsupported_media_type` (415): POST body is not `application/json`
        - `url_too_long` (414): GET URL over soft cap (~8KB)
        - `not_found` (404): Unknown path or resource
        - `missing_query` (400): Required search `q` missing
        - `invalid_filter` (400): Invalid list filter (`status`, `limit`, `offset`, `mine`); body names `parameter` and `allowed`
        - `invalid_arguments` (400): MCP tool arguments failed schema validation; body names `parameter`
        - `invalid_diff_format` (400): Unknown `format` on proposal diff
        - `confirm_required` (400): GET withdraw without `confirm=1`
        - `missing_expected_rev` (400): Proposal submitted without `expectedRev` / `rev`
        - `version_mismatch` (409): Stale `rev` / `expectedRev`
        - `proposal_not_found` (404): Unknown proposal id
        - `proposal_not_pending` (409): Action needs a pending proposal
        - `proposal_not_owned` (403): Withdraw of another token’s proposal
        - `validation_failed` (400): Several validation errors at once (message `N validation errors`)
        - `duplicate_node_id` (400): The same node id appears more than once in a full tab or batch
        - `missing_node_id` (400): Node / op without an id
        - `missing_node_type` (400): Node without a `type`
        - `invalid_node_type` (400): `type` is not a string
        - `invalid_nodes` (400): `flow.nodes` / `flow.configs` not an array
        - `invalid_node` (400): Node entry is not an object
        - `invalid_wires` (400): Bad wires shape, missing target, or more ports than outputs
        - `z_mismatch` (400): Node `z` differs from the flow id
        - `node_already_exists` (400): `add` op for an id already in the flow
        - `protected_property` (400): Tried to change id / type / z
        - `node_not_found` (404): Unknown node (single error only; several errors → `validation_failed`)
        - `field_not_found` (404): Unknown field on a node
        - `text_not_found` (400): `replace_text` find had no match
        - `ambiguous_match` (409): `replace_text` matched more than once
        - `unknown_node_type` (400): Proposal uses a type that is not installed
        - `node_type_not_found` (404): Unknown type on node-types detail
        - `chunk_conflict` (409): Same draft seq re-sent with different data
        - `missing_chunks` (400): Draft submit with gaps in seq
        - `draft_hash_mismatch` (400): Draft sha256 does not match assembled bytes
        - `upstream_error` (502): Node-RED runtime/flows error
        - `internal_error` (500): Unexpected server error
      enum:
        - access_link_missing
        - bearer_scheme_required
        - token_expired
        - access_link_invalid
        - wrong_token_kind
        - ai_features_disabled
        - auth_required
        - insufficient_permissions
        - invalid_link_kind
        - invalid_ttl
        - access_link_limit
        - access_link_not_found
        - link_create_not_supported
        - rate_limited
        - payload_too_large
        - invalid_json
        - unsupported_media_type
        - url_too_long
        - not_found
        - missing_query
        - invalid_filter
        - invalid_arguments
        - invalid_diff_format
        - confirm_required
        - missing_expected_rev
        - version_mismatch
        - proposal_not_found
        - proposal_not_pending
        - proposal_not_owned
        - validation_failed
        - duplicate_node_id
        - missing_node_id
        - missing_node_type
        - invalid_node_type
        - invalid_nodes
        - invalid_node
        - invalid_wires
        - z_mismatch
        - node_already_exists
        - protected_property
        - node_not_found
        - field_not_found
        - text_not_found
        - ambiguous_match
        - unknown_node_type
        - node_type_not_found
        - chunk_conflict
        - missing_chunks
        - draft_hash_mismatch
        - upstream_error
        - internal_error
    # END GENERATED ERROR CODES

    WrongTokenKindError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [code, expectedKind, actualKind]
          properties:
            code:
              type: string
              const: wrong_token_kind
            expectedKind:
              $ref: '#/components/schemas/AccessLinkKind'
            actualKind:
              $ref: '#/components/schemas/AccessLinkKind'
            hint:
              type: string

    InvalidLinkKindError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [code]
          properties:
            code:
              type: string
              const: invalid_link_kind
            error:
              type: string
              example: "kind must be one of: 'get_link', 'rest', 'mcp'"

    LinkCreateNotSupportedError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [code]
          properties:
            code:
              type: string
              const: link_create_not_supported
            error:
              type: string
              example: GET access links cannot create tokens
            hint:
              type: string

    NodeTypeListItem:
      type: object
      required: [type, module]
      properties:
        type:
          type: string
        module:
          type: string
        category:
          type: string
        description:
          type: string
        enabled:
          type: boolean
        version:
          type: string
        setId:
          type: string

    NodeTypeListResult:
      type: object
      required: [types, total, truncated]
      properties:
        types:
          type: array
          items:
            $ref: '#/components/schemas/NodeTypeListItem'
        total:
          type: integer
        truncated:
          type: boolean
        q:
          type: string

    NodeTypeDetail:
      type: object
      required: [type, module]
      properties:
        type:
          type: string
        module:
          type: string
        setId:
          type: string
        version:
          type: string
        enabled:
          type: boolean
        category:
          type: string
        color:
          type: string
        inputs:
          type: number
        outputs:
          type: number
        properties:
          type: object
          additionalProperties: true
        defaults:
          type: object
          additionalProperties: true
        requiredConfigNodeTypes:
          type: array
          items:
            type: string
        help:
          type: string

    PaletteSearchItem:
      type: object
      required: [module, installed]
      properties:
        module:
          type: string
        description:
          type: string
        version:
          type: string
        updated:
          type: string
        downloads:
          type: number
        link:
          type: string
        installed:
          type: boolean

    PaletteSearchResult:
      type: object
      required: [q, modules, total, truncated, hint]
      properties:
        q:
          type: string
        modules:
          type: array
          items:
            $ref: '#/components/schemas/PaletteSearchItem'
        total:
          type: integer
        truncated:
          type: boolean
        hint:
          type: string

    PayloadTooLargeError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            code:
              type: string
              const: payload_too_large

    VersionConflictError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            code:
              type: string
              const: version_mismatch
            expectedRev:
              type: string
            currentRev:
              type: string

    AiAccessSettingsPatch:
      type: object
      properties:
        enabled:
          type: boolean
        allowWithoutAdminAuth:
          type: boolean
        defaultLinkTtlSeconds:
          type: integer
          minimum: 1
        defaultDeploymentType:
          $ref: '#/components/schemas/DeploymentType'
        maxActiveLinks:
          type: integer
          minimum: 1
      additionalProperties: false

    AiAccessSettingsResponse:
      type: object
      required:
        - ok
        - adminAuthEnabled
        - canEdit
        - mcpPageUrl
        - settings
        - sources
        - precedence
      properties:
        ok:
          type: boolean
        adminAuthEnabled:
          type: boolean
        canEdit:
          type: boolean
        permission:
          oneOf:
            - type: string
              enum: [read, '*']
            - type: 'null'
        mcpPageUrl:
          type: string
        apiBase:
          type: string
        settings:
          type: object
          required:
            - enabled
            - allowWithoutAdminAuth
            - defaultLinkTtlSeconds
            - defaultDeploymentType
            - maxActiveLinks
          properties:
            enabled:
              type: boolean
            allowWithoutAdminAuth:
              type: boolean
            defaultLinkTtlSeconds:
              type: integer
            defaultDeploymentType:
              $ref: '#/components/schemas/DeploymentType'
            maxActiveLinks:
              type: integer
        sources:
          type: object
          additionalProperties:
            type: string
            enum: [default, settings.js, disk]
        disk:
          type: object
          additionalProperties: true
        precedence:
          type: array
          items:
            type: string
          description: Highest-wins order for effective values

    Status:
      type: object
      required:
        - ok
        - version
        - adminAuthEnabled
        - aiAccessEnabled
        - aiFeaturesEnabled
        - allowWithoutAdminAuth
        - permission
        - capabilities
        - defaultLinkTtlSeconds
        - maxActiveLinks
        - defaultDeploymentType
      properties:
        ok:
          type: boolean
        version:
          type: string
        adminAuthEnabled:
          type: boolean
        aiAccessEnabled:
          type: boolean
          description: Master switch from resolved settings (disk / settings.js)
        aiFeaturesEnabled:
          type: boolean
        allowWithoutAdminAuth:
          type: boolean
        permission:
          oneOf:
            - type: string
              enum: [read, '*']
            - type: 'null'
          description: |
            Admin session permission, or `null` for access-link callers
            (AI tokens are not admin sessions — see `caller`).
        capabilities:
          $ref: '#/components/schemas/Capabilities'
        storageDir:
          type: string
          description: |
            Server-side plugin storage directory. Admin sessions only; omitted
            for access-link (AI token) callers.
        defaultLinkTtlSeconds:
          type: integer
        maxActiveLinks:
          type: integer
        defaultDeploymentType:
          $ref: '#/components/schemas/DeploymentType'
        caller:
          description: Who is calling — admin session or access-link Bearer.
          oneOf:
            - type: object
              required: [kind]
              properties:
                kind:
                  type: string
                  const: admin
                permission:
                  type: string
                  enum: [read, '*']
            - type: object
              required: [kind, linkId]
              properties:
                kind:
                  type: string
                  const: access-link
                linkId:
                  type: string
                label:
                  type: string
                expiresAt:
                  type: string
                  format: date-time
                  description: ISO expiry time of this access-link token.
                minutesRemaining:
                  type: integer
                  minimum: 0
                  description: >
                    Whole minutes until `expiresAt`, rounded up so it is never 0 while
                    the token is still valid (never negative).
                secondsRemaining:
                  type: integer
                  minimum: 0
                  description: >
                    Whole seconds until `expiresAt` (rounded up; never negative).
                permissions:
                  type: array
                  items:
                    type: string
                  description: >
                    Access-link capabilities (e.g. status:read, flows:read,
                    proposals:create, proposals:read-own)
                baseUrls:
                  type: object
                  required: [api, rpc, openapi, openapiJson, discovery]
                  properties:
                    api:
                      type: string
                    rpc:
                      type: string
                    openapi:
                      type: string
                    openapiJson:
                      type: string
                    discovery:
                      type: string

    Capabilities:
      type: object
      required: [view, manageLinks, approveProposals]
      properties:
        view:
          type: boolean
        manageLinks:
          type: boolean
        approveProposals:
          type: boolean

    DeploymentType:
      type: string
      enum: [nodes, flows, full]
      default: nodes

    AccessLinkKind:
      type: string
      enum: [get_link, rest, mcp]
      description: |
        Surface this token is minted for (admin `POST /links` only):
        - `get_link` → path token on `/mcp/a//…` (GET-only; **cannot**
          create further tokens)
        - `rest` → `Authorization: Bearer` on this REST API
        - `mcp` → `Authorization: Bearer` on `/mcp/rpc`
        Default on create when omitted: `get_link`. Wrong surface → **403**
        `wrong_token_kind`.

    AccessLinkPublic:
      type: object
      required: [id, kind, createdAt, expiresAt]
      properties:
        id:
          type: string
        kind:
          $ref: '#/components/schemas/AccessLinkKind'
        label:
          type: string
        createdAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
        createdBy:
          type: string

    AccessLinkCreated:
      allOf:
        - $ref: '#/components/schemas/AccessLinkPublic'
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: Raw bearer token (returned only at creation)
            url:
              type: string
              nullable: true
              description: >
                Self-contained GET link (`http(s)://{host}{httpAdminRoot}/mcp/a/{token}`).
                Present for kind=get_link; omitted for rest/mcp. The get-link
                URL itself has no create-link operation.

    CreateLinkRequest:
      type: object
      properties:
        ttlSeconds:
          type: number
          exclusiveMinimum: 0
          description: |
            Lifetime in seconds (default 14400 = 4h). Caps by `kind`:
            `get_link` ≤ 86400 (24h); `rest` / `mcp` ≤ 2592000 (30d).
            Over the cap → **400**.
        label:
          type: string
        kind:
          $ref: '#/components/schemas/AccessLinkKind'
          description: |
            Token surface (default `get_link`). Unknown values return
            **400** `invalid_link_kind`.

    FlowsSnapshot:
      type: object
      required: [rev, flows]
      properties:
        rev:
          type: string
        flows:
          type: array
          items:
            type: object
            additionalProperties: true

    FlowSnapshot:
      type: object
      required: [rev, flow]
      properties:
        rev:
          type: string
        flow:
          $ref: '#/components/schemas/FlowTab'

    FlowSummary:
      type: object
      description: Lightweight flow view from `GET /flows/{id}?summary=1`
      required: [rev, flowId, nodes, nodeCount]
      properties:
        rev:
          type: string
        flowId:
          type: string
        label:
          type: string
        type:
          type: string
        nodeCount:
          type: integer
        nodes:
          type: array
          items:
            type: object
            required: [id, type, bytes]
            properties:
              id:
                type: string
              type:
                type: string
              name:
                type: string
              wires: {}
              bytes:
                type: integer
                description: UTF-8 byte size of the full node JSON

    NodeSnapshot:
      type: object
      required: [rev, flowId, node]
      properties:
        rev:
          type: string
        flowId:
          oneOf:
            - type: string
            - type: 'null'
        node:
          type: object
          additionalProperties: true

    NodeFieldSnapshot:
      type: object
      required: [rev, flowId, nodeId, field, value, totalLines, bytes]
      properties:
        rev:
          type: string
        flowId:
          oneOf:
            - type: string
            - type: 'null'
        nodeId:
          type: string
        field:
          type: string
        value:
          type: string
        totalLines:
          type: integer
        bytes:
          type: integer
        lineRange:
          type: object
          required: [start, end]
          properties:
            start:
              type: integer
            end:
              type: integer

    SearchResult:
      type: object
      required: [rev, q, hits, hitCount, truncated, cap]
      properties:
        rev:
          type: string
        q:
          type: string
        flowId:
          type: string
        hitCount:
          type: integer
        truncated:
          type: boolean
        cap:
          type: integer
        hits:
          type: array
          items:
            type: object
            required: [flowId, nodeId, field, lines, snippet]
            properties:
              flowId:
                oneOf:
                  - type: string
                  - type: 'null'
              nodeId:
                type: string
              field:
                type: string
              lines:
                type: array
                items:
                  type: integer
              snippet:
                type: string

    FlowTab:
      type: object
      description: |
        Nested tab shape from `GET /flows/{id}` / `get_flow`. Reuse this object
        as `payload.flow` on `submit_proposal`. `get_flow` always includes
        `type` (defaults to `tab` when Node-RED omits it) and `nodes` (array,
        possibly empty). `configs` is optional.
      required: [id, type, nodes]
      properties:
        id:
          type: string
        type:
          type: string
          description: Tab/subflow type; `get_flow` defaults omitted values to `tab`
          default: tab
        label:
          type: string
        disabled:
          type: boolean
        info:
          type: string
        env:
          type: array
          items: {}
        nodes:
          type: array
          description: Child nodes for the tab (required on submit_proposal)
          items:
            type: object
            additionalProperties: true
        configs:
          type: array
          items:
            type: object
            additionalProperties: true
      additionalProperties: true

    ProposalStatus:
      type: string
      enum: [pending, approved, rejected, failed, withdrawn, expired, superseded]

    Proposal:
      type: object
      required:
        - id
        - type
        - status
        - expectedRev
        - payload
        - createdBy
        - createdAt
        - updatedAt
        - title
        - displayName
        - reviewUrl
        - reviewPath
      properties:
        id:
          type: string
        type:
          type: string
          const: replace_flow
        status:
          $ref: '#/components/schemas/ProposalStatus'
        expectedRev:
          type: string
        payload:
          $ref: '#/components/schemas/ReplaceFlowPayload'
        baseFlow:
          $ref: '#/components/schemas/FlowTab'
          description: |
            Flow tab snapshot at `expectedRev` when the proposal was submitted.
            Diffs compare `payload.flow` against this base. Omitted on legacy
            proposals created before this field existed.
        title:
          type: string
          maxLength: 80
          description: |
            Short human display title. Set at submit (AI-supplied or
            auto-generated); ` in ` is appended when the title does
            not already name the flow. Always present on API responses
            (resolved for legacy rows).
        storedTitle:
          type: string
          maxLength: 80
          description: |
            The title as saved. With an AI-supplied title this is that title
            (sanitized) without the ` in ` suffix that `title` may
            add. When no title was given it is the auto-generated title, which
            may already contain the flow name. Present on every proposal
            response, including submit.
        flowLabel:
          type: string
          description: |
            Human label of the target flow tab (stored snapshot, else the live
            flows). Empty string when unknown.
        displayName:
          type: string
          description: |
            `title · ` in Node-RED **server local time**
            with an explicit UTC offset (e.g. `Edit fn1 (function) · 16 Oct 01:35 +00:00`).
            Prefer this when referring to the proposal with a human. UIs with a
            browser should render `createdAt` in the client's local timezone
            instead of parsing this embedded server clock.
        reviewUrl:
          type: string
          description: |
            Absolute deep link (`https://host/mcp#proposal=`) built from the
            request Host/protocol. Uses `X-Forwarded-Host` / `X-Forwarded-Proto`
            only when Express `trust proxy` is enabled; otherwise falls back to
            the relative `reviewPath` when Host is unavailable.
        reviewPath:
          type: string
          description: |
            Relative deep link `/mcp#proposal=` (under `httpAdminRoot` when
            set) that opens the admin page, switches to Proposals, and
            scrolls/highlights this item.
        createdBy:
          $ref: '#/components/schemas/ProposalCreator'
        createdAt:
          type: string
          format: date-time
          description: |
            ISO-8601 instant when the proposal was created. Prefer this for
            client-side local-time rendering.
        updatedAt:
          type: string
          format: date-time
        mine:
          type: boolean
          description: |
            Present on access-link responses: true when this token created the
            proposal.
        conflictingProposals:
          type: array
          description: |
            Pending proposals on the same flow (any token). Included on submit
            and on read of pending proposals when siblings exist.
          items:
            type: object
            required: [id, title, displayName, mine]
            properties:
              id:
                type: string
              title:
                type: string
              displayName:
                type: string
              mine:
                type: boolean
              overlappingNodeIds:
                type: array
                items:
                  type: string
              overlappingFields:
                type: array
                items:
                  type: object
                  required: [nodeId, fields]
                  properties:
                    nodeId:
                      type: string
                    fields:
                      type: array
                      items:
                        type: string
        decidedBy:
          $ref: '#/components/schemas/ProposalDecisionBy'
        reason:
          type: string
          description: |
            Present when rejected, failed, or withdrawn. On revision conflict
            after approve, explains that expectedRev is stale and the AI should
            re-read flows and submit a new proposal.
        resultRev:
          type: string

    ReplaceFlowPayload:
      type: object
      required: [flowId]
      description: |
        **Canonical form (preferred):** `{ flowId, nodes: [op…] }` with no
        `flow`. Ops apply to the live tab at top-level `expectedRev`.

        Supported `nodes[]` ops:
        - `replace_text` — `{ op:"replace_text", node, field, find, replace, occurrence? }`
        - `set` — `{ id, set:{…} }` or `{ op:"set", id, …props }`
        - `add` — `{ op:"add", node:{ id, type, … } }`
        - `remove` — `{ op:"remove", id }` (or `{ id }` remove shape)

        **Alternative:** top-level `nodes` on `CreateProposalRequest` is an
        alias for `payload.nodes` (same shapes).

        **Full tab:** `{ flowId, flow }` from `GET /flows/{id}` still works
        (optionally with `nodes` applied first when both are present).
      properties:
        flowId:
          type: string
        flow:
          $ref: '#/components/schemas/FlowTab'
        nodes:
          type: array
          description: |
            Canonical node ops (`replace_text` / `set` / `add` / `remove`)
            applied to the live tab when `flow` is omitted
          items:
            type: object
            additionalProperties: true

    ProposalCreator:
      type: object
      required: [kind, linkId]
      properties:
        kind:
          type: string
          const: access-link
        linkId:
          type: string
        label:
          type: string

    ProposalDecisionBy:
      type: object
      required: [kind]
      description: |
        Admin for approve/reject/fail; access-link for withdraw.
      properties:
        kind:
          type: string
          enum: [admin, access-link]
        username:
          type: string
        linkId:
          type: string
        label:
          type: string

    CreateProposalRequest:
      type: object
      required: [type, expectedRev, payload]
      properties:
        type:
          type: string
          const: replace_flow
        expectedRev:
          type: string
          description: Preferred field name for the current flow revision (required)
        expected_rev:
          type: string
          description: Alias for expectedRev
        title:
          type: string
          maxLength: 80
          description: |
            Optional short human title (≤80 chars, sanitized). Auto-generated
            from the change set when omitted.
        payload:
          $ref: '#/components/schemas/ReplaceFlowPayload'
        nodes:
          type: array
          description: Optional top-level alias for payload.nodes
          items:
            type: object
            additionalProperties: true
      example:
        type: replace_flow
        expectedRev: 'abc123rev'
        title: Rename inject to Hello
        payload:
          flowId: tab1
          nodes:
            - op: replace_text
              node: n1
              field: template
              find: Hello
              replace: Hi

    ApproveProposalRequest:
      type: object
      properties:
        deploymentType:
          $ref: '#/components/schemas/DeploymentType'

    RejectProposalRequest:
      type: object
      properties:
        reason:
          type: string
          default: Rejected by admin

    ProposalDiffLine:
      type: object
      required: [op, text]
      properties:
        op:
          type: string
          enum: [equal, add, remove]
        text:
          type: string

    ProposalDiffPropertyChange:
      oneOf:
        - type: object
          required: [key, kind, before, after]
          properties:
            key:
              type: string
            kind:
              const: scalar
            before: {}
            after: {}
        - type: object
          required: [key, kind, before, after, lines]
          properties:
            key:
              type: string
            kind:
              const: text
            before:
              type: string
            after:
              type: string
            lines:
              type: array
              items:
                $ref: '#/components/schemas/ProposalDiffLine'

    ProposalDiffModifiedNode:
      type: object
      required: [id, type, changes]
      properties:
        id:
          type: string
        type:
          type: string
        name:
          type: string
        changes:
          type: array
          description: Changed properties only (modified nodes).
          items:
            $ref: '#/components/schemas/ProposalDiffPropertyChange'

    ProposalDiffPresenceProperty:
      type: object
      required: [key, kind, value]
      properties:
        key:
          type: string
        kind:
          type: string
          enum: [scalar, text]
        value: {}
        preview:
          type: string
          enum: [html]
          description: When set, the /mcp UI may offer a sandboxed HTML preview.

    ProposalDiffPresenceNode:
      type: object
      required: [id, type, summary, keyFields, properties]
      description: |
        Added or removed node as one readable block: summary sentence, key
        fields (name, wires by node name, code/HTML), and full properties.
      properties:
        id:
          type: string
        type:
          type: string
        name:
          type: string
        summary:
          type: string
          description: |
            Human sentence, e.g. Adds template node "demo-html" to flow "Test Flow".
        keyFields:
          type: array
          items:
            $ref: '#/components/schemas/ProposalDiffPresenceProperty'
        properties:
          type: array
          description: 'Full property list (UI: under All properties).'
          items:
            $ref: '#/components/schemas/ProposalDiffPresenceProperty'

    ProposalDiffSections:
      type: object
      required: [summary, added, removed, modified]
      properties:
        summary:
          type: object
          required: [added, removed, modified, tabChanges]
          properties:
            added:
              type: integer
            removed:
              type: integer
            modified:
              type: integer
            tabChanges:
              type: array
              items:
                type: string
        added:
          type: array
          items:
            $ref: '#/components/schemas/ProposalDiffPresenceNode'
        removed:
          type: array
          items:
            $ref: '#/components/schemas/ProposalDiffPresenceNode'
        modified:
          type: array
          items:
            $ref: '#/components/schemas/ProposalDiffModifiedNode'

    ProposalDiff:
      allOf:
        - $ref: '#/components/schemas/ProposalDiffSections'
        - type: object
          required:
            - proposalId
            - flowId
            - expectedRev
            - currentRev
            - baseRevStale
            - compareAgainst
          properties:
            proposalId:
              type: string
            flowId:
              type: string
            expectedRev:
              type: string
            currentRev:
              type: string
            baseRevStale:
              type: boolean
              description: |
                True only for pending proposals when expectedRev ≠ live rev.
                Never true for approved/rejected/failed.
            compareAgainst:
              type: string
              enum: [base, current]
            baseSnapshotMissing:
              type: boolean
              description: True for legacy proposals without a stored baseFlow
            vsCurrent:
              $ref: '#/components/schemas/ProposalDiffSections'
              description: |
                Present when pending and baseRevStale — proposal vs live flow.

    AuditActor:
      oneOf:
        - type: object
          required: [kind, linkId]
          properties:
            kind:
              const: access-link
            linkId:
              type: string
            label:
              type: string
        - type: object
          required: [kind]
          properties:
            kind:
              const: admin
            username:
              type: string
        - type: object
          required: [kind]
          properties:
            kind:
              const: system

    AuditEntry:
      type: object
      required: [id, at, action, actor]
      properties:
        id:
          type: string
        at:
          type: string
          format: date-time
        action:
          type: string
        actor:
          $ref: '#/components/schemas/AuditActor'
        details:
          type: object
          additionalProperties: true