openapi: 3.1.0

info:
  title: GeoVector CMS publish protocol
  version: '1.0.0'
  summary: The HTTP contract a site implements to receive articles from GeoVector.
  description: |
    GeoVector publishes generated articles to a client-owned site by calling a
    small HTTP API the **client** implements. This document is the normative
    contract: it describes what GeoVector sends and what a conforming receiver
    must answer.

    You implement this endpoint. GeoVector calls it. There is nothing to install
    — a receiver is four handlers on one path, and any language can serve them.

    ## Companion artifacts

    | File | What it is |
    |---|---|
    | `article-html-policy.v1.json` | The allowlist and structural rules to apply to `content`. Machine-readable, generated from the reference implementation. |
    | `html-policy-conformance.v1.json` | Test vectors for that policy. Run all of them through your content check. |
    | `verify-receiver.sh` | A curl script that exercises this contract against your live endpoint. |
    | `geovector-cms-integration-guide.html` | The readable companion to this spec, with worked examples. |

    ## Node and TypeScript

    `@geo/static-cms-server` implements everything here. If you are on Node,
    use it and skip the rest. This spec exists because most client sites are
    not on Node, and a contract that only exists as TypeScript is not a
    contract.

    ## The parts you must not skip

    Three requirements are load-bearing for security, and every one of them is
    invisible until it is exploited:

    1. **Apply the content policy.** You are storing HTML from a remote system
       and rendering it on your own domain. Anyone who obtains the bearer token
       has stored XSS unless you decide what markup you keep. See
       `article-html-policy.v1.json`.
    2. **Validate `slug` on every verb, including GET and DELETE.** Read and
       delete are the paths people forget. If your storage is filesystem- or
       git-backed, an unvalidated slug is a path traversal.
    3. **Compare the bearer token in constant time,** over fixed-length
       digests. `hash_equals` (PHP), `hmac.compare_digest` (Python),
       `subtle.ConstantTimeCompare` (Go), `OpenSSL.secure_compare` (Ruby),
       `crypto.timingSafeEqual` (Node).

    ## Validate, do not sanitize

    A conforming receiver **rejects** content outside the policy; it does not
    strip and store the remainder. GeoVector hashes the HTML it sent and detects
    out-of-band edits by re-hashing what `GET ?include=content` returns. A
    receiver that silently rewrites content changes those bytes, so every
    article it accepts is reported as edited. Store what arrived, or store
    nothing.

  contact:
    name: GeoVector
    url: https://www.geovector.ai
  license:
    name: Proprietary

servers:
  - url: '{siteUrl}'
    description: The client site receiving articles.
    variables:
      siteUrl:
        default: https://example.com
        description: >-
          Origin GeoVector is configured with. Must be HTTPS. If the origin
          redirects (apex to www, say), configure the canonical one — GeoVector
          follows same-registrable-domain redirects on verify but publishes
          should not redirect in steady state.

security:
  - bearerAuth: []

tags:
  - name: publish
    description: Create, read, delete articles.
  - name: discovery
    description: What the receiver supports.

