openapi: 3.1.0
info:
  title: Stixel Maker API
  version: 0.1.0
  contact:
    name: Backend
    url: https://stixelmaker.com
  description: |
    REST API for the Stixel Maker sticker design app. Backend is a Rust
    Rocket service backed by SurrealDB. WebSocket route at `/ws/{project_id}`
    carries real-time canvas / presence / image-status messages and is
    described separately in the **WebSocket** section at the bottom of this
    spec.

    ## Authentication

    All authenticated endpoints expect a Stytch session JWT in the
    `Authorization: Bearer <token>` header. Clients obtain the token via
    the Stytch SDK after a successful sign-in (Magic Link, SMS OTP, or
    OAuth — currently Google and Facebook).

    Token validation on the backend:
    - JWKs are cached from Stytch's discovery endpoint and refreshed on
      unknown `kid` values.
    - Algorithm: RS256.
    - Required claims: `sub` (Stytch user_id, used as the canonical user
      id throughout this API), `iss` (`stytch.com/<project_id>`),
      `aud` (`<project_id>`).
    - 30 s leeway on `exp`.

    Two auth modes are visible on routes:
    - **AuthenticatedUser** — required. Missing / malformed / expired
      tokens get HTTP 401 with no body.
    - **OptionalUser** — best-effort. The route still runs anonymously
      if no token is present; an invalid token degrades to anonymous
      rather than 401. Endpoints documented with `security: []` and a
      note "OptionalUser" follow this pattern.

    Anonymous browsing creates projects and uploads owned by no one. The
    `POST /me/claim` endpoint transfers those records to the
    authenticating user during the sign-up flow (see ADR 0003 in the
    repo).

    ## Conventions

    - All timestamps are ISO 8601 strings (`2026-06-06T12:00:00Z` or
      RFC 3339 with offset).
    - Record IDs (project, image) are bare string keys, not the
      `table:key` SurrealDB form. Some endpoints accept either form for
      backwards compatibility; new clients should use bare keys.
    - Multipart upload routes expect the file in a form field named
      `file`.
    - Pagination uses opaque cursors (currently ISO timestamps from
      `created_at` or `last_accessed_at`). Send the last item's
      timestamp as `cursor` to fetch the next page. Limits clamp to
      200 server-side.

servers:
  - url: https://api.stixelmaker.com
    description: Production
  - url: http://localhost:8000
    description: Local dev (Rocket default)

tags:
  - name: Health
    description: Liveness / smoke endpoints, no auth required.
  - name: Catalog
    description: Static product configuration (sizes, presets).
  - name: Quote
    description: |
      Sticker-first sizing quotes (docs/sticker-first-reframe.md). The packer
      computes how many copies of one sticker fit N-up per Gooten medium and
      which (medium, pack) combination best serves the requested quantity.
  - name: Projects
    description: Project CRUD, cloning, soft-delete, and rename.
  - name: Project images
    description: Per-project image upload + list + print-asset upload.
  - name: Me
    description: User-scoped collections — recents, owned assets, claim.
  - name: Account
    description: Per-user profile (display name, avatar). Email + OAuth
      provider live in Stytch and are read client-side via
      `stytch.user.getSync()`.

security: []

