openapi: 3.0.3
info:
  title: Linkdown Worker API
  version: '0.1.0'
  description: |
    HTTP API for the Linkdown Cloudflare Worker (`apps/workers`).

    The worker is a Hono.js app deployed on Cloudflare Workers. It backs the
    dashboard (`apps/dashboard`), the Rust CLI (`apps/cli`), and the public
    share viewer at `https://lnkdwn.com/d/:token`.

    ## Authentication

    Authenticated endpoints require a Clerk session JWT in
    `Authorization: Bearer <token>`. The worker validates the token against
    Clerk's Backend API JWKS endpoint. Public endpoints (device-flow,
    share viewer, webhooks) do not require auth.

    ## Error envelopes

    Two coexisting error envelopes are in use:

    1. **Legacy** `{ error: string, code?: string }` — pre-existing endpoints
       in `routes/auth.ts`, `routes/decks.ts`, and `routes/share.ts`. The
       `code` field is omitted on the 500 path; a `code` is present on most
       4xx paths. The 404 handler at the top level returns
       `{ error: "Not found", code: "NOT_FOUND" }`.

    2. **Standard** `{ code: ErrorCode, message: string, details?: object }` —
       the new envelope (`apps/workers/src/lib/errors.ts`) used by the
       `app.onError` hook and by the `errorResponse` helper. `code` is one
       of the `ErrorCode` enum values; `message` is human-readable; `details`
       carries optional field-level validation errors keyed by field name.

    Both are documented under `components.schemas`. Per-route `responses`
    entries reference both as needed.

    ## Rate limiting

    All write endpoints and most read endpoints are gated by the
    Cloudflare Workers Rate Limiting binding declared in `wrangler.jsonc`.
    Limits are edge-atomic. On exceed the route returns
    `429 { error: "Rate limit exceeded", code: "RATE_LIMITED" }` with a
    `Retry-After: 60` header. Per-binding limits:

    | Binding             | Limit      | Used by                                              |
    |---------------------|------------|------------------------------------------------------|
    | `RL_DEVICE_CODE`    | 10/min/IP  | `POST /auth/device/code`                             |
    | `RL_DEVICE_TOKEN`   | 60/min/IP  | `POST /auth/device/token`                            |
    | `RL_DECKS_CREATE`   | 50/min/uid | `POST /decks`                                        |
    | `RL_SHARE_VIEW`     | 120/min/IP | `GET /d/:token`, `GET /d/:token/file`                 |
    | `RL_SHARE_INFO`     | 120/min/IP | `GET /d/:token/info`                                 |
    | `RL_SHARE_LIST`     | 60/min/IP  | `GET /shares`, `GET /decks/:id/share`                |
    | `RL_SHARE_DELETE`   | 60/min/IP  | `DELETE /shares/:id`                                 |
    | `RL_BILLING_PORTAL` | 60/min/uid | `POST /billing/portal`, `POST /billing/checkout`        |

    ## Conventions

    - All timestamps are ISO-8601 strings (`createdAt`, `updatedAt`,
      `expiresAt`).
    - All IDs are UUIDv4 strings unless otherwise noted.
    - The base URL is `https://api.lnkdwn.com` in production and
      `https://api.staging.lnkdwn.com` in staging.
    - Webhooks are signed with HMAC-SHA256 (Svix for Clerk, Stripe scheme
      for Stripe). Bodies must be read raw (string) before JSON
      deserialization so the signature remains valid.
  contact:
    name: Linkdown maintainers
    url: https://github.com/0451-software/linkdown
  license:
    name: UNLICENSED
    url: https://github.com/0451-software/linkdown
servers:
  - url: https://api.lnkdwn.com
    description: Production
  - url: https://api.staging.lnkdwn.com
    description: Staging
tags:
  - name: Health
    description: Liveness and platform endpoints.
  - name: Auth
    description: Clerk device-flow (RFC 8628) + authenticated profile.
  - name: Me
    description: Authenticated user profile + tier-quota snapshot.
  - name: Storage
    description: Authenticated user storage usage.
  - name: Decks
    description: Deck CRUD and upload lifecycle.
  - name: Shares
    description: Authenticated share-link management (create / list / revoke).
  - name: Public
    description: Unauthenticated public share viewer.
  - name: Billing
    description: Stripe billing portal session creation.
  - name: Webhooks
    description: Inbound webhooks from Clerk and Stripe.
security:
  - ClerkBearer: []