paths:
  /api/publish:
    post:
      tags: [publish]
      operationId: publishArticle
      summary: Create or update an article
      description: |
        Upsert by `slug`. Answer `200` with the article's public URL.

        Order of checks matters, because each one exists to keep the next from
        running on hostile input:

        1. Bearer auth → `401`
        2. Body size, enforced **before** parsing → `413`
        3. JSON parse → `400 invalid_json`
        4. Schema, including the slug pattern → `400 invalid_payload`
        5. Content policy → `400 unsafe_content`
        6. Store

        Publishing the same slug twice is normal and expected: GeoVector
        re-publishes on every edit. `date` is the publication date — set it on
        create and never overwrite it on update, or re-publishing re-dates the
        article and costs the client search ranking. Track edits with
        `dateModified`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublishRequest'
            examples:
              minimal:
                summary: The required fields only
                value:
                  title: How AI assistants pick sources
                  slug: how-ai-assistants-pick-sources
                  category: guide
                  date: '2026-08-06'
                  featured: false
                  format: html
                  content: "<article>\n<h1>How AI assistants pick sources</h1>\n<main>\n<p>Body.</p>\n</main>\n</article>"
              withMetadata:
                summary: With the optional display fields a receiver may persist
                value:
                  title: How AI assistants pick sources
                  slug: how-ai-assistants-pick-sources
                  category: guide
                  date: '2026-08-06'
                  dateModified: '2026-08-06'
                  featured: true
                  format: html
                  content: "<article>\n<h1>How AI assistants pick sources</h1>\n<main>\n<p>Body.</p>\n</main>\n</article>\n<script type=\"application/ld+json\">\n{\"@context\":\"https://schema.org\",\"@type\":\"FAQPage\",\"mainEntity\":[]}\n</script>"
                  excerpt: A short summary used on index pages.
                  author: GeoVector
                  readTime: 6 min read
                  tags: [ai-search, seo]
                  thumbnail:
                    gradientFrom: '#10b981'
                    gradientTo: '#0284c7'
                  metaDescription: What decides whether an AI assistant cites you.
                  ogImageUrl: 'https://cdn.example.com/og/how-ai-assistants-pick-sources.png'
                  locale: en
                  noindex: false
      responses:
        '200':
          description: Stored.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublishResponse'
              examples:
                created:
                  value:
                    {
                      action: created,
                      url: 'https://example.com/articles/how-ai-assistants-pick-sources',
                    }
                updated:
                  value:
                    {
                      action: updated,
                      url: 'https://example.com/articles/how-ai-assistants-pick-sources',
                    }
        '400':
          description: |
            Malformed JSON, a payload that fails the schema, or content outside
            the HTML policy. Distinguish them by `error` — `unsafe_content` in
            particular tells GeoVector the renderer emitted something the
            receiver will not keep, which is a bug on one side or the other and
            is worth surfacing rather than retrying.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/InvalidJsonError'
                  - $ref: '#/components/schemas/InvalidPayloadError'
                  - $ref: '#/components/schemas/UnsafeContentError'
              examples:
                invalidJson:
                  value: { error: invalid_json }
                invalidPayload:
                  value:
                    error: invalid_payload
                    issues:
                      formErrors: []
                      fieldErrors:
                        slug: ['slug must be kebab-case']
                unsafeContent:
                  value:
                    error: unsafe_content
                    violations:
                      - rule: event-handler-attribute
                        detail: 'Event handler `onerror` is never allowed.'
                        offset: 142
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '500':
          $ref: '#/components/responses/StorageError'

    get:
      tags: [publish]
      operationId: getArticle
      summary: Check an article exists, or read its stored HTML back
      description: |
        Without `include`, an existence probe. With `include=content`, returns
        the HTML currently stored so GeoVector can detect out-of-band edits by
        hashing it.

        **A slug that fails the pattern is answered `200 {"exists": false}`,
        not `400.`** A malformed slug cannot name a stored article, so that is
        the honest answer, and it keeps the value away from your storage layer
        entirely. It is also required: GeoVector's setup check probes the
        reserved slug `__verify__`, which fails the pattern by design and
        expects a `200`. Returning `400` breaks connection setup.
      parameters:
        - name: slug
          in: query
          required: true
          schema:
            type: string
          description: >-
            The article slug. Validate against the pattern before it reaches
            storage; see the description above for why an invalid one is still
            a 200.
        - name: include
          in: query
          required: false
          schema:
            type: string
            enum: [content]
          description: >-
            `content` returns the stored HTML. Omit for a bare existence check.
      responses:
        '200':
          description: >-
            Whether the article exists, plus its stored content when
            `include=content` was requested and it does.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ArticleLookupResponse'
              examples:
                absent:
                  value: { exists: false }
                present:
                  value: { exists: true }
                withContent:
                  value:
                    exists: true
                    content: "<article>\n<h1>Title</h1>\n<main>\n<p>Body.</p>\n</main>\n</article>"
                    format: html
        '400':
          description: The `slug` parameter was absent altogether.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SlugRequiredError'
              example: { error: slug_required }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/StorageError'

    delete:
      tags: [publish]
      operationId: unpublishArticle
      summary: Unpublish an article
      description: |
        Remove the article from the live site. `not_found` is a success, not an
        error — GeoVector may unpublish something already gone, and treating
        that as a failure makes retries impossible.

        Prefer a soft delete: mark the row unpublished and stop serving it,
        rather than destroying it. GeoVector attributes AI citations to
        published URLs over time, and a hard delete throws away the history that
        attribution is measured against.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeleteRequest'
            example: { slug: how-ai-assistants-pick-sources }
      responses:
        '200':
          description: Removed, or already absent.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeleteResponse'
              examples:
                deleted: { value: { action: deleted } }
                notFound: { value: { action: not_found } }
        '400':
          description: Malformed JSON, or a slug that fails the pattern.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/InvalidJsonError'
                  - $ref: '#/components/schemas/InvalidPayloadError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '500':
          $ref: '#/components/responses/StorageError'

  /api/publish/meta:
    get:
      tags: [discovery]
      operationId: getPublishMeta
      summary: Advertise the receiver's options
      description: |
        Lets GeoVector's setup UI show facts the receiver owns — its real
        category list, rather than a free-text box the user can get wrong.

        Fetched at setup and when the user re-runs verify, never on publish.
        Implementing it is optional; answering `404 meta_not_advertised` is
        conforming and drops the UI back to free-text categories.

        The optional `capabilities` object is where a receiver says what it
        cannot do — no images, a smaller body cap, one locale, a subset of the
        optional fields. Without it every one of those is discovered at publish
        time, which is the most expensive moment to find out.
      responses:
        '200':
          description: Receiver metadata.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetaResponse'
              examples:
                baseline:
                  summary: v1 baseline — no capabilities advertised
                  value:
                    protocol: geovector-cms
                    version: '1'
                    categories: [comparison, guide, industry, research]
                    defaultCategory: guide
                    articlePathTemplate: /articles/{slug}
                withCapabilities:
                  summary: A receiver that declares what it does and does not support
                  value:
                    protocol: geovector-cms
                    version: '1'
                    categories: [comparison, guide, industry, research]
                    defaultCategory: guide
                    articlePathTemplate: /articles/{slug}
                    capabilities:
                      protocolVersions: ['1']
                      supportsDelete: true
                      maxBodyBytes: 10485760
                      imageMimeTypes: []
                      locales: [en]
                      fields:
                        [
                          excerpt,
                          author,
                          readTime,
                          tags,
                          thumbnail,
                          dateModified,
                          metaDescription,
                          canonicalUrl,
                          ogImageUrl,
                          noindex,
                        ]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            The receiver does not advertise metadata. A conforming answer, not
            a fault.
          headers:
            Cache-Control:
              $ref: '#/components/headers/NoStore'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetaNotAdvertisedError'
              example: { error: meta_not_advertised }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A shared secret GeoVector holds and sends as `Authorization: Bearer
        <token>` on every request, including GET.

        Compare it in constant time over fixed-length digests — hash both sides
        first, because a comparison that returns early on a length mismatch
        leaks the token's length. Serve the endpoint over HTTPS only; the token
        is in the clear on the wire otherwise. If you rotate it, expect publish
        failures for the window in which the two sides disagree.

  headers:
    NoStore:
      description: >-
        Prevents authenticated metadata and article content from being retained
        by browsers, intermediaries, or CDNs.
      schema:
        type: string
        const: no-store

  responses:
    Unauthorized:
      description: >-
        Absent, malformed or incorrect bearer token. Answer identically in all
        three cases — a response that distinguishes "no token" from "wrong
        token" is a probing oracle.
      headers:
        Cache-Control:
          $ref: '#/components/headers/NoStore'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/UnauthorizedError'
          example: { error: unauthorized }

    PayloadTooLarge:
      description: |
        The body exceeded the receiver's cap.

        Enforce this **before parsing**, or the payload is already in your
        memory and the response is theatre. `Content-Length` is a cheap
        fast-reject and nothing more — it is absent on a chunked request and
        can lie on any request — so also meter the stream as it arrives and
        abandon it mid-flight once it passes the ceiling.

        10 MB is the reference default and fits an article plus a base64
        illustration (~2 MB). Drop it to 5 MB if you ignore `imageBase64`.
      headers:
        Cache-Control:
          $ref: '#/components/headers/NoStore'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PayloadTooLargeError'
          example: { error: payload_too_large }

    StorageError:
      description: >-
        A receiver storage operation failed. The original exception must be
        logged receiver-side and never returned over HTTP.
      headers:
        Cache-Control:
          $ref: '#/components/headers/NoStore'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/StorageErrorBody'
          example: { error: storage_error }

  schemas:
    Slug:
      type: string
      minLength: 1
      maxLength: 200
      pattern: '^[a-z0-9][a-z0-9-]*$'
      description: >-
        Kebab-case: lowercase alphanumerics plus interior hyphens. Enforce on
        POST, GET and DELETE alike. This is the only thing standing between a
        filesystem- or git-backed store and a path traversal.
      examples: [how-ai-assistants-pick-sources]

    IsoDate:
      type: string
      pattern: '^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$'
      description: |
        ISO-8601 calendar date, optionally with a time and UTC offset. Week
        dates and ordinal dates are not accepted — they are legal ISO and
        useless as a publication date, and every extra accepted shape is one
        more thing a receiver has to parse.

        Reject anything else, including locale-formatted strings like
        `August 06, 2026`. Those parse only in JavaScript, and a receiver that
        accepts them is one a sender will keep feeding them to until the day it
        meets a receiver written in another language.

        The pattern is not sufficient on its own: check that the day exists.
        Most lenient parsers turn `2026-02-30` into 2 March rather than
        failing, which converts a typo into a silently wrong publication date.
      examples: ['2026-08-06', '2026-08-06T09:30:00Z']

    AbsoluteHttpUrl:
      type: string
      format: uri
      maxLength: 2048
      pattern: '^[Hh][Tt][Tt][Pp][Ss]?://[^\s"''`<>\x00-\x1f\x7f]+$'
      description: |
        Absolute `http` or `https` URL. Reject other schemes, reject relative
        URLs, and reject any value containing a quote, backtick, angle bracket,
        whitespace or control character.

        The scheme is matched case-insensitively, which is why it is spelled out
        character by character — `HTTPS://example.com` is a valid absolute URL
        and every URL parser normalises it, so a receiver that rejects it
        refuses something the sender considers well-formed. The control
        characters are in the class for the same reason as the quotes: they are
        invisible in a log and a rendered attribute is where they stop being
        harmless.

        That last rule is the one that is easy to miss, and skipping it is a
        cross-site-scripting hole. A URL parser is not a validator here: most
        accept `https://x.com/"><script>alert(1)</script>` and report the scheme
        as `https`, because they only percent-encode when *serialising*. This
        protocol stores what was sent rather than a rewritten form, so the raw
        string is what you render — and dropped straight into `href="…"` it
        closes the attribute and opens a script tag on your own domain.

        Reject rather than percent-encode, for the same reason `content` is
        rejected rather than sanitized: rewriting hides the sender's mistake
        instead of reporting it. A real URL needs none of those characters
        unescaped.

        Rejecting relative URLs matters too — the sender does not know your
        public origin, which is precisely what the `url` in the POST response
        exists to establish.

    PublishRequest:
      type: object
      additionalProperties: true
      required: [title, slug, category, date, content, format]
      description: >-
        Unknown properties are permitted and should be ignored — new optional
        fields are added over time and must not break existing receivers.
      properties:
        title:
          type: string
          minLength: 1
          description: >-
            Article title as plain text, unescaped. The `content` HTML already
            contains its own escaped `<h1>`; rendering both gives two titles.
        slug:
          $ref: '#/components/schemas/Slug'
        category:
          type: string
          minLength: 1
          description: >-
            One of the values from `/api/publish/meta` when advertised. Map an
            unrecognised value to a default rather than rejecting it.
        date:
          $ref: '#/components/schemas/IsoDate'
        dateModified:
          allOf:
            - $ref: '#/components/schemas/IsoDate'
          description: Last edit. Safe to overwrite on every publish.
        featured:
          type: boolean
          default: false
          description: Receiver-defined prominence hint. Ignore it if meaningless.
        format:
          type: string
          const: html
          description: >-
            Always `html` in v1. Reject other values rather than guessing.
        content:
          type: string
          minLength: 1
          description: |
            The article as an HTML fragment — not a document. No `<html>`,
            `<head>` or `<body>`; wrap it in your own layout.

            Validate against `article-html-policy.v1.json` and reject anything
            outside it with `400 unsafe_content`. Store exactly what you
            accepted, byte for byte.

            A trailing `<script type="application/ld+json">` block carrying
            `FAQPage` schema is part of the fragment when the article has an FAQ
            section. Render it into the served HTML — it is inert, and it is the
            structured data the whole integration exists to deliver. Two things
            destroy it: injecting the fragment with client-side JavaScript, and
            passing it through an HTML-to-text step that strips tags without
            removing script bodies first.
        imageBase64:
          type: string
          description: >-
            Base64 illustration, no data-URI prefix and no declared MIME type.
            Dropping it is conforming; the reference receiver does. Sniff the
            type from the bytes if you store it — do not trust a filename.
        excerpt:
          type: string
          description: Short summary. Derive one from the content if absent.
        author:
          type: string
        readTime:
          type: string
          description: >-
            Pre-rendered, e.g. `6 min read`. If you derive your own instead,
            strip script and style bodies before counting words — tag-stripping
            alone leaves the JSON-LD's restated Q&A in the text.
          examples: ['6 min read']
        tags:
          type: array
          items: { type: string }
        thumbnail:
          $ref: '#/components/schemas/Thumbnail'
        metaDescription:
          type: string
          maxLength: 500
          description: >-
            `<meta name="description">`. Fall back to `excerpt` when absent.
        canonicalUrl:
          allOf:
            - $ref: '#/components/schemas/AbsoluteHttpUrl'
          description: >-
            `<link rel="canonical">`. Present only when the article is
            syndicated and the original lives elsewhere. Absent means
            self-canonical, which is the normal case — do not invent one.
        ogImageUrl:
          allOf:
            - $ref: '#/components/schemas/AbsoluteHttpUrl'
          description: >-
            `<meta property="og:image">`, already hosted by the sender. Distinct
            from `imageBase64`, which asks you to store bytes and mint your own
            URL. A sender sends one or the other.
        locale:
          type: string
          pattern: '^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$'
          description: >-
            BCP-47 language tag for `<html lang>`. Validate the shape, not the
            IANA registry — the registry is neither cheap nor portable to check,
            and a tag you cannot serve should be reported through
            `capabilities.locales` at setup time instead.
          examples: ['en', 'en-GB', 'zh-Hans']
        noindex:
          type: boolean
          description: >-
            Render `<meta name="robots" content="noindex">` and leave the page
            out of your sitemap. Absence means index — treating it as noindex
            would bury every article published by a sender that predates the
            field. If you emit a sitemap, filter on this too: listing a page
            that tells crawlers not to index it is a contradictory signal.

    Thumbnail:
      type: object
      required: [gradientFrom, gradientTo]
      description: >-
        CSS colours for a generated gradient card, for receivers that render
        index tiles and have no image.
      properties:
        gradientFrom: { type: string, examples: ['#10b981'] }
        gradientTo: { type: string, examples: ['#0284c7'] }

    PublishResponse:
      type: object
      required: [action, url]
      properties:
        action:
          type: string
          enum: [created, updated]
        url:
          type: string
          format: uri
          description: >-
            The article's absolute public URL. **This is the source of truth**
            for where the article lives — GeoVector records it and later crawls
            it to attribute AI citations. Return the URL the page is actually
            served at, not a template guess.
          examples:
            ['https://example.com/articles/how-ai-assistants-pick-sources']

    ArticleLookupResponse:
      type: object
      required: [exists]
      properties:
        exists: { type: boolean }
        content:
          type: string
          description: >-
            Present only when `include=content` was requested and the article
            exists. Return exactly the bytes you stored; any normalisation makes
            the article look edited.
        format:
          type: string
          examples: [html]

    DeleteRequest:
      type: object
      required: [slug]
      properties:
        slug:
          $ref: '#/components/schemas/Slug'

    DeleteResponse:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [deleted, not_found]

    MetaResponse:
      type: object
      required:
        [protocol, version, categories, defaultCategory, articlePathTemplate]
      properties:
        protocol:
          type: string
          const: geovector-cms
          description: >-
            Identifies the endpoint as this protocol. GeoVector's setup check
            uses it to tell a conforming receiver from an unrelated 200.
        version:
          type: string
          const: '1'
        categories:
          type: array
          minItems: 1
          items: { type: string }
          description: Values the receiver accepts in `category`.
        defaultCategory:
          type: string
          description: Pre-selected in the setup UI. Must appear in `categories`.
        articlePathTemplate:
          type: string
          description: >-
            Display-only hint for the setup UI's "articles will publish at"
            preview. Deprecated as a source of truth — the POST response `url`
            is authoritative — and kept only because existing receivers send it.
          examples: ['/articles/{slug}']
        capabilities:
          $ref: '#/components/schemas/Capabilities'

    Capabilities:
      type: object
      additionalProperties: true
      description: |
        What this receiver can actually do, so a sender finds out at setup time
        rather than when a user clicks Publish on a finished article.

        Every field is optional, and **absence means "unknown, assume the v1
        baseline"** — never "unsupported". Most deployed receivers predate this
        object entirely and support more than they advertise. A sender that
        treated absence as a limit would break them.

        Omit the whole object rather than sending `{}`. An empty object reads as
        "declared, and supports nothing", which is the opposite of what it
        means.

        Be honest about the gaps: the value of this object is a receiver saying
        it drops images or cannot serve a locale, so the sender can say so
        before the work is done. Advertising a field you silently discard is
        worse than advertising nothing.

        **Enforcement status, as of document revision 1.** The field
        descriptions below state what each capability is *for*. The GeoVector
        sender currently parses and type-checks all of them and acts on none:
        only `categories`, `defaultCategory` and `articlePathTemplate` — the
        fields outside this object — change its behaviour today. Declaring
        `supportsDelete: false` does not yet hide an Unpublish control, and
        `imageMimeTypes: []` does not yet stop illustrations being attached.
        Declare them accurately regardless; they are the input to that work.
      properties:
        protocolVersions:
          type: array
          items: { type: string }
          description: Versions spoken. Absent means `['1']`.
          examples: [['1']]
        supportsDelete:
          type: boolean
          description: >-
            Whether `DELETE /api/publish` unpublishes. Absent means yes — it is
            in the v1 baseline. An explicit `false` lets an append-only store
            say so, and the sender hides its Unpublish control instead of
            surfacing a failure afterwards.
        maxBodyBytes:
          type: integer
          minimum: 1
          description: >-
            Your request-body ceiling. A sender can compare a rendered payload
            against it and explain the problem, instead of relaying a bare
            `413`.
        imageMimeTypes:
          type: array
          items: { type: string }
          description: >-
            Types accepted for `imageBase64`. An explicit empty array means
            images are not accepted at all — say that rather than silently
            dropping them.
          examples: [['image/png', 'image/webp']]
        locales:
          type: array
          items: { type: string }
          description: >-
            BCP-47 tags you can publish under. Absent means unknown; a list lets
            a sender block a locale that would land on a page you cannot route.
        fields:
          type: array
          items: { type: string }
          description: >-
            Optional payload fields you actually persist, by name. Absent means
            unknown, and a sender should keep sending everything it has — the
            fields are additive and ignoring one is still conforming. Present,
            it tells the setup UI which parts of an article survive the trip.
          examples: [['excerpt', 'tags', 'metaDescription', 'canonicalUrl']]

    HtmlViolation:
      type: object
      required: [rule, detail, offset]
      description: >-
        One reason content was refused. Rule ids and their meanings are in
        `article-html-policy.v1.json`.
      properties:
        rule:
          type: string
          enum:
            - disallowed-element
            - disallowed-attribute
            - event-handler-attribute
            - unsafe-url
            - unquoted-attribute-value
            - malformed-markup
            - invalid-json-ld
        detail:
          type: string
          description: Human-readable explanation, safe to show a publisher.
        offset:
          type: integer
          minimum: 0
          description: Character index in `content` where the problem starts.

    UnauthorizedError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: unauthorized }

    InvalidJsonError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: invalid_json }

    InvalidPayloadError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: invalid_payload }
        issues:
          type: object
          description: >-
            Optional field-level detail. Shape is receiver-defined; GeoVector
            displays it verbatim to help a user fix the cause.
          additionalProperties: true

    UnsafeContentError:
      type: object
      required: [error, violations]
      properties:
        error: { type: string, const: unsafe_content }
        violations:
          type: array
          maxItems: 10
          items:
            $ref: '#/components/schemas/HtmlViolation'
          description: >-
            The body is refused on the first violation regardless; the rest are
            returned so someone fixing a template does not discover them one
            publish at a time.

    SlugRequiredError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: slug_required }

    PayloadTooLargeError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: payload_too_large }

    MetaNotAdvertisedError:
      type: object
      required: [error]
      properties:
        error: { type: string, const: meta_not_advertised }

    StorageErrorBody:
      type: object
      required: [error]
      additionalProperties: false
      properties:
        error: { type: string, const: storage_error }
