openapi: 3.0.3
info:
  title: Open Cinema Project — Public API
  version: 1.0.0
  description: |
    Free, open API for indie & repertory cinema showtimes.

    The Open Cinema Project is a nonprofit, anti-Big-Showtime infrastructure project.
    Anyone can use this API for any purpose: a weekend newsletter, a city film calendar,
    a Discord bot, festival programming, academic research. We only ask that you credit
    "Open Cinema Project" when you display the data.

    ## Authentication
    Every JSON endpoint requires a free API key, sent as either:
    - `Authorization: Bearer oc_live_<your-key>`  (recommended)
    - `X-API-Key: oc_live_<your-key>`
    - `?api_key=oc_live_<your-key>`              (URL fallback; appears in proxy logs)

    Get a key in seconds at <https://opencinemaproject.com/developers>.

    ## MCP server (for AI assistants)
    Open Cinema also speaks the [Model Context Protocol](https://modelcontextprotocol.io)
    at `POST https://opencinemaproject.com/api/mcp` (streamable HTTP transport).
    Drop the URL into Claude, ChatGPT, Cursor, or any MCP-aware client to query
    showtimes, theaters, films, and status without an API key — anonymous access,
    rate limited per IP. Discovery manifest:
    [`/.well-known/mcp.json`](https://opencinemaproject.com/.well-known/mcp.json).
    Quickstart: [docs/MCP.md](https://github.com/opencinemaproject/opencinemaproject/blob/main/docs/MCP.md).

    ## End-user accounts (cookie auth)
    A separate, lightweight account system powers the "follow a film/theater/city
    and get email alerts" feature exposed under `/me/follows`. It is intentionally
    decoupled from the developer API-key system above:

    - Sign-in is passwordless: `POST /api/v1/public/account/login` mints a magic
      link, the user clicks it, `GET /api/v1/public/account/verify` consumes the
      token and sets the `oc_user_session` httpOnly cookie.
    - All subsequent `/account/*` and `/follows/*` calls authenticate via that
      cookie (`UserSessionCookie` security scheme). They are *not* callable with
      an `oc_live_…` developer key.
    - Signed-in users can mint personal **MCP tokens** (`ocu_…`) at
      `POST /api/v1/public/account/mcp-token`. These bearer tokens let an AI
      assistant act on their behalf against the MCP server (e.g. create follows
      from a Claude conversation). Use the `McpUserToken` security scheme.

    Most JSON endpoints under `/api/v1/public/` require a developer key —
    there is no same-origin or referer-based bypass. Exceptions:
    - `.ics` calendar feeds — calendar apps cannot send Authorization
      headers; IP rate-limited instead.
    - `POST /keys` — self-service key signup (otherwise no one could
      ever get a key).
    - `POST /report-link` — anonymous broken-link reports from any
      visitor; per-IP rate-limited.
    - `/account/*` and `/follows/*` — these belong to the *separate*
      end-user account track described below. They authenticate via the
      `oc_user_session` cookie (set by `/account/verify`), not via
      `oc_live_…` developer keys. The `/account/login`,
      `/account/verify`, and `/follows/unsubscribe` entrypoints are
      key-free and cookie-free by design (sign-in flow + email link).

    ## Rate limits
    Default per-key: **60 req/min, 10 000 req/day**.
    Every response carries `X-RateLimit-Limit-{Minute,Day}` and
    `X-RateLimit-Remaining-{Minute,Day}`. Limit-exceeded responses set
    `Retry-After`. Need higher limits? Email <hi@opencinemaproject.com>.

    ## Errors
    All errors share one envelope:
    ```json
    { "error": { "code": "<machine_code>", "message": "<human readable>", "request_id": "req_…" } }
    ```
    Common codes: `missing_api_key`, `invalid_api_key`, `rate_limited`,
    `invalid_coordinates`, `film_not_found`, `method_not_allowed`,
    `internal_error`. Always include the `request_id` when contacting support.

    ## Pagination
    List endpoints return:
    ```json
    { "pagination": { "limit": 50, "has_more": true, "next_cursor": "<opaque>" } }
    ```
    Pass `?cursor=<value>` on the next request. Cursors encode the boundary row's
    sort key + id, so pages stay stable even when new rows are inserted.

  contact:
    name: Open Cinema Project
    email: hi@opencinemaproject.com
    url: https://opencinemaproject.com/developers
  license:
    name: Open Database License (data) / MIT (clients)
    url: https://opendatacommons.org/licenses/odbl/

servers:
  - url: https://opencinemaproject.com
    description: Production
  - url: http://localhost:5000
    description: Local development

tags:
  - name: Discovery
    description: Theaters, films, and screenings.
  - name: Calendar
    description: iCal subscription feeds. Key-free by design.
  - name: Operations
    description: Status, error reporting, and key issuance.
  - name: Account
    description: |
      End-user accounts: passwordless sign-in (magic links), session probe,
      sign-out, and personal MCP token management. Authenticated by the
      `oc_user_session` cookie set by `/account/verify`.
  - name: Follows
    description: |
      Per-user film/theater/city follows that drive email alerts. All
      JSON endpoints require a signed-in user session cookie. The
      one-click unsubscribe handler is intentionally key-free and
      authenticated by a signed token in the URL.

security:
  - BearerAuth: []
  - ApiKeyHeader: []
  - ApiKeyQuery: []

paths:
  /api/v1/public/theaters:
    get:
      tags: [Discovery]
      summary: List theaters
      description: |
        Three modes (mutually exclusive in priority order):
          1. `?ids=a,b,c` — fetch a specific list of theaters (max 200 ids).
          2. `?lat=&lon=&radius_km=` — geo-radius search (PostGIS), ordered by distance.
          3. (no params) — alphabetical, paged.
      parameters:
        - { in: query, name: ids,        schema: { type: string }, description: "Comma-separated theater ids." }
        - { in: query, name: lat,        schema: { type: number, minimum: -90,  maximum:  90  } }
        - { in: query, name: lon,        schema: { type: number, minimum: -180, maximum: 180 } }
        - { in: query, name: radius_km,  schema: { type: number, default: 30, maximum: 200 } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [theaters, pagination]
                properties:
                  theaters:
                    type: array
                    items: { $ref: '#/components/schemas/Theater' }
                  search_center:
                    type: object
                    properties:
                      lat: { type: number }
                      lon: { type: number }
                  radius_km: { type: number }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/screenings:
    get:
      tags: [Discovery]
      summary: List upcoming screenings
      description: |
        Always ordered by `(start_time ASC, screening_id ASC)` for stable
        cursor pagination. Without `?date=`, returns only future screenings.
      parameters:
        - { in: query, name: date,        schema: { type: string, format: date }, description: "YYYY-MM-DD; restrict to a single calendar day in the theater's local timezone." }
        - { in: query, name: lat,         schema: { type: number, minimum: -90,  maximum:  90  } }
        - { in: query, name: lon,         schema: { type: number, minimum: -180, maximum: 180 } }
        - { in: query, name: radius_km,   schema: { type: number, default: 30 } }
        - { in: query, name: theater_id,  schema: { type: string } }
        - { in: query, name: film_id,     schema: { type: string }, description: "Canonical or alias film id." }
        - { in: query, name: title,       schema: { type: string }, description: "Case-insensitive substring of film title." }
        - { in: query, name: format,      schema: { type: string }, description: "Exact match against any element in screenings.formats (e.g. \"35mm\", \"DCP\")." }
        - { in: query, name: accessibility, schema: { type: string }, description: "Comma-separated codes (any-match): WHEELCHAIR, AUDIO_DESC, CLOSED_CAPTIONS, etc." }
        - { in: query, name: events,      schema: { type: string }, description: "Comma-separated codes (any-match): DIRECTOR_QA, FESTIVAL, etc." }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [screenings, pagination]
                properties:
                  screenings:
                    type: array
                    items: { $ref: '#/components/schemas/Screening' }
                  theater_last_updated:
                    type: object
                    additionalProperties: { type: string, format: date-time }
                    description: Per-theater "last data refresh" timestamps; lets clients show a freshness indicator.
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/films/{filmId}:
    get:
      tags: [Discovery]
      summary: Film details
      description: |
        Looks up a film by canonical id (or any registered alias — aliased
        ids resolve transparently). When TMDB metadata is available, the
        response is enriched with poster/backdrop/synopsis/trailer/cast.
      parameters:
        - in: path
          name: filmId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Film' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/status:
    get:
      tags: [Operations]
      summary: Scraper-fleet health snapshot
      description: |
        Identical to the JSON behind the public `/status` page.
        Edge-cached for 60s with 5min SWR.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: See `/status` page for shape; subject to additive changes.
        '500':
          $ref: '#/components/responses/InternalError'

  /api/v1/public/report-link:
    post:
      tags: [Operations]
      summary: Report a broken purchase link
      description: |
        User-submitted "this ticket-buy link is broken" report. Logs to
        `link_reports` and flags the screening for re-checking.

        This endpoint is anonymous-allowed (no API key needed) so that any
        visitor on opencinemaproject.com can flag a broken link. Per-IP
        rate-limited at 30/min, 200/day.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [screening_id]
              properties:
                screening_id: { type: string }
                reason:       { type: string, description: "Free-form short label, e.g. 'broken_link', '404', 'sold_out_misreport'." }
                user_comment: { type: string, maxLength: 1000 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:    { type: boolean }
                  report_id:  { type: integer }
                  message:    { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/keys:
    post:
      tags: [Operations]
      summary: Issue a free API key
      description: |
        Self-service key issuance. Returns the plaintext key **once** —
        save it immediately, we cannot show it again. One active key per
        email. To rotate, contact support.

        This endpoint itself does not require a key (otherwise no one
        could ever sign up).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name:         { type: string, minLength: 2, maxLength: 120 }
                email:        { type: string, format: email, maxLength: 254 }
                intended_use: { type: string, maxLength: 1000, description: "Optional free-form description of what you're building." }
      responses:
        '201':
          description: Key issued
          content:
            application/json:
              schema:
                type: object
                required: [api_key, key_prefix, rate_limits, created_at]
                properties:
                  api_key:    { type: string, description: "Plaintext key. Shown ONCE.", example: "oc_live_AbCdEfGh..." }
                  key_prefix: { type: string, example: "oc_live_AbCd" }
                  rate_limits:
                    type: object
                    properties:
                      per_minute: { type: integer, example: 60 }
                      per_day:    { type: integer, example: 10000 }
                  created_at: { type: string, format: date-time }
                  message:    { type: string }
                  docs:       { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: A key already exists for this email.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/account/oauth/apple:
    post:
      tags: [Account]
      summary: Sign in with Apple (mobile / web)
      description: |
        Native Apple Sign In for the Open Cinema mobile app and web. The
        client obtains an Apple OIDC identity token (via
        `expo-apple-authentication` or AppleID.js) and posts it here.
        The server verifies the token's signature against Apple's JWKS,
        validates `iss`/`aud`/`exp`, find-or-creates a `users` row,
        links `(apple, sub)` in `user_oauth_identities`, and returns an
        opaque session token.

        The returned `session_token` is sent on every subsequent request
        as `Authorization: Bearer ocs_…` (the `BearerSession` scheme
        below). It is valid for 30 days.

        This endpoint does **not** require a developer API key.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identity_token]
              properties:
                identity_token: { type: string, description: "Apple OIDC identity token (JWT, RS256)." }
      responses:
        '200':
          description: Session issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Identity token failed verification (invalid signature, wrong audience, expired, etc.).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: |
            `needs_email` — Apple's "Hide My Email" relay omitted the
            email AND no prior `(apple, sub)` linkage exists. The client
            should fall back to magic-link sign-in to establish the
            email, then re-link Apple.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Apple sign-in is not configured on this server, or Apple's JWKS endpoint was unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/public/account/oauth/google:
    post:
      tags: [Account]
      summary: Sign in with Google (mobile / web)
      description: |
        Native Google Sign In. The client obtains a Google OIDC
        identity token (via `expo-auth-session` or Google Identity
        Services) and posts it here. Server-side handling is identical
        to the Apple endpoint, but the `aud` claim is checked against
        the union of `GOOGLE_OAUTH_IOS_CLIENT_ID`,
        `GOOGLE_OAUTH_ANDROID_CLIENT_ID`, and `GOOGLE_OAUTH_WEB_CLIENT_ID`.

        Returns the same `OAuthSession` envelope as the Apple endpoint.
        This endpoint does **not** require a developer API key.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identity_token]
              properties:
                identity_token: { type: string, description: "Google OIDC identity token (JWT, RS256)." }
      responses:
        '200':
          description: Session issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Identity token failed verification.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: '`needs_email` — see Apple endpoint for semantics.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Google sign-in is not configured, or Google's JWKS endpoint was unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/public/changes:
    get:
      tags: [Discovery]
      summary: Incremental sync feed
      description: |
        Returns rows changed since `?since=<iso8601>`, unioned across the
        requested types and ordered by `updated_at` ascending. Same opaque
        cursor scheme as `/screenings` and `/theaters`. `since` cannot be
        more than 60 days in the past — for older snapshots, repaginate
        `/theaters` and `/screenings` from scratch.
      parameters:
        - in: query
          name: since
          required: true
          schema: { type: string, format: date-time }
          description: Lower bound on `updated_at` (ISO 8601).
        - in: query
          name: types
          schema: { type: string }
          description: Comma-separated; default = all. Allowed values "screenings", "theaters", "link_health".
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [changes, pagination]
                properties:
                  changes:
                    type: array
                    items:
                      type: object
                      required: [type, id, updated_at, data]
                      properties:
                        type: { type: string, example: screenings }
                        id:   { type: string }
                        updated_at: { type: string, format: date-time }
                        data: { type: object, additionalProperties: true }
                  since:      { type: string, format: date-time }
                  types:
                    type: array
                    items: { type: string }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/webhooks:
    description: |
      ## Webhook delivery contract

      When an event fires we POST a small JSON envelope to the URL you
      registered. Every delivery carries three response headers:

      | Header                    | Meaning                                            |
      | ------------------------- | -------------------------------------------------- |
      | `X-OpenCinema-Event`      | Event type, e.g. `screening.updated`.              |
      | `X-OpenCinema-Delivery`   | Unique per-attempt id; useful for de-duping retries. |
      | `X-OpenCinema-Signature`  | `sha256=<hex>` — see signing scheme below.         |

      ### Signing

      The signature value is the raw request body HMAC'd with a key
      derived from your secret:

      ```
      signing_key = HMAC_SHA256("oc-webhook-signing-pepper-v1", sha256(secret))
      sig         = "sha256=" + HMAC_SHA256(signing_key, raw_body).hex()
      ```

      Receivers compute the same signing key from their plaintext
      secret and `timingSafeEqual` against the header. We never store
      the plaintext secret on our side — only its sha256 hash.

      ### Retries

      One initial delivery plus five retries spaced 1 min, 5 min,
      30 min, 2 h, 8 h (6 attempts total). After the sixth failure the
      delivery is marked `failed` and we stop.

      Verification snippets (Node + Python) live in
      [docs/WEBHOOKS.md](https://github.com/opencinemaproject/opencinemaproject/blob/main/docs/WEBHOOKS.md#3-verify-the-signature).
    get:
      tags: [Operations]
      summary: List your webhook subscriptions
      description: Returns webhooks owned by the calling API key. Includes the last 5 deliveries per webhook.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [webhooks, available_event_types]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
                  available_event_types:
                    type: array
                    items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Operations]
      summary: Subscribe to webhook events
      description: |
        Returns the signing **`secret`** in plaintext exactly once.
        Verify deliveries with HMAC-SHA256 — see
        [docs/WEBHOOKS.md](https://github.com/opencinemaproject/opencinemaproject/blob/main/docs/WEBHOOKS.md).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, event_types]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS endpoint that will receive POSTs. Private/internal hosts are rejected.
                event_types:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum:
                      - screening.created
                      - screening.updated
                      - theater.created
                      - theater.updated
                      - link.broken
                description: { type: string, maxLength: 500 }
      responses:
        '201':
          description: Created (secret returned ONCE).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      secret: { type: string, description: 'Plaintext signing secret. Shown ONCE.' }
                      message: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Per-key webhook quota exceeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [Operations]
      summary: Delete a webhook subscription
      parameters:
        - in: query
          name: id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted_id: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/public/account/login:
    post:
      tags: [Account]
      summary: Request a sign-in magic link
      description: |
        Step 1 of passwordless sign-in. Generates a one-time magic-link
        token for the supplied email and sends it (or, in development,
        logs it to the server console). The 202 response body is the
        same regardless of whether the email is on file — we will not
        confirm or deny account existence (registration-oracle defense).

        Per-IP throttled. No API key, no cookie required.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 254 }
                next:
                  type: string
                  description: |
                    Optional relative path to land on after `/verify`
                    succeeds. Must start with `/` and pass a strict
                    allowlist; invalid values are silently dropped in
                    favor of `/me/follows`.
      responses:
        '202':
          description: Magic link issued (or silently swallowed; response is identical either way).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        '400':
          description: Email missing or malformed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/public/account/verify:
    get:
      tags: [Account]
      summary: Consume a magic link and start a session
      description: |
        Step 2 of passwordless sign-in. Validates the token from the
        emailed magic link, mints a long-lived session row, and sets
        the `oc_user_session` httpOnly cookie. On success, 302-redirects
        to a safe `?next=` path or `/me/follows`.

        Errors render as plain HTML (calendar/email clients hate JSON,
        and the user is in a browser anyway).
      security: []
      parameters:
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: One-time magic-link token from the email.
        - in: query
          name: next
          schema: { type: string }
          description: Relative path to redirect to after success. Strictly validated.
      responses:
        '302':
          description: Sign-in succeeded; session cookie set; client redirected.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: '`oc_user_session=…; HttpOnly; Secure; SameSite=Lax; Path=/`'
            Location:
              schema: { type: string }
        '400':
          description: Token missing or invalid (HTML response).
          content:
            text/html:
              schema: { type: string }
        '410':
          description: Token expired (HTML response).
          content:
            text/html:
              schema: { type: string }

  /api/v1/public/account/me:
    get:
      tags: [Account]
      summary: Session probe
      description: |
        Returns whether the caller has a valid `oc_user_session` cookie.
        Polled by the site header to decide between "Sign in" and
        "Hi, you@ — My follows · Sign out". Always 200; an unauthenticated
        caller simply gets `{ "signed_in": false }`.
      security:
        - UserSessionCookie: []
        - {}
      responses:
        '200':
          description: Session status.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [signed_in]
                    properties:
                      signed_in: { type: boolean, enum: [false] }
                  - type: object
                    required: [signed_in, email, user_id]
                    properties:
                      signed_in: { type: boolean, enum: [true] }
                      email:     { type: string, format: email }
                      user_id:   { type: integer }

  /api/v1/public/account/logout:
    post:
      tags: [Account]
      summary: Sign out
      description: |
        Revokes the session row backing the `oc_user_session` cookie
        and clears the cookie. Calling without (or with a stale)
        cookie is a no-op success rather than an error.
      security:
        - UserSessionCookie: []
        - {}
      responses:
        '200':
          description: Signed out (idempotent).
          headers:
            Set-Cookie:
              schema: { type: string }
              description: Cleared `oc_user_session` cookie.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }

  /api/v1/public/account/mcp-token:
    get:
      tags: [Account]
      summary: List my MCP tokens
      description: |
        Lists the signed-in user's non-revoked personal MCP tokens.
        Only the token prefix is returned — plaintext is never stored
        and cannot be recovered.
      security:
        - UserSessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [tokens]
                properties:
                  tokens:
                    type: array
                    items: { $ref: '#/components/schemas/McpTokenRecord' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
    post:
      tags: [Account]
      summary: Mint a new MCP token
      description: |
        Mints a new personal MCP token (`ocu_…`) for the signed-in user.
        Returns the plaintext token **once** — save it immediately, we
        cannot show it again. Use it as `Authorization: Bearer ocu_…`
        against the MCP server.
      security:
        - UserSessionCookie: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 100
                  description: Human-friendly label, e.g. "Claude desktop". Defaults to "AI assistant".
      responses:
        '201':
          description: Token created (plaintext returned ONCE).
          content:
            application/json:
              schema:
                type: object
                required: [token, record]
                properties:
                  token:
                    type: string
                    description: Plaintext token. Shown ONCE.
                    example: ocu_AbCdEfGh...
                  record: { $ref: '#/components/schemas/McpTokenRecord' }
                  hint: { type: string }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
    delete:
      tags: [Account]
      summary: Revoke an MCP token
      security:
        - UserSessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: integer, description: Token row id from the list endpoint. }
      responses:
        '200':
          description: Token revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: boolean }
                  id:      { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/public/follows:
    get:
      tags: [Follows]
      summary: List my follows
      description: Returns up to 500 follows owned by the signed-in user, newest first.
      security:
        - UserSessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [follows]
                properties:
                  follows:
                    type: array
                    items: { $ref: '#/components/schemas/Follow' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
    post:
      tags: [Follows]
      summary: Create (or upsert) a follow
      description: |
        Creates a follow for the signed-in user. The `(user, kind, target)`
        triple is unique — re-posting the same follow updates `delivery_mode`
        / `label` instead of erroring.

        - `kind=film` or `kind=theater` requires `target_id` (canonical id;
          film aliases are resolved transparently). The `label` is auto-filled
          from the resolved row's title/name.
        - `kind=city` requires `lat`, `lon`, `radius_km` (1–200). The server
          mints a deterministic `target_id` like `geo:40.730,-73.997@30km`
          so two near-identical city follows from the same user collapse.

        Every write rotates the unsubscribe nonce, so any older emailed
        unsubscribe link for this follow stops working.
      security:
        - UserSessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind:
                  type: string
                  enum: [film, theater, city]
                target_id:
                  type: string
                  description: Required for `film`/`theater` follows. Ignored for `city`.
                lat:        { type: number, minimum: -90,  maximum:  90,  description: 'Required for `city`.' }
                lon:        { type: number, minimum: -180, maximum: 180, description: 'Required for `city`.' }
                radius_km:  { type: number, minimum: 1, maximum: 200, description: 'Required for `city`.' }
                label:      { type: string, maxLength: 200, description: 'Optional display label (city only — auto-filled for film/theater).' }
                delivery_mode:
                  type: string
                  enum: [immediate, digest, off]
                  default: digest
      responses:
        '201':
          description: Follow created or updated.
          content:
            application/json:
              schema:
                type: object
                required: [follow]
                properties:
                  follow: { $ref: '#/components/schemas/Follow' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }

  /api/v1/public/follows/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
        description: Follow row id from `GET /follows`.
    patch:
      tags: [Follows]
      summary: Update a follow's delivery mode
      description: |
        Updates only the `delivery_mode` of an existing follow. Rotates
        the unsubscribe nonce so any older emailed unsubscribe link for
        this follow stops working.
      security:
        - UserSessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [delivery_mode]
              properties:
                delivery_mode:
                  type: string
                  enum: [immediate, digest, off]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [follow]
                properties:
                  follow: { $ref: '#/components/schemas/Follow' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Follows]
      summary: Delete a follow
      security:
        - UserSessionCookie: []
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  id:      { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/NotAuthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/public/follows/unsubscribe:
    get:
      tags: [Follows]
      summary: One-click unsubscribe (email link target)
      description: |
        Target of the one-click unsubscribe link in every alert email.
        The token is an HMAC-signed envelope of `(follow_id, user_id,
        issued_at, nonce)`; verification is two-stage:

          1. HMAC over `SESSION_SECRET` proves the link was issued by us.
          2. `sha256(presented_nonce)` must match
             `user_follows.unsubscribe_token_hash`. The hash is rotated
             on every `delivery_mode` write (including this handler), so
             links from older emails for the same follow stop working.

        On success, sets the follow's `delivery_mode` to `off`, rotates
        the nonce, and renders an HTML confirmation page.

        GET-only by design even though it mutates state — corporate
        mail scanners pre-fetch links and we accept that as the price
        of frictionless one-click. Re-enable is one click in
        `/me/follows`.
      security: []
      parameters:
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: Signed unsubscribe token from the alert email.
      responses:
        '200':
          description: Unsubscribed (or already unsubscribed). HTML confirmation page.
          content:
            text/html:
              schema: { type: string }
        '400':
          description: Token malformed or HMAC failed (HTML).
          content:
            text/html:
              schema: { type: string }
        '410':
          description: Token superseded by a newer email cycle (HTML).
          content:
            text/html:
              schema: { type: string }

  /api/v1/public/calendar.ics:
    get:
      tags: [Calendar]
      summary: Location-based iCal feed (no key required)
      description: |
        Subscribable from any iCal-compatible app (Apple Calendar, Google
        Calendar, Outlook). Use `webcal://` to trigger the OS subscribe
        prompt. **Key-free by design** — calendar apps cannot send
        Authorization headers. Rate-limited by IP (~30/min).
      security: []
      parameters:
        - { in: query, name: lat,       schema: { type: number, minimum: -90,  maximum:  90  } }
        - { in: query, name: lon,       schema: { type: number, minimum: -180, maximum: 180 } }
        - { in: query, name: radius_km, schema: { type: number, default: 30,  maximum: 200  } }
        - { in: query, name: days,      schema: { type: integer, default: 14, maximum:  60  } }
        - { in: query, name: film_id,   schema: { type: string }, description: "Optional: narrow to a single film." }
      responses:
        '200':
          description: RFC 5545 calendar
          content:
            text/calendar:
              schema: { type: string }

  /api/v1/public/theaters/{id}/calendar.ics:
    get:
      tags: [Calendar]
      summary: Per-theater iCal feed (no key required)
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - { in: query, name: days, schema: { type: integer, default: 30, maximum: 90 } }
      responses:
        '200':
          description: RFC 5545 calendar
          content:
            text/calendar:
              schema: { type: string }

  /api/v1/public/films/{filmId}/calendar.ics:
    get:
      tags: [Calendar]
      summary: Per-film iCal feed (no key required)
      security: []
      parameters:
        - in: path
          name: filmId
          required: true
          schema: { type: string }
        - { in: query, name: days, schema: { type: integer, default: 60, maximum: 180 } }
      responses:
        '200':
          description: RFC 5545 calendar
          content:
            text/calendar:
              schema: { type: string }

components:
  parameters:
    Limit:
      in: query
      name: limit
      description: Page size (1–200).
      schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
    Cursor:
      in: query
      name: cursor
      description: Opaque cursor token from a previous page's `pagination.next_cursor`.
      schema: { type: string }

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: oc_live_*
      description: |
        Developer API key issued by `POST /api/v1/public/keys`.
        Format: `oc_live_<24+ url-safe chars>`.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
    ApiKeyQuery:
      type: apiKey
      in: query
      name: api_key
    BearerSession:
      type: http
      scheme: bearer
      bearerFormat: ocs_*
      description: |
        End-user session token issued by `/account/oauth/apple`,
        `/account/oauth/google`, or the magic-link verify flow. Sent as
        `Authorization: Bearer ocs_…`. Used by the native mobile app
        and accepted by every endpoint that also accepts the
        `oc_user_session` cookie (`/account/me`, `/account/logout`,
        `/account/follows`, etc.). Distinct from the developer API
        keys above (which are `oc_live_*` / `oc_test_*`).
    UserSessionCookie:
      type: apiKey
      in: cookie
      name: oc_user_session
      description: |
        Opaque end-user session token set by `GET /api/v1/public/account/verify`
        after a magic-link sign-in. HttpOnly, Secure, SameSite=Lax, ~30 day
        sliding expiry. Sent automatically by browsers; non-browser clients
        must capture the `Set-Cookie` from `/verify` and replay it. This is
        a *separate* auth track from `BearerAuth`/`X-API-Key` — developer
        keys do not grant access to `/account/*` or `/follows/*`. The
        equivalent bearer-token form for native clients is `BearerSession`
        above.
    McpUserToken:
      type: http
      scheme: bearer
      bearerFormat: ocu_*
      description: |
        Personal MCP token minted by a signed-in user via
        `POST /api/v1/public/account/mcp-token`. Format: `ocu_<32+ url-safe
        chars>`. Sent as `Authorization: Bearer ocu_…` to the MCP server
        (`/api/mcp`) so an AI assistant can act on the user's behalf
        (e.g. create or list their follows). Plaintext is shown ONCE at
        creation time — only a sha256 lives in the database. Listed in
        this spec for discoverability; it is not accepted by the JSON
        endpoints under `/api/v1/public/` (those use the cookie or
        `BearerSession`).

  schemas:
    OAuthSession:
      type: object
      required: [session_token, expires_at, user, provider]
      properties:
        session_token: { type: string, example: "ocs_AbCdEf...", description: "Opaque bearer token. Send as `Authorization: Bearer <token>` on subsequent requests." }
        expires_at:    { type: string, format: date-time, description: "Absolute expiry (30 days from issue)." }
        user:
          type: object
          required: [id, email]
          properties:
            id:    { type: integer, example: 42 }
            email: { type: string, format: email, example: "you@example.com" }
        provider:
          type: string
          enum: [apple, google]

    Pagination:
      type: object
      required: [limit, has_more]
      properties:
        limit:       { type: integer, example: 50 }
        has_more:    { type: boolean, example: true }
        next_cursor: { type: string, nullable: true, description: "Pass back as ?cursor=…" }

    WebhookDelivery:
      description: |
        Outbound JSON envelope POSTed to a subscribed URL. The envelope
        shape is constant; the `data` field shape depends on `type`:
          - `screening.created`, `screening.updated` → see ScreeningEventData
          - `theater.created`,   `theater.updated`   → see TheaterEventData
          - `link.broken`                            → see LinkBrokenEventData
      type: object
      required: [type, id, created_at, data]
      properties:
        type:
          type: string
          enum: [screening.created, screening.updated, theater.created, theater.updated, link.broken]
        id:         { type: string, example: "evt_482917", description: "Per-event id, useful for idempotency keys downstream." }
        created_at: { type: string, format: date-time }
        data:
          oneOf:
            - $ref: '#/components/schemas/ScreeningEventData'
            - $ref: '#/components/schemas/TheaterEventData'
            - $ref: '#/components/schemas/LinkBrokenEventData'
          discriminator:
            propertyName: __event_kind
      example:
        type: screening.updated
        id:   evt_482917
        created_at: "2026-05-02T18:14:31.220Z"
        data:
          id: scr_abc123
          theater_id: theater_metrograph
          film_id: film_xyz789
          start_time: "2026-05-04T19:00:00-04:00"
          is_sold_out: false
          status: scheduled
          updated_at: "2026-05-02T18:14:30.000Z"

    ScreeningEventData:
      type: object
      required: [id, theater_id, start_time, status, updated_at]
      properties:
        id:           { type: string, example: scr_abc123 }
        theater_id:   { type: string, example: theater_metrograph }
        film_id:      { type: string, example: film_xyz789 }
        start_time:   { type: string, format: date-time }
        is_sold_out:  { type: boolean }
        status:       { type: string, example: scheduled }
        updated_at:   { type: string, format: date-time }

    TheaterEventData:
      type: object
      required: [id, name, updated_at]
      properties:
        id:         { type: string, example: theater_metrograph }
        name:       { type: string }
        timezone:   { type: string, example: America/New_York }
        updated_at: { type: string, format: date-time }

    LinkBrokenEventData:
      type: object
      required: [screening_id, url, checked_at]
      properties:
        screening_id: { type: string }
        url:          { type: string, format: uri }
        status_code:  { type: integer, nullable: true, example: 500 }
        error_message: { type: string, nullable: true }
        checked_at:   { type: string, format: date-time }

    Theater:
      type: object
      required: [id, name, location, timezone]
      properties:
        id:        { type: string, example: "theater_alamo_dfw_cedars" }
        name:      { type: string }
        address:   { type: string }
        location:
          type: object
          required: [lat, lon]
          properties:
            lat: { type: number }
            lon: { type: number }
        timezone:  { type: string, example: "America/Chicago" }
        website:   { type: string, nullable: true }
        distance_km:           { type: number, nullable: true, description: "Only present on geo searches." }
        last_screening_update: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Screening:
      type: object
      required: [id, film_id, theater_id, start_time]
      properties:
        id:                  { type: string }
        film_id:             { type: string }
        film_title:          { type: string }
        film_rating:         { type: string, nullable: true }
        film_runtime_min:    { type: integer, nullable: true }
        theater_id:          { type: string }
        theater_name:        { type: string }
        theater_timezone:    { type: string }
        start_time:          { type: string, format: date-time }
        formats:
          type: array
          items: { type: string }
          example: ["35mm", "DCP"]
        is_sold_out:         { type: boolean }
        distance_km:         { type: number, nullable: true }
        accessibility_features:
          type: array
          items: { type: string }
        special_events:
          type: array
          items: { type: string }
        updated_at:          { type: string, format: date-time }
        theater_last_updated: { type: string, format: date-time, nullable: true }
        checkout:
          type: object
          properties:
            type: { type: string, enum: [deeplink], example: deeplink }
            url:  { type: string, format: uri, nullable: true }

    Film:
      type: object
      required: [id, title]
      properties:
        id:               { type: string }
        requested_id:     { type: string, nullable: true, description: "Set if you queried via an alias; the canonical id is in `id`." }
        title:            { type: string }
        original_title:   { type: string, nullable: true }
        year:             { type: integer, nullable: true }
        runtime_min:      { type: integer, nullable: true }
        rating:           { type: string, nullable: true, description: "MPAA rating (G/PG/PG-13/R/NC-17)." }
        vote_average:     { type: number, nullable: true, description: "TMDB 0–10 score." }
        poster_url:       { type: string, nullable: true }
        backdrop_url:     { type: string, nullable: true }
        synopsis:         { type: string, nullable: true }
        genres:
          type: array
          items: { type: string }
        trailer_url:       { type: string, nullable: true }
        trailer_embed_url: { type: string, nullable: true }
        director:          { type: string, nullable: true }
        cast:
          type: array
          items: { type: string }
        external_ids:
          type: object
          additionalProperties: true

    Webhook:
      type: object
      required: [id, url, event_types, status, created_at]
      properties:
        id:                   { type: integer, example: 42 }
        url:                  { type: string, format: uri }
        event_types:
          type: array
          items: { type: string }
        status:               { type: string, enum: [active, disabled, deleted] }
        description:          { type: string, nullable: true }
        created_at:           { type: string, format: date-time }
        last_delivery_at:     { type: string, format: date-time, nullable: true }
        last_delivery_status: { type: integer, nullable: true, description: 'Most recent HTTP response code.' }
        consecutive_failures: { type: integer, example: 0 }
        secret_prefix:        { type: string, example: 'whsec_kHe57j' }
        recent_deliveries:
          type: array
          description: Last 5 delivery attempts (most recent first).
          items:
            type: object
            properties:
              id:             { type: integer }
              status:         { type: string, enum: [pending, succeeded, failed, retrying] }
              response_code:  { type: integer, nullable: true }
              attempt:        { type: integer }
              created_at:     { type: string, format: date-time }
              delivered_at:   { type: string, format: date-time, nullable: true }
              error_message:  { type: string, nullable: true }

    Follow:
      type: object
      required: [id, kind, target_id, delivery_mode, created_at, updated_at]
      properties:
        id:            { type: integer, example: 17 }
        kind:          { type: string, enum: [film, theater, city] }
        target_id:
          type: string
          description: |
            Canonical id of the followed entity. For `city` follows this is
            a server-minted slug like `geo:40.730,-73.997@30km`.
          example: theater_metrograph
        lat:           { type: number, nullable: true, description: 'Only for `city`.' }
        lon:           { type: number, nullable: true, description: 'Only for `city`.' }
        radius_km:     { type: number, nullable: true, description: 'Only for `city`.' }
        label:         { type: string, nullable: true, description: 'Display label (auto-filled for film/theater).' }
        delivery_mode: { type: string, enum: [immediate, digest, off] }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time }

    McpTokenRecord:
      type: object
      required: [id, token_prefix, label, created_at]
      properties:
        id:           { type: integer, example: 3 }
        token_prefix: { type: string, example: 'ocu_AbCdEfGh', description: 'First 12 chars; full token is never stored.' }
        label:        { type: string, example: 'Claude desktop' }
        created_at:   { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:       { type: string, example: "rate_limited" }
            message:    { type: string }
            request_id: { type: string, example: "req_7d7207cc74f4b8de" }

  responses:
    BadRequest:
      description: Malformed request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotAuthenticated:
      description: No valid `oc_user_session` cookie was presented.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded. Inspect `Retry-After` header.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Internal server error. Include the `request_id` when reporting.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
