# The public Flambe API contract.
#
# This file is the source of truth for the reference section of docs.flambe.dev
# and for the machine-readable spec served at /openapi.yaml. It is written by
# hand; `scripts/sync-openapi.mjs` diffs it against the routes actually mounted
# by the Express app (parsed out of backend/src by scripts/extract-routes.mjs)
# and fails the build if either side gains an endpoint the other does not have.
# That gate is the reason this document can be trusted.
#
# Deliberately excluded from the public surface, and from the drift gate's
# expectations: /api/internal/** (service-to-service, x-api-key),
# /api/admin/** (operator-only), /api/webhooks/** (inbound from Clerk) and
# /__sentry-test. See scripts/sync-openapi.mjs for the exclusion list.
openapi: 3.1.0

info:
  title: Flambe API
  version: '1.0.0'
  summary: Import, digitize and manage recipes programmatically.
  description: |
    The Flambe API is the same HTTP interface the Flambe apps use. Everything the
    mobile app and web portal can do — importing a recipe from a URL, a photo of
    a cookbook page or a block of pasted text, organizing a library, planning
    meals, building grocery lists and sharing with a household — is available
    here.

    ## Base URL

    ```
    https://api.flambe.dev
    ```

    There is no separate sandbox host. Use a dedicated test user for
    experimentation.

    ## Authentication

    Every endpoint outside of `/health` requires a bearer token:

    ```http
    Authorization: Bearer <token>
    ```

    Tokens are [Clerk](https://clerk.com) session JWTs. See
    [Authentication](https://docs.flambe.dev/docs/guides/authentication) for how
    to obtain one.

    ## Conventions

    - All request and response bodies are JSON, except file upload, which is
      `multipart/form-data`.
    - Field names are `snake_case`. A handful of newer endpoints accept
      `camelCase`; where that is true the schema says so explicitly.
    - Timestamps are ISO 8601 strings in UTC (`2026-09-10T18:22:04.512Z`).
    - Identifiers are UUID v4 strings. Do not parse them for meaning.
    - Errors return a non-2xx status and a body of `{ "error": "message" }`, or
      `{ "errors": [...] }` when request validation failed.

    ## Rate limits

    Requests are rate limited per IP. Exceeding the limit returns `429`. Treat
    `429` and `5xx` as retryable with exponential backoff; treat `4xx` as
    terminal.

    ## Working with agents

    A plain-text summary of these docs for LLM context is published at
    [/llms.txt](https://docs.flambe.dev/llms.txt), the full corpus at
    [/llms-full.txt](https://docs.flambe.dev/llms-full.txt), and any page can be
    fetched as Markdown by appending `.md` to its URL.
  license:
    name: Proprietary
    url: https://flambe.dev/terms
  contact:
    name: Flambe Support
    url: https://flambe.dev/contact

servers:
  - url: https://api.flambe.dev
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Imports
    description: |
      Turn a URL, image, or block of text into a structured recipe. This is
      Flambe's flagship capability and the most likely reason to reach for the
      API. Imports are asynchronous — see the
      [Imports guide](https://docs.flambe.dev/docs/guides/imports).
  - name: Recipes
    description: The recipe library — create, read, update, delete, and sync.
  - name: Collections
    description: Named groupings of recipes.
  - name: Meal plans
    description: Scheduled meals built from recipes.
  - name: Grocery lists
    description: Shopping lists, optionally derived from meal plans.
  - name: Wishlists
    description: Saved cookbooks and recipes a user wants but does not own yet.
  - name: Cook sessions
    description: Live cooking sessions — which recipes are open right now.
  - name: Timers
    description: Server-backed kitchen timers that survive app restarts.
  - name: Files
    description: Upload and retrieve images and other binary assets.
  - name: Households
    description: Multi-user sharing — members, invites, and the shared library.
  - name: Sharing
    description: Public share links and the unauthenticated endpoints that resolve them.
  - name: Sources
    description: Provenance — the cookbook, website, or creator a recipe came from.
  - name: Creators
    description: Cookbook authors and social creators (v2 provenance model).
  - name: Notifications
    description: In-app notification feed and unread counts.
  - name: Push tokens
    description: Device registration for push notifications.
  - name: Books
    description: Cookbook lookup used when attributing a recipe to a book.
  - name: AI
    description: Embedding and assistant primitives used by semantic search.
  - name: Users
    description: The authenticated user's profile.
  - name: Events
    description: Server-sent event stream for live updates.
  - name: Auth
    description: |
      Legacy username/password endpoints. New integrations should authenticate
      with Clerk instead — see the Authentication guide.
  - name: System
    description: Health and service metadata.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A Clerk session JWT. Obtain one with a Clerk frontend SDK
        (`session.getToken()`) or, for server-to-server use, with a Clerk
        machine token. The token's `sub` claim is the Flambe user id that scopes
        every request.

  parameters:
    RecipeId:
      name: id
      in: path
      required: true
      description: Recipe identifier.
      schema: { type: string, format: uuid }
    ImportId:
      name: id
      in: path
      required: true
      description: Import identifier.
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items to return.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor returned as `next_cursor` by the previous page.
        Base64-encoded DynamoDB `ExclusiveStartKey`; treat it as opaque.
      schema: { type: string }
    Since:
      name: since
      in: query
      required: false
      description: |
        Delta-sync watermark. Return only records changed at or after this
        ISO 8601 timestamp.
      schema: { type: string, format: date-time }

  responses:
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/ValidationError'
    Unauthorized:
      description: |
        No bearer token was supplied, or it was invalid or expired.

        `Access token required` means the `Authorization` header was absent
        entirely — a client bug, not an expired session. `Invalid or expired
        token` means the token was present but did not verify.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              summary: Header absent
              value: { error: Access token required }
            invalid:
              summary: Token present but not valid
              value: { error: Invalid or expired token }
    Forbidden:
      description: Authenticated, but not allowed to act on this resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such resource, or it is not visible to this user.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: The resource is not in a state that permits this operation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Rate limit exceeded. Retry with exponential backoff.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServerError:
      description: Unexpected server error. Safe to retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      description: The standard error envelope.
      required: [error]
      properties:
        error:
          type: string
          description: A human-readable message. Not a stable machine identifier.
          examples: [Recipe not found]

    ValidationError:
      type: object
      description: |
        Returned when `express-validator` rejects the request body. Each entry
        identifies the offending field.
      required: [errors]
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              type: { type: string, examples: [field] }
              path: { type: string, description: The rejected field., examples: [title] }
              msg: { type: string, examples: [Invalid value] }
              location: { type: string, enum: [body, query, params, headers] }
              value: { description: The value that was rejected. }

    Ingredient:
      type: object
      description: A single ingredient line, parsed into quantity, unit and name.
      required: [name]
      properties:
        name:
          type: string
          description: The ingredient itself, without quantity or preparation.
          examples: [all-purpose flour]
        quantity:
          type: number
          description: Amount, as a decimal. Fractions are normalized (`½` becomes `0.5`).
          examples: [2.5]
        unit:
          type: string
          description: Unit for `quantity`, as written in the source.
          examples: [cups]
        preparation:
          type: string
          description: How the ingredient is prepared before use.
          examples: [sifted]
        isOptional:
          type: boolean
          description: Whether the recipe marks this ingredient as optional.
          default: false

    Component:
      type: object
      description: |
        A sub-recipe within a recipe — a sauce, a dough, a streusel. Components
        carry their own ingredients and instructions so a multi-part recipe does
        not flatten into one ambiguous list.
      required: [title, ingredients, instructions]
      properties:
        title:
          type: string
          examples: [Brown butter frosting]
        description: { type: string }
        notes: { type: string }
        ingredients:
          type: array
          items: { $ref: '#/components/schemas/Ingredient' }
        instructions:
          type: array
          items: { type: string }

    Media:
      type: object
      description: An image or video attached to a recipe.
      properties:
        file_id: { type: string, format: uuid }
        url:
          type: string
          format: uri
          description: |
            CDN URL on `cdn.flambe.dev`. Width variants are available by
            appending `@{width}w.webp` for widths 320, 480, 640, 800, 960, 1200
            and 1600; any other width falls back to the original.
        blur_hash:
          type: string
          description: BlurHash placeholder to render while the image loads.
        width: { type: integer }
        height: { type: integer }
        kind: { type: string, enum: [image, video] }

    Recipe:
      type: object
      description: A recipe in the authenticated user's library.
      required: [title]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id:
          type: string
          readOnly: true
          description: Owner. Always the caller for recipes created through this API.
        title: { type: string, examples: [Brown Butter Chocolate Chip Cookies] }
        subtitle: { type: string }
        description: { type: string }
        notes:
          type: string
          description: Free-form user notes. Always a string; never null.
          default: ''
        tags:
          type: array
          items: { type: string }
          examples: [[dessert, cookies]]
        ingredients:
          type: array
          description: |
            Top-level ingredients. When a recipe uses `components`, this list
            holds only the ingredients that belong to no component.
          items: { $ref: '#/components/schemas/Ingredient' }
        components:
          type: array
          items: { $ref: '#/components/schemas/Component' }
        instructions:
          type: array
          items: { type: string }
        media:
          type: array
          items: { $ref: '#/components/schemas/Media' }
        blur_hash: { type: string }
        prep_time:
          type: integer
          description: Minutes of active preparation.
          examples: [20]
        cook_time:
          type: integer
          description: Minutes of cooking.
          examples: [12]
        servings: { type: integer, examples: [24] }
        difficulty: { type: string, enum: [easy, medium, hard] }
        rating:
          type: number
          description: The user's own rating, 0–5.
          minimum: 0
          maximum: 5
        source_section:
          type: string
          description: Where in the source this recipe appeared, e.g. a chapter or page.
        nutrition: { $ref: '#/components/schemas/Nutrition' }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    Nutrition:
      type: object
      description: |
        Computed per-serving nutrition. Derived from ingredient matching against
        USDA FoodData Central and Open Food Facts, so values are estimates.
      properties:
        calories: { type: number }
        protein_g: { type: number }
        fat_g: { type: number }
        carbs_g: { type: number }
        fiber_g: { type: number }
        sugar_g: { type: number }
        sodium_mg: { type: number }
        per: { type: string, enum: [serving, recipe] }
        computed_at: { type: string, format: date-time }
        ingredients_hash:
          type: string
          description: |
            Hash of the ingredient list the figures were computed from. If it no
            longer matches the recipe, the nutrition is stale.

    Import:
      type: object
      description: |
        An asynchronous recipe-extraction job. Poll it, or subscribe to
        `/api/events/stream`, until `status` is terminal.
      required: [type]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        type:
          type: string
          enum: [web, media, text, image_batch]
          description: |
            - `web` — fetch and parse an https URL (recipe site, Instagram, YouTube).
            - `media` — read one or more uploaded images, e.g. cookbook pages.
            - `text` — parse pasted or uploaded plain text.
            - `image_batch` — a parent job that fans out into per-image children.
        status:
          type: string
          enum: [pending, queued, processing, completed, failed, cancelled]
          description: |
            `pending` on creation, then `queued` once enqueued, `processing`
            while a worker holds it. `completed`, `failed` and `cancelled` are
            terminal.
          readOnly: true
        url:
          type: string
          format: uri
          description: Source URL, for `web` imports. Must be `https`.
        file_id:
          type: string
          description: Deprecated single-file form of `file_ids`.
          deprecated: true
        file_ids:
          type: array
          items: { type: string, format: uuid }
          description: Uploaded files to read, for `media` imports.
        text_file_id:
          type: string
          format: uuid
          description: An uploaded text file to parse, as an alternative to `text`.
        source_url:
          type: string
          format: uri
          description: Attribution URL for a `text` import. Must be `https`.
        source_section: { type: string, maxLength: 120 }
        recipe_id:
          type: string
          format: uuid
          readOnly: true
          description: The recipe this import produced. Present once `completed`.
        batch_id:
          type: string
          format: uuid
          description: Groups children of an `image_batch` parent.
        error:
          type: string
          readOnly: true
          description: Failure reason, present when `status` is `failed`.
        progress:
          type: object
          readOnly: true
          description: Coarse progress for long-running imports.
          properties:
            stage: { type: string, examples: [extracting] }
            percent: { type: integer, minimum: 0, maximum: 100 }
        processing_started_at:
          type: string
          format: date-time
          readOnly: true
          description: |
            When the current attempt began. The retry budget is anchored here,
            not to `created_at`, so a retry gets a full budget.
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    Collection:
      type: object
      required: [name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        name: { type: string, examples: [Weeknight dinners] }
        description: { type: string }
        tags:
          type: array
          items: { type: string }
        recipes:
          type: array
          description: Recipe ids in the collection.
          items: { type: string, format: uuid }
        recipe_count: { type: integer, readOnly: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    MealPlan:
      type: object
      required: [name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        name: { type: string, examples: [Week of Sep 14] }
        description: { type: string }
        end_date: { type: string, format: date }
        meals:
          type: array
          items: { $ref: '#/components/schemas/Meal' }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    Meal:
      type: object
      description: |
        A recurring slot in a meal plan — a time window on a set of weekdays,
        holding one or more items.
      required: [start_time, end_time, days_of_week, items]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string, examples: [Dinner] }
        start_time: { type: string, examples: ['18:00'] }
        end_time: { type: string, examples: ['19:30'] }
        days_of_week:
          type: array
          description: Weekdays the slot recurs on, 0 = Sunday.
          items: { type: integer, minimum: 0, maximum: 6 }
        items:
          type: array
          items:
            type: object
            properties:
              recipe_id: { type: string, format: uuid }
              name:
                type: string
                description: Free-text item, for something that is not a saved recipe.
              portion: { type: number }
              nutrition: { $ref: '#/components/schemas/Nutrition' }
        notes: { type: string }
        nutrition: { $ref: '#/components/schemas/Nutrition' }

    GroceryList:
      type: object
      required: [name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        name: { type: string, examples: [Saturday shop] }
        type: { type: string, examples: [manual] }
        description: { type: string }
        archived: { type: boolean, default: false }
        due_date: { type: string, format: date }
        items:
          type: array
          items: { $ref: '#/components/schemas/GroceryItem' }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    GroceryItem:
      type: object
      properties:
        key:
          type: string
          description: Stable identifier for the line within its list.
        name: { type: string, examples: [unsalted butter] }
        quantity: { type: number }
        unit: { type: string }
        checked: { type: boolean, default: false }
        recipe_id:
          type: string
          format: uuid
          description: The recipe that contributed this line, when derived.
        aisle: { type: string }

    Wishlist:
      type: object
      required: [name]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        name: { type: string }
        description: { type: string }
        items:
          type: array
          items: { $ref: '#/components/schemas/WishlistItem' }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    WishlistItem:
      type: object
      properties:
        itemKey:
          type: string
          description: Stable identifier for the item within its wishlist.
        kind: { type: string, enum: [book, recipe, source] }
        title: { type: string }
        authors:
          type: array
          items: { type: string }
        cover_url: { type: string, format: uri }
        source_v2_id: { type: string }
        added_at: { type: string, format: date-time }

    CookSession:
      type: object
      description: |
        A live cooking session. Tracks which recipes are open so the apps can
        restore state across devices.
      properties:
        id: { type: string, format: uuid, readOnly: true }
        user_id: { type: string, readOnly: true }
        status: { type: string, enum: [active, ended] }
        recipe_ids:
          type: array
          items: { type: string, format: uuid }
        started_at: { type: string, format: date-time }
        ended_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    Timer:
      type: object
      description: |
        A server-backed timer. Because it lives on the server it still fires
        when the app is backgrounded or the device restarts.

        Timer fields are `camelCase`, unlike the rest of the API.
      required: [dueAt]
      properties:
        timerId: { type: string, format: uuid, readOnly: true }
        title: { type: string, examples: [Rest the dough] }
        dueAt: { type: string, format: date-time }
        status:
          type: string
          enum: [scheduled, paused, fired, acked, cancelled]
          readOnly: true
        remainingMs:
          type: integer
          description: Milliseconds left when paused.
        payload:
          type: object
          additionalProperties: true
          description: Arbitrary data echoed back when the timer fires.
        deliveryPrefs:
          type: object
          description: How to notify when the timer fires.
          properties:
            push: { type: boolean }
            sound: { type: string }
        sessionId: { type: string, format: uuid }
        recipeId: { type: string, format: uuid }
        contextType: { type: string }
        contextId: { type: string }
        idempotencyKey:
          type: string
          description: |
            Supply a stable key to make timer creation idempotent — a retry with
            the same key returns the original timer instead of creating a second.
        ttl: { type: integer, description: Seconds until the record is expired. }
        createdAt: { type: string, format: date-time, readOnly: true }

    FileRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, readOnly: true }
        url: { type: string, format: uri, description: CDN URL for the asset. }
        content_type: { type: string, examples: [image/jpeg] }
        size: { type: integer, description: Bytes. }
        width: { type: integer }
        height: { type: integer }
        blur_hash: { type: string }
        created_at: { type: string, format: date-time, readOnly: true }

    Household:
      type: object
      description: A group of users who share part of their libraries.
      properties:
        id: { type: string, format: uuid, readOnly: true }
        name: { type: string }
        members:
          type: array
          items:
            type: object
            properties:
              user_id: { type: string }
              role: { type: string, enum: [owner, admin, member] }
              joined_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    HouseholdInvite:
      type: object
      properties:
        id: { type: string, format: uuid, readOnly: true }
        household_id: { type: string, format: uuid }
        recipient_email: { type: string, format: email }
        recipient_user_id: { type: string }
        role: { type: string, enum: [admin, member], default: member }
        token:
          type: string
          description: Redeem with `POST /api/households/invites/{token}/accept`.
        status: { type: string, enum: [pending, accepted, revoked] }
        created_at: { type: string, format: date-time, readOnly: true }

    ShareLink:
      type: object
      description: An unlisted public link to one resource.
      properties:
        id: { type: string, format: uuid, readOnly: true }
        token:
          type: string
          description: The public path segment — `https://flambe.dev/share/{token}`.
        resource_type: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
        resource_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time, readOnly: true }
        revoked_at: { type: string, format: date-time }

    SourceV2:
      type: object
      description: |
        A provenance record — the cookbook, website, or social account a recipe
        came from. Shared across users rather than owned by one.
      properties:
        id: { type: string }
        display_name: { type: string, examples: [Salt Fat Acid Heat] }
        name: { type: string }
        kind: { type: string, enum: [book, website, social, handwritten, other] }
        platform: { type: string, examples: [instagram] }
        authors:
          type: array
          items: { type: string }
        coverUrl: { type: string, format: uri }
        coverKey: { type: string }
        recipe_count: { type: integer, readOnly: true }

    Creator:
      type: object
      description: A cookbook author or social creator.
      properties:
        id: { type: string }
        display_name: { type: string, examples: [Samin Nosrat] }
        profiles:
          type: array
          items: { $ref: '#/components/schemas/CreatorProfile' }

    CreatorProfile:
      type: object
      description: One platform presence belonging to a creator.
      properties:
        profile_key:
          type: string
          description: '`{platform}:{handle}`, e.g. `instagram:saminnosrat`.'
        platform: { type: string, examples: [instagram] }
        handle: { type: string }
        url: { type: string, format: uri }
        avatar_url: { type: string, format: uri }
        creator_id: { type: string }

    Notification:
      type: object
      required: [title]
      properties:
        id: { type: string, format: uuid, readOnly: true }
        title: { type: string }
        subtitle: { type: string }
        link:
          type: string
          description: Deep link to open when tapped.
        type: { type: string, examples: [import_complete] }
        read_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time, readOnly: true }

    PushToken:
      type: object
      required: [token]
      properties:
        token: { type: string, description: Expo or APNs device token. }
        platform: { type: string, enum: [ios, android, web] }
        device_id: { type: string }
        device_name: { type: string }
        device_type: { type: string }
        app_version: { type: string }
        app_build: { type: string }
        session_id: { type: string }

    User:
      type: object
      properties:
        id: { type: string, description: Clerk user id. }
        email: { type: string, format: email }
        household_ids:
          type: array
          items: { type: string, format: uuid }
        is_admin: { type: boolean }
        created_at: { type: string, format: date-time }

    Paginated:
      type: object
      description: The shape of a cursor-paginated list.
      properties:
        items: { type: array, items: {} }
        next_cursor:
          type: [string, 'null']
          description: Pass as `cursor` to fetch the next page. `null` on the last page.

paths:
  /health:
    get:
      tags: [System]
      summary: Health check
      operationId: healthCheck
      description: |
        Liveness probe. Requires no authentication, and is what
        [status.flambe.dev](https://status.flambe.dev) polls for the Web service
        component.
      security: []
      responses:
        '200':
          description: The service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: [OK] }
                  timestamp: { type: string, format: date-time }
                  uptime: { type: number, description: Process uptime in seconds. }

  /api/imports:
    post:
      tags: [Imports]
      summary: Start an import
      operationId: createImport
      description: |
        Creates an import job and enqueues it. Returns `201` immediately with a
        record whose `status` is `pending` — extraction happens asynchronously
        on a worker, and takes anywhere from a few seconds to a couple of
        minutes depending on the source.

        Which fields are required depends on `type`:

        | `type` | Required | Notes |
        | --- | --- | --- |
        | `web` | `url` | Must be `https`. Recipe sites, Instagram, YouTube, TikTok. |
        | `media` | `file_ids` | Upload images first with `POST /api/files`. |
        | `text` | `text` or `text_file_id` | `text` must be at least 3 characters. |
        | `image_batch` | `file_ids` | Fans out into one child import per image. |

        Track the job by polling `GET /api/imports/{id}` or by subscribing to
        `GET /api/events/stream`. When `status` becomes `completed`, `recipe_id`
        points at the created recipe.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [web, media, text, image_batch]
                url:
                  type: string
                  format: uri
                  description: Required for `web`. Rejected unless the scheme is `https`.
                file_ids:
                  type: array
                  minItems: 1
                  items: { type: string, format: uuid }
                  description: Required for `media` and `image_batch`.
                file_id:
                  type: string
                  deprecated: true
                  description: Legacy single-file alias for `file_ids`.
                text:
                  type: string
                  minLength: 3
                  description: "Raw recipe text, for `type: text`."
                text_file_id:
                  type: string
                  format: uuid
                  description: An uploaded text file, as an alternative to `text`.
                source_url:
                  type: string
                  format: uri
                  description: Attribution URL for a `text` import. Must be `https`.
                source_section:
                  type: string
                  maxLength: 120
                  description: Chapter or page the recipe came from.
                youtube_extract_video:
                  type: boolean
                  description: |
                    For YouTube `web` imports, also analyze the video frames
                    rather than only the description and transcript.
                batch_id:
                  type: string
                  format: uuid
                  description: Attach this import to an existing batch parent.
                cover_file_ids:
                  type: array
                  maxItems: 12
                  items: { type: string, format: uuid }
                  description: Uploaded images to consider as the recipe cover.
            examples:
              web:
                summary: Import from a URL
                value:
                  type: web
                  url: https://www.seriouseats.com/best-chocolate-chip-cookies
              cookbookPages:
                summary: Two photographed cookbook pages
                value:
                  type: media
                  file_ids:
                    - 6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11
                    - 7a2d3f6b-7b1b-4d5f-8e3a-3d6b6c0f2b22
                  source_section: Chapter 4 — Breads
              pastedText:
                summary: Parse pasted text
                value:
                  type: text
                  text: |
                    Brown Butter Cookies
                    2 1/4 cups flour, 1 cup brown butter, 2 eggs...
                  source_url: https://example.com/recipe
      responses:
        '201':
          description: Import created and enqueued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Import' }
              examples:
                created:
                  value:
                    id: 4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f
                    user_id: user_2abcDEF
                    type: web
                    status: pending
                    url: https://www.seriouseats.com/best-chocolate-chip-cookies
                    created_at: '2026-09-10T18:22:04.512Z'
                    updated_at: '2026-09-10T18:22:04.512Z'
        '400':
          description: |
            Validation failed, or the payload does not satisfy the rules for its
            `type` — a `web` import without a `url`, a non-`https` URL, a
            `media` import with no `file_ids`, or a `text` import with neither
            `text` nor `text_file_id`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Error'
                  - $ref: '#/components/schemas/ValidationError'
              examples:
                missingUrl:
                  value: { error: url is required for web imports }
                notHttps:
                  value: { error: Only https URLs are allowed }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/v2:
    get:
      tags: [Imports]
      summary: List imports (paginated)
      operationId: listImports
      description: |
        The full import archive, newest first by `created_at`, cursor
        paginated. Use this to page back through history; use
        `/api/imports/working-set` for the bounded set a UI renders.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of imports.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: '#/components/schemas/Import' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/working-set:
    get:
      tags: [Imports]
      summary: List the import working set
      operationId: getImportWorkingSet
      description: |
        The newest imports by `created_at`, intentionally not paginated — this
        is the bounded window the apps' import screens render.

        Ordering is by **creation**, not by recency of activity. An old import
        that just failed or is being retried has a fresh `updated_at` but keeps
        its original `created_at`, so it is *not* pulled into this window. Use
        `/api/imports/v2` when you need the archive.

        Batch parents of any child in the window are fetched and included even
        when the parent itself falls outside it.
      responses:
        '200':
          description: The working set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Import' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}:
    get:
      tags: [Imports]
      summary: Get an import
      operationId: getImport
      description: |
        Fetch one import. Poll this to follow a job to completion; a one- to
        two-second interval is plenty, and `/api/events/stream` avoids polling
        altogether.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      responses:
        '200':
          description: The import.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Import' }
              examples:
                completed:
                  summary: Finished, with the recipe it produced
                  value:
                    id: 4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f
                    type: web
                    status: completed
                    url: https://www.seriouseats.com/best-chocolate-chip-cookies
                    recipe_id: 9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
                    created_at: '2026-09-10T18:22:04.512Z'
                    updated_at: '2026-09-10T18:22:31.004Z'
                failed:
                  summary: Failed
                  value:
                    id: 4d1f8c2e-9a3b-4f1e-8c7d-1b2a3c4d5e6f
                    type: web
                    status: failed
                    error: Could not fetch the page (403)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}/retry:
    post:
      tags: [Imports]
      summary: Retry an import
      operationId: retryImport
      description: |
        Re-enqueue a finished import, reusing the original source. The record is
        reset to `pending` and `processing_started_at` is re-stamped, which
        gives the attempt a full retry budget rather than inheriting the elapsed
        time of the previous one.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      responses:
        '200':
          description: Re-enqueued; the updated record is returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Import' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The import is still in flight and so cannot be retried.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}/cancel:
    post:
      tags: [Imports]
      summary: Cancel an import
      operationId: cancelImport
      description: |
        Cancel an import that has not finished. A worker already mid-extraction
        observes the cancellation at its next checkpoint, so the job may run a
        little longer before it stops.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      responses:
        '200':
          description: Cancelled; the updated record is returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Import' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Already in a terminal state and so cannot be cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}/children:
    get:
      tags: [Imports]
      summary: List child imports
      operationId: listImportChildren
      description: |
        For an `image_batch` parent, the per-image child imports it fanned out
        into. Each child succeeds or fails independently, so a batch can be
        partially complete.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      responses:
        '200':
          description: The children of this import.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Import' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}/report:
    post:
      tags: [Imports]
      summary: Report a bad import
      operationId: reportImport
      description: |
        Flag a completed import whose extraction was wrong. Reports feed triage
        of the extraction pipeline. One report per import per user.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 200
                  description: Short category. Defaults to `bad_import`.
                  examples: [missing_ingredients]
                details:
                  type: string
                  maxLength: 2000
      responses:
        '201':
          description: Report recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  report_id: { type: string, format: uuid }
                  created_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The import has not completed yet, or this user already reported it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                notCompleted:
                  value: { error: Import must be completed before reporting }
                duplicate:
                  value: { error: Import has already been reported }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/imports/{id}/source:
    post:
      tags: [Imports, Sources]
      summary: Attach provenance to an import
      operationId: setImportSource
      description: |
        Tell the pipeline where this import came from — a cookbook, a website, a
        creator — before or while it runs, so the resulting recipe is attributed
        correctly.
      parameters:
        - $ref: '#/components/parameters/ImportId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                source:
                  type: object
                  additionalProperties: true
                  description: A single provenance descriptor.
                sources:
                  type: array
                  items: { type: object, additionalProperties: true }
                  description: Several descriptors, for a batch.
                mode:
                  type: string
                  description: How to combine with provenance already attached.
      responses:
        '200':
          description: Provenance attached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Import' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes:
    get:
      tags: [Recipes]
      summary: List recipes
      operationId: listRecipes
      description: |
        The authenticated user's recipe library, plus any recipes shared with
        them through a household.

        Supports two independent mechanisms:

        - **Pagination** — `limit` (default 24, max 100) and `cursor`.
        - **Delta sync** — pass `updated_since` to receive only recipes changed
          since that instant, along with `revoked_ids` for recipes that are no
          longer visible. Mirror `server_timestamp` back as the next
          `updated_since`.

        Change history is retained for 30 days. A watermark older than 25 days
        is refused with `full_sync_required: true` and an empty result — drop
        your local state and re-sync without `updated_since`.
      parameters:
        - name: updated_since
          in: query
          required: false
          description: Delta-sync watermark, ISO 8601. Omit for a full list.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 24 }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Recipes, and the watermark to use next time.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes:
                    type: array
                    items: { $ref: '#/components/schemas/Recipe' }
                  revoked_ids:
                    type: array
                    description: |
                      Recipes no longer visible — deleted, or un-shared. Only
                      meaningful during a delta sync.
                    items: { type: string, format: uuid }
                  server_timestamp:
                    type: string
                    format: date-time
                    description: Use as the next `updated_since`.
                  full_sync_required:
                    type: boolean
                    description: |
                      `true` when the supplied watermark is too old to serve.
                      Discard local state and re-sync in full.
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Recipes]
      summary: Create a recipe
      operationId: createRecipe
      description: |
        Create a recipe directly, without going through an import. Only `title`
        is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Recipe'
                - type: object
                  required: [title]
            examples:
              minimal:
                summary: Title only
                value: { title: Weeknight carbonara }
              full:
                summary: A complete recipe
                value:
                  title: Brown Butter Chocolate Chip Cookies
                  subtitle: Makes two dozen
                  tags: [dessert, cookies]
                  prep_time: 20
                  cook_time: 12
                  servings: 24
                  difficulty: easy
                  ingredients:
                    - { name: all-purpose flour, quantity: 2.25, unit: cups }
                    - { name: unsalted butter, quantity: 1, unit: cup, preparation: browned }
                    - { name: flaky salt, isOptional: true }
                  instructions:
                    - Brown the butter and let it cool to room temperature.
                    - Cream with both sugars, then beat in the eggs.
                    - Bake at 350°F for 12 minutes.
      responses:
        '201':
          description: Recipe created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/changes:
    get:
      tags: [Recipes]
      summary: Stream recipe change events
      operationId: listRecipeChanges
      description: |
        An append-only event log of changes to the user's recipes — the
        lower-level primitive behind delta sync, useful when you need to know
        *what* changed rather than just the current state.

        `since` is a numeric sequence watermark, not a timestamp. Start at `0`
        and carry forward the value the response returns.
      parameters:
        - name: since
          in: query
          required: false
          description: Sequence watermark. Defaults to `0` (from the beginning).
          schema: { type: integer, minimum: 0, default: 0 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 100 }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of change events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        recipe_id: { type: string, format: uuid }
                        type: { type: string, examples: [updated] }
                        seq: { type: integer }
                        at: { type: string, format: date-time }
                  next_cursor: { type: [string, 'null'] }
        '400':
          description: '`since` or `limit` was not a valid non-negative number.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badSince:
                  value: { error: Invalid since parameter }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}:
    get:
      tags: [Recipes]
      summary: Get a recipe
      operationId: getRecipe
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      responses:
        '200':
          description: The recipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Recipes]
      summary: Update a recipe
      operationId: updateRecipe
      description: |
        Replaces the supplied fields. Omitted fields are left untouched, so this
        behaves as a partial update despite being a `PUT`.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Recipe' }
            examples:
              rate:
                summary: Rate a recipe and add a note
                value: { rating: 5, notes: Chilled the dough overnight — better. }
      responses:
        '200':
          description: The updated recipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Recipes]
      summary: Delete a recipe
      operationId: deleteRecipe
      description: |
        Permanently deletes the recipe and records a revocation so that syncing
        clients remove their copies.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/nutrition:
    post:
      tags: [Recipes]
      summary: Compute nutrition
      operationId: computeRecipeNutrition
      description: |
        Estimates per-serving nutrition by matching each ingredient against USDA
        FoodData Central and Open Food Facts, then stores the result on the
        recipe. Values are estimates; ingredients that cannot be matched are
        skipped rather than guessed.

        The result is cached against a hash of the ingredient list, so calling
        this again without editing ingredients returns the stored figures.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      responses:
        '200':
          description: Nutrition for the recipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Nutrition' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/save-copy:
    post:
      tags: [Recipes]
      summary: Save a copy
      operationId: saveRecipeCopy
      description: |
        Copies a recipe that is visible to the caller — typically one shared
        through a household — into their own library as an independent record.
        Later edits to the original do not propagate.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      responses:
        '201':
          description: The new copy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/source:
    put:
      tags: [Recipes, Sources]
      summary: Set legacy source
      operationId: setRecipeSource
      deprecated: true
      description: |
        The v1 provenance model, kept for older clients. Use
        `PUT /api/recipes/{id}/source-v2` or `POST /api/recipes/{id}/sources`.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Source set.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/source-v2:
    put:
      tags: [Recipes, Sources]
      summary: Set the primary source
      operationId: setRecipeSourceV2
      description: |
        Attaches the recipe to a shared `SourceV2` provenance record. Supply
        either `source` to resolve or create one by descriptor, or
        `source_v2_id` to point at an existing record.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                source:
                  type: object
                  additionalProperties: true
                  description: Descriptor to resolve to an existing source, or create.
                source_v2_id:
                  type: string
                  description: Point at a source that already exists.
                source_ref:
                  type: object
                  additionalProperties: true
                  description: Where in the source this recipe sits — page, chapter, URL.
      responses:
        '200':
          description: Source attached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Recipes, Sources]
      summary: Clear the primary source
      operationId: clearRecipeSourceV2
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      responses:
        '200':
          description: Source cleared.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/sources:
    post:
      tags: [Recipes, Sources]
      summary: Attach a source
      operationId: attachRecipeSource
      description: |
        A recipe can cite more than one source — the cookbook it came from and
        the creator's post about it, say. This attaches an additional one.
        Pass `make_primary` to promote it to the headline attribution.
      parameters: [{ $ref: '#/components/parameters/RecipeId' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                provenance:
                  type: object
                  additionalProperties: true
                  description: Descriptor to resolve or create.
                source_v2_id: { type: string }
                ref:
                  type: object
                  additionalProperties: true
                  description: Location within the source.
                make_primary: { type: boolean, default: false }
      responses:
        '201':
          description: Attachment created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attachment_id: { type: string, format: uuid }
                  recipe: { $ref: '#/components/schemas/Recipe' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/recipes/{id}/sources/{attachmentId}:
    patch:
      tags: [Recipes, Sources]
      summary: Update a source attachment
      operationId: updateRecipeSourceAttachment
      parameters:
        - $ref: '#/components/parameters/RecipeId'
        - name: attachmentId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ref: { type: object, additionalProperties: true }
                make_primary: { type: boolean }
      responses:
        '200':
          description: Attachment updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Recipes, Sources]
      summary: Detach a source
      operationId: detachRecipeSource
      parameters:
        - $ref: '#/components/parameters/RecipeId'
        - name: attachmentId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Detached.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections:
    get:
      tags: [Collections]
      summary: List collections
      operationId: listCollections
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: The user's collections, including any shared with them.
          content:
            application/json:
              schema:
                type: object
                properties:
                  collections:
                    type: array
                    items: { $ref: '#/components/schemas/Collection' }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Collections]
      summary: Create a collection
      operationId: createCollection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
                tags: { type: array, items: { type: string } }
                recipes:
                  type: array
                  description: Recipe ids to seed the collection with.
                  items: { type: string, format: uuid }
            examples:
              seeded:
                value:
                  name: Weeknight dinners
                  description: 30 minutes or less
                  recipes: [9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d]
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections/{id}:
    get:
      tags: [Collections]
      summary: Get a collection
      operationId: getCollection
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The collection.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Collections]
      summary: Update a collection
      operationId: updateCollection
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                tags: { type: array, items: { type: string } }
                recipes:
                  type: array
                  description: Replaces the membership list wholesale.
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Collections]
      summary: Delete a collection
      operationId: deleteCollection
      description: Deletes the collection. The recipes in it are not deleted.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections/{id}/recipes:
    post:
      tags: [Collections]
      summary: Add a recipe to a collection
      operationId: addRecipeToCollection
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipeId]
              properties:
                recipeId: { type: string, format: uuid }
      responses:
        '200':
          description: Added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections/{id}/recipes/bulk:
    post:
      tags: [Collections]
      summary: Add several recipes to a collection
      operationId: addRecipesToCollectionBulk
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipeIds]
              properties:
                recipeIds:
                  type: array
                  minItems: 1
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: Added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections/{id}/recipes/{recipeId}:
    delete:
      tags: [Collections]
      summary: Remove a recipe from a collection
      operationId: removeRecipeFromCollection
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: recipeId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Collection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/collections/{id}/leave:
    post:
      tags: [Collections, Households]
      summary: Leave a shared collection
      operationId: leaveCollection
      description: |
        Removes the caller's access to a collection that was shared with them.
        The owner's copy is untouched. Owners cannot leave their own collection
        — delete it instead.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Left the collection.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/mealplans:
    get:
      tags: [Meal plans]
      summary: List meal plans
      operationId: listMealPlans
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: The user's meal plans.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mealplans:
                    type: array
                    items: { $ref: '#/components/schemas/MealPlan' }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Meal plans]
      summary: Create a meal plan
      operationId: createMealPlan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
                end_date: { type: string, format: date }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/mealplans/{id}:
    get:
      tags: [Meal plans]
      summary: Get a meal plan
      operationId: getMealPlan
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The meal plan, with its meals.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Meal plans]
      summary: Update a meal plan
      operationId: updateMealPlan
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                end_date: { type: string, format: date }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Meal plans]
      summary: Delete a meal plan
      operationId: deleteMealPlan
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/mealplans/{id}/meals:
    post:
      tags: [Meal plans]
      summary: Add a meal to a plan
      operationId: addMeal
      description: |
        Adds a recurring slot. `days_of_week` makes the slot repeat — `[1,3,5]`
        is Monday, Wednesday and Friday.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Meal'
                - type: object
                  required: [start_time, end_time, days_of_week, items]
            examples:
              dinner:
                value:
                  name: Dinner
                  start_time: '18:00'
                  end_time: '19:30'
                  days_of_week: [1, 3, 5]
                  items:
                    - { recipe_id: 9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d, portion: 1 }
                    - { name: green salad }
      responses:
        '201':
          description: Meal added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/mealplans/{id}/meals/{mealId}:
    put:
      tags: [Meal plans]
      summary: Update a meal
      operationId: updateMeal
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: mealId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Meal' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Meal plans]
      summary: Remove a meal
      operationId: deleteMeal
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: mealId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MealPlan' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/mealplans/{id}/export/{format}:
    get:
      tags: [Meal plans]
      summary: Export a meal plan
      operationId: exportMealPlan
      description: Renders the plan for use outside Flambe.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - name: format
          in: path
          required: true
          description: Output format.
          schema: { type: string, examples: [ics] }
      responses:
        '200':
          description: The exported plan, in the requested format.
          content:
            text/calendar:
              schema: { type: string }
            application/json:
              schema: { type: object, additionalProperties: true }
        '400':
          description: Unsupported format.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/grocery-lists:
    get:
      tags: [Grocery lists]
      summary: List grocery lists
      operationId: listGroceryLists
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: The user's grocery lists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  grocery_lists:
                    type: array
                    items: { $ref: '#/components/schemas/GroceryList' }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Grocery lists]
      summary: Create a grocery list
      operationId: createGroceryList
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/GroceryList'
                - type: object
                  required: [name]
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GroceryList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/grocery-lists/{id}:
    get:
      tags: [Grocery lists]
      summary: Get a grocery list
      operationId: getGroceryList
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The list, with its items.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GroceryList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Grocery lists]
      summary: Update a grocery list
      operationId: updateGroceryList
      description: |
        Supplying `items` replaces the whole item list — this is how a client
        persists check-offs and edits in one call.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GroceryList' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GroceryList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Grocery lists]
      summary: Delete a grocery list
      operationId: deleteGroceryList
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/grocery-lists/{id}/items:
    post:
      tags: [Grocery lists]
      summary: Append items to a grocery list
      operationId: addGroceryItems
      description: |
        Adds items without rewriting the list. Lines that match an existing item
        are merged rather than duplicated.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items: { $ref: '#/components/schemas/GroceryItem' }
            examples:
              fromRecipe:
                value:
                  items:
                    - { name: unsalted butter, quantity: 1, unit: cup }
                    - { name: flaky salt }
      responses:
        '200':
          description: Items appended.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GroceryList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/grocery-lists/{id}/leave:
    post:
      tags: [Grocery lists, Households]
      summary: Leave a shared grocery list
      operationId: leaveGroceryList
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Left the list.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/wishlists:
    get:
      tags: [Wishlists]
      summary: List wishlists
      operationId: listWishlists
      responses:
        '200':
          description: The user's wishlists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  wishlists:
                    type: array
                    items: { $ref: '#/components/schemas/Wishlist' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Wishlists]
      summary: Create a wishlist
      operationId: createWishlist
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/wishlists/{id}:
    get:
      tags: [Wishlists]
      summary: Get a wishlist
      operationId: getWishlist
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The wishlist, with its items.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Wishlists]
      summary: Update a wishlist
      operationId: updateWishlist
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Wishlists]
      summary: Delete a wishlist
      operationId: deleteWishlist
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/wishlists/{id}/items:
    post:
      tags: [Wishlists]
      summary: Add an item to a wishlist
      operationId: addWishlistItem
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WishlistItem' }
      responses:
        '200':
          description: Item added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Wishlists]
      summary: Remove an item from a wishlist
      operationId: removeWishlistItem
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [itemKey]
              properties:
                itemKey: { type: string }
      responses:
        '200':
          description: Item removed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/wishlists/default/items:
    post:
      tags: [Wishlists]
      summary: Add an item to the default wishlist
      operationId: addDefaultWishlistItem
      description: |
        Adds to the user's default wishlist, creating it if they have none. Use
        this for a one-tap "save for later" that does not make the caller pick a
        list first.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WishlistItem' }
      responses:
        '200':
          description: Item added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Wishlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/files:
    post:
      tags: [Files]
      summary: Upload files
      operationId: uploadFiles
      description: |
        Uploads one or more binary assets as `multipart/form-data` under the
        field name `files`. Images are stored, processed for width variants and
        given a BlurHash.

        This is the first half of a `media` import: upload the images here, then
        pass the returned ids as `file_ids` to `POST /api/imports`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  description: One or more files, all under the field name `files`.
                  items: { type: string, format: binary }
            encoding:
              files:
                contentType: image/jpeg, image/png, image/heic, image/webp
      responses:
        '201':
          description: Stored. Ids are in creation order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items: { $ref: '#/components/schemas/FileRecord' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Upload failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                failed:
                  value: { error: Upload failed }

  /api/files/{id}:
    get:
      tags: [Files]
      summary: Get a file
      operationId: getFile
      description: |
        Returns the asset for a file the caller can read — their own, or one
        shared through a household. Prefer the CDN `url` from the file's
        metadata for anything user-facing; it is cached at the edge.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The file.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/files/{id}/metadata:
    get:
      tags: [Files]
      summary: Get file metadata
      operationId: getFileMetadata
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Metadata for the file.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/files/metadata:
    get:
      tags: [Files]
      summary: List file metadata
      operationId: listFileMetadata
      description: Every file readable by the caller, paginated.
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of file metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/FileRecord' }
                  next_cursor: { type: [string, 'null'] }
        '400':
          description: The cursor could not be decoded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badCursor:
                  value: { error: Invalid cursor }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/files/metadata/query:
    post:
      tags: [Files]
      summary: Resolve file metadata in bulk
      operationId: queryFileMetadata
      description: |
        Resolves many file ids in one call. Ids the caller cannot read are
        omitted from the response rather than erroring, so a partial result is
        normal.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  minItems: 1
                  items: { type: string, format: uuid }
            examples:
              batch:
                value:
                  ids:
                    - 6f1c2e5a-6f0a-4c4e-9d2f-2c5a5b9e1a11
                    - 7a2d3f6b-7b1b-4d5f-8e3a-3d6b6c0f2b22
      responses:
        '200':
          description: Metadata for the readable subset of the requested ids.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/FileRecord' }
        '400':
          description: '`ids` was missing or empty.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                empty:
                  value: { error: ids must be a non-empty array of strings }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/files/metadata/stream:
    post:
      tags: [Files]
      summary: Stream file metadata
      operationId: streamFileMetadata
      description: |
        The same resolution as `/metadata/query`, delivered as newline-delimited
        JSON (`application/x-ndjson`) so a client can render the first records
        without waiting for the whole batch. One JSON object per line.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  minItems: 1
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: One JSON object per line, in no guaranteed order.
          content:
            application/x-ndjson:
              schema: { type: string }
        '400':
          description: '`ids` was missing or empty.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions:
    get:
      tags: [Cook sessions]
      summary: List cook sessions
      operationId: listCookSessions
      responses:
        '200':
          description: The user's sessions, most recent first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items: { $ref: '#/components/schemas/CookSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Cook sessions]
      summary: Start a cook session
      operationId: createCookSession
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                recipe_ids:
                  type: array
                  description: Recipes to open in the session.
                  items: { type: string, format: uuid }
      responses:
        '201':
          description: Session started.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/active:
    get:
      tags: [Cook sessions]
      summary: Get the active session
      operationId: getActiveCookSession
      description: |
        The session currently in progress, so a second device can join what is
        already being cooked. Returns `null` when nothing is active.
      responses:
        '200':
          description: The active session, or `null`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    oneOf:
                      - $ref: '#/components/schemas/CookSession'
                      - type: 'null'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/{id}:
    get:
      tags: [Cook sessions]
      summary: Get a cook session
      operationId: getCookSession
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Cook sessions]
      summary: Delete a cook session
      operationId: deleteCookSession
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/{id}/end:
    post:
      tags: [Cook sessions]
      summary: End a cook session
      operationId: endCookSession
      description: Marks the session `ended` and stamps `ended_at`.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Ended.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/{id}/restore:
    post:
      tags: [Cook sessions]
      summary: Restore an ended session
      operationId: restoreCookSession
      description: |
        Reopens a session that was ended — for when cooking was interrupted
        rather than finished.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Restored to `active`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The session is already active.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/{id}/recipes:
    post:
      tags: [Cook sessions]
      summary: Add a recipe to a session
      operationId: addRecipeToCookSession
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipe_id]
              properties:
                recipe_id: { type: string, format: uuid }
      responses:
        '200':
          description: Added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sessions/{id}/recipes/{recipeId}:
    delete:
      tags: [Cook sessions]
      summary: Remove a recipe from a session
      operationId: removeRecipeFromCookSession
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: recipeId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CookSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers:
    get:
      tags: [Timers]
      summary: List timers
      operationId: listTimers
      responses:
        '200':
          description: The user's timers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timers:
                    type: array
                    items: { $ref: '#/components/schemas/Timer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Timers]
      summary: Create a timer
      operationId: createTimer
      description: |
        Schedules a timer to fire at `dueAt`. Because the schedule lives on the
        server, it still fires if the app is backgrounded or the device
        restarts.

        Pass `idempotencyKey` to make retries safe — a second call with the same
        key returns the original timer rather than creating a duplicate.

        Note that timer fields are `camelCase`, unlike the rest of the API.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Timer'
                - type: object
                  required: [dueAt]
            examples:
              rest:
                value:
                  title: Rest the dough
                  dueAt: '2026-09-10T19:00:00.000Z'
                  recipeId: 9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d
                  idempotencyKey: rest-dough-9b8a7c6d
      responses:
        '201':
          description: Timer scheduled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '200':
          description: |
            An existing timer matched `idempotencyKey` and is returned unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers/{timerId}:
    get:
      tags: [Timers]
      summary: Get a timer
      operationId: getTimer
      parameters: [{ name: timerId, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The timer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers/{timerId}/ack:
    post:
      tags: [Timers]
      summary: Acknowledge a fired timer
      operationId: ackTimer
      description: |
        Confirms the user saw the alert, which stops further reminders for it.
      parameters: [{ name: timerId, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Acknowledged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers/{timerId}/pause:
    patch:
      tags: [Timers]
      summary: Pause a timer
      operationId: pauseTimer
      description: Freezes the countdown and records `remainingMs`.
      parameters: [{ name: timerId, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Paused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The timer is not in a pausable state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers/{timerId}/resume:
    patch:
      tags: [Timers]
      summary: Resume a paused timer
      operationId: resumeTimer
      description: |
        Restarts the countdown from `remainingMs`. Pass `dueAt` to resume to an
        explicit instant instead.
      parameters: [{ name: timerId, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                dueAt: { type: string, format: date-time }
      responses:
        '200':
          description: Resumed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The timer is not paused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/timers/{timerId}/cancel:
    patch:
      tags: [Timers]
      summary: Cancel a timer
      operationId: cancelTimer
      parameters: [{ name: timerId, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Timer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households:
    post:
      tags: [Households]
      summary: Create a household
      operationId: createHousehold
      description: The caller becomes the owner.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Household' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/me:
    get:
      tags: [Households]
      summary: List my households
      operationId: listMyHouseholds
      responses:
        '200':
          description: Households the caller belongs to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  households:
                    type: array
                    items: { $ref: '#/components/schemas/Household' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}:
    get:
      tags: [Households]
      summary: Get a household
      operationId: getHousehold
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The household, with its members.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Household' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    patch:
      tags: [Households]
      summary: Rename a household
      operationId: updateHousehold
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Household' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Households]
      summary: Delete a household
      operationId: deleteHousehold
      description: |
        Owner only. Dissolves the household and revokes every share made
        through it; members keep their own libraries.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/leave:
    post:
      tags: [Households]
      summary: Leave a household
      operationId: leaveHousehold
      description: |
        Removes the caller and revokes the resources they had shared into it.
        An owner must transfer ownership or delete the household instead.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Left.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/invites:
    post:
      tags: [Households]
      summary: Invite someone to a household
      operationId: createHouseholdInvite
      description: |
        Invite by `recipient_email` for someone who may not have an account
        yet, or by `recipient_user_id` for an existing Flambe user.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                recipient_email: { type: string, format: email }
                recipient_user_id: { type: string }
                role: { type: string, enum: [admin, member], default: member }
      responses:
        '201':
          description: Invite created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HouseholdInvite' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/invites/{inviteId}:
    delete:
      tags: [Households]
      summary: Revoke an invite
      operationId: revokeHouseholdInvite
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: inviteId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/invites/{token}/accept:
    post:
      tags: [Households]
      summary: Accept an invite
      operationId: acceptHouseholdInvite
      description: |
        Redeems an invite token and joins the caller to the household. Requires
        authentication — the invite is bound to whoever accepts it.
      parameters:
        - name: token
          in: path
          required: true
          description: The invite's `token`.
          schema: { type: string }
      responses:
        '200':
          description: Joined.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Household' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No such invite, or it was revoked or already used.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: The caller already belongs to this household.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/members/{userId}:
    delete:
      tags: [Households]
      summary: Remove a member
      operationId: removeHouseholdMember
      description: Owner or admin only.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: userId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/library:
    get:
      tags: [Households]
      summary: List the shared library
      operationId: getHouseholdLibrary
      description: Every resource shared into the household, by any member.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The shared library.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes: { type: array, items: { $ref: '#/components/schemas/Recipe' } }
                  collections: { type: array, items: { $ref: '#/components/schemas/Collection' } }
                  mealplans: { type: array, items: { $ref: '#/components/schemas/MealPlan' } }
                  grocerylists: { type: array, items: { $ref: '#/components/schemas/GroceryList' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/library/{resourceType}:
    post:
      tags: [Households]
      summary: Share a resource into a household
      operationId: grantHouseholdResource
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - name: resourceType
          in: path
          required: true
          schema: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_id]
              properties:
                resource_id: { type: string, format: uuid }
                role:
                  type: string
                  description: Access level granted to the other members.
                  enum: [viewer, editor]
                  default: viewer
      responses:
        '201':
          description: Shared.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/library/{resourceType}/bulk:
    put:
      tags: [Households]
      summary: Replace the grants for a resource type
      operationId: bulkGrantHouseholdResources
      description: |
        Sets the complete set of grants for one resource type in a single call.
        Anything the caller had shared that is absent from `grants` is revoked,
        which is what makes this safe to drive from a checkbox list.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - name: resourceType
          in: path
          required: true
          schema: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grants]
              properties:
                grants:
                  type: array
                  items:
                    type: object
                    required: [resource_id]
                    properties:
                      resource_id: { type: string, format: uuid }
                      role: { type: string, enum: [viewer, editor], default: viewer }
      responses:
        '200':
          description: Grants replaced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  granted: { type: integer }
                  revoked: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/library/{resourceType}/{resourceId}:
    delete:
      tags: [Households]
      summary: Unshare a resource
      operationId: revokeHouseholdResource
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - name: resourceType
          in: path
          required: true
          schema: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
        - { name: resourceId, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Unshared.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/library/my-resources/{resourceType}:
    get:
      tags: [Households]
      summary: List my shareable resources
      operationId: listMyHouseholdResources
      description: |
        The caller's own resources of one type, each annotated with whether it
        is currently shared into this household — the data behind a share
        picker.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - name: resourceType
          in: path
          required: true
          schema: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
      responses:
        '200':
          description: The caller's resources and their share state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        resource_id: { type: string, format: uuid }
                        title: { type: string }
                        shared: { type: boolean }
                        role: { type: string, enum: [viewer, editor] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/households/{id}/sharing/preferences:
    get:
      tags: [Households]
      summary: Get sharing preferences
      operationId: getHouseholdSharingPreferences
      description: |
        The caller's defaults for this household — for example, whether newly
        created recipes are shared automatically.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: The preferences.
          content:
            application/json:
              schema:
                type: object
                properties:
                  preferences: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Households]
      summary: Set sharing preferences
      operationId: setHouseholdSharingPreferences
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [preferences]
              properties:
                preferences: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  preferences: { type: object, additionalProperties: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/share-links:
    post:
      tags: [Sharing]
      summary: Create a share link
      operationId: createShareLink
      description: |
        Mints an unlisted public URL for one resource. Anyone with the link can
        view it without signing in, so treat the token as a secret. Share links
        are deliberately excluded from `sitemap.xml` and disallowed in
        `robots.txt`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_type, resource_id]
              properties:
                resource_type: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
                resource_id: { type: string, format: uuid }
      responses:
        '201':
          description: Link created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ShareLink' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/share-links/{id}:
    delete:
      tags: [Sharing]
      summary: Revoke a share link
      operationId: revokeShareLink
      description: |
        Revokes the link immediately. The URL stops resolving; any page already
        open keeps whatever it has already loaded.
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Revoked.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/shared/{token}:
    get:
      tags: [Sharing]
      summary: Resolve a share link
      operationId: getSharedResource
      description: |
        Resolves a share token to the resource it points at. **Unauthenticated**
        — this is what renders `https://flambe.dev/share/{token}` for a
        recipient who has no Flambe account.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The shared resource.
          content:
            application/json:
              schema:
                type: object
                properties:
                  resource_type: { type: string, enum: [recipe, collection, mealplan, grocerylist] }
                  resource:
                    oneOf:
                      - $ref: '#/components/schemas/Recipe'
                      - $ref: '#/components/schemas/Collection'
                      - $ref: '#/components/schemas/MealPlan'
                      - $ref: '#/components/schemas/GroceryList'
        '404':
          description: Unknown or revoked token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/shared/{token}/save:
    post:
      tags: [Sharing]
      summary: Save a shared resource
      operationId: saveSharedResource
      description: |
        Copies a shared resource into the caller's own library. Unlike
        resolving the link, this does require authentication.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '201':
          description: Saved; the new copy is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  resource_type: { type: string }
                  resource: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Unknown or revoked token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sources:
    get:
      tags: [Sources]
      summary: List sources (v1)
      operationId: listSources
      deprecated: true
      description: The v1 provenance model. Prefer `/api/v2/sources`.
      responses:
        '200':
          description: Sources referenced by the caller's recipes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sources: { type: array, items: { type: object, additionalProperties: true } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/sources/{id}/recipes:
    get:
      tags: [Sources]
      summary: List a source's recipes (v1)
      operationId: listSourceRecipes
      deprecated: true
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Recipes attributed to the source.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes: { type: array, items: { $ref: '#/components/schemas/Recipe' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/sources:
    get:
      tags: [Sources]
      summary: List sources
      operationId: listSourcesV2
      description: Sources referenced by recipes the caller can see.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Sources.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sources: { type: array, items: { $ref: '#/components/schemas/SourceV2' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/sources/{id}:
    get:
      tags: [Sources]
      summary: Get a source
      operationId: getSourceV2
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: The source.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SourceV2' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Sources]
      summary: Update a source
      operationId: updateSourceV2
      description: |
        Sources are shared across users, so corrections here — a fixed title, a
        better cover — are visible to everyone citing the same source.
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
                name: { type: string }
                authors: { type: array, items: { type: string } }
                kind: { type: string, enum: [book, website, social, handwritten, other] }
                platform: { type: string }
                coverUrl: { type: string, format: uri }
                coverKey: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SourceV2' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/sources/{id}/recipes:
    get:
      tags: [Sources]
      summary: List a source's recipes
      operationId: listSourceV2Recipes
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Recipes attributed to the source.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes: { type: array, items: { $ref: '#/components/schemas/Recipe' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/creators:
    get:
      tags: [Creators]
      summary: List creators
      operationId: listCreators
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Creators.
          content:
            application/json:
              schema:
                type: object
                properties:
                  creators: { type: array, items: { $ref: '#/components/schemas/Creator' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/creators/{id}:
    get:
      tags: [Creators]
      summary: Get a creator
      operationId: getCreator
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: The creator.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Creator' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    patch:
      tags: [Creators]
      summary: Rename a creator
      operationId: updateCreator
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [display_name]
              properties:
                display_name: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Creator' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/creators/{id}/profiles:
    get:
      tags: [Creators]
      summary: List a creator's profiles
      operationId: listCreatorProfiles
      description: The platform accounts belonging to one creator.
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Profiles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles: { type: array, items: { $ref: '#/components/schemas/CreatorProfile' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/creator-profiles:
    get:
      tags: [Creators]
      summary: List creator profiles
      operationId: listAllCreatorProfiles
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Profiles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles: { type: array, items: { $ref: '#/components/schemas/CreatorProfile' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/creator-profiles/{profile_key}:
    get:
      tags: [Creators]
      summary: Get a creator profile
      operationId: getCreatorProfile
      parameters:
        - name: profile_key
          in: path
          required: true
          description: '`{platform}:{handle}`, URL-encoded.'
          schema: { type: string, examples: ['instagram:saminnosrat'] }
      responses:
        '200':
          description: The profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatorProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/verified-recipes:
    get:
      tags: [Sources]
      summary: List verified recipes
      operationId: listVerifiedRecipes
      description: |
        Recipes whose extraction has been checked against the original source.
        Useful as a trusted corpus.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Verified recipes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipes: { type: array, items: { $ref: '#/components/schemas/Recipe' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/verified-recipes/{id}:
    get:
      tags: [Sources]
      summary: Get a verified recipe
      operationId: getVerifiedRecipe
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: The verified recipe.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Recipe' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/notifications:
    get:
      tags: [Notifications]
      summary: List notifications
      operationId: listNotifications
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Notifications, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notifications: { type: array, items: { $ref: '#/components/schemas/Notification' } }
                  next_cursor: { type: [string, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Notifications]
      summary: Create a notification
      operationId: createNotification
      description: |
        Writes a notification to the caller's own feed. Most notifications are
        produced by the platform — import completions and household invites —
        so this is mainly for a client that wants to surface something locally.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                subtitle: { type: string }
                link: { type: string }
                type: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Notification' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/notifications/unread-count:
    get:
      tags: [Notifications]
      summary: Get the unread count
      operationId: getUnreadNotificationCount
      description: Cheap enough to poll for a badge.
      responses:
        '200':
          description: The count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/notifications/{id}/read:
    patch:
      tags: [Notifications]
      summary: Mark a notification read
      operationId: markNotificationRead
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Marked read.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Notification' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/notifications/{id}/unread:
    patch:
      tags: [Notifications]
      summary: Mark a notification unread
      operationId: markNotificationUnread
      parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Marked unread.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Notification' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/notifications/mark-all-read:
    post:
      tags: [Notifications]
      summary: Mark all notifications read
      operationId: markAllNotificationsRead
      description: |
        Pass `before` to only clear notifications older than that instant, which
        avoids swallowing something that arrived while the user was reading.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                before: { type: string, format: date-time }
      responses:
        '200':
          description: Cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/push-tokens:
    post:
      tags: [Push tokens]
      summary: Register a device for push
      operationId: registerPushToken
      description: |
        Registering the same `token` again updates the existing record rather
        than creating a duplicate, so this is safe to call on every launch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/PushToken'
                - type: object
                  required: [token]
      responses:
        '201':
          description: Registered.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PushToken' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/push-tokens/me:
    get:
      tags: [Push tokens]
      summary: List my registered devices
      operationId: listMyPushTokens
      responses:
        '200':
          description: The caller's registered devices.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens: { type: array, items: { $ref: '#/components/schemas/PushToken' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/push-tokens/{token}:
    delete:
      tags: [Push tokens]
      summary: Unregister a device
      operationId: deletePushToken
      description: Call this on sign-out so the device stops receiving pushes.
      parameters:
        - name: token
          in: path
          required: true
          description: The device token, URL-encoded.
          schema: { type: string }
      responses:
        '200':
          description: Unregistered.
          content:
            application/json:
              schema: { type: object, properties: { success: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/books/search:
    get:
      tags: [Books]
      summary: Search cookbooks
      operationId: searchBooks
      description: |
        Looks up cookbooks by title, for attributing a recipe to a book. Backed
        by an external bibliographic catalogue.
      parameters:
        - name: q
          in: query
          required: true
          description: Search text.
          schema: { type: string, examples: [salt fat acid heat] }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 20, default: 8 }
      responses:
        '200':
          description: Matching books.
          content:
            application/json:
              schema:
                type: object
                properties:
                  books:
                    type: array
                    items:
                      type: object
                      properties:
                        title: { type: string }
                        authors: { type: array, items: { type: string } }
                        published_year: { type: integer }
                        cover_url: { type: string, format: uri }
                        isbn: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/books/by-author:
    get:
      tags: [Books]
      summary: List cookbooks by author
      operationId: listBooksByAuthor
      parameters:
        - name: name
          in: query
          required: true
          description: Author name.
          schema: { type: string, examples: [Samin Nosrat] }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 40, default: 20 }
      responses:
        '200':
          description: Books by that author.
          content:
            application/json:
              schema:
                type: object
                properties:
                  books: { type: array, items: { type: object, additionalProperties: true } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/ai/embed-query:
    post:
      tags: [AI]
      summary: Embed a search query
      operationId: embedQuery
      description: |
        Returns the embedding vector for a search string, using the same model
        that indexed the recipes. Use it to run your own semantic search over
        recipes you have synced locally — embedding the query client-side with a
        different model would not produce comparable distances.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, examples: [something warm with lentils] }
      responses:
        '200':
          description: The embedding.
          content:
            application/json:
              schema:
                type: object
                properties:
                  embedding:
                    type: array
                    items: { type: number }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/ai/session:
    post:
      tags: [AI]
      summary: Create a realtime assistant session
      operationId: createAiSession
      description: |
        Mints a short-lived client secret for the hands-free cooking assistant,
        so a client can open a realtime connection without ever holding a
        long-lived provider key. Scope it to a recipe with `recipeId`.

        The secret expires quickly — request one per session, at the moment the
        user starts talking, rather than caching it.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                recipeId:
                  type: string
                  format: uuid
                  description: Ground the assistant in this recipe.
                expertise:
                  type: string
                  description: How much cooking knowledge to assume.
      responses:
        '200':
          description: An ephemeral client secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  clientSecret: { type: string }
                  expiresAt:
                    type: integer
                    description: Unix seconds at which the secret stops working.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502':
          description: The upstream assistant provider refused to create a session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                upstream:
                  value: { error: Failed to create AI session }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/users/me:
    get:
      tags: [Users]
      summary: Get the current user
      operationId: getCurrentUser
      description: |
        Resolves the bearer token to a profile. The cheapest way to check that a
        token is valid.
      responses:
        '200':
          description: The authenticated user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/events/stream:
    get:
      tags: [Events]
      summary: Subscribe to live events
      operationId: streamEvents
      description: |
        A [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events)
        stream of changes to the caller's data — most usefully, import progress
        and completion, which removes the need to poll `GET /api/imports/{id}`.

        The connection is authenticated with the usual `Authorization: Bearer`
        header. Note that the browser's built-in `EventSource` cannot set
        headers, so on the web you need a fetch-based SSE client.

        The server sends a comment line every few seconds as a keep-alive;
        ignore lines beginning with `:`.
      responses:
        '200':
          description: An open event stream.
          content:
            text/event-stream:
              schema: { type: string }
              examples:
                importCompleted:
                  summary: An import finishing
                  value: |
                    : connected

                    event: import.updated
                    data: {"id":"4d1f8c2e","status":"processing"}

                    event: import.completed
                    data: {"id":"4d1f8c2e","recipe_id":"9b8a7c6d"}
        '401':
          description: |
            Missing or invalid bearer token. The response has an empty body.
        '500': { $ref: '#/components/responses/ServerError' }

  /api/auth/register:
    post:
      tags: [Auth]
      summary: Register (legacy)
      operationId: register
      deprecated: true
      description: |
        Legacy username/password registration, retained for older clients.
        Authentication is handled by Clerk now — see the
        [Authentication guide](https://docs.flambe.dev/docs/guides/authentication).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, email, password]
              properties:
                username: { type: string }
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        '201':
          description: Registered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  user: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: The username or email is already taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/auth/login:
    post:
      tags: [Auth]
      summary: Log in (legacy)
      operationId: login
      deprecated: true
      description: |
        Legacy username/password login, retained for older clients. Use Clerk
        instead.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string, format: password }
      responses:
        '200':
          description: Logged in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  user: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Bad credentials.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }
