openapi: "3.1.0"
info:
  title: publishwith.ai API
  description: |
    REST API for publishing governed AI-generated reports and dashboards.
    Designed for ChatGPT Custom Actions and any HTTP client.

    **Base URL:** `https://publishwith.ai`

    **Auth:** Bearer token — generate a personal API key from Connect.
    API keys are for ChatGPT Actions and clients that cannot complete OAuth;
    Enterprise workspaces may restrict or disable key creation.
  version: "1.0"
  contact:
    url: https://publishwith.ai

servers:
  - url: https://publishwith.ai

security:
  - bearerAuth: []

paths:
  /api/v1/call:
    post:
      operationId: invokeAction
      summary: Invoke any publishwith.ai action (complete API)
      description: |
        Generic endpoint for the COMPLETE publishwith.ai capability set — every
        operation available over MCP. Send `{ "action": "<name>", ...params }`.
        The `action` property lists every action and its parameters. Typed
        shortcuts exist for common flows: publish, list, search, share.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallRequest"
      responses:
        "200":
          description: Action result. Shape depends on the action.
          content:
            application/json:
              schema:
                # The ChatGPT importer rejects an object with neither `properties`
                # nor an `additionalProperties` schema (`additionalProperties: true`
                # is NOT enough). The fields below are the common ones; OpenAPI
                # allows additional fields by default, so action-specific results
                # still pass through.
                type: object
                properties:
                  artifact_id: { type: string }
                  slug: { type: string }
                  url: { type: string }
                  version: { type: integer }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/publish:
    post:
      operationId: publishArtifact
      summary: Publish an artifact
      description: |
        Create or update a governed HTML artifact (idempotent by derived slug).
        Static mode: supply `files` with `index.html` (HTML) or `index.md`
        (Markdown, auto-rendered to a styled page). Dynamic mode: supply
        `manifest` (YAML) and `templates` (Liquid).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishRequest"
            examples:
              static:
                summary: Monthly revenue report (static HTML)
                value:
                  name: Revenue Report Q1 2025
                  files:
                    index.html: "<html>...</html>"
              markdown:
                summary: Plain Markdown document (auto-rendered to HTML)
                value:
                  name: Project Notes
                  files:
                    index.md: "# Project Notes\n\nWrite **Markdown** directly. It is rendered to a styled page."
              dynamic:
                summary: Live churn dashboard (dynamic SSR)
                value:
                  name: Churn Dashboard
                  mode: dynamic
                  manifest: |
                    kind: mcp-html-artifact
                    version: 1
                    routes:
                      - path: /
                        template: index.html
                        queries:
                          total:
                            sql: "SELECT COUNT(*) as count FROM churned_customers"
                  templates:
                    index.html: "<html>{{ total.rows[0].count }} churned</html>"
      responses:
        "200":
          description: Artifact published
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/VersionChanged"

  /api/v1/artifacts:
    get:
      operationId: listArtifacts
      summary: List all artifacts in your tenant
      parameters:
        - name: query
          in: query
          required: false
          schema:
            type: string
          description: Filter artifacts by substring match on name, slug, or description.
      responses:
        "200":
          description: Array of artifacts
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ArtifactSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/artifacts/{slug}/source:
    get:
      operationId: getArtifactSource
      summary: Get an artifact's raw source files for editing
      description: |
        Returns an artifact's raw source files (text inline, binary base64), the
        same shape publishArtifact accepts. Use this to edit an existing artifact
        instead of the rendered public URL, which returns HTML and would clobber a
        Markdown source.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: |
            Artifact slug (returned by publishArtifact). To edit: fetch the source
            here, change it, then publish it back. Do not use the rendered page
            from the public URL as your edit source: it is HTML, so editing it
            turns a Markdown (index.md) artifact into HTML. Publish replaces the
            whole version, so send back every file this returns, not just the
            changed one.
        - name: version
          in: query
          required: false
          schema:
            type: integer
          description: Version to read (defaults to the current published version).
      responses:
        "200":
          description: The artifact's source files
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourceResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/search:
    get:
      operationId: searchArtifacts
      summary: Full-text search across your artifacts
      description: |
        Searches artifact content (HTML body, Markdown, text), not just metadata.
        Results are relevance-ranked with highlighted snippets and filtered to
        artifacts you can view. Requires a Team or Enterprise plan.
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
          description: Search query.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 20
            maximum: 50
          description: Maximum number of results (default 20, max 50).
      responses:
        "200":
          description: Array of search results
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SearchResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/v1/people:
    get:
      operationId: findPeople
      summary: Resolve a person by name or email in your organization directory
      description: |
        Resolves a recipient (e.g. "Federico Coletto") against the tenant
        directory to share an artifact with the right person. Returns real users
        and provisioned directory people (active=false, never signed in). A share
        to a provisioned person resolves on their first login. Tenant-scoped.
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
          description: Name or email to resolve (e.g. "Federico Coletto" or "federico@").
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 25
            maximum: 50
          description: Maximum number of matches (default 25, max 50).
      responses:
        "200":
          description: Matching people
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        email:
                          type: string
                        active:
                          type: boolean
                          description: True if a real platform user; false if provisioned (never signed in).
                        user_id:
                          type: string
                          description: Platform user id (present when active).
                        external_id:
                          type: string
                          description: IdP object id (present for provisioned people).
                        job_title:
                          type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/v1/artifacts/{slug}/request-upload:
    post:
      operationId: requestUpload
      summary: Get an upload token to attach binary files out-of-band
      description: |
        Returns an upload token so large binary files (images, fonts > 64 KiB)
        can be uploaded directly via curl with no model output tokens spent on
        base64 encoding. The token is also returned by publishArtifact.
        Upload via POST /api/v1/upload/{token}?name=<filename> with raw bytes.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Artifact slug (returned by publishArtifact).
      responses:
        "200":
          description: Upload token and ready-to-use curl command
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_token:
                    type: string
                    description: 32-hex-char token; bound to this artifact, caller, and version.
                  upload_url:
                    type: string
                    description: Full URL for the upload endpoint.
                  upload_expires_at:
                    type: string
                    description: RFC3339 expiry timestamp (5-minute TTL).
                  curl:
                    type: string
                    description: Ready-to-paste curl command (substitute the filename).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/upload/{token}:
    post:
      operationId: uploadFile
      summary: Upload a binary file using an upload token
      description: |
        Upload raw file bytes to an artifact using a token from requestUpload or
        publishArtifact. This endpoint is token-authenticated — no Bearer header
        required. The token is multi-use within its 5-minute TTL. Files undergo
        the same extension and magic-byte checks as inline publish.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
          description: Upload token from requestUpload or publishArtifact response.
        - name: name
          in: query
          required: true
          schema:
            type: string
          description: Filename for the uploaded file (e.g. logo.png).
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      security: []
      responses:
        "200":
          description: File uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  file:
                    type: string
                    description: Filename as stored.
                  version:
                    type: integer
                    description: Artifact version the file was added to.
                  bytes:
                    type: integer
                    description: Number of bytes written.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "410":
          description: Upload token has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: File exceeds the per-asset size limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/artifacts/{slug}/share:
    post:
      operationId: shareWith
      summary: Share an artifact with a person, group, or audience
      description: |
        Additively grant access to one typed principal (tenant user, group,
        external email, or audience). Internal principals open the canonical URL;
        an external email gets a tokenized, expiring share link. Never changes
        document visibility.
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Artifact slug (returned by publishArtifact)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ShareRequest"
      responses:
        "200":
          description: Access granted (or de-duplicated/unchanged/conflict)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShareResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Personal API key from Connect

  schemas:
    CallRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum: [create_artifact, list_artifacts, delete_artifact, publish_static, publish_dynamic, execute_sql, list_tables, validate_artifact, set_access, set_visibility, set_published, set_ttl, share_with, revoke_access, list_access, list_views, preview, list_groups, list_users, find_people, create_user, list_audience_groups, set_audience_group, delete_audience_group, publish_python, run_python, validate_python, search, docs]
          description: |
            The action to invoke. Parameters per action:
            - `create_artifact` — name, mode (static|dynamic), description
            - `list_artifacts` — query (optional)
            - `delete_artifact` — artifact_id
            - `publish_static` — artifact_id, files {filename: content}, description (optional). Entry point index.html or index.md (Markdown auto-renders).
            - `publish_dynamic` — artifact_id, manifest (YAML), templates {name: liquid}, description (optional)
            - `execute_sql` — artifact_id, sql, params (array, optional), confirmation_token (required for destructive ops)
            - `list_tables` — artifact_id
            - `validate_artifact` — artifact_id, manifest
            - `set_access` — artifact_id, and any of visibility (private|tenant|public), owners[], editors[], viewers[], groups[], replace (bool: revoke grants not listed), owner_only (bool: reset to owner-only + private). DECLARATIVE bulk. owners/editors must be tenant users.
            - `set_visibility` — artifact_id, visibility (private|tenant|public)
            - `set_published` — artifact_id, published (boolean). Toggle online/offline; content kept. published=true requires version>0.
            - `set_ttl` — artifact_id, ttl_days (integer). After ttl_days the artifact auto-goes-offline behind a wall for public visitors; owner and MCP/REST still access it. 0 removes the TTL and brings an expired artifact back online. Max 365.
            - `share_with` — artifact_id, principal ({type: tenant_user|tenant_group|external_email|audience, value}), access (view|edit|own, optional), delivery (auto|canonical|link, optional), ttl_days (optional, external only). ADDITIVE single principal; returns result_code + resolved_lane + share_url? Never changes visibility.
            - `revoke_access` — artifact_id, and principal ({type, value}) OR grant_id. Removes the direct grant only; response states residual access.
            - `list_access` — artifact_id. Who CAN access: visibility + owner + every live grant with provenance.
            - `list_views` — artifact_id, limit (optional). Who DID access (the access log).
            - `preview` — artifact_id, path (optional), method (optional), body (optional), api_name (optional)
            - `list_groups` — query (optional)
            - `list_users` — query (optional)
            - `create_user` — email, groups (array, optional)
            - `list_audience_groups` — (no params) lists this tenant's audience groups of external contacts
            - `set_audience_group` — name, contacts (array of {display_name, email}). Creates or replaces the group's membership by name.
            - `delete_audience_group` — audience_group_id
            - `validate_python` — script, libraries (array, optional). SYNCHRONOUS, team+. Validates the Python without running it; returns ok, message (what is unavailable + the in-sandbox alternative), capabilities, available_libraries, and granted_connections (egress names you may declare). Call before publish_python.
            - `publish_python` — script (Python), manifest (report.yaml: output_type html|dataset, libraries[], db_write, schedule, connections), name (create) or artifact_id (update), description (optional). team+ editor. Linted first; unsupported imports rejected. Each connection in the manifest must be granted to one of your groups (a tenant admin grants connections in the UI); db_write is admin-only.
            - `run_python` — artifact_id. team+ editor. Runs the stored report against its own SQLite and renders a fresh version; identical output is a no_change (version unchanged). Returns version, status, output_sha, url.
            - `search` — query, limit (optional)
            - `docs` — topic (optional)
        artifact_id: { type: string }
        name: { type: string }
        mode: { type: string, enum: [static, dynamic] }
        description: { type: string }
        files:
          type: object
          description: "Filename to UTF-8 content (publish_static). Use index.html OR index.md as the entry point; add css/js/images/more pages as extra keys."
          properties:
            index.html: { type: string, description: "HTML entry point." }
            index.md: { type: string, description: "Markdown entry point, auto-rendered to a styled page." }
          additionalProperties: { type: string }
        manifest: { type: string }
        script: { type: string, description: "Python report source (publish_python, validate_python). Builtins read_input/write_output/log/now; modules requests, db (db.query/db.execute/db.list_tables), tabular (SQL over in-memory rows), llm. No pandas/numpy/matplotlib." }
        libraries: { type: array, items: { type: string }, description: "Optional libstore packages the report imports, e.g. [openpyxl, jinja2] (publish_python, validate_python). Only declared libs are mounted." }
        templates:
          type: object
          description: "Template name to Liquid content (publish_dynamic)."
          properties:
            index.html: { type: string, description: "Main Liquid template." }
          additionalProperties: { type: string }
        sql: { type: string }
        params: { type: array, items: { type: string }, description: "Positional SQL parameters, bound in order. Numbers may be sent as strings." }
        confirmation_token: { type: string }
        principal: { $ref: "#/components/schemas/Principal" }
        visibility: { type: string, enum: [private, tenant, public], description: "Document-wide visibility (set_access, set_visibility)." }
        owners: { type: array, items: { type: string }, description: "Owner emails (set_access). Must be tenant users." }
        editors: { type: array, items: { type: string }, description: "Editor emails (set_access). Must be tenant users." }
        viewers: { type: array, items: { type: string }, description: "Viewer emails (set_access). An email unknown to the tenant becomes an external share link." }
        replace: { type: boolean, description: "set_access: revoke live direct grants not present in the new owners/editors/viewers/groups set." }
        owner_only: { type: boolean, description: "set_access: reset access to owner-only and visibility to private." }
        access: { type: string, enum: [view, edit, own], description: "Access level to grant (share_with). Owners/editors must be tenant users." }
        delivery: { type: string, enum: [auto, canonical, link], description: "Delivery channel (share_with): auto resolves by lane, canonical forces the in-app URL, link forces a tokenized share link." }
        grant_id: { type: string, description: "Grant id to revoke (revoke_access; alternative to principal)." }
        published: { type: boolean, description: "Toggle online/offline (set_published). true=online, false=offline (content kept)." }
        ttl_days: { type: integer, description: "Time-to-live in days (set_ttl; also publish_static/publish_dynamic, and external grant expiry for share_with). After this many days a public artifact walls off; owner and MCP/REST keep access. 0 removes the TTL. Max 365." }
        path: { type: string }
        method: { type: string }
        body: { type: object, additionalProperties: { type: string }, description: "Form field key-value pairs for POST preview." }
        api_name: { type: string }
        email: { type: string }
        groups: { type: array, items: { type: string } }
        contacts:
          type: array
          description: "External recipients (set_audience_group). Membership is fully replaced on each call."
          items: { $ref: "#/components/schemas/AudienceContact" }
        audience_group_id: { type: string, description: "Audience group ID (delete_audience_group)." }
        query: { type: string }
        limit: { type: integer }
        topic: { type: string }

    AudienceContact:
      type: object
      required:
        - email
      properties:
        display_name: { type: string }
        email: { type: string }

    Principal:
      type: object
      required:
        - type
        - value
      properties:
        type:
          type: string
          enum: [tenant_user, tenant_group, external_email, audience]
          description: "Principal kind. tenant_user/tenant_group are internal (canonical URL); external_email gets a tokenized share link; audience fans out external links to a saved contact group."
        value:
          type: string
          description: "Identifier: an email (tenant_user/external_email), a group name (tenant_group), or an audience group id (audience)."

    PublishRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          description: Artifact display name. A URL-safe slug is derived from this.
          example: Revenue Report Q1 2025
        mode:
          type: string
          enum: [static, dynamic]
          default: static
          description: |
            `static` — upload HTML/CSS/JS files.
            `dynamic` — server-side rendering via YAML manifest + Liquid templates.
        files:
          type: object
          description: |
            Filename → file content (UTF-8 text).
            Required for static mode. The entry point is `index.html` (HTML)
            or `index.md` (Markdown, auto-rendered to a styled HTML page).
            Additional assets (CSS, JS, images, more `.md`/`.html` pages) may
            be included alongside it as extra keys.
          properties:
            index.html: { type: string, description: "HTML entry point." }
            index.md: { type: string, description: "Markdown entry point, auto-rendered to a styled page." }
          additionalProperties:
            type: string
          example:
            index.md: "# Revenue\n\nQ1 revenue reached **$501k**."
        manifest:
          type: string
          description: YAML manifest for dynamic mode. Defines queries and routes.
        templates:
          type: object
          description: Template name → Liquid template content for dynamic mode.
          properties:
            index.html: { type: string, description: "Main Liquid template." }
          additionalProperties:
            type: string
        ttl_days:
          type: integer
          description: |
            Optional time-to-live in days. After this many days the artifact
            auto-goes-offline behind a wall for public visitors; the owner and
            MCP/REST keep access. 0 removes the TTL. Max 365. Omit to leave any
            existing TTL untouched on re-publish.
          example: 10
        base_version:
          type: integer
          description: |
            Optional. The version this static update is based on (the version you
            last read). If another author published a newer version since then,
            the publish is rejected with 409 VERSION_CHANGED instead of silently
            overwriting their work. Same-author updates need no base_version.
          example: 3
        confirmation_token:
          type: string
          description: |
            From a 409 VERSION_CHANGED response. Re-call publish with the same
            files and this token to overwrite the version another author published.

    PublishResponse:
      type: object
      properties:
        artifact_id:
          type: string
          format: uuid
        slug:
          type: string
          example: revenue-report-q1-2025
        url:
          type: string
          format: uri
          description: Authenticated URL (requires publishwith.ai account).
          example: https://publishwith.ai/a/revenue-report-q1-2025/
        version:
          type: integer
          description: Version number of the published content.
        expires_at:
          type: string
          format: date-time
          description: TTL deadline, present only when ttl_days was set.
        upload_token:
          type: string
          description: Upload token for adding binary files out-of-band (5-minute TTL).
        upload_url:
          type: string
          description: Full URL for the upload endpoint.
        upload_expires_at:
          type: string
          description: RFC3339 expiry timestamp for the upload token.
        curl:
          type: string
          description: Ready-to-paste curl command for uploading a binary file.

    SourceResponse:
      type: object
      properties:
        artifact_id:
          type: string
          format: uuid
        mode:
          type: string
          enum: [static, dynamic]
        version:
          type: integer
          description: The version these source files belong to.
        files:
          type: object
          description: |
            Map of filename to content, the same shape publishArtifact accepts.
            Text files (.md, .html, .css, .js, .json, ...) are inline UTF-8;
            binary assets (.png, fonts, ...) are base64-encoded.
          properties:
            index.html:
              type: string
            index.md:
              type: string
          additionalProperties:
            type: string

    ArtifactSummary:
      type: object
      properties:
        artifact_id:
          type: string
          format: uuid
        name:
          type: string
        slug:
          type: string
        mode:
          type: string
          enum: [static, dynamic]
        version:
          type: integer
        url:
          type: string
          format: uri
        status:
          type: string
          enum: [draft, online, offline, expired]
          description: Derived lifecycle state. `expired` means past its TTL (walled for public visitors).
        expires_at:
          type: string
          format: date-time
          description: TTL deadline, present only when a TTL is set.

    SearchResult:
      type: object
      properties:
        artifact_id:
          type: string
          format: uuid
        snippet:
          type: string
          description: Matching text excerpt with the query terms highlighted.
        score:
          type: number
          description: Relevance score (higher is more relevant).
        slug:
          type: string
        name:
          type: string
        url:
          type: string
          format: uri

    ShareRequest:
      type: object
      required:
        - principal
      properties:
        principal: { $ref: "#/components/schemas/Principal" }
        access:
          type: string
          enum: [view, edit, own]
          default: view
          description: "Access level to grant. Owners/editors (edit/own) must be tenant users."
        delivery:
          type: string
          enum: [auto, canonical, link]
          default: auto
          description: "auto resolves by lane (internal→canonical, external→link); canonical forces the in-app URL; link forces a tokenized share link."
        ttl_days:
          type: integer
          description: Expiry in days for an external (link) grant. Default 90. Ignored for internal grants.

    ShareResponse:
      type: object
      description: "Outcome of an additive share. result_code says what happened; an external grant also returns share_url. An audience fan-out returns created[] + deduplicated_count."
      properties:
        result_code:
          type: string
          enum: [created, deduplicated, upgraded, unchanged, conflict]
          description: "created=new grant; deduplicated=identical grant already existed; upgraded=raised to a higher level; unchanged=already had access via visibility/group; conflict=already holds a higher level (not downgraded)."
        resolved_lane:
          type: string
          enum: [internal, external]
        resolved_principal_type:
          type: string
          enum: [tenant_user, tenant_group, external_email, audience]
        opens_via:
          type: string
          description: "How the principal opens the artifact: 'canonical URL' or 'share link'."
        share_url:
          type: string
          format: uri
          description: Tokenized share URL, present only for an external (link) grant.
          example: https://publishwith.ai/p/abc123def456
        grant_id:
          type: string
          description: The created or affected grant id.
        current_access_level:
          type: string
          description: On a conflict, the principal's existing (higher) access level.
        created:
          type: array
          description: For an audience fan-out, the per-contact links created.
          items:
            type: object
            properties:
              email: { type: string }
              share_url: { type: string, format: uri }
        deduplicated_count:
          type: integer
          description: For an audience fan-out, the number of contacts skipped because they already had a live grant.

    Error:
      type: object
      properties:
        error:
          type: string

    VersionChanged:
      type: object
      description: |
        Cross-author publish conflict: another author published a newer version
        after your base_version. No merge is attempted. Re-call publish with the
        same files and the confirmation_token to overwrite.
      properties:
        code:
          type: string
          enum: [VERSION_CHANGED]
          description: Result code identifying the conflict.
        message:
          type: string
        hint:
          type: string
        current_version:
          type: integer
          description: The live head version another author published.
        confirmation_token:
          type: string
          description: Pass back as confirmation_token to overwrite the current version.

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: No tenant associated with this API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    VersionChanged:
      description: Another author published a newer version (cross-author conflict)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/VersionChanged"
    NotFound:
      description: Artifact not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