paths:
  /:
    get:
      tags: [Health]
      summary: Service banner
      description: Plain-text marker so smoke checks can identify the service.
      responses:
        '200':
          description: OK
          content:
            text/plain:
              schema: { type: string }
              examples:
                default:
                  value: Stixel Maker Implementation POC Backend

  /health:
    get:
      tags: [Health]
      summary: Liveness probe
      description: Returns `ok` if the process is up. Does not exercise the DB.
      responses:
        '200':
          description: OK
          content:
            text/plain:
              schema: { type: string }
              examples:
                default: { value: ok }

  /sizes:
    get:
      tags: [Catalog]
      summary: List available canvas presets
      description: |
        Sticker sheet sizes the editor surfaces in the size picker. Sourced
        from `shared/products.json`; never user-mutable.
      responses:
        '200':
          description: Array of presets
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CanvasPreset' }

  /quote:
    post:
      tags: [Quote]
      summary: Compute a sticker packing quote
      description: |
        Pure compute — no side effects, no auth. Given a sticker's aspect
        ratio, the customer's chosen longest side (inches), and a desired
        minimum quantity, returns the packer's chosen medium, N-up layout,
        and Gooten pack. Pack counts are the printer's constraint
        ({1,2,3,4,5,10,25}); when the needed sheet count falls between packs
        the quote rounds up and `bonus_qty` reports the extra stickers the
        customer receives.

        The price is *not* included — the commerce API's price table, keyed
        by (`medium`, `pack`), is the pricing authority; clients resolve the
        price via the commerce API's `GET /prices`.

        The aspect ratio is client-supplied here for responsiveness; the
        backend re-derives it from the stored asset when the draft project
        is generated at cart-add, so a dishonest client cannot skew the
        layout that is actually printed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuotePayload' }
      responses:
        '200':
          description: Packing quote
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteResponse' }
        '422':
          description: |
            Unpackable input — body carries a machine-readable reason:
            `size_out_of_bounds`, `does_not_fit`, `quantity_too_large`,
            or `invalid_input`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteError' }

  /drafts:
    post:
      tags: [Quote]
      summary: Create a sticker-first draft project
      description: |
        OptionalUser. Commits the customer's sticker lines into a **draft
        project**, entering the lifecycle at `draft`. Two request shapes are
        accepted, and they behave differently on purpose:

        **One line** — `{asset_id, longest_side_in, qty}`. The web
        storefront's cart-add. The packer picks the medium and N-up layout and
        the canvas is seeded with the grid, because the web render depends on
        the layout existing at order time.

        **Many lines** — `{items: [...]}`. The mobile checkout call
        (`docs/ios-order-data-contract.md`). Creates **one** project carrying
        every line's intent and a copy of each line's artwork, with **no
        layout at all**: `canvas.objects` is empty and the sheets are produced
        after the order by the batch packer, so stickers can be combined
        across sheets. Every line is validated and priced *before* anything is
        written, so a bad line cannot leave a half-built project behind; the
        error names the offending line via `item_index`.

        Each line gets its **own** image copy even when two lines share a
        source asset — the same photo can be ordered at two sizes, and each
        line needs a distinct identity so a placed sticker attributes to the
        right one.

        The source asset is a gallery row (strictly owner-matched) or a
        project-scoped upload (capability model: holding the unguessable
        asset id authorizes the draft, the same trust boundary as a shared
        project link — this is how the anonymous sticker workspace and
        phone-QR uploads order without an account).

        The customer never sees this as a "project" — it surfaces as a
        resumable Draft. Each returned quote's (medium, pack) identifies the
        price-table entry the commerce API charges for; the project id is
        what checkout sends to the commerce API.

        The server derives the artwork aspect ratio from the stored processed
        image; the `aspect` field is a fallback for environments where the
        image is unreachable (dev without CDN). A dishonest client cannot
        skew the printed layout when the image is readable.

        At most 50 lines per request.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DraftPayload' }
      responses:
        '200':
          description: Draft created
          content:
            application/json:
              schema:
                type: object
                required: [project, quotes]
                properties:
                  project: { $ref: '#/components/schemas/Project' }
                  quotes:
                    type: array
                    items: { $ref: '#/components/schemas/PackQuote' }
                    description: |
                      One quote per requested line, in request order — the
                      pricing truth to display before payment
                      (`total_stickers`, `bonus_qty`).
                  quote:
                    oneOf:
                      - $ref: '#/components/schemas/PackQuote'
                      - type: "null"
                    description: |
                      **Deprecated — read `quotes` instead.** Present only on
                      single-line requests, where it mirrors `quotes[0]`, so
                      the web client keeps working unchanged.
                  print_checks:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/PrintCheck'
                        - type: "null"
                      description: |
                        `null` for a line whose artwork could not be
                        measured — not assessed, rather than fine.
                    description: |
                      One verdict per requested line, positionally aligned
                      with `quotes`. Positional rather than keyed by asset
                      because two lines may order the same asset at different
                      sizes, which is exactly when the verdicts differ.

                      Server-derived from the stored image, so unlike the
                      `source_px` a client sends to `POST /quote` this cannot
                      be skewed. If the two disagree, believe this one.
                  print_check:
                    oneOf:
                      - $ref: '#/components/schemas/PrintCheck'
                      - type: "null"
                    description: |
                      Present only on single-line requests, mirroring
                      `print_checks[0]` — the same singular/plural pairing as
                      `quote`/`quotes`.
        '404':
          description: Asset missing, deleted, or not a gallery image
        '403':
          description: Caller does not own the asset
        '409':
          description: |
            Asset not ready (`asset_not_ready`) or aspect unreadable with no
            usable fallback (`aspect_unavailable`).
        '422':
          description: |
            Packer rejection (same error body as `POST /quote`), an empty
            item list (`no_items`), more than 50 lines (`too_many_items`),
            or more than 100 stickers requested across all lines
            (`too_many_stickers`). The sticker cap counts what was asked
            for, not what pack rounding will deliver.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteError' }

  /projects/{id}/quote:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Quote]
      summary: Re-derive a draft's per-line quotes from its stored intents
      description: |
        The `/drafts` response carries quotes only at creation time; checkout
        happens later, and the checkout service (commerce-api, BRC-64) must
        re-derive the authoritative quote server-side rather than trust a
        client's copy of it. Clients may also use this to re-price a resumed
        draft.

        Pure recomputation from the persisted intents (server-derived aspect
        included), so the quotes match what the draft returned. Quotes carry
        the physical answer — medium, pack, sticker counts; **prices live in
        the commerce service**, keyed on `layout.medium_title` + `pack`.
      operationId: getProjectQuote
      responses:
        '200':
          description: Per-line quotes, in intent order.
          content:
            application/json:
              schema:
                type: object
                required: [project_name, quotes]
                properties:
                  project_name:
                    type: string
                  quotes:
                    type: array
                    items: { $ref: '#/components/schemas/PackQuote' }
                  line_images:
                    type: array
                    items: { type: [string, 'null'] }
                    description: |
                      One entry per line, in `quotes` order: the public URL
                      of that line's cut-out (the project-scoped image copy's
                      `processed_url`), or null if it has none yet. Lets a
                      checkout page show the sticker rather than the
                      project's auto-generated name.
        '404':
          description: No such project.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum: [project_not_found]
        '409':
          description: |
            The project has no sticker lines to quote (a legacy sheet
            project) — asking is a caller bug, not an empty answer.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
                    enum: [no_quotable_lines]
        '422':
          description: |
            A stored line no longer quotes (data drift — the draft flow
            validated these inputs once). `item_index` names the line.
          content:
            application/json:
              schema:
                type: object
                required: [error, item_index]
                properties:
                  error:
                    type: string
                    enum: [unquotable_line]
                  item_index:
                    type: integer

  /projects/{id}/tally:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Quote]
      summary: Ordered-versus-delivered tally (internal, service-to-service)
      description: |
        Internal-only (`X-Internal-Token`). The commerce approve gate calls
        this at approval time: an order whose current layout would deliver
        fewer stickers than were paid for must not be approvable, from any
        code path. Same arithmetic the operator proofing panel displays.
      operationId: getProjectTally
      responses:
        '200':
          description: Per-line tally plus the aggregate verdict.
          content:
            application/json:
              schema:
                type: object
                required: [satisfied, short_lines, tally]
                properties:
                  satisfied:
                    type: boolean
                    description: Every line delivers at least what was ordered.
                  short_lines:
                    type: integer
                    description: Lines currently under-delivering (blocks approval).
                  tally:
                    type: array
                    items: { $ref: '#/components/schemas/LineTally' }
        '404':
          description: No such project.

  /projects/{id}/fulfillment-info:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Quote]
      summary: Fulfillment info for the Gooten provider (server-to-server)
      description: |
        Live lookup used by the commerce Gooten fulfillment provider at
        submit time. Returns what should actually be printed, plus the
        sticker-first lifecycle state. The provider refuses to submit unless
        `state` is `approved`; legacy projects return `state: null` and keep
        the pre-reframe manual flow.

        **`sheets` is the thing to submit.** An order is packed across sheets
        *after* the customer pays, so the cart's line items describe what was
        charged, not what gets printed — submitting from them would send
        Gooten sheets that don't exist. Each entry carries its own medium,
        pack count and print master, and each becomes one Gooten recipe.

        Empty for legacy and single-canvas projects, which keep the pre-sheet
        path where the customer's own chosen variant is the better authority.
      responses:
        '200':
          description: Fulfillment info
          content:
            application/json:
              schema:
                type: object
                required: [print_asset_url, state, sheets]
                properties:
                  print_asset_url:
                    type: [string, "null"]
                    description: |
                      Mirrors the **first** sheet's master. Kept for readers
                      that predate sheets; prefer `sheets`.
                  state:
                    oneOf:
                      - $ref: '#/components/schemas/ProjectState'
                      - type: "null"
                  sheets:
                    type: array
                    items:
                      type: object
                      required: [id, pack]
                      properties:
                        id: { type: string }
                        pack:
                          type: integer
                          description: How many copies of this sheet get printed
                        print_asset_url:
                          type: [string, "null"]
                          description: Null until this sheet has been rendered
                        medium_title:
                          type: [string, "null"]
                          description: |
                            Matches `CanvasPreset.title` exactly — commerce
                            keys its Gooten SKU table on it.
        '404':
          description: Not found

  /projects/{id}/ordered:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Quote]
      summary: Mark a draft as ordered (service-only)
      description: |
        Advances the lifecycle `draft → ordered`. Guarded by the
        `X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) —
        called by the commerce `order.placed` subscriber before it triggers
        the print render. Legacy projects (no lifecycle state) return 409 and
        the caller ignores it.
      responses:
        '200':
          description: New state
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { $ref: '#/components/schemas/ProjectState' }
        '401':
          description: Missing/invalid internal token
        '404':
          description: Not found
        '409':
          description: Illegal transition (legacy project or not in `draft`)

  /projects/{id}/approved:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Quote]
      summary: Mark a project approved (service-only)
      description: |
        Advances the lifecycle `rendered → approved`. Guarded by the
        `X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) —
        called by commerce-api when an operator approves the ORDER, which is
        the business-state authority (BRC-63/BRC-72). Keeps design-side
        surfaces truthful and makes the later `submitted` flip legal. Never
        called by clients.
      operationId: markProjectApproved
      responses:
        '200':
          description: New state
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { $ref: '#/components/schemas/ProjectState' }
        '401':
          description: Missing/invalid internal token
        '404':
          description: Not found
        '409':
          description: Illegal transition (project not in `rendered`)

  /projects/{id}/submitted:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Quote]
      summary: Mark a sheet as submitted to Gooten (service-only)
      description: |
        Advances the lifecycle `approved → submitted`. Guarded by the
        `X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) —
        called by the commerce Gooten provider after a successful live
        submission, never by clients.
      responses:
        '200':
          description: New state
          content:
            application/json:
              schema:
                type: object
                properties:
                  state: { $ref: '#/components/schemas/ProjectState' }
        '401':
          description: Missing/invalid internal token
        '404':
          description: Not found
        '409':
          description: Illegal transition (project not in `approved`)

  /projects:
    get:
      tags: [Projects]
      summary: List the authenticated user's owned projects
      description: |
        Returns projects where `owner_id` matches the JWT `sub`. Does
        **not** include shared / collaborated / viewed projects — see
        `GET /me/projects` for the full Recents-joined view.
      security: [{ BearerAuth: [] }]
      responses:
        '200':
          description: Array of owned projects
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Projects]
      summary: Create a new project from a preset
      description: |
        OptionalUser. Logged-in callers become the owner and get a Recents
        row; anonymous callers create an unowned project that may be
        claimed later via `POST /me/claim`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateProjectPayload' }
      responses:
        '200':
          description: New project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '500': { $ref: '#/components/responses/InternalError' }

  /projects/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: Get a project by id
      description: |
        OptionalUser. Accepts either bare key (`abc123`) or the legacy
        `projects:abc123` form. Authenticated callers also get a
        Recents-row touch as a side effect (`last_accessed_at = now`).
      responses:
        '200':
          description: Project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404':
          description: Not found
    put:
      tags: [Projects]
      summary: Update a project's canvas + name
      description: |
        OptionalUser. Replaces `canvas` and `nm` on the existing record.
        Other fields (collaborators, owner, etc.) are not editable through
        this endpoint — use the dedicated endpoints (`PATCH .../name` for
        rename; `POST .../delete` for soft-delete).

        ⚠ This route does not currently enforce ownership — any client
        with a valid project id can submit a canvas update. Treat with
        care from a mobile client and gate the UI accordingly.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Project' }
      responses:
        '200':
          description: Updated project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404':
          description: Project not found

  /projects/{id}/delete:
    post:
      tags: [Projects]
      summary: Soft-delete a project (owner only)
      description: |
        Sets `deleted_at`. Project records are never hard-deleted (ADR
        0005). Other users' Recents rows pointing at the project become
        tombstones the frontend renders with a deleted treatment.
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200': { description: Deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not the owner }
        '404': { description: Project not found }

  /projects/{id}/undelete:
    post:
      tags: [Projects]
      summary: Restore a soft-deleted project (owner only)
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200': { description: Restored }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not the owner }
        '404': { description: Project not found }

  /projects/{id}/name:
    patch:
      tags: [Projects]
      summary: Rename a project (owner only)
      description: |
        Trims whitespace server-side and rejects empty / whitespace-only
        names with 400.
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RenamePayload' }
      responses:
        '200':
          description: Updated project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400': { description: Empty or whitespace-only name }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not the owner }
        '404': { description: Project not found }

  /projects/{id}/clone:
    post:
      tags: [Projects]
      summary: Duplicate a project
      description: |
        OptionalUser. Creates a new project named "Copy of <original>"
        with the same canvas + image references. Authenticated callers
        become the owner of the clone; anonymous callers get an unowned
        copy.
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200':
          description: New cloned project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404': { description: Source project not found }

  /projects/{id}/associate:
    post:
      tags: [Projects]
      summary: Take ownership of an unowned project (auth required)
      description: |
        If the project currently has no owner, claims it for the caller.
        Used by the desktop "Save My Work" anonymous-to-authenticated flow
        when the user signs in mid-edit.
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200':
          description: Updated project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Project not found }

  /projects/{id}/images:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Project images]
      summary: List images attached to a project
      description: OptionalUser. Returns the project's images in upload order.
      responses:
        '200':
          description: Array of image assets
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ImageAsset' }
        '404': { description: Project not found }
    post:
      tags: [Project images]
      summary: Upload an image to a project
      description: |
        OptionalUser. Multipart form with field name `file`. The route
        sniffs the first 32 bytes for a supported format signature
        (JPEG / PNG / WebP / HEIC) and rejects unknown formats with 415.

        HEIC handling: only accepted when the backend was built with the
        `heic` cargo feature (libheif present). Without it HEIC also
        returns 415 — clients should pre-convert (iOS Safari does this
        automatically when `accept` is JPEG-only).

        The response is the **freshly-created** ImageAsset with
        `status: Uploading`. Background processing (remove.bg →
        Cloudflare Images) runs asynchronously; subscribe to the project
        WebSocket for `Processing → Ready` status updates and the final
        `processed_url`.

        Background removal is skipped when the upload is already a cutout —
        either because the client set `preprocessed`, or because the image
        visibly uses transparency. The uploaded bytes then become the
        processed asset directly (canonicalized to PNG).

        Maximum upload size is 20 MB (driven by `config.json:
        maxImageUploadMb`).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                preprocessed:
                  type: boolean
                  default: false
                  description: |
                    Assert that the file is already background-removed, so
                    remove.bg is skipped and the upload is stored as the
                    processed asset as-is. Optional; omitting it is
                    equivalent to `false`.

                    Server-side alpha detection applies either way, so a
                    genuine cutout skips removal even without this flag. Set
                    it when the client already knows (e.g. re-uploading a
                    sticker this service produced), or to force the skip for
                    an image whose transparency is too sparse to detect.
      responses:
        '404': { description: Project not found — uploads into a project that no longer exists are refused, not orphaned }
        '200':
          description: Created asset (still Uploading)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImageAsset' }
        '415': { description: Unsupported image format }
        '500': { $ref: '#/components/responses/InternalError' }

  /projects/{id}/print_asset:
    post:
      tags: [Project images]
      summary: Upload a print-ready PNG for a project (legacy)
      description: |
        Multipart form with field name `file`; stored to R2 as the project's
        `print_asset_url`.

        **Legacy / no current caller.** The print master is now rendered
        **server-side** on demand (`POST /projects/{id}/render`) rather than
        uploaded by the client — the web editor no longer calls this. Kept as a
        valid endpoint; see ADR 0013.

        ⚠ Currently no auth guard — callers can attach a print asset to
        any project id they know. Documented as-is.
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Updated project with `print_asset_url`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404': { description: Project not found }

  /projects/{id}/render:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Project images]
      summary: Trigger (or join) an async server-side print render
      description: |
        Renders the project's print masters server-side (Cloudflare Browser
        Rendering → R2) on a background task and returns immediately with the
        current status. A multi-sheet project renders **one master per
        sheet**, sequentially. **Single-flight per project**:
        if a render is already in flight, this coalesces onto it rather than
        starting a second. Poll `GET` for completion. Open (project-scoped),
        like the other `/projects/{id}` routes. Drivers: the editor's
        Download-PNG button and the commerce `order.placed` subscriber.
      responses:
        '200':
          description: Current render status (render started or already running)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RenderStatus' }
    get:
      tags: [Project images]
      summary: Poll a project's print-render status
      description: |
        Returns the current render status. Falls back to the project's
        persisted `print_asset_url` when this process has no in-memory record
        (e.g. after a restart, or a render completed in a prior run), so a
        completed asset always reports `ready`.

        A project can be several sheets, each with its own print master, so
        the response also lists them. `status: ready` requires **every** sheet
        to have a master — an order with one still missing reports
        `rendering`, because approving it would submit sheets that don't exist
        yet. A single-canvas project reports one sheet and behaves exactly as
        before.
      responses:
        '200':
          description: Render status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RenderStatusDetail' }

  /projects/{id}/thumbnail:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Project images]
      summary: Ensure the project's canvas thumbnail is fresh
      description: |
        Lazily (re)renders the project's canvas thumbnail (ADR 0006) if stale
        and returns its URL. Called at checkout so the cart shows a current
        design preview without depending on the heavier, async print render.
      responses:
        '200':
          description: The current thumbnail URL (null if it couldn't be produced)
          content:
            application/json:
              schema:
                type: object
                required: [thumbnail_url]
                properties:
                  thumbnail_url:
                    type: [string, "null"]

  /me/projects:
    get:
      tags: [Me]
      summary: List the caller's projects via Recents (owned + collab + viewed)
      description: |
        Returns the Recents-joined view that backs the home screen
        Projects tab. Each row carries the derived role (`Owner` |
        `Collab` | `Viewed`) so the UI can render different treatments
        for shared vs owned projects.

        Sorted by `last_accessed_at` DESC. Pagination via `cursor`
        (timestamp of the last item).
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Array of recent-projects rows
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RecentProject' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/assets:
    get:
      tags: [Me]
      summary: List the caller's gallery image assets
      description: |
        Returns the caller's *gallery* images — reusable, top-level copies
        owned by the user (`owner_id` matches the JWT `sub` and
        `project_id` is null). Project-owned image copies are excluded;
        they live and die with their project. Sorted by `created_at` DESC.
        Cursor is an ISO timestamp.
      security: [{ BearerAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Array of owned assets
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ImageAsset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Me]
      summary: Upload a photo straight into the caller's gallery
      description: |
        Sticker-first flow (Phase 4b): creates a *gallery* image
        (`project_id = null`) directly — no project involved. Runs the same
        pipeline as project uploads (format sniffing, background removal).
        The returned asset starts as `Uploading`; there is **no WebSocket
        room** for gallery uploads — poll `GET /me/assets` until the asset
        is `Ready` (or `Error`). Multipart form, file field named `file`.

        As with project uploads, background removal is skipped when the
        image is already a cutout (client-asserted via `preprocessed`, or
        detected from its alpha channel).
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                preprocessed:
                  type: boolean
                  default: false
                  description: |
                    Assert that the file is already background-removed, so
                    remove.bg is skipped and the upload is stored as the
                    processed asset as-is. Optional; server-side alpha
                    detection applies either way.
      responses:
        '200':
          description: The new gallery asset (status `Uploading`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImageAsset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '415':
          description: Unsupported image format

  /me/assets/{asset_id}:
    patch:
      tags: [Me]
      summary: Toggle the hidden flag on an asset (owner only)
      security: [{ BearerAuth: [] }]
      parameters:
        - in: path
          name: asset_id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HiddenPatch' }
      responses:
        '200': { description: Updated }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not the owner of the asset }
        '404': { description: Asset not found }
    delete:
      tags: [Me]
      summary: Soft-delete a gallery image (owner only)
      description: |
        Sets `deleted_at` on the gallery image — the user-facing tombstone.
        The image disappears from the gallery immediately; an operator later
        hard-deletes it (Cloudflare object + row) from the admin cleanup
        screen. Distinct from the reversible `hidden` toggle (PATCH).
      security: [{ BearerAuth: [] }]
      parameters:
        - in: path
          name: asset_id
          required: true
          schema: { type: string }
      responses:
        '200': { description: Soft-deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not the owner of the asset }
        '404': { description: Asset not found }

  /me/recents/{project_id}:
    parameters:
      - in: path
        name: project_id
        required: true
        schema: { type: string }
    patch:
      tags: [Me]
      summary: Toggle the per-user hidden flag on a Recents row
      description: |
        Hides a project from the caller's Recents grid without affecting
        ownership or membership. Other users still see the project
        normally.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HiddenPatch' }
      responses:
        '200': { description: Updated }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      tags: [Me]
      summary: Remove a Recents row for the caller
      description: |
        Hard-deletes the row from the caller's Recents — re-engaging
        (view or edit) re-creates it.
      security: [{ BearerAuth: [] }]
      responses:
        '200': { description: Removed }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/claim:
    post:
      tags: [Me]
      summary: Apply an anonymous-claim payload (sign-up flow)
      description: |
        Called by the frontend after sign-up to attribute anonymous
        projects and uploaded assets to the freshly-authenticated user.
        The payload is built from the browser's localStorage record of
        what the anonymous session touched.

        Per-item failures are tolerated silently (the payload may be
        stale — projects deleted, assets gone), so this endpoint always
        returns 200 if the JWT is valid.

        See ADR 0003 in the repo for the design rationale.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClaimPayload' }
      responses:
        '200': { description: Claim applied (best-effort) }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/account:
    get:
      tags: [Account]
      summary: Get the caller's account profile
      description: |
        Returns the persistent account row (display_name + avatar_url).
        Lazy-creates an empty row on first call so signup flows don't
        need a separate hook.

        Email and OAuth provider are **not** stored backend-side. Read
        them from Stytch on the client via `stytch.user.getSync()`.
      security: [{ BearerAuth: [] }]
      responses:
        '200':
          description: Account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      tags: [Account]
      summary: Update the caller's profile
      description: |
        Every field is optional and absence means "leave it alone", so the
        profile page can save one section without resending the others.

        * `display_name` — blank input clears it (server trims). No length
          or content validation beyond that.
        * `shipping_address` — an object replaces the stored address;
          explicit `null`, or an object whose every line is blank, clears
          it. Absent leaves it untouched.
        * `notifications` — replaces the preferences wholesale.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AccountPatch' }
      responses:
        '200':
          description: Updated account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Account]
      summary: Delete the caller's account
      description: |
        Ends the account, for real — App Store Review Guideline 5.1.1(v),
        which requires an app offering account creation to offer account
        deletion inside the app.

        Three things happen, in this order:

        1. **The Stytch user is deleted.** This goes first because it is the
           step most likely to fail, and a failure here means nothing else
           has been touched: the caller keeps their session and their data,
           and the response says the account was not deleted. While the
           Stytch user exists a magic link to that address still signs in,
           so this — not the rows below — is what makes a deletion real.
        2. **Orders are unlinked** on the commerce API: the Stytch id, the
           email and the shipping address come off the order rows, while
           the money, the line items and the lifecycle stay. Order records
           outlive the customer for accounting; the person behind them does
           not.
        3. **Design data is deleted** — for real, storage included. Projects
           and gallery images are purged along with their Cloudflare Images
           objects and R2 print masters, and the account row and recents go
           with them. This is not the tombstone the customer's own Remove
           button leaves: that one waits for an operator to clear it, and a
           closed account should not wait on anybody.

        Steps 2 and 3 run after the caller can no longer authenticate, so a
        failure in either is logged rather than returned: reporting failure
        for an account that is already gone would be worse than useless to
        the person who asked.

        Anonymous work is untouched — a project with no owner was never
        attached to this person.
      operationId: deleteMeAccount
      security: [{ BearerAuth: [] }]
      responses:
        '204':
          description: Account deleted. Nothing is returned.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502':
          description: |
            The identity provider refused the deletion. Nothing was
            changed — the account, its data and the caller's session are
            all intact.
        '503':
          description: |
            Account deletion is not configured on this deployment
            (no Stytch project secret). Nothing was changed.

  /me/avatar:
    post:
      tags: [Account]
      summary: Upload an avatar image
      description: |
        Multipart form with field name `file`. Bytes are pushed to
        Cloudflare Images and the resulting CDN URL is stored as
        `avatar_url` on the Account record. Returns the updated Account.

        Requires Cloudflare Images credentials in env
        (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`). Returns 502 if
        Cloudflare rejects the upload.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Updated account with avatar_url
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { description: Cloudflare Images upload failed }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Stytch session JWT. See top-of-doc `Authentication` section for
        how to obtain a token and what claims must be present.

  parameters:
    ProjectId:
      in: path
      name: id
      required: true
      description: |
        Project record id. Bare key (e.g. `wxgfzld9u9zu6w5omwlc`) is the
        canonical form. Legacy `projects:wxgfzld9u9zu6w5omwlc` is also
        accepted on read paths.
      schema: { type: string }
    Limit:
      in: query
      name: limit
      required: false
      schema:
        type: integer
        default: 50
        maximum: 200
        minimum: 1
    Cursor:
      in: query
      name: cursor
      required: false
      description: |
        Opaque pagination cursor. For Recents (`/me/projects`) this is
        the `last_accessed_at` of the last item; for owned assets
        (`/me/assets`) it's the `created_at`. Both are ISO 8601
        timestamps.
      schema: { type: string }

  responses:
    Unauthorized:
      description: Missing, malformed, or expired JWT
    InternalError:
      description: Unexpected server error

  schemas:
    CanvasPreset:
      type: object
      required: [id, w, h, title, price_cents, default, print_at_home, disabled]
      properties:
        id: { type: integer }
        w: { type: number, format: float, description: Width in inches }
        h: { type: number, format: float, description: Height in inches }
        title: { type: string }
        price_cents: { type: integer }
        default: { type: boolean }
        print_at_home: { type: boolean }
        print_stock_id:
          type: [string, "null"]
        disabled: { type: boolean }

    CanvasObject:
      type: object
      required: [id, image_id, x, y, rotation, width, height]
      properties:
        id: { type: string }
        image_id: { type: string }
        x: { type: number, format: float }
        y: { type: number, format: float }
        rotation: { type: number, format: float }
        width: { type: number, format: float }
        height: { type: number, format: float }
        is_placeholder:
          type: [boolean, "null"]

    Canvas:
      type: object
      required: [width, height, dpi, objects]
      properties:
        width: { type: number, format: float, description: Inches }
        height: { type: number, format: float, description: Inches }
        dpi: { type: integer }
        objects:
          type: array
          items: { $ref: '#/components/schemas/CanvasObject' }

    CollaboratorRole:
      type: string
      enum: [Owner, Collab]

    Collaborator:
      type: object
      required: [user_id, role]
      properties:
        user_id: { type: string }
        role: { $ref: '#/components/schemas/CollaboratorRole' }

    Project:
      type: object
      required: [nm, created, images, canvas, owner_id, auto_arrange, collaborators]
      properties:
        id:
          type: string
          description: |
            Bare key. Absent when serialized in a context where the
            record hasn't been persisted yet.
        nm:
          type: string
          description: Project name. (Field is named `nm` historically.)
        created:
          type: string
          description: ISO 8601 creation timestamp
        images:
          type: array
          items: { type: string }
          description: Image ids attached to the canvas
        canvas: { $ref: '#/components/schemas/Canvas' }
        owner_id:
          type: [string, "null"]
        print_asset_url:
          type: [string, "null"]
        auto_arrange: { type: boolean }
        collaborators:
          type: array
          items: { $ref: '#/components/schemas/Collaborator' }
        deleted_at:
          type: [string, "null"]
          description: ISO timestamp of soft-delete, or null
        canvas_updated_at:
          type: [string, "null"]
        thumbnail_url:
          type: [string, "null"]
          description: CDN URL of the auto-generated project thumbnail
        thumbnail_generated_at:
          type: [string, "null"]
        intent:
          oneOf:
            - $ref: '#/components/schemas/StickerIntent'
            - type: "null"
          description: |
            **Deprecated — read `intents` instead.** Mirrors the first entry
            of `intents` so clients written before a project could hold a
            whole order keep working. Absent on legacy sheet projects.
        intents:
          type: array
          items: { $ref: '#/components/schemas/StickerIntent' }
          description: |
            Every sticker line the customer ordered: one entry per
            `{asset, size, quantity}`. A project now represents an entire
            order rather than a single sheet, so an order with four different
            stickers is one project with four intents.

            Absent or empty on legacy sheet projects and on projects written
            before this field existed — in that case fall back to `intent`.
        sheets:
          type: array
          items: { $ref: '#/components/schemas/Sheet' }
          description: |
            The printable sheets this project resolves to. An order's stickers
            are packed across sheets, and stickers from different lines can
            share one.

            Absent or empty in two different situations, which
            `intents` tells apart:

            - **Legacy / single-sheet project** (no `intents`) — the layout is
              in `canvas`; treat the project as one sheet.
            - **Awaiting layout** (has `intents`) — the customer's lines are
              recorded but nothing has been packed yet. There is genuinely no
              layout, and `canvas.objects` is empty.

            When sheets *are* present, `canvas` mirrors `sheets[0].canvas` so
            readers written before sheets keep working. Prefer `sheets`:
            `canvas` shows only the first of them.
        state:
          oneOf:
            - $ref: '#/components/schemas/ProjectState'
            - type: "null"
          description: |
            Sticker-first lifecycle state. Absent on legacy projects, which
            keep the pre-reframe manual fulfillment flow.

    Sheet:
      type: object
      required: [id, preset_id, pack, canvas]
      description: |
        One printable sheet: a medium, how many copies of it to print, and the
        artwork laid out on it.
      properties:
        id:
          type: string
          description: |
            Stable identifier, so moving a sticker between sheets refers to a
            sheet that survives reordering.
        preset_id:
          type: integer
          description: Which Gooten medium — matches `CanvasPreset.id` from `/sizes`
        pack:
          type: integer
          description: |
            How many identical copies of this sheet to print (Gooten's pack
            constraint: 1, 2, 3, 4, 5, 10 or 25).

            This is why moving a sticker between sheets changes delivered
            quantities: a slot on a `pack: 10` sheet yields ten stickers, the
            same slot on a `pack: 2` sheet yields two.
        canvas: { $ref: '#/components/schemas/Canvas' }
        print_asset_url:
          type: [string, "null"]
          description: This sheet's print master in R2, once rendered
        state:
          oneOf:
            - $ref: '#/components/schemas/ProjectState'
            - type: "null"
          description: |
            Per-sheet lifecycle. The project's own `state` is the minimum
            across its sheets, so an order is only `approved` when every sheet
            is.
        thumbnail_url:
          type: [string, "null"]

    StickerIntent:
      type: object
      required: [asset_id, longest_side_in, desired_min_qty]
      description: |
        What the customer asked for on the sizing page — preserved verbatim,
        distinct from what the packer produced (medium choice, bonus
        stickers).
      properties:
        asset_id:
          type: string
          description: Gallery asset the sticker was made from
        longest_side_in:
          type: number
          description: Customer's slider value — the sticker's longest side, inches
        desired_min_qty:
          type: integer
          description: |
            Desired minimum quantity. Pack rounding may deliver more (bonus
            stickers); it never delivers fewer.
        aspect:
          type: [number, "null"]
          description: |
            Width / height of the **visible subject** — the cut-out artwork's
            own bounding box, not the padded image's — resolved when the line
            was created.

            Stored rather than re-derived because layout runs after the order
            is placed, and re-reading every line's artwork then would mean an
            image fetch and decode per line on the order path. Null on lines
            created before this field existed.
        image_id:
          type: [string, "null"]
          description: |
            The project-scoped image copy representing this line.

            Each line gets its own copy even when two lines share a source
            asset, because the same photo can be ordered at two different
            sizes. That makes this the unambiguous key for attributing a
            placed sticker back to the line it satisfies. Null on projects
            created before this field existed.

    ProjectState:
      type: string
      enum: [draft, ordered, rendered, approved, submitted]
      description: |
        Sticker-first project lifecycle, forward-only:
        draft → ordered → rendered → approved → submitted. A proofing
        adjustment that regenerates the print master moves approved back to
        rendered (re-approval required). Gooten submission is refused unless
        the state is `approved`.

    QuotePayload:
      type: object
      required: [aspect, longest_side_in, qty]
      properties:
        aspect:
          type: number
          description: Sticker artwork width/height ratio (> 0)
        longest_side_in:
          type: number
          description: Chosen longest side, inches (slider value)
        qty:
          type: integer
          description: Desired minimum quantity (>= 1)
        source_px:
          type: integer
          description: |
            Longest side of the artwork's **visible subject**, in pixels, for
            the print-quality check. Optional and client-measured: this
            endpoint does no asset lookup, and fetching the image on every
            drag of the size slider would be absurd. A wrong value yields a
            wrong *warning* and nothing else — `POST /drafts` computes the
            authoritative verdict from the stored image.

            Measure the opaque bounding box, not the image: a cut-out sits in
            a transparent margin that carries no detail, and counting it
            overstates resolution.

            Omit (or send 0) for no verdict.

    LineTally:
      type: object
      required: [asset_id, longest_side_in, required, delivered, short_by, bonus]
      description: |
        One order line's ordered-versus-delivered arithmetic. A sheet is
        printed `pack` times, so one slot on a 10-pack sheet delivers ten
        stickers — this is the arithmetic that catches a layout silently
        shorting a line.
      properties:
        asset_id: { type: string }
        image_id: { type: [string, "null"] }
        longest_side_in: { type: number }
        required:
          type: integer
          description: What the customer ordered.
        delivered:
          type: integer
          description: What the current layout would print.
        short_by:
          type: integer
          description: How many short; any non-zero value blocks approval.
        bonus:
          type: integer
          description: Surplus over the order — harmless pack rounding.

    SheetLayout:
      type: object
      required:
        [preset_id, medium_title, medium_w_in, medium_h_in, sticker_w_in,
         sticker_h_in, rotated, cols, rows, n_up]
      properties:
        preset_id:
          type: integer
          description: Matches `CanvasPreset.id` from `/sizes`
        medium_title: { type: string }
        medium_w_in: { type: number }
        medium_h_in: { type: number }
        sticker_w_in:
          type: number
          description: Sticker width after aspect resolution (pre-rotation)
        sticker_h_in: { type: number }
        rotated:
          type: boolean
          description: Whether the sticker is rotated 90° on the sheet
        cols: { type: integer }
        rows: { type: integer }
        n_up:
          type: integer
          description: Copies per sheet (cols × rows)

    PrintCheck:
      type: object
      required: [effective_dpi, band]
      description: |
        Whether the artwork has the pixels for the requested print size.

        Print masters render at 300 dpi and nothing in the pipeline invents
        detail, so a cut-out from a small or heavily-cropped photo stretched
        up the size slider prints soft. This says so.

        **Advisory only.** A `poor` band never blocks a quote, a draft, or an
        order. Clients should show a warning and let the customer decide.

        Absent from a response means *not assessed* — never "fine".
      properties:
        effective_dpi:
          type: number
          description: |
            Pixels per printed inch at the requested size:
            `subject_longest_side_px / longest_side_in`.
        band:
          type: string
          enum: [good, fair, poor]
          description: |
            `good` — at or above 300 dpi; show nothing.
            `fair` — 150–299 dpi; fine at arm's length, soft up close.
            `poor` — below 150 dpi; visibly soft or pixelated at this size.
        sharp_up_to_in:
          type: number
          description: |
            Largest longest-side, in inches, that would still reach 300 dpi.
            Present only when `band` is not `good` — the useful thing to tell
            someone standing at a size slider is which size works, not that
            this one doesn't.

    QuoteResponse:
      allOf:
        - $ref: '#/components/schemas/PackQuote'
        - type: object
          properties:
            print_check:
              $ref: '#/components/schemas/PrintCheck'
      description: |
        A `PackQuote` with one additional key. Every packing field sits at the
        top level exactly as it always has; `print_check` is additive and
        present only when the request carried a usable `source_px`.

    PackQuote:
      type: object
      required:
        [layout, sheets_needed, pack, total_stickers, bonus_qty, pack_sheets]
      properties:
        layout: { $ref: '#/components/schemas/SheetLayout' }
        sheets_needed:
          type: integer
          description: Sheets required to satisfy the requested quantity
        pack:
          type: integer
          description: |
            The Gooten pack rounded up to — the number of identical sheets
            printed. Identifies the price-table entry together with the medium.
        total_stickers:
          type: integer
          description: pack × n_up — what the customer actually receives
        bonus_qty:
          type: integer
          description: total_stickers − requested qty
        pack_sheets:
          type: integer
          description: Alias of `pack` (sheet count of the pack)

    QuoteError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum:
            - size_out_of_bounds
            - does_not_fit
            - quantity_too_large
            - invalid_input
            - no_items
            - too_many_items
            - too_many_stickers
        item_index:
          type: integer
          description: |
            Which line failed, on `POST /drafts`. Zero-based index into the
            request's `items`; always 0 for a single-line request. Absent on
            `POST /quote`, which has no lines.

    DraftPayload:
      description: |
        Either a whole order (`{items: [...]}`) or a single line posted at the
        top level. The single-line form is what the web storefront sends and
        stays valid indefinitely.
      oneOf:
        - $ref: '#/components/schemas/DraftItemsPayload'
        - $ref: '#/components/schemas/DraftItem'

    DraftItemsPayload:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/DraftItem' }
          description: |
            Every sticker line in the order. Produces **one** project with no
            layout — sheets are packed after the order is placed.

    DraftItem:
      type: object
      required: [asset_id, longest_side_in]
      description: |
        One sticker line. Exactly one of `qty` or `desired_min_qty` must be
        present — they are the same field, with the second accepted so the
        naming in `docs/ios-order-data-contract.md` works as written.
      properties:
        asset_id:
          type: string
          description: |
            Gallery asset owned by the caller, or a project-scoped upload
            whose id the caller holds.
        longest_side_in:
          type: number
          description: Chosen longest side, inches (slider value)
        qty:
          type: integer
          description: Desired minimum quantity (>= 1)
        desired_min_qty:
          type: integer
          description: Alias for `qty`.
        aspect:
          type: number
          description: |
            Client's view of the artwork width/height ratio. Fallback only —
            the server re-derives from the stored image when readable.

    CreateProjectPayload:
      type: object
      required: [preset_id]
      properties:
        preset_id:
          type: integer
          description: Matches `CanvasPreset.id` from `/sizes`

    RenamePayload:
      type: object
      required: [name]
      properties:
        name: { type: string }

    ImageStatus:
      type: string
      enum: [Uploading, Processing, Ready, Error]
      description: |
        Pipeline state. Frontend also synthesizes a `TooSmall` status for
        Ready images whose decoded dimensions fall below a threshold —
        that value is **not** sent by the backend.

    RenderStatus:
      type: object
      required: [status]
      description: |
        Status of a project's server-side print render (see
        `POST/GET /projects/{id}/render`). `print_asset_url` is present only
        when `status` is `ready`; `error` only when `failed`.
      properties:
        status:
          type: string
          enum: [idle, rendering, ready, failed]
        print_asset_url:
          type: string
          description: Present when `status` is `ready` — the R2 print master URL.
        error:
          type: string
          description: Present when `status` is `failed`.

    SheetRenderStatus:
      type: object
      required: [sheet_id, preset_id, pack]
      description: One sheet's print master, if it has been rendered yet.
      properties:
        sheet_id: { type: string }
        preset_id:
          type: integer
          description: Gooten medium — matches `CanvasPreset.id` from `/sizes`
        pack:
          type: integer
          description: How many identical copies of this sheet get printed
        print_asset_url:
          type: [string, "null"]
          description: Null until this sheet has been rendered.
        state:
          oneOf:
            - $ref: '#/components/schemas/ProjectState'
            - type: "null"

    RenderStatusDetail:
      type: object
      required: [status]
      description: |
        `RenderStatus` plus the per-sheet breakdown. The top-level fields are
        unchanged, so readers written before sheets keep working.
      properties:
        status:
          type: string
          enum: [idle, rendering, ready, failed]
        print_asset_url:
          type: string
          description: |
            Present when `status` is `ready`. Mirrors the **first** sheet's
            master; read `sheets` for the rest.
        error:
          type: string
          description: Present when `status` is `failed`.
        sheets:
          type: array
          items: { $ref: '#/components/schemas/SheetRenderStatus' }
          description: |
            Every sheet this project prints. A single-canvas project reports
            one synthetic entry.
        all_sheets_rendered:
          type: boolean
          description: |
            True only when every sheet has a master. This — not
            `print_asset_url` — is what says an order is printable.

    ImageAsset:
      type: object
      required: [status]
      properties:
        id:
          type: string
          description: Bare key (UUID v4 generated at upload time)
        status: { $ref: '#/components/schemas/ImageStatus' }
        original_url:
          type: [string, "null"]
          description: |
            Deprecated / not persisted. The source upload lives only in
            transient staging on disk during background removal; nothing
            durable points at `/uploads/`. Always null on stored records —
            render `processed_url` instead.
        processed_url:
          type: [string, "null"]
          description: |
            Cloudflare Images CDN URL after remove.bg has run. Drives
            the visible image in the editor.
        owner_id:
          type: [string, "null"]
        created_at:
          type: [string, "null"]
        deleted_at:
          type: [string, "null"]
        hidden_at:
          type: [string, "null"]
          description: Per-asset hidden toggle (see `PATCH /me/assets/{id}`)
        project_id:
          type: [string, "null"]
          description: |
            The owning project, for a *project* image (deleted with its
            project). Null for a *gallery* image — the user's reusable,
            top-level copy, cleaned up independently. Gallery copies are
            de-duplicated per owner by `content_hash`.
        content_hash:
          type: [string, "null"]
          description: |
            SHA-256 (hex) of the processed PNG bytes. De-dup key for
            gallery copies, so uploading/claiming/re-using the same image
            never creates a second gallery row for one user.

    RecentRole:
      type: string
      enum: [Owner, Collab, Viewed]
      description: |
        Derived. `Owner` if the project's `owner_id` matches the caller.
        `Collab` if the caller is in the project's collaborators list.
        `Viewed` if the caller has a Recents row but no membership.

    RecentProject:
      type: object
      required: [project_id, name, canvas, role, last_accessed_at]
      properties:
        project_id: { type: string }
        name: { type: string }
        canvas: { $ref: '#/components/schemas/Canvas' }
        role: { $ref: '#/components/schemas/RecentRole' }
        last_accessed_at: { type: string }
        hidden_at:
          type: [string, "null"]
        deleted_at:
          type: [string, "null"]
          description: Carries forward from the underlying Project
        canvas_updated_at:
          type: [string, "null"]
        thumbnail_url:
          type: [string, "null"]

    TouchedProject:
      type: object
      required: [project_id, last_accessed_at]
      properties:
        project_id: { type: string }
        last_accessed_at: { type: string }

    ClaimPayload:
      type: object
      required: [touched_projects, uploaded_assets]
      properties:
        touched_projects:
          type: array
          items: { $ref: '#/components/schemas/TouchedProject' }
          description: Projects the anonymous session interacted with
        uploaded_assets:
          type: array
          items: { type: string }
          description: Asset ids the anonymous session uploaded

    Account:
      type: object
      required: [id, created_at, notifications]
      properties:
        id:
          type: string
          description: Stytch user_id
        display_name:
          type: [string, "null"]
        avatar_url:
          type: [string, "null"]
          description: Cloudflare Images CDN URL
        created_at: { type: string }
        updated_at:
          type: [string, "null"]
        shipping_address:
          oneOf:
            - $ref: '#/components/schemas/ShippingAddress'
            - type: "null"
          description: >
            Where their stickers go by default. Absent until they give one;
            Stripe still collects the authoritative address at checkout.
        notifications:
          $ref: '#/components/schemas/NotificationPrefs'

    ShippingAddress:
      type: object
      description: >
        Every line is optional on purpose — a half-filled address is more
        useful than a refused one, and Stripe collects the copy that ships.
      properties:
        name: { type: [string, "null"] }
        line1: { type: [string, "null"] }
        line2: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        postal_code: { type: [string, "null"] }
        country:
          type: [string, "null"]
          description: ISO 3166-1 alpha-2, upper-case.

    NotificationPrefs:
      type: object
      description: >
        Which emails the customer wants. The two that follow an order
        default on; marketing defaults off.
      required: [order_shipped, order_delivered, promotions]
      properties:
        order_shipped: { type: boolean, default: true }
        order_delivered: { type: boolean, default: true }
        promotions: { type: boolean, default: false }

    HiddenPatch:
      type: object
      required: [hidden]
      properties:
        hidden: { type: boolean }

    AccountPatch:
      type: object
      description: >
        A partial profile update: send only what changed. `shipping_address:
        null` deletes the stored address, while omitting the field leaves it
        alone.
      properties:
        display_name: { type: string }
        shipping_address:
          oneOf:
            - $ref: '#/components/schemas/ShippingAddress'
            - type: "null"
        notifications: { $ref: '#/components/schemas/NotificationPrefs' }

