openapi: 3.0.3
info:
  title: Rebrand Stack — Public API (core)
  version: 0.1.0
  summary: Identity, organizations, wallet, branding. The cross-cutting hub layer every spoke consumes.
  description: |
    This is the public-API catalog for `rs-core` — the central hub of the
    Rebrand Stack platform. It covers identity (`/v1/me`, `/v1/partner-by-host`),
    organizations (`/v1/orgs/*`), the token wallet (`/v1/wallet/*`), and the
    branding cascade resolver (`/v1/branding`).

    **Authentication.** Two schemes work today:

      - **Personal Access Token** (`Authorization: Bearer amcpat_…`). Scoped,
        revocable, issued from `/app/settings/api-tokens`. The intended
        auth for chatbot integrations (ChatGPT custom GPTs, Claude / Claude
        Code via MCP) and personal scripts. _Status: PAT issuance ships in
        Phase 10 of the spec; until then use a service key as a stopgap._
      - **Service key** (`Authorization: Bearer amcsk_…`). Admin-issued,
        product-scoped, no per-user identity. For trusted server-side
        callers only.

    A few endpoints accept either auth scheme; per-operation `security`
    blocks below say which.

    **Wallet-charging endpoints** support two safety affordances callers
    SHOULD use:
      - `?dry_run=true` returns the would-be reservation receipt without
        writing to the ledger. AI agents should call this first, surface
        the cost to the user, and only commit on confirmation.
      - `confirmed: true` in the request body is required on any operation
        whose base cost ≥ 10 tokens. Refuses without it. This forces a
        round-trip with the chatbot before expensive ops.

    _Status: `dry_run` + `confirmed` are described in_
    [docs/public-api-audit.md](https://github.com/wholehogsoftware/rs-core/blob/main/docs/public-api-audit.md)
    _and land alongside the PAT work; today the wallet middleware accepts
    them without harm but doesn't enforce the confirmation gate yet._

    **Idempotency.** Every mutating POST accepts an `Idempotency-Key`
    header. Replays return the original receipt. _Status: today only
    `/v1/wallet/reserve` reads it (and only from the JSON body, not the
    header); commit/refund and the rest are no-ops on retry. This is on
    the Phase 10 punch list._

    **Source of truth:** the route handlers under
    [apps/core/src/app/v1/](https://github.com/wholehogsoftware/rs-core/tree/main/apps/core/src/app/v1).
    Where this spec drifts from the code, file an issue — the code wins.

  contact:
    name: Rebrand Stack
    url: https://rebrandstack.com
  license:
    name: Proprietary
servers:
  - url: https://core.rebrandstack.com
    description: Production
  - url: https://core.{partnerDomain}
    description: Partner-branded production (any partner with a root_domain)
    variables:
      partnerDomain:
        default: rebrandstack.com
        description: The partner's root domain (e.g. doctorrebrand.com)
  - url: http://localhost:3000
    description: Local dev

# ---------- Top-level reusable bits ----------

tags:
  - name: Identity
    description: Who the caller is, and which partner they belong to.
  - name: Organizations
    description: Multi-tenancy under partners. Every charge happens in the context of an org.
  - name: Wallet
    description: Token reservations + commits + refunds + balance + ledger.
  - name: Branding
    description: Brand cascade resolver (partner → org → team → agent).
  - name: Content
    description: Long-form articles (Houseopedia today, more sources later). Partner-scoped reads.
  - name: Newsletters
    description: Subscriber lists, templates, campaigns, send. Wallet-charging on send.
  - name: Recruiting
    description: MLS-based agent search + unlock + lists. Wallet-charging on unlock.
  - name: Video
    description: Video job catalog + area lookup. Render endpoint is internal today; will join when the public handoff promoter ships.
  - name: Websites
    description: Multi-tenant sites + custom domains. Wallet-charging on publish + custom-domain attach.
  - name: Admin
    description: Super-admin / partner-admin surfaces. Require `admin:super` or `admin:partner` PAT scope.
  - name: PATs
    description: Personal Access Token management. Browser-only (JWT users); PATs cannot mint other PATs.

security:
  # Default for every endpoint unless overridden. PAT is the intended
  # chatbot/script path; service-key is the trusted-server fallback.
  - PersonalAccessToken: []
  - ServiceKey: []

# ============================================================================
# Paths
# ============================================================================

paths:
  # ---------- Identity ------------------------------------------------------

  /v1/me:
    get:
      operationId: getMe
      tags: [Identity]
      summary: Who is the caller?
      description: |
        Returns the authenticated user's identity, super-admin flag,
        partner-admin flag, and the partner record they belong to.
        Required for chatbots that need to render personalized prompts
        ("Hi {me.email}, your active org is {…}").

        Requires a user-bound principal (PAT or user JWT). Service keys
        get `400 user_only`.
      security:
        - PersonalAccessToken: [identity:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
              example:
                id: 4f1b1a3c-2c8f-4b2a-9c1c-2e8d3a3d5d7a
                email: alice@example.com
                isSuperAdmin: false
                isPartnerAdmin: true
                partner:
                  id: 1c726b4d-522c-48f3-8623-efe882c56731
                  slug: amc
                  name: Rebrand Stack
                  rootDomain: rebrandstack.com
                  supportEmail: help@rebrandstack.com
                  noReplyEmail: no-reply@rebrandstack.com
                  logoUrl: null
                  brand: {}
                  industry: real-estate
                  enabledSpokes: [core, video, photos, content, newsletters, websites, recruiting, leads]
                  status: active
        '400': { $ref: '#/components/responses/UserOnly' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Agent row missing (auth.users row exists but agents row was deleted; corrupt state)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/partner-by-host:
    get:
      operationId: getPartnerByHost
      tags: [Identity]
      summary: Resolve partner by host (anonymous).
      description: |
        Public, unauthenticated. The sign-in form calls this so it knows
        which partner the user is signing into, so it can encode the
        partner-prefixed email before calling Supabase's signInWithOtp.

        Returns only the fields safe to expose pre-auth: id, slug, name,
        rootDomain, brand. No member counts, no enabled_spokes (those
        live behind `/v1/me`).
      security: []  # anonymous
      parameters:
        - in: query
          name: host
          required: true
          schema: { type: string }
          example: core.doctorrebrand.com
      responses:
        '200':
          description: OK — partner=null when host doesn't match any partner's root_domain
          content:
            application/json:
              schema:
                type: object
                properties:
                  partner:
                    nullable: true
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      slug: { type: string, example: doctor-rebrand }
                      name: { type: string, example: Doctor Rebrand }
                      rootDomain: { type: string, example: doctorrebrand.com }
                      brand: { type: object, additionalProperties: true }
                    required: [id, slug, name, rootDomain, brand]
                required: [partner]

  # ---------- Branding ------------------------------------------------------

  /v1/branding:
    get:
      operationId: getBranding
      tags: [Branding]
      summary: Effective brand for an agent (cascade resolver).
      description: |
        Resolves the agent's effective brand by merging four layers in
        order: partner → org → team → agent (last-wins). Spokes call this
        when rendering anything user-facing — emails, video composes,
        site headers, newsletter footers, etc.

        Auth: user PAT/JWT resolves the caller's own brand (`agentId`
        param ignored). A service key may resolve any agent's brand
        (`agentId` required).
      security:
        - PersonalAccessToken: [branding:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: agentId
          schema: { type: string, format: uuid }
          description: Required when using a service key; ignored for user tokens.
        - in: query
          name: orgId
          schema: { type: string, format: uuid }
          description: Pin the cascade to a specific org. Defaults to the agent's first membership.
        - in: query
          name: teamId
          schema: { type: string, format: uuid }
          description: Merge the team's brand layer on top of org's.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Brand' }
              example:
                agentName: Alice Owens
                headshotUrl: https://cdn.example.com/alice.jpg
                brokerage: Owens Realty
                website: https://owensrealty.com
                logoUrl: https://cdn.example.com/logo.png
                primaryColor: "#0a3d62"
                voiceGuidelines: "Warm, concise."
        '400': { $ref: '#/components/responses/MissingAgent' }
        '404': { $ref: '#/components/responses/NoAgent' }

  # ---------- Organizations -------------------------------------------------

  /v1/orgs/active:
    get:
      operationId: getActiveOrg
      tags: [Organizations]
      summary: Read the caller's active org.
      description: |
        Returns the org the user is currently acting in (cookie-derived,
        falls back to their first membership). For wallet-charging ops
        the caller should always include an explicit `orgId`; this
        endpoint just tells UI/chatbots what to default it to.
      security:
        - PersonalAccessToken: [orgs:read]
      responses:
        '200':
          description: OK (or `null` if the user has no memberships)
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: '#/components/schemas/OrgMembership' }
                  - { type: 'null' }
        '400': { $ref: '#/components/responses/UserOnly' }
    post:
      operationId: setActiveOrg
      tags: [Organizations]
      summary: Set the caller's active org.
      description: |
        Verifies membership, then writes the cookie scoped to the
        partner's parent domain so the choice is shared across product
        subdomains for the partner currently being served.
      security:
        - PersonalAccessToken: [orgs:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orgId]
              properties:
                orgId: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  orgId: { type: string, format: uuid }
        '400': { description: "missing_org or user_only" }
        '403': { description: "User isn't a member of `orgId`" }

  /v1/orgs/memberships:
    get:
      operationId: listMemberships
      tags: [Organizations]
      summary: Orgs the caller belongs to.
      description: |
        Returns every org the user can act in — via `org_members` or via
        a team membership. Owner rows beat team-reached rows; results are
        in stable join order (oldest first).
      security:
        - PersonalAccessToken: [orgs:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OrgMembership' }

  /v1/orgs/for-agent:
    get:
      operationId: getDefaultOrgForAgent
      tags: [Organizations]
      summary: An agent's default billing org (first membership).
      description: |
        Used by spokes that have only an `agent_id` and need to know
        which org to bill — e.g. amc-video's scheduled job runner.

        Auth: user tokens may only call for themselves (`agentId` is
        forced to the caller). Service keys may resolve any agent
        (`agentId` required).
      security:
        - PersonalAccessToken: [orgs:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: agentId
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK (`null` when the agent has no memberships — not a 404)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      orgId: { type: string, format: uuid }
                      orgName: { type: string }
                      planKey: { type: string, nullable: true }
                      role: { type: string, enum: [owner, member] }
                  - { type: 'null' }

  /v1/orgs/plan:
    get:
      operationId: getOrgPlan
      tags: [Organizations]
      summary: An org's current plan key.
      description: |
        Used by spokes to gate plan-tier features (e.g. amc-video's
        Enterprise-only BYO-key flow). Returns just `{ planKey }`.
      security:
        - PersonalAccessToken: [orgs:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: orgId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  planKey: { type: string, nullable: true, example: enterprise }
        '404': { description: "Organization not found" }

  /v1/orgs/{id}/owners:
    post:
      operationId: promoteToOwner
      tags: [Organizations]
      summary: Promote an existing member to owner.
      description: |
        Idempotent: promoting an existing owner is a no-op 200 with
        `changed: false`. The target must already be a member of the
        org — invite them first via the team UI; this route only
        changes role, never creates rows.
      security:
        - PersonalAccessToken: [orgs:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId]
              properties:
                userId: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  role: { type: string, enum: [owner] }
                  changed: { type: boolean }
        '403': { description: "Caller is not an owner of `id`" }
        '404': { description: "Target user is not a member of `id` — invite them first" }

  /v1/orgs/{id}/owners/{userId}:
    delete:
      operationId: demoteFromOwner
      tags: [Organizations]
      summary: Demote an owner to plain member.
      description: |
        Refuses with 409 if `userId` is the only remaining owner (the DB
        trigger `org_members_block_last_owner` from migration 0043
        prevents leaving an org ownerless).
      security:
        - PersonalAccessToken: [orgs:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: userId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  role: { type: string, enum: [creator, owner] }
                  changed: { type: boolean }
        '403': { description: "Caller is not an owner of `id`" }
        '404': { description: "Target user is not a member of `id`" }
        '409': { description: "last_owner — cannot demote the only remaining owner" }

  # ---------- Wallet --------------------------------------------------------

  /v1/wallet/balance:
    get:
      operationId: getWalletBalance
      tags: [Wallet]
      summary: Org token balance.
      security:
        - PersonalAccessToken: [wallet:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: orgId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Balance' }
              example: { plan: 250, topup: 1200, total: 1450 }
        '400': { description: "missing_org" }
        '403': { description: "User is not a member of `orgId`" }

  /v1/wallet/ledger:
    get:
      operationId: getWalletLedger
      tags: [Wallet]
      summary: Org token ledger (append-only history).
      description: |
        Returns the most-recent ledger entries (default 50, max 500).
        Useful for chatbots that want to answer "where did my tokens
        go?" questions.
      security:
        - PersonalAccessToken: [wallet:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: orgId
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LedgerEntry' }

  /v1/wallet/reserve:
    post:
      operationId: walletReserve
      tags: [Wallet]
      summary: Reserve tokens for an upcoming operation.
      description: |
        Two-step wallet pattern: `reserve` holds tokens against the
        balance, `commit` deducts them on success, `refund` returns them
        on failure. Idempotent on `idempotencyKey` (or
        `Idempotency-Key` header — planned).

        Two body shapes:
          - **Single op:** `{ operationKey, count? }` (count defaults to 1)
          - **Composite:** `{ lineItems: [{operationKey, count}, …] }` —
            a job that combines a base op + N modifier ops. The wallet
            sums per-op costs server-side from the operations catalog.

        **Safety: `?dry_run=true`** returns the would-be receipt without
        writing the ledger. The chatbot should call this first, surface
        the cost, only retry without `dry_run` after the user confirms.

        **Safety: `confirmed: true`** in the body is required on any
        operation whose base cost ≥ 10 tokens. Without it, expensive
        ops fail closed.

        _Status: dry_run + confirmed are documented; enforcement lands
        with Phase 10. Today reserve accepts them without harm but does
        not block on `confirmed`._
      security:
        - PersonalAccessToken: [wallet:write]
        - ServiceKey: []
      parameters:
        - in: query
          name: dry_run
          schema: { type: boolean, default: false }
          description: When true, returns the would-be receipt without writing the ledger.
        - in: header
          name: Idempotency-Key
          schema: { type: string }
          description: Replay-safe key. Same `(orgId, key)` returns the prior receipt untouched.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReserveBody' }
            examples:
              single:
                summary: Single-op reservation (typical case)
                value:
                  orgId: 4f1b1a3c-2c8f-4b2a-9c1c-2e8d3a3d5d7a
                  productKey: recruiting
                  operationKey: unlock
                  count: 1
                  externalRef: recruiting:unlock:AGENT-12345
                  idempotencyKey: recruiting-unlock-4f1b-AGENT-12345
                  confirmed: true
              composite:
                summary: Composite job (base + modifiers)
                value:
                  orgId: 4f1b1a3c-2c8f-4b2a-9c1c-2e8d3a3d5d7a
                  productKey: video
                  lineItems:
                    - { operationKey: render.stock_avatar, count: 1 }
                    - { operationKey: mod.burned_captions, count: 1 }
                  externalRef: video:job:abc123
                  confirmed: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReserveResult' }
              example:
                reservationId: 9b3c1f2e-7d8a-4b5c-9e1f-2a3b4c5d6e7f
                tokens: 10
                balanceAfter: { plan: 250, topup: 1190, total: 1440 }
        '400': { description: "invalid_request — see issues[] for Zod details" }
        '402': { description: "insufficient_balance" }
        '403': { description: "wrong_product — service key trying to spend for another product" }

  /v1/wallet/commit:
    post:
      operationId: walletCommit
      tags: [Wallet]
      summary: Commit a prior reservation (debit the wallet).
      security:
        - PersonalAccessToken: [wallet:write]
        - ServiceKey: []
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orgId, reservationId]
              properties:
                orgId: { type: string, format: uuid }
                reservationId: { type: string, format: uuid }
      responses:
        '200':
          description: OK — returns the post-commit balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  balanceAfter: { $ref: '#/components/schemas/Balance' }
        '404': { description: "Reservation not found or doesn't belong to `orgId`" }
        '409': { description: "Reservation already committed or refunded" }

  /v1/wallet/refund:
    post:
      operationId: walletRefund
      tags: [Wallet]
      summary: Refund a prior reservation (release the hold).
      description: |
        Same shape as commit; flips the reservation state to `refunded`
        and returns tokens to the bucket they were drawn from.
      security:
        - PersonalAccessToken: [wallet:write]
        - ServiceKey: []
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orgId, reservationId]
              properties:
                orgId: { type: string, format: uuid }
                reservationId: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  balanceAfter: { $ref: '#/components/schemas/Balance' }
        '404': { description: "Reservation not found" }
        '409': { description: "Reservation already committed or refunded" }

  # ---------- Content ------------------------------------------------------

  /api/v1/content/articles:
    get:
      operationId: listContentArticles
      tags: [Content]
      summary: List articles available to the caller's partner.
      description: |
        Partner-scoped — articles are tagged by partner_id (migration
        0047). User PATs see their own partner's articles; service keys
        must pass `?partnerId=`.
      security:
        - PersonalAccessToken: [content:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
        - in: query
          name: status
          schema:
            type: string
            enum: [queued, fetched, failed, skipped]
            default: fetched
        - in: query
          name: partnerId
          schema: { type: string, format: uuid }
          description: Required when using a service key; ignored with user PAT.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  articles:
                    type: array
                    items: { $ref: '#/components/schemas/ArticleSummary' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        '400': { description: "missing_partner — service key didn't pass partnerId" }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/content/articles/{id}:
    get:
      operationId: getContentArticle
      tags: [Content]
      summary: Single article (partner-scoped).
      description: |
        Cross-partner reads return 404 — we don't leak whether the id
        exists in some other partner's catalog.
      security:
        - PersonalAccessToken: [content:read]
        - ServiceKey: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: partnerId
          schema: { type: string, format: uuid }
          description: Required when using a service key.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '404':
          description: Not in the caller's partner catalog (or doesn't exist).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ---------- Newsletters --------------------------------------------------

  /api/v1/newsletter/campaigns:
    get:
      operationId: listNewsletterCampaigns
      tags: [Newsletters]
      summary: List the org's newsletter campaigns.
      security:
        - PersonalAccessToken: [newsletters:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/NewsletterCampaign' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createNewsletterCampaign
      tags: [Newsletters]
      summary: Create a draft campaign.
      security:
        - PersonalAccessToken: [newsletters:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewsletterCampaignDraft' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterCampaign' }

  /api/v1/newsletter/campaigns/{id}/send:
    post:
      operationId: sendNewsletterCampaign
      tags: [Newsletters]
      summary: Send a draft campaign (wallet-charging).
      description: |
        Approval-gate-aware: large lists (>5000 recipients) or
        unverified orgs (first 2 sends, high bounce/complaint rates)
        route to pending_platform_approval and return 202.

        Wallet charges `newsletters.email` × `recipient_count`. Honor
        `?dry_run=true` to surface the cost without sending. For lists
        >500 recipients the request body must include `confirmed: true`
        per the spend-safety design.

        Suppressed addresses (newsletter.suppressions, platform.suppressions)
        are filtered server-side before reservation — the receipt
        reflects only addresses that will actually receive.
      security:
        - PersonalAccessToken: [newsletters:send, wallet:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: dry_run
          schema: { type: boolean, default: false }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                confirmed: { type: boolean }
      responses:
        '200':
          description: Sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterSendResult' }
        '202':
          description: Routed to platform approval (large list / abuse-gate trigger)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending_platform_approval] }
                  reasons:
                    type: array
                    items: { type: string }
                  recipient_count: { type: integer }
        '402': { description: "insufficient_tokens" }
        '409': { description: "bad_status / org_not_active / missing_mailing_address / missing_confirmed" }

  /api/v1/newsletter/lists:
    get:
      operationId: listNewsletterLists
      tags: [Newsletters]
      summary: List the org's subscriber lists.
      security:
        - PersonalAccessToken: [newsletters:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/NewsletterList' }
    post:
      operationId: createNewsletterList
      tags: [Newsletters]
      summary: Create a subscriber list.
      security:
        - PersonalAccessToken: [newsletters:write]
      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/NewsletterList' }

  /api/v1/newsletter/lists/{id}:
    get:
      operationId: getNewsletterList
      tags: [Newsletters]
      summary: Read list metadata.
      security:
        - PersonalAccessToken: [newsletters:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterList' }
        '404': { description: "Not found" }
    patch:
      operationId: updateNewsletterList
      tags: [Newsletters]
      summary: Update list metadata.
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          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: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterList' }

  /api/v1/newsletter/lists/{id}/subscribers:
    post:
      operationId: addSubscribersToList
      tags: [Newsletters]
      summary: Bulk add subscribers to a list.
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subscriberIds]
              properties:
                subscriberIds:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  added: { type: integer }
                  skipped: { type: integer, description: Already-on-list count }
    delete:
      operationId: removeSubscribersFromList
      tags: [Newsletters]
      summary: Bulk remove subscribers from a list.
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subscriberIds]
              properties:
                subscriberIds:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: OK

  /api/v1/newsletter/subscribers:
    get:
      operationId: listNewsletterSubscribers
      tags: [Newsletters]
      summary: List the org's subscribers (paginated).
      security:
        - PersonalAccessToken: [newsletters:read]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
        - in: query
          name: status
          schema:
            type: string
            enum: [active, bounced, complained, unsubscribed]
            description: Defaults to active when omitted.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscribers:
                    type: array
                    items: { $ref: '#/components/schemas/NewsletterSubscriber' }
                  total: { type: integer }
    post:
      operationId: createNewsletterSubscriber
      tags: [Newsletters]
      summary: Create a single subscriber.
      description: |
        Honors the per-org subscriber cap. 409 with `cap_exceeded` when
        the org would exceed `newsletter_subscriber_cap`.
      security:
        - PersonalAccessToken: [newsletters:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewsletterSubscriberDraft' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterSubscriber' }
        '409': { description: "cap_exceeded — org's subscriber cap" }

  /api/v1/newsletter/subscribers/{id}:
    get:
      operationId: getNewsletterSubscriber
      tags: [Newsletters]
      summary: Read a subscriber.
      security:
        - PersonalAccessToken: [newsletters:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterSubscriber' }
        '404': { description: "Not found" }
    patch:
      operationId: updateNewsletterSubscriber
      tags: [Newsletters]
      summary: Update subscriber fields.
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                status: { type: string, enum: [active, unsubscribed] }
                custom_fields: { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterSubscriber' }
    delete:
      operationId: deleteNewsletterSubscriber
      tags: [Newsletters]
      summary: Hard-delete a subscriber.
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: "Deleted" }

  /api/v1/newsletter/subscribers/import:
    post:
      operationId: importNewsletterSubscribers
      tags: [Newsletters]
      summary: Bulk CSV import.
      description: |
        Synchronous up to 5,000 rows; larger imports return 202 with a
        job_id once the BullMQ fan-out lands (see Phase 3+ on the
        newsletters audit). Auto-triggers double opt-in for imports
        >500 rows.

        Suppression-aware: rows in `newsletter.suppressions` or
        `platform.suppressions` are skipped with `reason: suppressed`.
      security:
        - PersonalAccessToken: [newsletters:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listId, rows]
              properties:
                listId: { type: string, format: uuid }
                rows:
                  type: array
                  maxItems: 5000
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string, format: email }
                      first_name: { type: string }
                      last_name: { type: string }
                      custom_fields: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Imported (sync path)
          content:
            application/json:
              schema:
                type: object
                properties:
                  imported: { type: integer }
                  skipped:
                    type: array
                    items:
                      type: object
                      properties:
                        email: { type: string }
                        reason: { type: string, enum: [suppressed, duplicate, invalid] }

  /api/v1/newsletter/templates:
    get:
      operationId: listNewsletterTemplates
      tags: [Newsletters]
      summary: List the org's templates.
      security:
        - PersonalAccessToken: [newsletters:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/NewsletterTemplate' }
    post:
      operationId: createNewsletterTemplate
      tags: [Newsletters]
      summary: Create a template.
      security:
        - PersonalAccessToken: [newsletters:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewsletterTemplateDraft' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterTemplate' }

  /api/v1/newsletter/templates/{id}:
    get:
      operationId: getNewsletterTemplate
      tags: [Newsletters]
      summary: Read a template.
      security:
        - PersonalAccessToken: [newsletters:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterTemplate' }
    patch:
      operationId: updateNewsletterTemplate
      tags: [Newsletters]
      summary: Update template (blocks, schedule, etc.).
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewsletterTemplateDraft' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NewsletterTemplate' }
    delete:
      operationId: deleteNewsletterTemplate
      tags: [Newsletters]
      summary: Soft-delete a template (active=false).
      security:
        - PersonalAccessToken: [newsletters:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: "Deleted" }

  /api/v1/newsletter/render:
    post:
      operationId: renderNewsletterPreview
      tags: [Newsletters]
      summary: Preview-render a campaign without sending.
      description: |
        Composes the campaign HTML + text for one synthetic recipient.
        Free (no wallet charge).
      security:
        - PersonalAccessToken: [newsletters:read]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [campaignId]
              properties:
                campaignId: { type: string, format: uuid }
                personalization:
                  type: object
                  description: Optional merge values for the preview.
                  additionalProperties: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  html: { type: string }
                  text: { type: string }
                  subject: { type: string }

  /api/v1/newsletter/admin/campaigns/{id}/approve:
    post:
      operationId: adminApproveNewsletterCampaign
      tags: [Admin, Newsletters]
      summary: Super-admin override — approve + send a pending campaign.
      description: |
        Bypasses abuse + cap gates. Used when a customer's send was
        flagged for manual review and platform staff cleared it.
        Wallet-charging (same as the agent-initiated send).
      security:
        - PersonalAccessToken: [admin:super]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Sent

  /api/v1/newsletter/admin/campaigns/{id}/reject:
    post:
      operationId: adminRejectNewsletterCampaign
      tags: [Admin, Newsletters]
      summary: Super-admin override — reject a pending campaign.
      security:
        - PersonalAccessToken: [admin:super]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Rejected

  /api/v1/newsletter/admin/orgs:
    get:
      operationId: adminListNewsletterOrgs
      tags: [Admin, Newsletters]
      summary: Cross-org newsletter status (super-admin only).
      security:
        - PersonalAccessToken: [admin:super]
      responses:
        '200':
          description: OK

  /api/v1/newsletter/admin/orgs/{id}/caps:
    patch:
      operationId: adminUpdateNewsletterCaps
      tags: [Admin, Newsletters]
      summary: Raise an org's subscriber/email caps + unsuspend.
      security:
        - PersonalAccessToken: [admin:super]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                newsletter_subscriber_cap: { type: integer, minimum: 0 }
                newsletter_monthly_email_cap: { type: integer, minimum: 0 }
                unsuspend: { type: boolean }
      responses:
        '200':
          description: OK

  # ---------- Recruiting ---------------------------------------------------

  /api/v1/recruiting/search:
    post:
      operationId: searchRecruitingAgents
      tags: [Recruiting]
      summary: MLS-based agent search (free).
      description: |
        Filter the national MLS roster. Returns paginated results with
        an `unlocked: boolean` flag per row indicating whether THIS org
        has already paid to reveal the contact (cheap re-views).
      security:
        - PersonalAccessToken: [recruiting:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecruitingSearchFilters' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecruitingSearchResult' }

  /api/v1/recruiting/unlock:
    post:
      operationId: unlockRecruitingAgent
      tags: [Recruiting]
      summary: Pay to reveal an agent's contact info (wallet-charging).
      description: |
        Costs 10 tokens (`recruiting.unlock` op). Idempotent: re-unlocking
        an agent already unlocked for this org returns the existing row
        without charging.

        **Confirmed required.** Per spend-safety design, ops with base
        cost ≥ 10 require `confirmed: true` in the body. Without it,
        refuses.

        **`?dry_run=true`** returns the would-be receipt without
        charging or writing `recruiting.unlocks` — use it to surface
        the cost before confirming.
      security:
        - PersonalAccessToken: [recruiting:unlock, wallet:write]
      parameters:
        - in: query
          name: dry_run
          schema: { type: boolean, default: false }
        - in: header
          name: Idempotency-Key
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_mls_id]
              properties:
                agent_mls_id: { type: string, description: MLS-side stable agent identifier }
                confirmed: { type: boolean, description: Required for charging ops (cost ≥ 10) }
      responses:
        '200':
          description: OK (returns existing unlock if already unlocked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  unlock:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      credits_spent: { type: integer }
                      unlocked_at: { type: string, format: date-time }
                  contact: { $ref: '#/components/schemas/MlsAgentContact' }
                  reused:
                    type: boolean
                    description: True when no charge happened (already unlocked).
        '201':
          description: Created (first-time unlock)
          content:
            application/json:
              schema:
                type: object
                properties:
                  unlock:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      credits_spent: { type: integer }
                      unlocked_at: { type: string, format: date-time }
                  contact: { $ref: '#/components/schemas/MlsAgentContact' }
                  reused: { type: boolean, example: false }
        '402': { description: "insufficient_credits — wallet balance < 10" }
        '404': { description: "agent_not_found in mls-data" }
        '409': { description: "missing_confirmed — body lacked `confirmed: true`" }

  /api/v1/recruiting/agents/{id}/contact:
    get:
      operationId: getRecruitingAgentContact
      tags: [Recruiting]
      summary: Re-view a previously-unlocked agent's contact (free).
      description: |
        Free — the unlock is what costs tokens; the re-view returns the
        snapshot stored at unlock time (sub-100ms, change-immune
        against mls-data mutations). Pre-snapshot legacy rows
        atomically backfill on first re-access.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, description: agent_mls_id }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact: { $ref: '#/components/schemas/MlsAgentContact' }
                  source:
                    type: string
                    enum: [snapshot, mls-data]
                    description: snapshot is the fast path; mls-data fires on legacy pre-snapshot rows.
        '404': { description: "not_unlocked — POST /unlock first to pay" }

  /api/v1/recruiting/lists:
    get:
      operationId: listRecruitingLists
      tags: [Recruiting]
      summary: List the org's recruiting lists.
      security:
        - PersonalAccessToken: [recruiting:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RecruitingList' }
    post:
      operationId: createRecruitingList
      tags: [Recruiting]
      summary: Create a list.
      security:
        - PersonalAccessToken: [recruiting:read]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                notes: { type: string }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecruitingList' }

  /api/v1/recruiting/lists/{id}:
    get:
      operationId: getRecruitingList
      tags: [Recruiting]
      summary: Read a list.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecruitingList' }
    patch:
      operationId: updateRecruitingList
      tags: [Recruiting]
      summary: Update list metadata.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                notes: { type: string }
      responses:
        '200':
          description: OK
    delete:
      operationId: deleteRecruitingList
      tags: [Recruiting]
      summary: Delete a list.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: "Deleted" }

  /api/v1/recruiting/lists/{id}/members:
    post:
      operationId: addRecruitingListMembers
      tags: [Recruiting]
      summary: Add unlocked agents to a list.
      description: |
        Every agent must already be unlocked for the org — adding
        un-unlocked ids returns 409 with the offending ids.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_mls_ids]
              properties:
                agent_mls_ids:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: OK
        '409':
          description: not_unlocked — body returns the offending mls_ids
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: not_unlocked }
                  offending_ids:
                    type: array
                    items: { type: string }
    delete:
      operationId: removeRecruitingListMembers
      tags: [Recruiting]
      summary: Remove agents from a list.
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_mls_ids]
              properties:
                agent_mls_ids:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: OK

  /api/v1/recruiting/saved-searches:
    get:
      operationId: listSavedSearches
      tags: [Recruiting]
      summary: List saved searches.
      security:
        - PersonalAccessToken: [recruiting:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SavedSearch' }
    post:
      operationId: createSavedSearch
      tags: [Recruiting]
      summary: Save a search filter set for later.
      security:
        - PersonalAccessToken: [recruiting:read]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, filters]
              properties:
                name: { type: string }
                filters: { $ref: '#/components/schemas/RecruitingSearchFilters' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SavedSearch' }

  /api/v1/recruiting/saved-searches/{id}:
    get:
      operationId: getSavedSearch
      tags: [Recruiting]
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SavedSearch' }
    patch:
      operationId: updateSavedSearch
      tags: [Recruiting]
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                filters: { $ref: '#/components/schemas/RecruitingSearchFilters' }
      responses:
        '200':
          description: OK
    delete:
      operationId: deleteSavedSearch
      tags: [Recruiting]
      security:
        - PersonalAccessToken: [recruiting:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: "Deleted" }

  /api/v1/recruiting/export/csv:
    get:
      operationId: exportRecruitingCsv
      tags: [Recruiting]
      summary: CSV export of unlocked agents.
      description: |
        With `?list_id=`: exports just that list. Without: every unlock
        in the org. List exports are ownership-gated (creator or org
        owner only). All exports are audited in `recruiting.exports`.
      security:
        - PersonalAccessToken: [recruiting:export]
      parameters:
        - in: query
          name: list_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: CSV body
          content:
            text/csv:
              schema:
                type: string
        '403': { description: "not_list_owner" }
        '404': { description: "empty_export or list_not_found" }

  # ---------- Video --------------------------------------------------------

  /api/areas/search:
    get:
      operationId: searchVideoAreas
      tags: [Video]
      summary: Neighborhood / area search for video market reports.
      security:
        - PersonalAccessToken: [video:read]
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
          description: Free-text query (city/neighborhood name).
        - in: query
          name: state
          schema: { type: string, description: ISO 2-letter US state code }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  areas:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        label: { type: string }
                        city: { type: string }
                        state: { type: string }

  /api/v1/video/jobs:
    get:
      operationId: listVideoJobs
      tags: [Video]
      summary: List the org's completed video jobs.
      description: |
        Cross-spoke callers (websites, newsletters embedding video
        cards) call this with a service key. End users get their own
        org's jobs.
      security:
        - PersonalAccessToken: [video:read]
        - ServiceKey: []
      parameters:
        - in: query
          name: orgId
          schema: { type: string, format: uuid }
          description: Required with service-key auth; ignored with PAT.
        - in: query
          name: content_type
          schema: { type: string, description: Filter to one content type key }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: '#/components/schemas/VideoJob' }

  /api/v1/video/jobs/{id}:
    get:
      operationId: getVideoJob
      tags: [Video]
      summary: Single video job detail.
      security:
        - PersonalAccessToken: [video:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VideoJob' }
        '404': { description: "Not found" }

  # ---------- Websites -----------------------------------------------------

  /api/v1/sites:
    get:
      operationId: listSites
      tags: [Websites]
      summary: List the org's sites.
      security:
        - PersonalAccessToken: [websites:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Site' }
    post:
      operationId: createSite
      tags: [Websites]
      summary: Create a site from a template.
      description: |
        Auto-attaches a default partner subdomain (`<slug>.<partner-root>`)
        if Cloudflare wildcard DNS is configured. Free (no wallet
        charge on create — publish is what costs).
      security:
        - PersonalAccessToken: [websites:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, kind, template_slug]
              properties:
                name: { type: string }
                kind:
                  type: string
                  enum: [website, landing_page, listing_site]
                template_slug: { type: string, example: agent-personal-modern }
                slug:
                  type: string
                  description: URL-safe identifier. Auto-generated from name if omitted.
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Site' }

  /api/v1/sites/{id}:
    get:
      operationId: getSite
      tags: [Websites]
      summary: Read site detail.
      security:
        - PersonalAccessToken: [websites:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Site' }
        '404': { description: "Not found" }
    patch:
      operationId: updateSite
      tags: [Websites]
      summary: Update site metadata or blocks.
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                blocks:
                  type: array
                  items: { type: object, additionalProperties: true }
                brand: { type: object, additionalProperties: true }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Site' }
    delete:
      operationId: archiveSite
      tags: [Websites]
      summary: Soft-archive a site (status='archived').
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: "Archived" }

  /api/v1/sites/{id}/publish:
    post:
      operationId: publishSite
      tags: [Websites]
      summary: Publish a site (wallet-charging).
      description: |
        Charges `sites.lovable.publish` (spec name) — currently the
        code uses `site_publish`; the audit Tier-2 reconciliation
        renames this. ~100 tokens, so `confirmed: true` required.
        Honors `?dry_run=true`.
      security:
        - PersonalAccessToken: [websites:write, wallet:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: dry_run
          schema: { type: boolean, default: false }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                confirmed: { type: boolean }
      responses:
        '200':
          description: Published
          content:
            application/json:
              schema:
                type: object
                properties:
                  site: { $ref: '#/components/schemas/Site' }
                  publish: { $ref: '#/components/schemas/SitePublish' }
        '402': { description: "insufficient_tokens" }
        '409': { description: "missing_confirmed" }

  /api/v1/sites/{id}/unpublish:
    post:
      operationId: unpublishSite
      tags: [Websites]
      summary: Unpublish a site (free).
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Site' }

  /api/v1/sites/{id}/domains:
    get:
      operationId: listSiteDomains
      tags: [Websites]
      summary: Domains attached to a site.
      security:
        - PersonalAccessToken: [websites:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SiteDomain' }
    post:
      operationId: attachCustomDomain
      tags: [Websites]
      summary: Attach a custom domain (wallet-charging).
      description: |
        Charges `sites.domain.attach_custom` (~50 tokens; `confirmed: true`
        required). Returns DNS records the user must add to verify
        ownership.
      security:
        - PersonalAccessToken: [websites:write, wallet:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: dry_run
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: myrealtor.com }
                confirmed: { type: boolean }
      responses:
        '201':
          description: Pending DNS verification
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SiteDomain' }
        '402': { description: "insufficient_tokens" }
        '409': { description: "missing_confirmed" }

  /api/v1/sites/{id}/domains/{domain}:
    get:
      operationId: getSiteDomain
      tags: [Websites]
      security:
        - PersonalAccessToken: [websites:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: domain
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SiteDomain' }
    post:
      operationId: updateSiteDomain
      tags: [Websites]
      summary: Update a site domain (e.g. re-trigger verification).
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: domain
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
    delete:
      operationId: detachSiteDomain
      tags: [Websites]
      summary: Remove a custom domain.
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: domain
          required: true
          schema: { type: string }
      responses:
        '204': { description: "Detached" }

  /api/v1/sites/{id}/domains/{domain}/status:
    post:
      operationId: checkSiteDomainStatus
      tags: [Websites]
      summary: Re-check DNS / Vercel status for a domain.
      security:
        - PersonalAccessToken: [websites:read]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: domain
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending, active, failed] }
                  verification: { type: object, additionalProperties: true }

  /api/v1/sites/{id}/domains/{domain}/promote:
    post:
      operationId: promoteSiteDomain
      tags: [Websites]
      summary: Promote a verified domain to the site's primary.
      security:
        - PersonalAccessToken: [websites:write]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: domain
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Site' }

  /api/v1/sites/templates:
    get:
      operationId: listSiteTemplates
      tags: [Websites]
      summary: List templates available to the caller's partner industry.
      description: |
        Industry-aware: real-estate partners get the agent-* templates,
        healthcare partners get doctor-* templates.
      security:
        - PersonalAccessToken: [websites:read]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SiteTemplate' }

  # ---------- PATs ---------------------------------------------------------

  /v1/webhooks:
    get:
      summary: List the org's outgoing-webhook endpoints (Phase 20).
      parameters:
        - in: query
          name: orgId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Endpoints
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string }
                        events: { type: array, items: { type: string } }
                        is_active: { type: boolean }
    post:
      summary: Create an outgoing-webhook endpoint (PAT scope webhooks:write).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orgId, url, events]
              properties:
                orgId: { type: string, format: uuid }
                url: { type: string, description: https only }
                events:
                  type: array
                  items: { type: string, enum: [lead.received, video.render.completed, campaign.sent] }
      responses:
        '200':
          description: Created — response includes the signing secret (verify X-AMC-Signature with it)
  /v1/webhooks/{id}:
    delete:
      summary: Soft-delete an endpoint.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: orgId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: ok }
  /v1/webhooks/emit:
    post:
      summary: "Emit an org event (SERVICE KEY ONLY — spokes fire events through core)."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orgId, eventType]
              properties:
                orgId: { type: string, format: uuid }
                eventType: { type: string, enum: [lead.received, video.render.completed, campaign.sent] }
                payload: { type: object }
      responses:
        '200': { description: "{ queued: n }" }
  /v1/tokens:
    get:
      operationId: listPats
      tags: [PATs]
      summary: List the caller's Personal Access Tokens.
      description: |
        Metadata only — token plaintext is never stored, only the
        SHA-256 hash. Browser-session required (PATs can't mint or
        manage other PATs).
      security: []  # explicit: only browser JWT works here
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items: { $ref: '#/components/schemas/Pat' }
        '403': { description: "session_required — PAT or service-key auth was attempted" }
    post:
      operationId: createPat
      tags: [PATs]
      summary: Mint a new PAT.
      description: |
        Returns the plaintext token EXACTLY ONCE in the response body
        (`token` field). The UI must surface a "copy now — won't be
        shown again" warning. The server stores only the SHA-256 hash.

        Scope validation: requested scopes must be in the known catalog
        (see lib/wallet/scopes.ts). The `admin:super` scope is only
        grantable by super-admins.
      security: []  # explicit: only browser JWT works here
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name:
                  type: string
                  description: User-friendly label (e.g. "ChatGPT custom GPT")
                scopes:
                  type: array
                  items: { type: string }
                  description: From the known scope catalog. See lib/wallet/scopes.ts.
                dailyTokenCap:
                  type: integer
                  nullable: true
                  description: Optional cap (tokens/day). Null = no cap.
                monthlyTokenCap:
                  type: integer
                  nullable: true
                expiresAt:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        '201':
          description: Created. Plaintext returned ONCE.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: The full plaintext PAT (`amcpat_…`). Copy now; cannot be retrieved again.
                  record: { $ref: '#/components/schemas/Pat' }
        '400': { description: "missing_name / invalid_scopes" }
        '403': { description: "scope_not_grantable (e.g. non-super-admin requesting admin:super) / session_required" }

  /v1/tokens/{id}:
    delete:
      operationId: revokePat
      tags: [PATs]
      summary: Revoke a PAT (soft-delete; sets revoked_at).
      description: |
        Idempotent: revoking an already-revoked token returns 200 with
        `alreadyRevoked: true`. Users may only revoke their own tokens;
        super-admins may revoke anyone's.
      security: []  # explicit: only browser JWT works here
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  alreadyRevoked: { type: boolean }
        '403': { description: "not_owner / session_required" }
        '404': { description: not_found }

# ============================================================================
# Components
# ============================================================================

components:
  securitySchemes:
    PersonalAccessToken:
      type: http
      scheme: bearer
      bearerFormat: amcpat_*
      description: |
        Per-user access token issued from `/app/settings/api-tokens`.
        Carry as `Authorization: Bearer amcpat_<token>`. Scopes are
        attached at token-creation time; each operation declares which
        scope it requires (see per-endpoint `security` blocks).
    ServiceKey:
      type: http
      scheme: bearer
      bearerFormat: amcsk_*
      description: |
        Admin-issued, product-scoped key. Carry as
        `Authorization: Bearer amcsk_<key>`. Bypasses per-user identity
        — use only for trusted server-side callers (workers, cron,
        internal scripts). May spend tokens only for its registered
        product.

  responses:
    Unauthorized:
      description: Missing or invalid token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: unauthorized, message: "Missing or invalid Authorization header" }
    UserOnly:
      description: Operation requires a user-bound token (service keys rejected)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: user_only, message: "This endpoint is per end-user; use a PAT or user JWT" }
    MissingAgent:
      description: missing_agent — service key didn't pass `?agentId=`
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NoAgent:
      description: Agent not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable machine-readable code (snake_case).
          example: insufficient_balance
        message:
          type: string
          description: Human-readable explanation.
        issues:
          type: array
          description: Zod issue list, present on `invalid_request`.
          items: { type: object, additionalProperties: true }

    Me:
      type: object
      required: [id, email, isSuperAdmin, isPartnerAdmin, partner]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        isSuperAdmin: { type: boolean }
        isPartnerAdmin: { type: boolean }
        partner:
          nullable: true
          $ref: '#/components/schemas/Partner'

    Partner:
      type: object
      required: [id, slug, name, rootDomain, supportEmail, noReplyEmail, brand, industry, enabledSpokes, status]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string, example: amc }
        name: { type: string, example: Rebrand Stack }
        rootDomain: { type: string, example: rebrandstack.com }
        supportEmail: { type: string, format: email }
        noReplyEmail: { type: string, format: email }
        logoUrl: { type: string, nullable: true }
        brand: { type: object, additionalProperties: true }
        industry:
          type: string
          enum: [real-estate, healthcare, generic]
        enabledSpokes:
          type: array
          items: { type: string }
          example: [core, video, photos, content, newsletters, websites, recruiting, leads]
        status:
          type: string
          enum: [active, suspended]

    OrgMembership:
      type: object
      required: [orgId, orgName, role]
      properties:
        orgId: { type: string, format: uuid }
        orgName: { type: string }
        role:
          type: string
          enum: [owner, member]

    Brand:
      type: object
      description: |
        Effective brand after the partner → org → team → agent cascade.
        Every field is optional (each layer may contribute or not).
      properties:
        agentName: { type: string }
        headshotUrl: { type: string, format: uri }
        brokerage: { type: string, description: "Legacy field name kept for compatibility; rendered as `<orgNoun> / display name` in UI" }
        website: { type: string, format: uri }
        logoUrl: { type: string, format: uri }
        primaryColor: { type: string, example: "#0a3d62" }
        voiceGuidelines: { type: string }
      additionalProperties: true

    Balance:
      type: object
      required: [plan, topup, total]
      properties:
        plan:
          type: integer
          description: Plan-bucket tokens (refreshed on cycle, may expire).
          example: 250
        topup:
          type: integer
          description: Top-up-bucket tokens (purchased, durable).
          example: 1200
        total:
          type: integer
          description: Convenience sum (`plan + topup`).
          example: 1450

    LedgerEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [plan_grant, topup_purchase, reserve, commit, refund, expire]
        amount:
          type: integer
          description: Positive (grant/purchase) or negative (debit/expire).
        bucket:
          type: string
          enum: [plan, topup]
        status:
          type: string
          description: Snapshot at write time (e.g. 'reserved' / 'committed' / 'refunded' for matched reserve/commit/refund triples).
        product: { type: string, nullable: true, example: recruiting }
        operation_key: { type: string, nullable: true, example: unlock }
        external_ref: { type: string, nullable: true }
        payment_ref: { type: string, nullable: true }
        reason: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    ReserveBody:
      type: object
      required: [orgId, productKey]
      properties:
        orgId: { type: string, format: uuid }
        productKey:
          type: string
          example: recruiting
          description: Which spoke is spending. A service key may only spend for its registered product.
        operationKey:
          type: string
          example: unlock
          description: Required unless `lineItems` is supplied.
        count:
          type: integer
          minimum: 1
          default: 1
        lineItems:
          type: array
          description: Composite reservation — base op + N modifier ops. Mutually exclusive with `operationKey`.
          items:
            type: object
            required: [operationKey, count]
            properties:
              operationKey: { type: string }
              count: { type: integer, minimum: 1 }
        idempotencyKey:
          type: string
          description: Replay-safe key. Same `(orgId, key)` returns the prior receipt untouched.
        externalRef:
          type: string
          description: Caller-side foreign reference for audit (e.g. `recruiting:unlock:AGENT-12345`).
        confirmed:
          type: boolean
          default: false
          description: |
            Required (planned, see /docs) on any op whose base_token_cost ≥ 10.
            Acknowledges the user has been shown + has agreed to the cost.

    ReserveResult:
      type: object
      required: [reservationId, tokens, balanceAfter]
      properties:
        reservationId: { type: string, format: uuid }
        tokens:
          type: integer
          description: Total tokens reserved (sum of line items × per-unit costs).
        balanceAfter: { $ref: '#/components/schemas/Balance' }

    # ---------- Content schemas ----------

    ArticleSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        source_key: { type: string, example: houseopedia }
        title: { type: string }
        slug: { type: string }
        excerpt: { type: string, nullable: true }
        cover_image_url: { type: string, format: uri, nullable: true }
        category: { type: string, nullable: true }
        published_at: { type: string, format: date-time, nullable: true }
        url: { type: string, format: uri, description: Canonical link to source }

    Article:
      allOf:
        - { $ref: '#/components/schemas/ArticleSummary' }
        - type: object
          properties:
            body_html: { type: string }
            body_text: { type: string }
            partner_id: { type: string, format: uuid }

    # ---------- Newsletter schemas ----------

    NewsletterCampaignDraft:
      type: object
      required: [list_id, subject, blocks]
      properties:
        list_id: { type: string, format: uuid }
        template_id: { type: string, format: uuid, nullable: true }
        subject: { type: string }
        from_name: { type: string, nullable: true }
        reply_to: { type: string, format: email, nullable: true }
        blocks:
          type: array
          items: { type: object, additionalProperties: true }

    NewsletterCampaign:
      allOf:
        - { $ref: '#/components/schemas/NewsletterCampaignDraft' }
        - type: object
          properties:
            id: { type: string, format: uuid }
            org_id: { type: string, format: uuid }
            status:
              type: string
              enum: [draft, pending_agent_approval, pending_platform_approval, sending, sent, failed, cancelled]
            recipient_count: { type: integer, nullable: true }
            sent_at: { type: string, format: date-time, nullable: true }
            failure_reason: { type: string, nullable: true }
            stats: { type: object, additionalProperties: true }
            created_at: { type: string, format: date-time }

    NewsletterSendResult:
      type: object
      properties:
        status: { type: string, enum: [sent, failed] }
        recipient_count: { type: integer }
        delivered: { type: integer }
        failed: { type: integer }
        failure_samples:
          type: array
          items: { type: string }
        tokens_reserved: { type: integer }
        wallet_refunded: { type: boolean }

    NewsletterList:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        subscriber_count: { type: integer }
        created_at: { type: string, format: date-time }

    NewsletterSubscriberDraft:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        custom_fields: { type: object, additionalProperties: true }
        list_ids:
          type: array
          items: { type: string, format: uuid }
          description: Optional initial list memberships.

    NewsletterSubscriber:
      allOf:
        - { $ref: '#/components/schemas/NewsletterSubscriberDraft' }
        - type: object
          properties:
            id: { type: string, format: uuid }
            org_id: { type: string, format: uuid }
            status:
              type: string
              enum: [active, bounced, complained, unsubscribed]
            consent_confirmed_at: { type: string, format: date-time, nullable: true }
            last_bounce_at: { type: string, format: date-time, nullable: true }
            unsubscribed_at: { type: string, format: date-time, nullable: true }
            created_at: { type: string, format: date-time }

    NewsletterTemplateDraft:
      type: object
      required: [name, blocks]
      properties:
        name: { type: string }
        subject_line: { type: string, nullable: true }
        blocks:
          type: array
          items: { type: object, additionalProperties: true }
        default_list_id: { type: string, format: uuid, nullable: true }
        schedule:
          type: object
          nullable: true
          description: |
            Optional recurring schedule. Shape per
            apps/newsletters/src/lib/campaigns/scheduler.ts —
            { kind: 'daily'|'weekly'|'monthly', hour, tz, … }
          additionalProperties: true
        active: { type: boolean, default: true }

    NewsletterTemplate:
      allOf:
        - { $ref: '#/components/schemas/NewsletterTemplateDraft' }
        - type: object
          properties:
            id: { type: string, format: uuid }
            org_id: { type: string, format: uuid }
            last_tick_at: { type: string, format: date-time, nullable: true }
            created_at: { type: string, format: date-time }

    # ---------- Recruiting schemas ----------

    RecruitingSearchFilters:
      type: object
      description: |
        Mirror of MlsAgentSearchFilters in
        apps/recruiting/src/lib/mls/types.ts.
      properties:
        q: { type: string }
        state: { type: string, description: ISO 2-letter US state code }
        city: { type: string }
        zip: { type: string }
        centerLat: { type: number }
        centerLng: { type: number }
        radiusMiles: { type: number }
        officeInclude:
          type: array
          items: { type: string }
        officeExclude:
          type: array
          items: { type: string }
        totalUnitsMin: { type: integer }
        totalUnitsMax: { type: integer }
        totalVolumeMin: { type: integer }
        totalVolumeMax: { type: integer }
        listingPctMin: { type: number }
        listingPctMax: { type: number }
        limit: { type: integer, minimum: 1, maximum: 200, default: 50 }
        offset: { type: integer, minimum: 0, default: 0 }

    RecruitingAgentSummary:
      type: object
      properties:
        mlsId: { type: string }
        fullName: { type: string }
        office: { type: string, nullable: true }
        city: { type: string, nullable: true }
        state: { type: string, nullable: true }
        zip: { type: string, nullable: true }
        totalUnits: { type: integer, nullable: true }
        totalVolume: { type: integer, nullable: true }
        avgUnitPrice: { type: number, nullable: true }
        listingPct: { type: number, nullable: true }
        unlocked:
          type: boolean
          description: True when this org has already paid to unlock the contact.

    RecruitingSearchResult:
      type: object
      properties:
        agents:
          type: array
          items: { $ref: '#/components/schemas/RecruitingAgentSummary' }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
        notice:
          type: string
          nullable: true
          description: Soft error (e.g. mls_access_blocked) when the search degraded gracefully.

    MlsAgentContact:
      type: object
      properties:
        mlsId: { type: string }
        email: { type: string, format: email, nullable: true }
        primaryPhone: { type: string, nullable: true }
        mobilePhone: { type: string, nullable: true }
        address:
          type: object
          properties:
            street: { type: string, nullable: true }
            city: { type: string, nullable: true }
            state: { type: string, nullable: true }
            zip: { type: string, nullable: true }

    RecruitingList:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        name: { type: string }
        notes: { type: string, nullable: true }
        agent_count: { type: integer }
        created_at: { type: string, format: date-time }

    SavedSearch:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        name: { type: string }
        filters: { $ref: '#/components/schemas/RecruitingSearchFilters' }
        last_run_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    # ---------- Video schemas ----------

    VideoJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        agent_id: { type: string, format: uuid }
        content_type_key: { type: string, example: market-report }
        status:
          type: string
          enum: [queued, processing, succeeded, failed, cancelled]
          description: |
            Coarse-grained job status. Detailed sub-status is in
            `running_status` while the job is processing.
        running_status:
          type: string
          nullable: true
          description: 'Fine-grained: fetching_data, synthesizing_voice, rendering_avatar, composing, distributing, finalizing'
        video_url: { type: string, format: uri, nullable: true }
        thumbnail_url: { type: string, format: uri, nullable: true }
        cost_breakdown:
          type: object
          nullable: true
          additionalProperties: true
          description: Per-stage token spend snapshot.
        created_at: { type: string, format: date-time }
        finalized_at: { type: string, format: date-time, nullable: true }

    # ---------- Websites schemas ----------

    Site:
      type: object
      properties:
        id: { type: string, format: uuid }
        org_id: { type: string, format: uuid }
        kind:
          type: string
          enum: [website, landing_page, listing_site]
        name: { type: string }
        slug: { type: string }
        status:
          type: string
          enum: [draft, published, archived, expired]
        primary_domain: { type: string, nullable: true }
        published_at: { type: string, format: date-time, nullable: true }
        blocks:
          type: array
          items: { type: object, additionalProperties: true }
        brand: { type: object, additionalProperties: true }
        updated_at: { type: string, format: date-time }

    SiteDomain:
      type: object
      properties:
        domain: { type: string }
        site_id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending, active, failed, removed]
        is_primary: { type: boolean }
        verification:
          type: object
          nullable: true
          additionalProperties: true
        ssl_issued_at: { type: string, format: date-time, nullable: true }
        ssl_expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    SitePublish:
      type: object
      properties:
        id: { type: string, format: uuid }
        site_id: { type: string, format: uuid }
        snapshot: { type: object, additionalProperties: true }
        published_at: { type: string, format: date-time }

    SiteTemplate:
      type: object
      properties:
        slug: { type: string, example: agent-personal-modern }
        name: { type: string }
        description: { type: string, nullable: true }
        industry:
          type: string
          enum: [real-estate, healthcare, generic]
        kind:
          type: string
          enum: [website, landing_page, listing_site]
        preview_url: { type: string, format: uri, nullable: true }

    # ---------- PAT schemas ----------

    Pat:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        prefix:
          type: string
          description: First 14 chars of the plaintext (`amcpat_AbC123`). Display-only.
        scopes:
          type: array
          items: { type: string }
        daily_token_cap: { type: integer, nullable: true }
        monthly_token_cap: { type: integer, nullable: true }
        last_used_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
