openapi: 3.1.0
info:
  title: Bidama API v1
  version: "1.0.0"
  description: |
    Bearer-key authenticated API for the Bidama sharing service (links, markdown
    documents, files, images, collections, and Notion mirrors). Every endpoint is
    authenticated with an API key (`Authorization: Bearer bdma_<key>`) carrying
    `read`, `write`, and/or `delete` scopes, except `GET /api/v1/openapi.yaml`
    which is public.

    This document is the machine-readable companion to `API.md` and the public
    `/docs/api` page. Human-readable endpoint and MCP tool catalogs live there;
    HTTP server tests enforce this file against the registered route table.
servers:
  - url: /
    description: Deployment origin; paths include the /api/v1 prefix.

security:
  - bearerAuth: []

tags:
  - name: meta
  - name: resources
  - name: content
  - name: attachments
  - name: jobs
  - name: analytics

paths:
  /api/v1/openapi.yaml:
    get:
      tags: [meta]
      summary: Machine-readable OpenAPI 3.1 contract
      security: []
      responses:
        "200":
          description: OpenAPI document.
          content:
            application/yaml:
              schema:
                type: string
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/me:
    get:
      tags: [meta]
      summary: Get the authenticated user
      responses:
        "200":
          description: Current user profile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/uploads:
    post:
      tags: [resources]
      summary: Upload a file or image and create a resource
      description: The server sniffs the uploaded bytes. Image MIME types create `image` resources; all other allowed MIME types create `file` resources. `max_views` is accepted only for file resources.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/UploadCreateRequest" }
      responses:
        "201":
          description: Created file or image resource detail.
          headers:
            ETag:
              description: Editable-state ETag for subsequent conditional writes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadCreateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }

  /api/v1/artifacts:
    post:
      tags: [resources]
      summary: Upload an HTML artifact and create one resource
      description: |
        Accepts either one ZIP in the `archive` part or direct file parts mapped
        by the JSON `metadata.files` array. A non-empty root `index.html` is
        preferred when present; otherwise the first `.html` or `.htm` file in
        upload/archive order becomes the entry point and must be non-empty. ZIP
        and direct modes cannot be mixed.
        Multipart filenames are never used as artifact paths.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/ArtifactUploadRequest" }
      responses:
        "201":
          description: Created artifact resource detail.
          headers:
            ETag:
              description: Editable-state ETag for subsequent conditional writes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactResourceDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { description: Slug or storage quota conflict. }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/v1/resources/{slug}/artifact/deployments:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    post:
      tags: [resources]
      summary: Publish an HTML artifact deployment
      description: |
        Creates a new immutable deployment and origin, then atomically moves the
        artifact's current-deployment pointer. Replace mode accepts either one ZIP
        in the `archive` part or direct file parts mapped by `metadata.files`.
        Patch mode (`metadata.mode = patch`) carries only `metadata.upserts`, reuses
        unchanged content-addressed blobs from `base_deployment_id`, and rejects a
        stale base with 412. A no-op patch returns 200 without allocating an origin
        or deployment. Creation and access-control fields are not accepted. The
        optional `If-Match` precondition uses the editable-state ETag returned by
        resource detail.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
          description: Optional resource-detail ETag. A stale value returns 412 `etag_mismatch`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/ArtifactRedeployRequest" }
      responses:
        "200":
          description: Current artifact resource detail when a patch made no effective change.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactResourceDetail" }
        "201":
          description: Updated artifact resource detail for the new current deployment.
          headers:
            ETag:
              description: Editable-state ETag for subsequent conditional writes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactResourceDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: Artifact resource not found. }
        "409": { description: Storage quota conflict. }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/v1/resources/{slug}/artifact/files:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      tags: [resources]
      summary: List files in the current HTML artifact deployment
      responses:
        "200":
          description: Current deployment entry point and canonical file list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactFileList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Artifact resource not found. }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/v1/resources/{slug}/artifact/source:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      tags: [resources]
      summary: Read one editable source file from the current HTML artifact deployment
      description: Returns UTF-8 source inside a no-store JSON response. Binary files and text files larger than 1 MiB are not exposed by this endpoint.
      parameters:
        - { name: path, in: query, required: true, schema: { type: string, maxLength: 1024 } }
      responses:
        "200":
          description: Verified UTF-8 source with deployment and content hash metadata.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactSource" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Artifact resource or path not found. }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/v1/analytics/resources/{slug}:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      tags: [analytics]
      summary: Resource analytics summary
      responses:
        "200": { description: Analytics summary. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Resource not found. }

  /api/v1/resources:
    get:
      tags: [resources]
      summary: List owned resources
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 200 } }
        - { name: cursor, in: query, schema: { type: string }, description: Cursor returned as `next_cursor` from a previous page; currently the next numeric offset. }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: q, in: query, schema: { type: string }, description: Matches title, slug, and description. }
        - { name: type, in: query, schema: { type: string, enum: [link, markdown, file, image, collection, notion, bio, artifact] } }
        - { name: sort, in: query, schema: { type: string, enum: [newest, pinned, title, expiring, most_viewed, recently_updated], default: newest } }
        - { name: label, in: query, schema: { type: string } }
        - { name: upload_request, in: query, schema: { type: string, format: uuid }, description: Limit file resources to items received through one public upload request. }
        - { name: archived, in: query, schema: { type: string, enum: [false, active, true, archived, all], default: false }, description: "false/active = unarchived only, true/archived = archived only, all = both." }
        - { name: pinned, in: query, schema: { type: boolean } }
      responses:
        "200":
          description: Paginated resource summaries.
          headers:
            ETag:
              description: Weak validator for the result page, derived from the newest `updated` timestamp across the returned items. Send as `If-None-Match` to collapse identical polling results into 304 responses.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [items, has_more, counts]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ResourceSummary" }
                  has_more: { type: boolean }
                  next_cursor:
                    type: string
                    description: Cursor to pass to the next list request when `has_more` is true.
                  counts: { $ref: "#/components/schemas/ResourceCounts" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [resources]
      summary: Create a resource
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResourceCreateRequest" }
      responses:
        "201":
          description: Created resource detail.
          headers:
            ETag:
              description: Editable-state ETag for subsequent conditional writes.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResourceCreateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }

  /api/v1/resources/{slug}:
    parameters:
      - { name: slug, in: path, required: true, schema: { type: string } }
    get:
      tags: [resources]
      summary: Get resource detail
      responses:
        "200":
          description: Full resource detail.
          headers:
            ETag:
              description: Validator for editable resource state. Send it as `If-Match` on PATCH to opt into optimistic concurrency checks; analytics counters, generated URLs, and background-derived file variants are excluded.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ResourceDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [resources]
      summary: Update a resource
      description: |
        Partial update. Only the fields present in the body are changed. Field
        applicability depends on the resource type (e.g. `target_url`/`og_*` for
        links, `raw_markdown`/`draft_token` for markdown, `original_filename`
        for files). Collection item edits use the collection-specific endpoints.
        Renaming via `slug` returns the new slug and URL. `If-Match` is optional;
        when present it must match the current editable-state ETag from
        `GET /api/v1/resources/{slug}` or the update returns
        `412 etag_mismatch`.
      parameters:
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
          description: Optional editable-state ETag from GET /api/v1/resources/{slug}.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResourceUpdate" }
      responses:
        "200":
          description: Updated; returns the (possibly new) slug and URL.
          headers:
            ETag:
              description: Editable-state ETag after the update.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedResource" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
    delete:
      tags: [resources]
      summary: Soft-delete a resource
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: [deleted] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/resources/{slug}/qr:
    get:
      tags: [content]
      summary: Generate QR code for resource URL
      description: |
        Returns a QR code image encoding the resource's public URL with
        `?src=qr` appended, so QR scans are tracked in the BySourceKind
        analytics bucket as "qr" traffic.
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { name: format, in: query, schema: { type: string, enum: [png, svg], default: png }, description: "Output image format. SVG is a vector document that scales without aliasing." }
        - { name: size, in: query, schema: { type: integer, minimum: 128, maximum: 2048, default: 512 }, description: "Image dimension in pixels (PNG) or viewBox units (SVG)." }
      responses:
        "200":
          description: QR code image.
          content:
            image/png:
              schema: { type: string, format: binary }
            image/svg+xml:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/resources/{slug}/markdown/versions:
    get:
      tags: [content]
      summary: List markdown version history
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Markdown version summaries, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/MarkdownVersionSummary" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/resources/{slug}/markdown/versions/{versionID}:
    get:
      tags: [content]
      summary: Get one markdown version
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { name: versionID, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Full markdown version snapshot.
          headers:
            ETag:
              description: Current editable-state ETag for the parent resource; send it as `If-Match` when restoring this version.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarkdownVersion" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/resources/{slug}/markdown/versions/{versionID}/restore:
    post:
      tags: [content]
      summary: Restore a markdown version
      description: |
        Restores the selected snapshot as the current markdown body and appends
        a new history version. `If-Match` is optional; an exact validator uses
        an atomic revision check, while `*` keeps wildcard semantics.
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { name: versionID, in: path, required: true, schema: { type: string } }
        - name: If-Match
          in: header
          required: false
          schema: { type: string }
          description: Optional editable-state ETag from GET /api/v1/resources/{slug}.
      responses:
        "200":
          description: Restored; returns the resource slug and URL.
          headers:
            ETag:
              description: Editable-state validator after the restore.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedResource" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }

  /api/v1/resources/{slug}/file/optimize-original:
    post:
      tags: [jobs]
      summary: Queue original-file optimization
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        "202":
          description: Optimization job accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /api/v1/jobs:
    get:
      tags: [jobs]
      summary: List background jobs
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 0 } }
        - { name: cursor, in: query, schema: { type: string }, description: Cursor returned as `next_cursor` from a previous page; currently the next numeric offset. }
        - { name: offset, in: query, schema: { type: integer, minimum: 0 }, description: Numeric offset fallback; ignored when `cursor` is present. }
        - { name: status, in: query, schema: { type: string, enum: [pending, processing, ready, failed, skipped, cancelled] } }
        - { name: type, in: query, schema: { type: string, enum: [avif_variant, original_optimize, bio_inline_variant, dimension_backfill, notion_refresh, notion_page_refresh] } }
      responses:
        "200":
          description: Paginated jobs owned by the caller.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/jobs/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [jobs]
      summary: Get a background job
      responses:
        "200":
          description: Job status and result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key bearer token, e.g. `Authorization: Bearer bdma_<key>`."

  responses:
    BadRequest:
      description: Validation error.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing/invalid/expired API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
      headers:
        X-RateLimit-Limit:
          description: Per-minute request budget for the caller identity (API key id, or source IP for unauthenticated requests). Present when API v1 rate limiting is enabled.
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Remaining requests in the current window.
          schema: { type: integer }
        X-RateLimit-Reset:
          description: Unix timestamp when the bucket refills to the limit.
          schema: { type: integer }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: API key missing a required scope.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found or not owned by the caller.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: Conflicting job state.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    PreconditionFailed:
      description: ETag precondition failed.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    PayloadTooLarge:
      description: Request body exceeds the endpoint limit.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unavailable:
      description: Feature disabled by configuration.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Error:
      type: object
      required: [error, error_code]
      properties:
        error: { type: string }
        error_code: { type: string }
        field:
          type: string
          description: Present when the failure is tied to a single request field (e.g. `max_views`).
        details:
          type: array
          description: Present when multiple validation failures occurred.
          items: { $ref: "#/components/schemas/ErrorDetail" }
    ErrorDetail:
      type: object
      required: [field, code, message]
      properties:
        field: { type: string }
        code: { type: string }
        message: { type: string }
    ResourceCounts:
      type: object
      description: Global active-resource counts for the caller, independent of list filters.
      required: [all, link, markdown, file, image, artifact, collection, notion, bio, pinned]
      properties:
        all: { type: integer }
        link: { type: integer }
        markdown: { type: integer }
        file: { type: integer }
        image: { type: integer }
        artifact: { type: integer }
        collection: { type: integer }
        notion: { type: integer }
        bio: { type: integer }
        pinned: { type: integer }
    Me:
      type: object
      required: [id, email, is_admin, role, features]
      properties:
        id: { type: string }
        email: { type: string }
        preferred_locale: { type: string }
        is_admin: { type: boolean }
        role: { type: string, enum: [user, admin] }
        disabled_at: { type: string }
        permissions:
          type: object
          required: [allow_link, allow_markdown, allow_file, allow_notion]
          properties:
            allow_link: { type: boolean }
            allow_markdown: { type: boolean }
            allow_file: { type: boolean }
            allow_notion: { type: boolean }
        features:
          type: object
          required: [notion_enabled, llm_enabled, artifacts_enabled]
          properties:
            notion_enabled: { type: boolean }
            llm_enabled: { type: boolean }
            artifacts_enabled: { type: boolean }
        storage:
          type: object
          description: Present when storage accounting is configured for the runtime.
          required: [used_bytes, quota_bytes]
          properties:
            used_bytes: { type: integer }
            quota_bytes: { type: integer }
    CreatedResource:
      type: object
      required: [slug, url]
      properties:
        slug: { type: string }
        url: { type: string }
    UploadCreateRequest:
      type: object
      required: [file]
      properties:
        file: { type: string, format: binary }
        type:
          type: string
          enum: [file, image]
          description: Optional assertion checked against the MIME type sniffed from the uploaded bytes.
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        visibility: { type: string, enum: [public, unlisted, private] }
        password: { type: string }
        expires_at: { type: string, format: date-time }
        max_views:
          type: integer
          minimum: 0
          description: Supported only when the sniffed resource type is `file`.
        labels:
          type: array
          items: { type: string }
          description: Repeat the multipart field or provide comma-separated values.
        original_filename:
          type: string
          description: Optional public filename override; never used as the storage object key.
    UploadCreateResponse:
      oneOf:
        - { $ref: "#/components/schemas/FileResourceDetail" }
        - { $ref: "#/components/schemas/ImageResourceDetail" }
    ArtifactFileMapping:
      type: object
      required: [part, path]
      additionalProperties: false
      properties:
        part: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$" }
        path: { type: string, maxLength: 1024, description: Canonical relative POSIX artifact path. }
    ArtifactUploadMetadata:
      type: object
      additionalProperties: false
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        visibility: { type: string, enum: [public, unlisted, private] }
        password: { type: string }
        expires_at: { type: string, format: date-time }
        labels: { type: array, items: { type: string } }
        files:
          type: array
          maxItems: 500
          items: { $ref: "#/components/schemas/ArtifactFileMapping" }
    ArtifactUploadRequest:
      type: object
      required: [metadata]
      properties:
        metadata:
          type: string
          contentMediaType: application/json
          contentSchema: { $ref: "#/components/schemas/ArtifactUploadMetadata" }
        archive:
          type: string
          format: binary
          description: ZIP mode; omit `metadata.files` and all direct file parts.
      additionalProperties:
        type: string
        format: binary
    ArtifactRedeployMetadata:
      type: object
      additionalProperties: false
      properties:
        mode:
          type: string
          enum: [replace, patch]
        base_deployment_id:
          type: string
          format: uuid
        entry_path:
          type: string
          maxLength: 1024
        files:
          type: array
          maxItems: 500
          items: { $ref: "#/components/schemas/ArtifactFileMapping" }
        upserts:
          type: array
          maxItems: 500
          items: { $ref: "#/components/schemas/ArtifactFileMapping" }
        delete_paths:
          type: array
          maxItems: 500
          items: { type: string, maxLength: 1024 }
    ArtifactRedeployRequest:
      type: object
      required: [metadata]
      properties:
        metadata:
          type: string
          contentMediaType: application/json
          contentSchema: { $ref: "#/components/schemas/ArtifactRedeployMetadata" }
        archive:
          type: string
          format: binary
          description: Replace-mode ZIP; omit `metadata.files`, patch fields, and all direct file parts.
      additionalProperties:
        type: string
        format: binary
    ResourceCreateCommon:
      type: object
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        visibility: { type: string, enum: [public, unlisted, private] }
        password: { type: string }
        expires_at: { type: string, format: date-time }
        labels: { type: array, items: { type: string } }
    LinkResourceCreate:
      allOf:
        - { $ref: "#/components/schemas/ResourceCreateCommon" }
        - type: object
          required: [type, target_url]
          properties:
            type: { type: string, const: link }
            target_url: { type: string, format: uri }
            redirect_rules:
              type: array
              items: { $ref: "#/components/schemas/LinkRedirectRule" }
            metadata: { type: object, additionalProperties: true }
            og_title: { type: string }
            og_description: { type: string }
            og_image_url: { type: string, format: uri }
            max_views: { type: integer, minimum: 0 }
    MarkdownResourceCreate:
      allOf:
        - { $ref: "#/components/schemas/ResourceCreateCommon" }
        - type: object
          required: [type]
          anyOf:
            - required: [body_markdown]
            - required: [raw_markdown]
          properties:
            type: { type: string, const: markdown }
            body_markdown: { type: string }
            raw_markdown:
              type: string
              description: Alias for `body_markdown`.
            max_views: { type: integer, minimum: 0 }
    CollectionResourceCreate:
      allOf:
        - { $ref: "#/components/schemas/ResourceCreateCommon" }
        - type: object
          required: [type]
          properties:
            type: { type: string, const: collection }
            layout: { type: string, default: list }
            settings: { type: object, additionalProperties: true }
    NotionResourceCreate:
      allOf:
        - { $ref: "#/components/schemas/ResourceCreateCommon" }
        - type: object
          required: [type]
          anyOf:
            - required: [source_url]
            - required: [fetch_url]
          properties:
            type: { type: string, const: notion }
            source_url: { type: string, format: uri }
            fetch_url:
              type: string
              format: uri
              description: Alias for `source_url`.
            root_page_id: { type: string }
            auto_refresh: { type: boolean, default: true }
            refresh_interval_seconds: { type: integer, minimum: 0 }
    BioResourceCreate:
      allOf:
        - { $ref: "#/components/schemas/ResourceCreateCommon" }
        - type: object
          required: [type]
          properties:
            type: { type: string, const: bio }
            theme: { $ref: "#/components/schemas/BioTheme" }
            theme_json: { $ref: "#/components/schemas/BioTheme" }
            settings: { type: object, additionalProperties: true }
            root_page_slug: { type: string }
            root_page_title: { type: string, maxLength: 200 }
    ResourceCreateRequest:
      oneOf:
        - { $ref: "#/components/schemas/LinkResourceCreate" }
        - { $ref: "#/components/schemas/MarkdownResourceCreate" }
        - { $ref: "#/components/schemas/CollectionResourceCreate" }
        - { $ref: "#/components/schemas/NotionResourceCreate" }
        - { $ref: "#/components/schemas/BioResourceCreate" }
      discriminator:
        propertyName: type
        mapping:
          link: "#/components/schemas/LinkResourceCreate"
          markdown: "#/components/schemas/MarkdownResourceCreate"
          collection: "#/components/schemas/CollectionResourceCreate"
          notion: "#/components/schemas/NotionResourceCreate"
          bio: "#/components/schemas/BioResourceCreate"
    NotionJobAccepted:
      type: object
      required: [slug, url, job_id, status, poll_url]
      properties:
        slug: { type: string }
        url: { type: string }
        job_id: { type: string }
        status:
          type: string
          enum: [pending, processing, ready, failed, skipped, cancelled]
        poll_url: { type: string }
        retry_after_seconds:
          type: integer
          description: Suggested delay before polling `poll_url` again when the accepted job is active.
    NotionPageJobAccepted:
      type: object
      required: [slug, url, notion_page_id, page_id, path, job_id, status, poll_url]
      properties:
        slug: { type: string }
        url: { type: string }
        notion_page_id: { type: string }
        page_id: { type: string }
        path: { type: string }
        job_id: { type: string }
        status:
          type: string
          enum: [pending, processing, ready, failed, skipped, cancelled]
        poll_url: { type: string }
        retry_after_seconds:
          type: integer
          description: Suggested delay before polling `poll_url` again when the accepted job is active.
    CreatedFiles:
      type: object
      properties:
        count: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              slug: { type: string }
              url: { type: string }
              original_filename: { type: string }
              mime_type: { type: string }
              size_bytes: { type: integer }
              active_job:
                allOf: [ { $ref: "#/components/schemas/ActiveJobSummary" } ]
                description: Present when an AVIF/WebP serving-variant background job was queued for this image upload; poll `job_url` until `status` reaches `ready`/`failed`/`skipped`.
    ActiveJobSummary:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, processing, ready, failed, skipped, cancelled] }
        job_url: { type: string, description: Poll URL for the job status endpoint. }
    BulkItemFailure:
      type: object
      required: [slug, status]
      properties:
        slug: { type: string }
        status: { type: string, enum: [failed] }
        error: { type: string }
    ResourceSummary:
      type: object
      properties:
        id: { type: string }
        slug: { type: string }
        type: { type: string, enum: [link, markdown, file, image, collection, notion, bio, artifact] }
        title: { type: string }
        description: { type: string }
        created: { type: string }
        updated: { type: string }
        url: { type: string }
        owner_id: { type: string }
        has_password: { type: boolean }
        expires_at: { type: string }
        target_url: { type: string }
        hit_count: { type: integer }
        max_views:
          type: integer
          description: Present for all summaries as a number, but only link, markdown, and file resources may set a nonzero value.
        labels: { type: array, items: { type: string } }
        archived_at: { type: string }
        pinned_at: { type: string }
        collection_item_count: { type: integer }
        collection_total_size_bytes:
          type: integer
          description: Sum of original collection item bytes, excluding generated image variants.
        collection_cover_item_id: { type: string }
        artifact_file_count: { type: integer }
        artifact_total_size_bytes: { type: integer }
    LinkData:
      type: object
      properties:
        target_url: { type: string }
        og_title: { type: string }
        og_description: { type: string }
        og_image_url: { type: string }
        redirect_rules:
          type: array
          items: { $ref: "#/components/schemas/LinkRedirectRule" }
    LinkRedirectRule:
      type: object
      required: [name, enabled, destination_url]
      properties:
        id: { type: string, description: Stable rule identifier. Generated from the name when omitted. }
        name: { type: string, maxLength: 120 }
        enabled: { type: boolean }
        platform:
          type: string
          enum: [ios, android, macos, windows, linux, desktop, mobile]
        referrer_host: { type: string, description: Exact normalized referrer hostname match. }
        query_param: { type: string, description: Query parameter that must be present. }
        query_value: { type: string, description: Optional exact value for query_param. }
        utm_source: { type: string }
        utm_medium: { type: string }
        utm_campaign: { type: string }
        utm_content: { type: string }
        utm_term: { type: string }
        starts_at: { type: string, description: Optional ISO datetime; rule is active at or after this time. }
        ends_at: { type: string, description: Optional ISO datetime; rule is active before this time. }
        destination_url:
          type: string
          format: uri
          description: HTTP(S) destination. App/deep-link schemes are intentionally not accepted.
    MarkdownData:
      type: object
      properties:
        raw_markdown: { type: string }
        rendered_html: { type: string }
    MarkdownVersionSummary:
      type: object
      properties:
        id: { type: string }
        version: { type: integer }
        source: { type: string, enum: [create, update, restore, baseline] }
        restored_from_version: { type: integer }
        size_bytes: { type: integer }
        preview: { type: string }
        created: { type: string }
    MarkdownVersion:
      allOf:
        - { $ref: "#/components/schemas/MarkdownVersionSummary" }
        - type: object
          properties:
            raw_markdown: { type: string }
            rendered_html: { type: string }
    FileOptimizationOption:
      type: [object, "null"]
      properties:
        eligible: { type: boolean }
        mode: { type: string }
        label: { type: string }
        description: { type: string }
        source_size_bytes: { type: integer }
    FileData:
      type: object
      properties:
        file_name: { type: string }
        original_filename: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        optimized_file_name: { type: string }
        optimized_mime: { type: string }
        avif_file_name: { type: string }
        avif_size_bytes: { type: integer }
        webp_file_name: { type: string }
        thumbnail_file_name:
          type: string
          description: Legacy bounded WebP rendition; omitted for new uploads.
        thumbnail_width: { type: integer }
        thumbnail_height: { type: integer }
        og_card_file_name:
          type: string
          description: Legacy JPEG social-card rendition; omitted for new uploads.
        original_optimized_at: { type: string }
        original_optimization_mode: { type: string }
        original_size_before_optimize: { type: integer }
        original_optimization: { $ref: "#/components/schemas/FileOptimizationOption" }
        image_width:
          type: integer
          description: Intrinsic image width in pixels; omitted when unknown.
        image_height:
          type: integer
          description: Intrinsic image height in pixels; omitted when unknown.
    CollectionItem:
      type: object
      properties:
        id: { type: string }
        position: { type: integer }
        display_name: { type: string }
        caption: { type: string }
        file_name: { type: string }
        original_filename: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        avif_file_name: { type: string }
        avif_size_bytes: { type: integer }
        webp_file_name: { type: string }
        thumbnail_file_name:
          type: string
          description: Legacy bounded WebP rendition; omitted for newly uploaded items.
        thumbnail_original_filename: { type: string }
        thumbnail_mime_type: { type: string }
        thumbnail_width: { type: integer }
        thumbnail_height: { type: integer }
        og_card_file_name:
          type: string
          description: Legacy JPEG social-card rendition; omitted for newly uploaded items.
        is_image: { type: boolean }
        image_width:
          type: integer
          description: Intrinsic image width in pixels; omitted when unknown.
        image_height:
          type: integer
          description: Intrinsic image height in pixels; omitted when unknown.
        source_resource_id: { type: string }
        source_resource_slug: { type: string }
        url: { type: string }
        download_url: { type: string }
        created: { type: string }
        updated: { type: string }
    CollectionData:
      type: object
      properties:
        item_count: { type: integer }
        image_count: { type: integer }
        total_size_bytes:
          type: integer
          description: Sum of original item bytes, excluding generated image variants.
        cover_item_id: { type: string }
        items:
          type: array
          items: { $ref: "#/components/schemas/CollectionItem" }
    QueueJobSummary:
      type: object
      properties:
        id: { type: string }
        status:
          type: string
          enum: [pending, processing, ready, failed, skipped, cancelled]
        error: { type: string }
    NotionPageSummary:
      type: object
      properties:
        id: { type: string }
        page_id: { type: string }
        parent_page_id: { type: string }
        path: { type: string }
        title: { type: string }
        version: { type: integer }
        status: { type: string, enum: [pending, ready, failed] }
        last_error: { type: string }
        last_refreshed_at: { type: string }
        refresh_after: { type: string }
        depth: { type: integer }
        active_job: { $ref: "#/components/schemas/QueueJobSummary" }
    NotionPageList:
      type: object
      properties:
        slug: { type: string }
        url: { type: string }
        source_url: { type: string }
        root_page_id: { type: string }
        status: { type: string, enum: [pending, ready, failed] }
        last_error: { type: string }
        last_refreshed_at: { type: string }
        refresh_after: { type: string }
        refresh_ttl_seconds: { type: integer, minimum: 3600 }
        current_version: { type: integer }
        active_job: { $ref: "#/components/schemas/QueueJobSummary" }
        count: { type: integer }
        pages:
          type: array
          items: { $ref: "#/components/schemas/NotionPageSummary" }
    NotionPageContent:
      allOf:
        - $ref: "#/components/schemas/NotionPageSummary"
        - type: object
          properties:
            last_refreshed_at: { type: string }
            refresh_after: { type: string }
            rendered_html: { type: string }
            blocks_json:
              type: object
              additionalProperties: true
            resolved_blocks_json:
              type: object
              description: Cached Notion block JSON with asset URL strings rewritten to local /notion-assets URLs when a stored asset is available.
              additionalProperties: true
            asset_map:
              type: object
              description: Scrubbed block/source aliases mapped to cached /notion-assets URLs. URL keys omit query strings and fragments.
              additionalProperties: { type: string }
    NotionAssetSummary:
      type: object
      properties:
        id: { type: string }
        block_id: { type: string }
        url: { type: string }
        source_url: { type: string }
        original_filename: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        avif_file_name: { type: string }
        avif_size_bytes: { type: integer }
        is_image: { type: boolean }
        created: { type: string }
        updated: { type: string }
    NotionAssetList:
      type: object
      properties:
        slug: { type: string }
        url: { type: string }
        source_url: { type: string }
        asset_count:
          type: integer
          description: Total locally stored Notion asset blob count for the resource.
        count: { type: integer }
        has_more: { type: boolean }
        limit: { type: integer }
        offset: { type: integer }
        assets:
          type: array
          items: { $ref: "#/components/schemas/NotionAssetSummary" }
    NotionData:
      type: object
      properties:
        source_url: { type: string }
        root_page_id: { type: string }
        auto_refresh: { type: boolean }
        status: { type: string, enum: [pending, ready, failed] }
        last_error: { type: string }
        last_refreshed_at: { type: string }
        refresh_after: { type: string }
        refresh_ttl_seconds: { type: integer, minimum: 3600 }
        page_count: { type: integer }
        asset_count: { type: integer }
        current_version: { type: integer }
        active_job: { $ref: "#/components/schemas/QueueJobSummary" }
        pages:
          type: array
          items: { $ref: "#/components/schemas/NotionPageSummary" }
    Attachment:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        file_name: { type: string }
        original_filename: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        avif_file_name: { type: string }
        avif_size_bytes: { type: integer }
        is_image: { type: boolean }
        created: { type: string }
    ResourceDetail:
      type: object
      required: [resource]
      properties:
        resource: { $ref: "#/components/schemas/ResourceSummary" }
        link: { $ref: "#/components/schemas/LinkData" }
        markdown: { $ref: "#/components/schemas/MarkdownData" }
        file: { $ref: "#/components/schemas/FileData" }
        collection: { $ref: "#/components/schemas/CollectionData" }
        notion: { $ref: "#/components/schemas/NotionData" }
        bio: { $ref: "#/components/schemas/BioData" }
        artifact: { $ref: "#/components/schemas/ArtifactData" }
        attachments:
          type: array
          items: { $ref: "#/components/schemas/Attachment" }
    LinkResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, link]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: link }
            link: { $ref: "#/components/schemas/LinkData" }
    MarkdownResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, markdown]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: markdown }
            markdown: { $ref: "#/components/schemas/MarkdownData" }
    FileResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, file]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: file }
            file: { $ref: "#/components/schemas/FileData" }
    ImageResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, file]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: image }
            file: { $ref: "#/components/schemas/FileData" }
    CollectionResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, collection]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: collection }
            collection: { $ref: "#/components/schemas/CollectionData" }
    NotionResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, notion]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: notion }
            notion: { $ref: "#/components/schemas/NotionData" }
    BioResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, bio]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: bio }
            bio: { $ref: "#/components/schemas/BioData" }
    ArtifactData:
      type: object
      required: [deployment_id, origin_label, origin_url, entry_path, file_count, total_size_bytes, published_at]
      properties:
        deployment_id: { type: string, format: uuid }
        origin_label: { type: string, pattern: "^[a-z2-7]{8,26}$" }
        origin_url: { type: string, format: uri }
        entry_path:
          type: string
          minLength: 1
          maxLength: 1024
          pattern: "\\.[hH][tT][mM][lL]?$"
        file_count: { type: integer, minimum: 1 }
        total_size_bytes: { type: integer, minimum: 1 }
        published_at: { type: string, format: date-time }
    ArtifactFile:
      type: object
      required: [path, mime_type, size_bytes, sha256, editable]
      properties:
        path: { type: string, maxLength: 1024 }
        mime_type: { type: string }
        size_bytes: { type: integer, minimum: 0 }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        editable:
          type: boolean
          description: True for supported UTF-8 text MIME types no larger than the 1 MiB source-editing limit.
    ArtifactFileList:
      type: object
      required: [deployment_id, entry_path, files]
      properties:
        deployment_id: { type: string, format: uuid }
        entry_path: { type: string, maxLength: 1024 }
        files:
          type: array
          items: { $ref: "#/components/schemas/ArtifactFile" }
    ArtifactSource:
      type: object
      required: [deployment_id, path, mime_type, size_bytes, sha256, encoding, content]
      properties:
        deployment_id: { type: string, format: uuid }
        path: { type: string, maxLength: 1024 }
        mime_type: { type: string }
        size_bytes: { type: integer, minimum: 0, maximum: 1048576 }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        encoding: { type: string, const: utf-8 }
        content: { type: string }
    ArtifactResourceDetail:
      allOf:
        - { $ref: "#/components/schemas/ResourceDetail" }
        - type: object
          required: [resource, artifact]
          properties:
            resource:
              allOf:
                - { $ref: "#/components/schemas/ResourceSummary" }
                - type: object
                  required: [type]
                  properties:
                    type: { type: string, const: artifact }
            artifact: { $ref: "#/components/schemas/ArtifactData" }
    ResourceCreateResponse:
      oneOf:
        - { $ref: "#/components/schemas/LinkResourceDetail" }
        - { $ref: "#/components/schemas/MarkdownResourceDetail" }
        - { $ref: "#/components/schemas/CollectionResourceDetail" }
        - { $ref: "#/components/schemas/NotionResourceDetail" }
        - { $ref: "#/components/schemas/BioResourceDetail" }
    ResourceUpdate:
      type: object
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        labels: { type: array, items: { type: string } }
        target_url: { type: string }
        og_title: { type: string }
        og_description: { type: string }
        og_image_url: { type: string }
        redirect_rules:
          type: array
          items: { $ref: "#/components/schemas/LinkRedirectRule" }
        raw_markdown: { type: string }
        draft_token: { type: string }
        original_filename: { type: string }
        password: { type: string }
        clear_password: { type: boolean }
        expires_at: { type: string }
        max_views:
          type: integer
          minimum: 0
          description: Supported only for link, markdown, and file resources.
        archived: { type: boolean }
        pinned: { type: boolean }
        notion_auto_refresh: { type: boolean }
        notion_refresh_ttl_seconds: { type: integer, minimum: 3600 }
    UploadRequest:
      type: object
      description: Upload request metadata. The sensitive public upload `token` is returned only in the create response and is empty in list responses.
      properties:
        id: { type: string }
        token:
          type: string
          description: Sensitive public upload token. Present only immediately after creation.
        title: { type: string }
        description: { type: string }
        upload_path: { type: string }
        has_password: { type: boolean }
        expires_at: { type: string }
        max_files: { type: integer }
        max_file_bytes: { type: integer }
        labels: { type: array, items: { type: string } }
        revoked_at: { type: string }
        created: { type: string }
        updated: { type: string }
    UploadRequestCreate:
      type: object
      required: [title, max_files, max_file_bytes]
      properties:
        title: { type: string }
        description: { type: string }
        password: { type: string }
        expires_at: { type: string }
        max_files: { type: integer, minimum: 1 }
        max_file_bytes: { type: integer, minimum: 1 }
        labels: { type: array, items: { type: string } }
    LabelsRenameRequest:
      type: object
      required: [from, to]
      properties:
        from:
          type: string
          description: Source label, normalized with the same rules as resource labels.
        to:
          type: string
          description: Destination label, normalized with the same rules as resource labels.
        dry_run:
          type: boolean
          default: false
    LabelsRenameResult:
      type: object
      required: [matched_count, changed_count, collision_count]
      properties:
        matched_count: { type: integer }
        changed_count: { type: integer }
        collision_count:
          type: integer
          description: Resources that already had the destination label and therefore merged/deduped.
    LinkCreate:
      type: object
      required: [target_url]
      properties:
        target_url: { type: string }
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        og_title: { type: string }
        og_description: { type: string }
        og_image_url: { type: string }
        redirect_rules:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/LinkRedirectRule" }
        password: { type: string }
        expires_at: { type: string }
        max_views: { type: integer, minimum: 0 }
        labels: { type: array, items: { type: string } }
    MarkdownCreate:
      type: object
      required: [raw_markdown]
      properties:
        raw_markdown: { type: string }
        draft_token: { type: string }
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        password: { type: string }
        expires_at: { type: string }
        max_views: { type: integer, minimum: 0 }
        labels: { type: array, items: { type: string } }
    NotionCreate:
      type: object
      description: Provide `source_url` or `url`; `url` is an alias used only when `source_url` is omitted.
      anyOf:
        - required: [source_url]
        - required: [url]
      properties:
        source_url:
          type: string
          format: uri
          description: Public Notion page URL to mirror.
        url:
          type: string
          format: uri
          description: Alias for source_url.
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        password: { type: string }
        expires_at: { type: string }
        labels: { type: array, items: { type: string } }
    CollectionCreate:
      type: object
      description: At least one uploaded `file` part or one `existing_slugs` value is required.
      properties:
        file:
          type: array
          items: { type: string, format: binary }
          description: Repeat this file part for each uploaded item.
        existing_slugs:
          type: array
          items: { type: string }
          description: Slugs of owned file resources to copy into the collection.
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        password: { type: string }
        expires_at: { type: string }
        labels: { type: string }
    CollectionItemsAdd:
      type: object
      description: At least one uploaded `file` part or one `existing_slugs` value is required.
      properties:
        file:
          type: array
          items: { type: string, format: binary }
          description: Repeat this file part for each uploaded item.
        existing_slugs:
          type: array
          items: { type: string }
          description: Slugs of owned file resources to copy into the collection.
    CollectionItemUpdate:
      type: object
      required: [id]
      properties:
        id: { type: string }
        position: { type: integer, minimum: 0 }
        display_name: { type: string }
        caption: { type: string }
    CollectionUpdate:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/CollectionItemUpdate" }
    BioTheme:
      type: object
      description: |
        Curated presentation presets. Every field is a strict enum; unknown
        values are discarded. An empty string means "use the default".
      properties:
        accent: { type: string, enum: ["", forest, plum, ember, rose, teal, amber, mono] }
        avatar_shape: { type: string, enum: ["", circle, squircle, rounded] }
        button_style: { type: string, enum: ["", outline, solid, soft, tint, ghost, shadow] }
        button_shape: { type: string, enum: ["", rounded, pill] }
        header_style: { type: string, enum: ["", plain, band] }
        density: { type: string, enum: ["", cozy, compact] }
        background: { type: string, enum: ["", tinted, inset] }
        font_preset: { type: string, enum: ["", serif, rounded, mono] }
        show_handle: { type: boolean, description: Prints the @slug handle under the display name. }
    BioData:
      type: object
      description: Link-in-bio profile, avatar metadata, theme, pages, and blocks.
      properties:
        id: { type: string }
        display_name: { type: string }
        headline: { type: string }
        bio: { type: string }
        avatar_file_name: { type: string }
        avatar_original_filename: { type: string }
        avatar_mime_type: { type: string }
        avatar_size_bytes: { type: integer }
        avatar_avif_file_name: { type: string }
        avatar_avif_size_bytes: { type: integer }
        avatar_url: { type: string }
        theme: { $ref: "#/components/schemas/BioTheme" }
        pages:
          type: array
          items: { $ref: "#/components/schemas/BioPage" }
    BioBlock:
      type: object
      required: [type]
      description: |
        One block on a bio page. Blocks present by `id` are updated in place;
        blocks without an `id` are created. File-backed blocks (`image`, `file`)
        keep their existing binary — upload/replace binaries with the dedicated
        block-file endpoints, not this snapshot.
      properties:
        id:
          type: string
          description: Existing block id to update; omit to create a new block.
        type: { type: string, enum: [section, text, link, page_link, image, file] }
        enabled: { type: boolean }
        position:
          type: integer
          description: Response-only public ordering position.
        label: { type: string, maxLength: 200 }
        body: { type: string }
        target_url: { type: string, maxLength: 4096 }
        linked_page_id:
          type: string
          description: Target bio page id for `page_link` blocks; takes precedence over `linked_page_slug`.
        linked_page_slug:
          type: string
          description: Target bio page slug for `page_link` blocks; used only when `linked_page_id` is empty.
        source_resource_id:
          type: string
          description: Id of an owned `file` resource referenced by this block.
        source_resource_slug:
          type: string
          description: Response-only slug for `source_resource_id` when available.
        style_json:
          type: object
          additionalProperties: true
          description: |
            Curated style hints; string values are enum-validated and unknown
            values are discarded. Supported keys: `variant` (`social` renders a
            link in the compact social icon row), `align` (`left`|`center`, for
            section/text blocks), `size` (`sm`|`lg`, for link blocks), and
            `tone` (reserved).
        config_json:
          type: object
          additionalProperties: true
          description: |
            Curated block config. Supported keys: `open_in_new_tab` (bool),
            `show_icon` (bool — leading platform icon or configured favicon on link buttons),
            `icon` (platform name from the curated icon set; empty = detect
            from the URL), `width` (reserved), and `alt` (string).
        file_name: { type: string, description: Response-only stored file name. }
        original_filename: { type: string }
        mime_type: { type: string }
        size_bytes: { type: integer }
        avif_file_name: { type: string }
        avif_size_bytes: { type: integer }
        is_image: { type: boolean }
        image_width:
          type: integer
          description: Response-only intrinsic width in pixels of the served image; omitted when unknown.
        image_height:
          type: integer
          description: Response-only intrinsic height in pixels of the served image; omitted when unknown.
        url: { type: string }
        download_url: { type: string }
    BioPage:
      type: object
      description: |
        A bio page and its ordered blocks. Exactly one page must have
        `is_root: true` with an empty `page_slug`.
      properties:
        id:
          type: string
          description: Existing page id to update; omit to create a new page.
        page_slug: { type: string }
        is_root: { type: boolean }
        title: { type: string, maxLength: 200 }
        description: { type: string }
        position:
          type: integer
          description: Response-only public ordering position.
        blocks:
          type: array
          maxItems: 100
          items: { $ref: "#/components/schemas/BioBlock" }
    BioCreate:
      type: object
      description: Creates a bio resource with a single empty root page.
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        display_name: { type: string }
        headline: { type: string }
        bio: { type: string }
        theme_json: { $ref: "#/components/schemas/BioTheme" }
        password: { type: string }
        expires_at: { type: string }
        labels: { type: array, items: { type: string } }
    BioUpdate:
      type: object
      description: |
        Full-snapshot update. `pages` is required and replaces the entire
        page/block tree (present-by-id updated, absent deleted, no-id
        created); a bio always keeps at least one page, so an empty or
        omitted `pages` is rejected. The profile fields `display_name`,
        `headline`, `bio`, and `theme_json` are also full-snapshot: they are
        always written, and omitting one clears it. The remaining resource
        fields (`slug`, `title`, `description`, `password`, `expires_at`,
        `archived`, `pinned`, `labels`) are optional and only
        applied when present.
      required: [pages]
      properties:
        slug: { type: string }
        title: { type: string }
        description: { type: string }
        display_name: { type: string }
        headline: { type: string }
        bio: { type: string }
        theme_json: { $ref: "#/components/schemas/BioTheme" }
        pages:
          type: array
          maxItems: 20
          description: A bio may contain at most 200 blocks in total across all pages.
          items: { $ref: "#/components/schemas/BioPage" }
        password: { type: string }
        clear_password: { type: boolean }
        expires_at: { type: string }
        archived: { type: boolean }
        pinned: { type: boolean }
        labels: { type: array, items: { type: string } }
    Job:
      type: object
      required: [id, type, status, result, error]
      properties:
        id: { type: string }
        type: { type: string }
        status: { type: string }
        result: { type: object, additionalProperties: true }
        error: { type: string }
        attempts: { type: integer }
        finished_at: { type: string }
        next_poll_seconds:
          type: integer
          description: Present only for active pending/processing jobs; suggested delay before the next status request.
    JobList:
      type: object
      required: [items, has_more]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/JobListItem" }
        has_more: { type: boolean }
        next_cursor:
          type: string
          description: Cursor to pass to the next list request when `has_more` is true; currently the next numeric offset.
    JobListItem:
      type: object
      required: [id, type, status, created, updated]
      properties:
        id: { type: string }
        type: { type: string }
        status: { type: string }
        resource_slug: { type: string }
        resource_id: { type: string }
        error: { type: string }
        created: { type: string }
        updated: { type: string }
        finished: { type: string }
    JobAccepted:
      type: object
      required: [job_id, status, poll_url]
      properties:
        job_id: { type: string }
        status: { type: string }
        attempts: { type: integer }
        finished_at: { type: string }
        poll_url: { type: string }
        retry_after_seconds:
          type: integer
          description: Suggested delay before polling `poll_url` again when the accepted job is active.
    AnalyticsSummary:
      type: object
      properties:
        total_hits: { type: integer }
        by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string }
              count: { type: integer }
        by_browser: { $ref: "#/components/schemas/BucketList" }
        by_os: { $ref: "#/components/schemas/BucketList" }
        by_mobile:
          type: object
          properties:
            mobile: { type: integer }
            desktop: { type: integer }
        by_referrer: { $ref: "#/components/schemas/BucketList" }
        by_utm_source: { $ref: "#/components/schemas/BucketList" }
        by_utm_medium: { $ref: "#/components/schemas/BucketList" }
        by_utm_campaign: { $ref: "#/components/schemas/BucketList" }
        by_utm_content: { $ref: "#/components/schemas/BucketList" }
        by_utm_term: { $ref: "#/components/schemas/BucketList" }
        by_redirect_rule: { $ref: "#/components/schemas/BucketList" }
        by_destination: { $ref: "#/components/schemas/BucketList" }
        by_platform: { $ref: "#/components/schemas/BucketList" }
        by_source_kind: { $ref: "#/components/schemas/BucketList" }
        by_country: { $ref: "#/components/schemas/BucketList" }
        unique_visitors: { type: integer }
        views_per_visitor: { type: number }
        geoip_enabled: { type: boolean }
        flushed: { type: boolean }
        as_of: { type: string, format: date-time }
        # Optional: the Go field is *BioClickSummary with omitempty, so it is
        # omitted entirely when absent (never serialized as null).
        bio_clicks: { $ref: "#/components/schemas/BioClickSummary" }
    BioClickSummary:
      type: object
      properties:
        total_clicks: { type: integer }
        by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string }
              count: { type: integer }
        by_page: { $ref: "#/components/schemas/BucketList" }
        by_block: { $ref: "#/components/schemas/BucketList" }
        by_block_type: { $ref: "#/components/schemas/BucketList" }
        by_destination: { $ref: "#/components/schemas/BucketList" }
        ctr_by_block:
          type: array
          items:
            type: object
            properties:
              block_id: { type: string }
              label: { type: string }
              clicks: { type: integer }
              ctr: { type: number }
        bio_ctr: { type: number }
    AnalyticsCSV:
      type: string
      description: |
        CSV with header `metric,name,date,count`. `metric` values include
        `total`, `by_day`, `by_mobile`, `by_browser`, `by_os`, `by_referrer`,
        UTM breakdowns, redirect breakdowns, platform, and source kind.
      examples:
        - |
          metric,name,date,count
          total,,,2
          by_day,,2026-06-05,2
          by_mobile,mobile,,1
          by_mobile,desktop,,1
    BucketList:
      type: array
      items:
        type: object
        properties:
          name: { type: string }
          count: { type: integer }