# ─── WebSocket route ────────────────────────────────────────────────────
#
# OpenAPI 3.1 has no first-class WebSocket support, so the `/ws/{project_id}`
# route is documented here as a comment rather than under `paths`.
#
#   GET /ws/{project_id}?client_id=<opaque>&name=<display>
#
# Upgrades the connection to a WebSocket carrying real-time canvas, presence,
# and image-processing messages. Query params (both optional):
#
#   - client_id — stable per-tab identifier. The web client persists this in
#     sessionStorage so it survives the magic-link redirect roundtrip;
#     presence dedupes on it. If omitted, the server assigns a random UUID
#     for the connection.
#   - name — display name surfaced in presence. When present, the server's
#     presence id for the tab becomes `<name>|<user_id>`; otherwise it's the
#     bare user/connection id.
#
# ── Authentication (per ADR 0002) ────────────────────────────────────────
#
# Browsers can't set arbitrary headers on a WebSocket, so the bearer token is
# passed via the `Sec-WebSocket-Protocol` subprotocol list, not the
# Authorization header. Auth is OPTIONAL — anonymous connections are allowed.
#
#   - To authenticate, send EXACTLY TWO subprotocol entries: the literal
#     string `bearer` followed by the raw JWT. In the browser:
#
#         new WebSocket(url, ["bearer", "<jwt>"])
#
#     The server reads entry[0] == `bearer` and takes entry[1] as the token.
#     The token is NOT concatenated into a single `bearer.<jwt>` value, and
#     there is no `stixel` subprotocol.
#   - On a valid token the handshake completes echoing back the single
#     negotiated subprotocol `bearer`. An invalid or expired token does NOT
#     reject the upgrade — it degrades to an anonymous connection.
#   - To connect anonymously, omit the subprotocol list entirely.
#
# ── Message protocol (JSON text frames in both directions) ───────────────
#
# Inbound (client → server):
#
#   { "type": "canvas_update", "objects": [CanvasObject, ...] }
#       Broadcast this tab's in-memory canvas objects to the other connected
#       tabs. The server relays the message to every other client on the
#       project and, for authenticated callers, touches the project's Recents
#       row. This does NOT persist the canvas — durable saves go through
#       `PUT /projects/{id}`. Any other JSON object is relayed as-is; there
#       is no JSON `ping` message (heartbeats are protocol-level, see below).
#
# Outbound (server → client):
#
#   { "type": "welcome", "id": "<presence_id>" }
#       Sent once, immediately after connect. `id` is this tab's presence
#       identifier (`<name>|<user_id>`, or a bare id when no name was given).
#       Clients store it to filter themselves out of the presence list.
#
#   { "type": "presence", "users": ["<presence_id>", ...] }
#       Current editors on the project, as an array of presence-id STRINGS
#       (not objects). Sent after connect and whenever presence changes.
#
#   { "type": "canvas_update", "objects": [CanvasObject, ...] }
#       Another tab's canvas edit (or a server-side project change). Replace
#       the local canvas objects with `objects`.
#
#   { "image_id": string, "status": ImageStatus, "percent": number,
#     "project_id": string, "original_url"?: string,
#     "processed_url"?: string, "error"?: string }
#       Image-pipeline progress for an upload on this project. Drives the
#       editor's Uploading → Processing → Ready (or Error) transitions.
#       `status` values line up with the REST `ImageStatus` enum.
#
# Messages relayed through the room-events table — the image-status messages,
# and `canvas_update`s echoed from another client — additionally carry a
# server-stamped `project_id` plus a record `id` field. The directly-emitted
# `welcome`, `presence`, and project-change `canvas_update` messages do not.
# Clients should ignore unrecognized fields.
#
# ── Heartbeat ────────────────────────────────────────────────────────────
#
# The server sends a WebSocket protocol-level Ping frame every 25s to keep
# idle connections from being reaped by Cloudflare/Fly. Standard WebSocket
# clients reply Pong automatically — no application handling required. There
# is no JSON-level ping/pong.