paths:
  /health:
    get:
      operationId: getHealth
      tags: [Health]
      summary: Health check
      description: Liveness probe. No auth. Returns the current ISO timestamp.
      security: []
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                required: [status, timestamp]
                properties:
                  status:
                    type: string
                    enum: [ok]
                  timestamp:
                    type: string
                    format: date-time
        default:
          description: |
            Unreachable in practice — `/health` is a no-arg liveness
            probe and never returns a non-2xx response. Declared to
            satisfy the OpenAPI `operation-4xx-response` convention.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /storage-usage:
    get:
      operationId: getStorageUsage
      tags: [Storage]
      summary: Get authenticated user's storage usage
      description: |
        Returns the authenticated user's total bytes used (sum of their deck
        `sizeBytes`), the byte cap for their tier, and the tier name.
        Backed by the `storage_usage` table.
      responses:
        '200':
          description: Storage usage snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StorageUsage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /auth/device/code:
    post:
      operationId: initiateDeviceCode
      tags: [Auth]
      summary: Initiate OAuth2 device authorization flow
      description: |
        Delegates to Clerk's `POST /v1/oauth/device_code` endpoint. Returns
        a `device_code`, a `user_code`, and a `verification_uri` that the
        user navigates to in a browser. Rate-limited to 10/min/IP via
        `RL_DEVICE_CODE`.
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceCodeRequest'
      responses:
        '200':
          description: Device-flow code issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceCodeResponse'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
        '502':
          description: Upstream Clerk call failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /auth/device/token:
    post:
      operationId: pollDeviceToken
      tags: [Auth]
      summary: Poll for device-flow access token
      description: |
        Polls Clerk's `POST /v1/oauth/token` endpoint with the `device_code`
        previously returned by `POST /auth/device/code`. While the user is
        still authorizing in the browser, returns `200 { error: "authorization_pending" }`.
        On success returns the OAuth2 access token (and refresh token if
        issued). Rate-limited to 60/min/IP via `RL_DEVICE_TOKEN`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeviceTokenRequest'
      responses:
        '200':
          description: |
            Either `authorization_pending` (user still authorizing) or a
            successful token response.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AuthorizationPending'
                  - $ref: '#/components/schemas/DeviceTokenResponse'
        '400':
          description: Missing `device_code` body field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
        '502':
          description: Upstream Clerk call failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /me:
    get:
      operationId: getMe
      tags: [Me]
      summary: Get authenticated user's profile
      description: |
        Returns the user's Clerk-backed profile, current tier, storage
        usage (used + limit), deck count, and `deckLimit` (or `null` for
        unlimited / unknown tiers).
      responses:
        '200':
          description: Profile snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /decks:
    get:
      operationId: listDecks
      tags: [Decks]
      summary: List authenticated user's decks
      description: |
        Returns a paginated list of the authenticated user's decks, ordered
        by `createdAt DESC`. Auth required.
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Paginated deck list.
          content:
            application/json:
              schema:
                type: object
                required: [decks, page, limit]
                properties:
                  decks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Deck'
                  page:
                    type: integer
                    minimum: 1
                  limit:
                    type: integer
                    minimum: 1
                    maximum: 100
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
    post:
      operationId: createDeck
      tags: [Decks]
      summary: Create a deck + presigned R2 upload URL
      description: |
        Creates a new deck row in `processing` status, generates an
        8-character slug, and returns a presigned S3 PUT URL targeting
        `decks/{clerkUserId}/{deckId}/{filename}` in the per-env R2 bucket.
        The URL is valid for 10 minutes. The deck is owned by the
        authenticated user; the `decks.userId` foreign key is the
        internal UUID. Tier limits enforced: `tierDeckLimit(user.tier)`
        (5/50/50 for free/starter/pro; `null` for unlimited tiers);
        free tier is also capped at 25 MB total storage and cannot
        replace or create a deck when the cap would be exceeded.
        Rate-limited to 50/min/uid via `RL_DECKS_CREATE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDeckInput'
      responses:
        '201':
          description: Deck created, presigned URL returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateDeckResponse'
        '400':
          $ref: '#/components/responses/BadRequestLegacy'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Tier-limit reached. The deck-count cap for the user's tier
            is exceeded, or the free-tier storage cap would be exceeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /decks/{id}:
    parameters:
      - $ref: '#/components/parameters/DeckId'
    get:
      operationId: getDeck
      tags: [Decks]
      summary: Get a single deck
      description: |
        Returns the deck if it exists and is owned by the authenticated
        user. Returns 404 for both missing and not-owned decks (no
        enumeration via 403 vs 404).
      responses:
        '200':
          description: Deck found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Deck'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
    put:
      operationId: updateDeck
      tags: [Decks]
      summary: Update deck metadata
      description: |
        Updates the deck's `name` and/or `framework`. When the
        `replaceFile: true` flag is set, also flips the deck to
        `processing`, clears `thumbnailKey` + `errorMessage`, and returns
        a fresh presigned PUT URL targeting the deck's existing storage
        key. The deck id, slug, and any pre-existing share links are
        preserved. The legacy response shape (no `replaceFile`) is
        `{ success: true }`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateDeckBody'
      responses:
        '200':
          description: |
            Updated. Body shape depends on `replaceFile`:
              - `false`/omitted → `{ success: true }`
              - `true` → `{ success: true, id, storageKey, uploadUrl }`
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [success]
                    properties:
                      success:
                        type: boolean
                        enum: [true]
                  - $ref: '#/components/schemas/ReplaceDeckFileResponse'
        '400':
          $ref: '#/components/responses/BadRequestLegacy'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
    delete:
      operationId: deleteDeck
      tags: [Decks]
      summary: Delete a deck
      description: |
        Deletes the deck row (cascading to its `shares`) and the
        underlying R2 object. R2 delete failure is logged but does not
        block the DB delete. Returns 404 for missing or not-owned decks.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    enum: [true]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /decks/{id}/complete:
    post:
      operationId: completeDeckUpload
      tags: [Decks]
      summary: Finalize a deck upload
      description: |
        Marks a deck as `ready` (or `failed` if `errorMessage` is set)
        after the client has finished the R2 PUT. Verifies the R2
        object exists via `R2_BUCKET.head()`; returns 400 `UPLOAD_INCOMPLETE`
        if the object is missing. Enforces free-tier limits (0.5 MB
        per-deck, 15 slides, no asset bundles). Updates `storage_usage`
        using the R2-reported size (not the client-supplied one — the
        free-tier storage quota is real R2 bytes, not client claims).
        Enqueues thumbnail generation when `status === 'ready'` and
        a `thumbnailKey` is supplied.
      parameters:
        - $ref: '#/components/parameters/DeckId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompleteDeckUploadInput'
      responses:
        '200':
          description: Deck finalized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompleteDeckUploadResponse'
        '400':
          description: |
            Bad request — missing deck, or R2 head() returned null
            (upload not yet visible), or client-supplied `sizeBytes` /
            `slideCount` out of bounds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Free-tier limit (size, slide count, or assets) exceeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /decks/{id}/share:
    parameters:
      - $ref: '#/components/parameters/DeckId'
    get:
      operationId: getDeckShare
      tags: [Shares]
      summary: Get the current share link for a deck
      description: |
        Returns the most recently created share row's public token and URL.
        404 if the deck doesn't exist OR isn't owned by the caller (no
        enumeration), or if the deck has no share row yet. Rate-limited to
        60/min/IP via `RL_SHARE_LIST`.
      responses:
        '200':
          description: Current public share link.
          content:
            application/json:
              schema:
                type: object
                required: [token, url, shares]
                properties:
                  token:
                    type: string
                    description: Opaque 21-character public share token.
                  url:
                    type: string
                    format: uri
                    description: Public deck URL built from the per-environment PUBLIC_URL.
                  shares:
                    type: array
                    description: Backwards-compatible share-list envelope for existing dashboard consumers.
                    items:
                      $ref: '#/components/schemas/ShareLink'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
    post:
      operationId: createDeckShare
      tags: [Shares]
      summary: Generate a share link
      description: |
        Creates a new share row and returns the public URL
        `https://lnkdwn.com/d/{token}`. The Pro-tier options
        (`expiresInDays`, `viewLimit`, `password`) require a paid tier;
        free-tier users get 403 `TIER_LIMIT_REACHED` if any Pro field is
        set. The token is a 21-character base64url-safe string.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateShareLinkInput'
      responses:
        '201':
          description: Share created.
          content:
            application/json:
              schema:
                type: object
                required: [shareUrl, token, expiresAt]
                properties:
                  shareUrl:
                    type: string
                    format: uri
                    description: |
                      Public URL where the deck can be viewed. The host
                      is `https://lnkdwn.com` and the path is
                      `/d/{token}`.
                  token:
                    type: string
                    description: Opaque share token. Use to construct `shareUrl`.
                  expiresAt:
                    type: string
                    format: date-time
                    nullable: true
                    description: ISO-8601 timestamp; `null` for never-expiring shares.
        '400':
          $ref: '#/components/responses/BadRequestLegacy'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Tier-limit reached. The user is on the free tier and tried
            to set a Pro-tier field (`expiresInDays`, `viewLimit`,
            `password`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
    delete:
      operationId: revokeAllDeckShares
      tags: [Shares]
      summary: Revoke all share links for a deck
      description: |
        Deletes every share row for the deck. The deck itself is not
        touched. 404 if the deck doesn't exist or isn't owned by the
        caller.
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    enum: [true]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /shares:
    get:
      operationId: listAllShares
      tags: [Shares]
      summary: List all shares for the authenticated user
      description: |
        Returns every share row for a deck owned by the authenticated
        user, joined with the deck's `name` and `slug`. Ordered by
        share `createdAt DESC`. Rate-limited to 60/min/IP via
        `RL_SHARE_LIST`.
      responses:
        '200':
          description: Share list (across decks).
          content:
            application/json:
              schema:
                type: object
                required: [shares]
                properties:
                  shares:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShareLinkWithDeck'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /shares/{id}:
    delete:
      operationId: revokeShare
      tags: [Shares]
      summary: Revoke a single share link
      description: |
        Deletes a single share row. Returns 404 if the share doesn't
        exist or its deck isn't owned by the caller. Rate-limited to
        60/min/IP via `RL_SHARE_DELETE`.
      parameters:
        - name: id
          in: path
          required: true
          description: Share row UUID.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    enum: [true]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /d/{token}:
    get:
      operationId: viewSharedDeck
      tags: [Public]
      summary: View a shared deck (public)
      description: |
        Public share viewer. Looks up the share by token, enforces
        expiration (410 `EXPIRED`), view limit (403 `LIMIT_REACHED`),
        and password (401 `PASSWORD_REQUIRED` / 403 `INVALID_PASSWORD`).
        On success, atomically increments the view counter (the
        `WHERE` clause also enforces the view limit, so a concurrent
        viewer cannot push it past the limit) and returns the deck
        metadata. Rate-limited to 120/min/IP via `RL_SHARE_VIEW`.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: Share token.
          schema:
            type: string
        - name: password
          in: query
          required: false
          description: Required when the share is password-protected.
          schema:
            type: string
      responses:
        '200':
          description: Deck metadata for the viewer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SharedDeckView'
        '401':
          description: Password required (share is password-protected, none supplied).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: |
            Invalid password, or view limit reached (atomic increment
            was rejected by the WHERE clause).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '410':
          description: Share link expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /d/{token}/file:
    get:
      operationId: downloadSharedDeckFile
      tags: [Public]
      summary: Download a shared deck's archive
      description: |
        Streams the deck's R2 archive body (zip / tarball / etc.) to the
        viewer, with the appropriate `Content-Type` derived from the
        storage key. Runs the same share + expiration + view-limit +
        password checks as `GET /d/:token` but does **not** increment
        the view counter (a single page render may fetch metadata +
        file + thumbnail). Rate-limited to 120/min/IP via `RL_SHARE_VIEW`.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
        - name: password
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: |
            The deck archive. Body is the raw bytes; `Content-Type` is
            derived from the storage key (e.g. `application/zip`).
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          description: Password required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: Invalid password or view limit reached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '410':
          description: Share link expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /d/{token}/file/{path}:
    get:
      operationId: downloadSharedDeckAsset
      tags: [Public]
      summary: Download a single entry from a shared deck archive
      description: |
        Extracts a single entry from the deck's R2 archive and serves
        it with the correct `Content-Type`. The dashboard's public
        viewer at `lnkdwn.com/d/<token>` uses this route to load the
        deck's entry-point HTML (`dist/index.html`) directly into an
        iframe; the deck's own relative asset references
        (`./assets/foo.css`) then resolve to this route naturally.

        Closes dashboard-002 (P1): the previous viewer decoded the
        full archive as text and stuffed it into an iframe srcdoc,
        which rendered only single-file frameworks (Marp with
        `--html`). Slidev and Reveal.js produce multi-file `dist/`
        builds whose asset references couldn't resolve inside srcdoc.

        -- Auth / gating --
        Runs the same share + expiration + view-limit + password
        checks as `GET /d/{token}/file`. Does NOT increment the
        view counter (a single page render does ~5 requests against
        this route — metadata + entry-point + 3-4 assets — and should
        count as one view). Rate-limited via the per-token burst
        binding `RL_SHARE_VIEW_BURST` (30/10s), shared with the
        archive-download route so a normal page-render fan-out fits
        comfortably under the budget.

        -- Path-traversal guard --
        The asset path is rejected with `400 INVALID_PATH` if it
        contains `..`, a null byte, or starts with `/`, or is longer
        than 512 bytes, or has malformed percent-encoding. The check
        runs before any zip lookup so a malicious entry name in the
        archive never gets the chance to escape.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
        - name: path
          in: path
          required: true
          description: |
            Archive-relative path of the entry to extract
            (e.g. `dist/index.html`, `dist/assets/index.css`). The
            path is URL-decoded and then checked against the
            path-traversal guard described above.
          schema:
            type: string
        - name: password
          in: query
          required: false
          schema:
            type: string
      responses:
        '200':
          description: |
            The extracted entry body. `Content-Type` is derived from
            the file extension (e.g. `text/html; charset=utf-8` for
            `.html`, `text/css` for `.css`, `application/javascript`
            for `.js`). `Cache-Control: private, max-age=300` is set
            so the browser caches content-addressed asset filenames
            (Slidev/Reveal.js emit `index-BjafBWJ3.js` style names)
            while leaving the password gate authoritative.
          headers:
            Cache-Control:
              schema:
                type: string
              description: Always `private, max-age=300`.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '400':
          description: Invalid path (path-traversal attempt or malformed encoding).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Password required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '403':
          description: Invalid password or view limit reached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '404':
          description: Share not found, deck not ready, or entry not in archive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '410':
          description: Share link expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /d/{token}/info:
    get:
      operationId: getShareInfo
      tags: [Public]
      summary: Get share metadata (no view-count increment)
      description: |
        Returns share + deck metadata without incrementing the view
        counter. Useful for the dashboard's "share preview" surface.
        Rate-limited to 120/min/IP via `RL_SHARE_INFO`.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Share + deck metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareInfo'
        '404':
          $ref: '#/components/responses/NotFoundLegacy'
        '410':
          description: Share link expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /billing/portal:
    post:
      operationId: createBillingPortalSession
      tags: [Billing]
      summary: Create a Stripe billing portal session
      description: |
        Returns a `url` for a Stripe-hosted billing portal session. The
        portal handles subscription changes, payment-method updates, and
        invoice history. If the user has no `stripeCustomerId` yet, a new
        Stripe customer is created (with `metadata.clerkUserId`) and the
        returned `cus_xxx` is persisted. Rate-limited to 60/min/uid via
        `RL_BILLING_PORTAL`. Stripe error details are never leaked —
        failures log to `console.error` and return a generic 500.
      responses:
        '200':
          description: Portal session URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingPortalResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: |
            Stripe API call failed. The worker logs the underlying error
            and returns a generic message; the dashboard renders a
            "Couldn't open billing portal" toast on this status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /billing/checkout:
    post:
      operationId: createBillingCheckoutSession
      tags: [Billing]
      summary: Create a one-time Stripe Checkout session for a tier upgrade
      description: |
        Returns a `url` for a Stripe-hosted checkout session for a
        specific tier (Starter, Pro, or Team). Used by the dashboard's
        /pricing "Upgrade" CTAs to start a new subscription without
        forcing the user through the customer portal.

        The `priceId` in the request body must equal one of the three
        configured Worker secrets: `STRIPE_STARTER_PRICE_ID`,
        `STRIPE_PRO_PRICE_ID`, or `STRIPE_TEAM_PRICE_ID`. Any other value
        is rejected with `400 INVALID_PRICE_ID` before the request ever
        reaches Stripe (so unknown ids never appear in Stripe's request
        logs).

        If the user has no `stripeCustomerId` yet, a new Stripe customer
        is created (with `metadata.clerkUserId`) and the returned
        `cus_xxx` is persisted — same path as `POST /billing/portal`.

        The checkout session is created with `mode: 'subscription'` and
        `allow_promotion_codes: true`. The resolved tier is stamped on
        both `subscription_data.metadata.tier` and the session's own
        `metadata.tier` so support can correlate a Stripe session back
        to a tier even before the webhook has fired. `success_url`
        includes the literal `{CHECKOUT_SESSION_ID}` placeholder
        (Stripe substitutes it at redirect time); `cancel_url` points
        at `/pricing?checkout=canceled`.

        Rate-limited to 60/min/uid (shared bucket with `/billing/portal`).
        Stripe error details are never leaked — failures log to
        `console.error` and return a generic 500.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BillingCheckoutRequest'
      responses:
        '200':
          description: Checkout session URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingCheckoutResponse'
        '400':
          description: |
            Bad request. One of:
            - `INVALID_PRICE_ID` — `priceId` is missing, not a string,
              an empty string, or doesn't match any of the three
              configured tier price ids.
            - `BAD_REQUEST` — request body is not valid JSON or is not
              a JSON object.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: |
            One of:
            - `PRICE_ID_NOT_CONFIGURED` — none of the three
              `STRIPE_*_PRICE_ID` Worker secrets are set yet, so the
              endpoint cannot resolve any tier. The dashboard should
              treat this as "not yet operational" and surface a
              "Checkout temporarily unavailable" message.
            - Generic Stripe API failure — the worker logs the
              underlying error and returns a generic message; the
              dashboard renders a "Couldn't open checkout" toast on
              this status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /webhooks/clerk:
    post:
      operationId: receiveClerkWebhook
      tags: [Webhooks]
      summary: Clerk user-sync webhook (Svix)
      description: |
        Receives Clerk `user.created`, `user.updated`, and `user.deleted`
        events. Signed with Svix (`svix-id`, `svix-timestamp`,
        `svix-signature` headers) using HMAC-SHA256. Unknown event types
        are silently acknowledged with `{ received: true, type, ignored: true }`.
        `user.deleted` requires `data.deleted === true` AND
        `data.object === "user"` to actually delete the row.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClerkWebhook'
      responses:
        '200':
          description: |
            Acknowledged. Body is `{ received: true, type }` for handled
            events; `{ received: true, type, ignored: true }` for unknown
            event types; or `{ received: true, type, ignored: "delete-not-confirmed" }`
            for `user.deleted` events missing the `deleted: true` flag.
          content:
            application/json:
              schema:
                type: object
                required: [received, type]
                properties:
                  received:
                    type: boolean
                    enum: [true]
                  type:
                    type: string
                  ignored:
                    oneOf:
                      - type: boolean
                        enum: [true]
                      - type: string
                        enum: [delete-not-confirmed]
        '400':
          description: |
            Bad request — missing Svix headers, or invalid JSON body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Invalid signature.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /webhooks/stripe:
    post:
      operationId: receiveStripeWebhook
      tags: [Webhooks]
      summary: Stripe subscription webhook
      description: |
        Receives Stripe subscription lifecycle events
        (`customer.subscription.created`, `customer.subscription.updated`,
        `customer.subscription.deleted`, `invoice.payment_succeeded`,
        `invoice.payment_failed`). Signed with Stripe's HMAC-SHA256
        scheme (`Stripe-Signature` header). The user is looked up via
        `metadata.clerk_user_id` on the subscription / invoice object.
        On `invoice.payment_failed`, the user is flagged `delinquent: true`
        and downgraded by the daily cron after `period_end`. Unknown
        event types are silently acknowledged with
        `{ received: true, type, ignored: true }`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StripeWebhook'
      responses:
        '200':
          description: Acknowledged.
          content:
            application/json:
              schema:
                type: object
                required: [received, type]
                properties:
                  received:
                    type: boolean
                    enum: [true]
                  type:
                    type: string
                  ignored:
                    type: boolean
                    enum: [true]
        '400':
          description: |
            Bad request — missing `Stripe-Signature` header, or invalid
            JSON body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Invalid signature.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/InternalErrorLegacy'
  /openapi.yaml:
    get:
      operationId: getOpenApiSpec
      tags: [Health]
      summary: This OpenAPI document
      description: |
        Returns the OpenAPI 3.0.3 spec for the worker, served as
        `text/yaml; charset=utf-8` with a 1-hour public cache. Bundled
        into the worker via `wrangler.jsonc`'s `[[rules]]` `type: Text`
        entry so the spec ships with the deploy.
      security: []
      responses:
        '200':
          description: OpenAPI document.
          content:
            text/yaml:
              schema:
                type: string
        default:
          description: |
            Unreachable in practice — `/openapi.yaml` always returns
            the bundled spec. Declared to satisfy the OpenAPI
            `operation-4xx-response` convention.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
components:
  securitySchemes:
    ClerkBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Clerk session JWT. Verified against Clerk's Backend API JWKS
        endpoint. The `sub` claim is the `user_...` Clerk id; the
        worker resolves it to the internal `users.id` UUID.
  parameters:
    DeckId:
      name: id
      in: path
      required: true
      description: Deck UUID.
      schema:
        type: string
        format: uuid
    PageParam:
      name: page
      in: query
      required: false
      description: 1-indexed page number. Default 1.
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      required: false
      description: |
        Page size. Default 20. Capped at 100 by the worker (values
        above 100 are silently clamped to 100).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
  responses:
    Unauthorized:
      description: Missing or invalid `Authorization` header, or Clerk JWT failed verification.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFoundLegacy:
      description: Resource not found (legacy envelope).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    BadRequestLegacy:
      description: Bad request (legacy envelope).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    InternalErrorLegacy:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: |
        Rate limit exceeded. Always includes a `Retry-After: 60` header.
      headers:
        Retry-After:
          schema:
            type: integer
            description: Seconds until the next request would be allowed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
  schemas:
    # ------------------------------------------------------------------
    # Error envelopes
    # ------------------------------------------------------------------
    ErrorEnvelope:
      description: |
        Legacy error envelope: `{ error, code? }`. Used by pre-existing
        endpoints in `routes/auth.ts`, `routes/decks.ts`, and
        `routes/share.ts`. The 500 path omits `code`; most 4xx paths
        include it.
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message.
        code:
          type: string
          description: |
            Stable machine-readable code. One of:
            `BAD_REQUEST`, `NOT_FOUND`, `UNAUTHORIZED`, `PASSWORD_REQUIRED`,
            `INVALID_PASSWORD`, `EXPIRED`, `LIMIT_REACHED`, `RATE_LIMITED`,
            `TIER_LIMIT_REACHED`, `UPLOAD_INCOMPLETE`. The 500 path
            does not include this field.
    AppErrorEnvelope:
      description: |
        Standard error envelope for new endpoints:
        `{ code, message, details? }`. The `code` field is one of the
        `ErrorCode` enum values below; `message` is human-readable;
        `details` carries optional field-level validation errors keyed
        by field name.
      type: object
      required: [code, message]
      properties:
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
        details:
          type: object
          additionalProperties: true
          description: |
            Optional field-level validation errors, keyed by field
            name. E.g. `{ "name": "must be 1..200 chars" }`.
    ErrorCode:
      type: string
      description: |
        Stable enum of error codes used by the standard
        `{ code, message, details? }` envelope.
      enum:
        - BAD_REQUEST
        - VALIDATION_FAILED
        - NOT_FOUND
        - CONFLICT
        - ALREADY_EXISTS
        - VERSION_MISMATCH
        - INTERNAL_ERROR
    # ------------------------------------------------------------------
    # Auth + device flow
    # ------------------------------------------------------------------
    DeviceCodeRequest:
      type: object
      properties:
        client_id:
          type: string
          description: |
            OAuth2 client id. The CLI sends `slides-cli`; defaults to
            that value when omitted.
    DeviceCodeResponse:
      type: object
      required: [device_code, user_code, verification_uri, interval, expires_in]
      properties:
        device_code:
          type: string
        user_code:
          type: string
        verification_uri:
          type: string
          format: uri
          description: |
            Browser URL the user navigates to. Falls back to
            `https://lnkdwn.com/login/device` when Clerk omits it.
        interval:
          type: integer
          description: Polling interval in seconds (default 5).
        expires_in:
          type: integer
          description: Seconds until `device_code` expires (default 300).
    DeviceTokenRequest:
      type: object
      required: [device_code]
      properties:
        device_code:
          type: string
        client_id:
          type: string
          description: |
            OAuth2 client id. The CLI sends `slides-cli`; defaults to
            that value when omitted.
    AuthorizationPending:
      description: |
        Returned (with `200`) while the user has not yet completed the
        browser authorization step.
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [authorization_pending]
    DeviceTokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token:
          type: string
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Lifetime of the access token in seconds. Default 3600.
        refresh_token:
          type: string
          nullable: true
    # ------------------------------------------------------------------
    # Me + storage
    # ------------------------------------------------------------------
    UserProfile:
      type: object
      required:
        - id
        - email
        - tier
        - storageUsedBytes
        - storageLimitBytes
        - deckCount
        - createdAt
      properties:
        id:
          type: string
          description: Clerk user id (`user_...`).
        email:
          type: string
          format: email
        displayName:
          type: string
          nullable: true
        avatarUrl:
          type: string
          format: uri
          nullable: true
        tier:
          type: string
          enum: [free, starter, pro]
          description: |
            User's current tier. `team` and unknown tiers also exist
            at runtime; the DB CHECK is `IN ('free', 'starter', 'pro')`
            so anything else is rolled back to `free` by the
            Stripe webhook.
        storageUsedBytes:
          type: integer
          format: int64
          minimum: 0
        storageLimitBytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            Byte cap for the user's tier (25 MB / 100 MB / 1 GB for
            free / starter / pro).
        deckCount:
          type: integer
          minimum: 0
        deckLimit:
          type: integer
          minimum: 1
          nullable: true
          description: |
            Max deck count for the user's tier. `5` for free, `50` for
            starter/pro. `null` for unlimited / unknown tiers
            (`team`, anything not in the `DECK_LIMITS` table).
        # `periodEnd` was added in launch A.2#11 (PR that landed
        # `GET /me` `periodEnd` on the worker — see
        # `apps/workers/src/routes/auth.ts:298` for the producer
        # and CHANGELOG.md "Changed" entry for the same). Missing
        # from this spec until the fix/shared-api-types audit closed
        # the gap. ISO 8601 string. `null` for free-tier users and
        # for paid users whose subscription has been deleted (the
        # `customer.subscription.deleted` webhook clears the
        # underlying `users.period_end` column).
        periodEnd:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
    StorageUsage:
      type: object
      required: [used, limit, tier]
      properties:
        used:
          type: integer
          format: int64
          minimum: 0
          description: Bytes currently used across all the user's decks.
        limit:
          type: integer
          format: int64
          minimum: 0
          description: |
            Byte cap for the user's tier. Same value as
            `UserProfile.storageLimitBytes`.
        tier:
          type: string
          enum: [free, starter, pro]
    # ------------------------------------------------------------------
    # Decks
    # ------------------------------------------------------------------
    Framework:
      type: string
      enum: [revealjs, slidev, marp, remark, custom]
      description: |
        Slide-deck framework. The DB CHECK constraint and the worker's
        `FRAMEWORK_ALLOWLIST` both enforce this 5-value set; the plan
        doc lists only 3 values — drift is tracked under audit item D5.
    DeckStatus:
      type: string
      enum: [processing, ready, failed]
    Deck:
      type: object
      required:
        - id
        - name
        - slug
        - framework
        - status
        - sizeBytes
        - slideCount
        - hasAssets
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 200
        slug:
          type: string
          minLength: 8
          maxLength: 8
          description: |
            8-character slug. The path-component of the public share
            URL is `/d/{token}`, where `token` is a separate
            share-row field; the deck `slug` is the deck's stable
            short id.
        framework:
          $ref: '#/components/schemas/Framework'
        status:
          $ref: '#/components/schemas/DeckStatus'
        errorMessage:
          type: string
          nullable: true
        slideCount:
          type: integer
          minimum: 0
        sizeBytes:
          type: integer
          format: int64
          minimum: 0
          description: |
            Client-supplied deck size. Used for the dashboard
            display; the quota gate is the R2-reported size.
        thumbnailKey:
          type: string
          nullable: true
        hasAssets:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    CreateDeckInput:
      type: object
      required: [name, framework]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Display name. Sanitized: control characters, null bytes,
            and HTML angle brackets are rejected. `1..200` chars.
        framework:
          $ref: '#/components/schemas/Framework'
        filename:
          type: string
          description: |
            Original filename. Basename is taken, null bytes are
            stripped, the character set is restricted to
            `[A-Za-z0-9._-]`, capped at 128 chars. Defaults to
            `archive.zip` when empty or invalid.
    CreateDeckResponse:
      type: object
      required: [id, slug, uploadUrl, storageKey]
      properties:
        id:
          type: string
          format: uuid
        slug:
          type: string
        uploadUrl:
          type: string
          format: uri
          description: |
            Presigned S3 PUT URL, valid for 10 minutes. PUT the deck
            archive here, then call `POST /decks/:id/complete` to
            finalize.
        storageKey:
          type: string
          description: R2 object key (`decks/{clerkUserId}/{deckId}/{filename}`).
    UpdateDeckBody:
      type: object
      description: |
        At least one of `name`, `framework`, or `replaceFile: true` is
        required. When `replaceFile: true`, a fresh presigned PUT URL
        is returned targeting the deck's existing storage key; the
        deck id, slug, and any pre-existing share links are preserved.
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        framework:
          $ref: '#/components/schemas/Framework'
        replaceFile:
          type: boolean
          default: false
          description: |
            When `true`, also flip the deck to `processing`, clear
            `thumbnailKey` + `errorMessage`, and return a fresh
            presigned PUT URL for the deck's existing storage key.
            Use to update a deck's file in place without losing the
            share URL.
    ReplaceDeckFileResponse:
      type: object
      required: [success, id, storageKey, uploadUrl]
      properties:
        success:
          type: boolean
          enum: [true]
        id:
          type: string
          format: uuid
        storageKey:
          type: string
        uploadUrl:
          type: string
          format: uri
    CompleteDeckUploadInput:
      type: object
      properties:
        sizeBytes:
          type: integer
          format: int64
          minimum: 0
          maximum: 1073741824
          description: |
            Client-supplied deck size. Bounded to 1 GB by the
            worker (defense in depth — the authoritative size
            comes from R2's `head().size`).
        slideCount:
          type: integer
          minimum: 0
          maximum: 10000
        hasAssets:
          type: boolean
          description: |
            Whether the deck archive references external assets.
            Free tier disallows this (returns 403
            `TIER_LIMIT_REACHED`).
        thumbnailKey:
          type: string
          description: |
            R2 key for the deck's thumbnail. When supplied and
            the deck transitions to `ready`, a thumbnail-generation
            job is enqueued.
        errorMessage:
          type: string
          description: |
            When set, the deck is marked `failed` rather than
            `ready`. Use for client-side processing failures.
    CompleteDeckUploadResponse:
      type: object
      required: [success, status]
      properties:
        success:
          type: boolean
          enum: [true]
        status:
          $ref: '#/components/schemas/DeckStatus'
    # ------------------------------------------------------------------
    # Shares
    # ------------------------------------------------------------------
    ShareLink:
      type: object
      required:
        - id
        - token
        - url
        - expiresAt
        - hasPassword
        - viewLimit
        - viewCount
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        token:
          type: string
        url:
          type: string
          format: uri
          description: |
            Public URL. Always `https://lnkdwn.com/d/{token}`.
        expiresAt:
          type: string
          format: date-time
          nullable: true
        hasPassword:
          type: boolean
        viewLimit:
          type: integer
          minimum: 1
          nullable: true
        viewCount:
          type: integer
          minimum: 0
        createdAt:
          type: string
          format: date-time
    ShareLinkWithDeck:
      allOf:
        - $ref: '#/components/schemas/ShareLink'
        - type: object
          required: [deckId, deckName, deckSlug]
          properties:
            deckId:
              type: string
              format: uuid
            deckName:
              type: string
            deckSlug:
              type: string
    CreateShareLinkInput:
      type: object
      properties:
        expiresInDays:
          type: integer
          minimum: 1
          maximum: 36500
          description: |
            Days until the share expires. **Pro tier only** — the
            free tier returns 403 `TIER_LIMIT_REACHED` when this
            is set. Capped at 36500 (100 years).
        viewLimit:
          type: integer
          minimum: 1
          maximum: 1000000000
          description: |
            Max view count. **Pro tier only**. Capped at 1 billion.
        password:
          type: string
          minLength: 1
          maxLength: 128
          description: |
            Plaintext password. Hashed via PBKDF2 server-side
            (100k iterations, salt) and the plaintext is
            discarded. Capped at 128 bytes to prevent the PBKDF2
            hash from being used as a CPU-DoS surface. **Pro tier
            only**.
    SharedDeckView:
      type: object
      required:
        - id
        - name
        - framework
        - slideCount
        - viewCount
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        framework:
          $ref: '#/components/schemas/Framework'
        slideCount:
          type: integer
          minimum: 0
        thumbnailKey:
          type: string
          nullable: true
        viewCount:
          type: integer
          minimum: 0
          description: |
            Post-increment view count. The view counter is
            atomically incremented in the same SQL statement that
            enforces the view limit, so a concurrent viewer
            cannot push the count past the limit.
    ShareInfo:
      type: object
      required: [deck, hasPassword, expiresAt, viewLimit, viewCount]
      properties:
        deck:
          type: object
          required: [id, name, framework, slideCount]
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            framework:
              $ref: '#/components/schemas/Framework'
            slideCount:
              type: integer
              minimum: 0
            thumbnailKey:
              type: string
              nullable: true
        hasPassword:
          type: boolean
        expiresAt:
          type: string
          format: date-time
          nullable: true
        viewLimit:
          type: integer
          minimum: 1
          nullable: true
        viewCount:
          type: integer
          minimum: 0
    # ------------------------------------------------------------------
    # Billing
    # ------------------------------------------------------------------
    BillingPortalResponse:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: |
            Stripe billing portal session URL. The dashboard
            navigates to this URL via `window.location.assign()`.
            The portal session's `return_url` is
            `${APP_URL}/dashboard/billing`.
    BillingCheckoutRequest:
      type: object
      required: [priceId]
      properties:
        priceId:
          type: string
          description: |
            Stripe `price_xxx` id of the tier to subscribe to. Must
            equal one of the three configured Worker secrets
            (`STRIPE_STARTER_PRICE_ID`, `STRIPE_PRO_PRICE_ID`, or
            `STRIPE_TEAM_PRICE_ID`); any other value is rejected
            with `400 INVALID_PRICE_ID` before the request reaches
            Stripe.
          example: price_1PqStaRxxxxxxxxxxxxxx
    BillingCheckoutResponse:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: |
            Stripe-hosted checkout session URL. The dashboard
            navigates to this URL via `window.location.assign()`.
            Stripe redirects the user to `success_url`
            (with `{CHECKOUT_SESSION_ID}` substituted) on a
            successful checkout, or to `cancel_url` if the user
            presses "Back" / closes the tab.
    # ------------------------------------------------------------------
    # Webhook payloads
    # ------------------------------------------------------------------
    ClerkWebhook:
      description: |
        Clerk webhook payload. The worker only acts on
        `user.created`, `user.updated`, and `user.deleted` event
        types; everything else is acknowledged with
        `{ received: true, type, ignored: true }`.
      type: object
      required: [type, data]
      properties:
        type:
          type: string
          enum:
            - user.created
            - user.updated
            - user.deleted
        data:
          type: object
          description: |
            Clerk user object. Field shape varies by event type;
            see the Clerk docs. The worker reads `id`,
            `email_addresses`, `primary_email_address_id`,
            `first_name`, `last_name`, `username`, `image_url`,
            `deleted`, and `object`.
          properties:
            id:
              type: string
              description: Clerk user id (`user_...`).
            email_addresses:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                  email_address:
                    type: string
                    format: email
            primary_email_address_id:
              type: string
            first_name:
              type: string
              nullable: true
            last_name:
              type: string
              nullable: true
            username:
              type: string
              nullable: true
            image_url:
              type: string
              format: uri
              nullable: true
            deleted:
              type: boolean
            object:
              type: string
              enum: [user]
    StripeWebhook:
      description: |
        Stripe webhook payload. The worker only acts on
        `customer.subscription.*`, `invoice.payment_succeeded`, and
        `invoice.payment_failed`. The user is looked up via
        `metadata.clerk_user_id`.
      type: object
      required: [type, data]
      properties:
        type:
          type: string
          enum:
            - customer.subscription.created
            - customer.subscription.updated
            - customer.subscription.deleted
            - invoice.payment_succeeded
            - invoice.payment_failed
        data:
          type: object
          required: [object]
          properties:
            object:
              type: object
              description: |
                The subscription or invoice object. Shape is
                documented in the Stripe API reference. The
                worker reads `metadata.clerk_user_id`, `customer`,
                `current_period_end`, `status`, `items.data[].price.id`,
                and `amount_paid` (invoices only).
