openapi: 3.1.0
info:
  title: Novence API
  version: 0.2.0
  description: |
    Control plane for autonomous AI agent website hosting.

    **Deploy polling:** use `GET /v1/projects/{id}/deployments/latest` until
    `deployment.status` is `live` (or `failed`). Project `status` is lifecycle
    (`active`/`deleted`) — it does not become `live`.

    **OTP TTL:** email verification codes expire in **15 minutes**.

    **Discovery:** `https://api.novence.ai/llms.txt` and `/openapi.yaml` redirect
    to `https://novence.ai/…`.
servers:
  - url: https://api.novence.ai
    description: Production API
  - url: http://127.0.0.1:8080
    description: Local
paths:
  /health:
    get:
      summary: Health check
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  product: { type: string }
                  environment: { type: string }
  /llms.txt:
    get:
      summary: Redirect to marketing-host agent brief
      responses:
        "302":
          description: Redirect to https://novence.ai/llms.txt
  /openapi.yaml:
    get:
      summary: Redirect to marketing-host OpenAPI
      responses:
        "302":
          description: Redirect to https://novence.ai/openapi.yaml
  /v1/bootstrap:
    post:
      summary: Create account + API key (unverified; OTP emailed, 15 min TTL)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [accountId, apiKey, plan, emailVerificationRequired]
                properties:
                  accountId: { type: string, format: uuid }
                  apiKey: { type: string }
                  plan: { type: string }
                  emailVerificationRequired: { type: boolean }
                  verificationCode: { type: string, description: Dev-only when Resend unset }
        "400":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/demo:
    post:
      summary: Anonymous account + API key (deploy first, claim later)
      description: |
        Creates an unverified Free account with no email. Same tight unverified
        quotas as bootstrap. Claim with `POST /v1/auth/claim` then verify OTP.
        Shares the bootstrap IP rate limit.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [accountId, apiKey, plan, anonymous, emailVerificationRequired]
                properties:
                  accountId: { type: string, format: uuid }
                  apiKey: { type: string }
                  plan: { type: string }
                  anonymous: { type: boolean }
                  emailVerificationRequired: { type: boolean }
                  claim: { type: string }
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/auth/claim:
    post:
      summary: Attach an email to an anonymous account and send OTP
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: OTP emailed
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string, format: uuid }
                  email: { type: string, format: email }
                  sent: { type: boolean }
                  emailVerificationRequired: { type: boolean }
                  alreadyVerified: { type: boolean }
                  verificationCode: { type: string, description: Dev-only when Resend unset }
        "409":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/auth/verify-email:
    post:
      summary: Verify email with OTP (expires in 15 minutes)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string, format: uuid }
                  emailVerified: { type: boolean }
                  alreadyVerified: { type: boolean }
  /v1/auth/resend-verification:
    post:
      summary: Email a new OTP (15 min TTL)
      description: |
        Always sends a code when the account exists, including if the email is
        already verified (needed for lost-key recovery). Response includes
        `sent: true` when an email was issued. `alreadyVerified: true` does **not**
        mean no code was sent. Use the same OTP with `POST /v1/auth/reissue-key`
        or `POST /v1/auth/verify-email`. Independent of the bootstrap rate limit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: OTP emailed
          content:
            application/json:
              schema:
                type: object
                required: [sent]
                properties:
                  sent: { type: boolean }
                  emailVerificationRequired: { type: boolean }
                  alreadyVerified: { type: boolean }
                  verificationCode: { type: string, description: Dev-only when Resend unset }
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/auth/reissue-key:
    post:
      summary: Recover a lost nv_ API key (OTP also verifies the email)
      description: |
        Consumes the OTP from `POST /v1/auth/resend-verification` (or the original
        bootstrap email). Issues a new account `nv_` key and revokes prior
        account-scoped keys (401). If the account was unverified, this call
        **also verifies the email** — do not spend a second OTP on
        `POST /v1/auth/verify-email`. Same response shape for already-verified
        accounts. Has its own rate limit (not the bootstrap limiter). Invalid
        OTP (400) does not consume the success budget. 429 includes Retry-After.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string }
      responses:
        "200":
          description: New apiKey issued; email is verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string, format: uuid }
                  apiKey: { type: string }
                  emailVerified: { type: boolean }
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/projects:
    get:
      summary: List projects
      description: Each project includes `suffix`, `url`, and deprecated `randomSuffix`.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
    post:
      summary: Create project
      security: [{ bearerAuth: [] }]
      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:
                type: object
                required: [project, url]
                properties:
                  project: { $ref: "#/components/schemas/Project" }
                  projectApiKey: { type: string }
                  url: { type: string, format: uri }
        "402":
          $ref: "#/components/responses/QuotaExceeded"
  /v1/publish-html:
    post:
      summary: Publish one HTML document
      description: |
        Write `/index.html` and deploy in one call. Omit `projectId` to create a project.
        Returns quickly — poll `GET /v1/projects/{id}/deployments/latest` until
        `deployment.status` is `live` or `failed`. For multi-file sites use the
        upload + deploy loop instead.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishHtmlRequest"
      responses:
        "201":
          description: Staging write + deployment created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishHtmlResponse"
        "400":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/QuotaExceeded"
  /v1/projects/{id}:
    get:
      summary: Get project (includes url + suffix)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "404":
          $ref: "#/components/responses/Error"
    patch:
      summary: Update project settings
      description: |
        Name, description, check thresholds, `analyticsEnabled` (cookieless
        edge traffic; off by default), and `shareGateEmails` (optional email OTP
        on `*.novence.ai` / `/p/{suffix}/` only; empty = public). Editor or owner.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                checkThresholds: { type: object }
                analyticsEnabled: { type: boolean }
                shareGateEmails:
                  type: array
                  maxItems: 5
                  items: { type: string, format: email }
                  description: |
                    Up to 5 invited emails. Non-empty enables the OTP gate on
                    platform hosts. Custom domains stay public. Saving does not send mail.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "404":
          $ref: "#/components/responses/Error"
  /v1/invites/accept:
    post:
      summary: Accept a project invite
      description: |
        Public. Optional Bearer must match the invited email.
        Unverified invitees receive `needsOtp: true` and must retry with `otp`.
        Returns a project-scoped `nv_` key once. Never emailed.
        Team seats require the **project owner** to be on Pro or Scale.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                otp: { type: string }
      responses:
        "200":
          description: Joined, or OTP required
        "400":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/members:
    get:
      summary: List project members and pending invites
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
    post:
      summary: Invite a collaborator
      description: |
        Pro/Scale owner required (HTTP 402 otherwise). Idempotent resend for a pending email.
        Only the project owner may invite `admin`. Do not share the owner's API key.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role: { type: string, enum: [viewer, editor, admin] }
      responses:
        "201":
          description: Invited
        "402":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/members/me:
    delete:
      summary: Leave a project
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: Left
        "403":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/members/{accountId}:
    patch:
      summary: Change a member role
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: accountId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role: { type: string, enum: [viewer, editor, admin] }
      responses:
        "200":
          description: Updated
    delete:
      summary: Remove a member
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: accountId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Removed
  /v1/projects/{id}/invites/{inviteId}/revoke:
    post:
      summary: Revoke a pending invite
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: inviteId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Revoked
  /v1/projects/{id}/usage:
    get:
      summary: Per-project usage for the current UTC month
      description: |
        Storage from confirmed files; deploys and check minutes from
        `usage_metrics` tagged with this `project_id`.
        `bandwidth_gb` is measured from edge-served bytes (including cache hits
        and Range responses). Quotas are account-level (shown for bar context);
        enforcement stays on the account (Free hard-block; Pro/Scale soft overage).
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [projectId, period, usage, quotas, bandwidthMetering]
                properties:
                  projectId: { type: string, format: uuid }
                  period:
                    type: object
                    required: [start]
                    properties:
                      start: { type: string, format: date-time }
                  usage:
                    type: object
                    required:
                      [
                        storage_bytes,
                        storage_gb,
                        deploys_count,
                        check_minutes,
                        custom_domains_count,
                        bandwidth_gb,
                      ]
                    properties:
                      storage_bytes: { type: number }
                      storage_gb: { type: number }
                      deploys_count: { type: number }
                      check_minutes: { type: number }
                      custom_domains_count: { type: integer, enum: [0, 1] }
                      bandwidth_gb:
                        type: number
                        description: GB served this UTC month for the project
                  quotas:
                    type: object
                    description: Account plan quotas (context for usage bars)
                  bandwidthMetering:
                    type: string
                    enum: [live, pending]
        "404":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/analytics:
    get:
      summary: Cookieless edge analytics for a hosted site
      description: |
        Pageviews (HTML GET), 404s, top paths, referrers, and countries from the
        edge when `analyticsEnabled` is true (off by default). No cookies.
        Default last 7 days; max 90. If disabled, `enabled` is false and totals
        are empty. Enable with `PATCH /v1/projects/{id}` `{ "analyticsEnabled": true }`
        or MCP `update_project_settings`.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [projectId, suffix, period, retentionDays, totals]
                properties:
                  projectId: { type: string, format: uuid }
                  suffix: { type: string }
                  enabled: { type: boolean }
                  period:
                    type: object
                    required: [from, to]
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                  retentionDays: { type: integer }
                  unavailable: { type: boolean }
                  reason: { type: string }
                  totals:
                    type: object
                    required: [pageviews, notFound]
                    properties:
                      pageviews: { type: number }
                      notFound: { type: number }
                  days:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string }
                        pageviews: { type: number }
                  topPages:
                    type: array
                    items:
                      type: object
                      properties:
                        path: { type: string }
                        pageviews: { type: number }
        "400":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/publish-html:
    post:
      summary: Publish HTML onto an existing project
      description: |
        Replace `/index.html` and deploy. Same body as `POST /v1/publish-html`
        without `projectId`. Poll deployments/latest until live or failed.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [html]
              properties:
                html: { type: string }
                title: { type: string }
                force: { type: boolean }
      responses:
        "201":
          description: Staging write + deployment created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishHtmlResponse"
        "400":
          $ref: "#/components/responses/Error"
        "402":
          $ref: "#/components/responses/QuotaExceeded"
        "404":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/uploads:
    post:
      summary: Presign one upload
      description: contentType optional — inferred from path extension when omitted.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string }
                contentType: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadPresign" }
  /v1/projects/{id}/uploads/batch:
    post:
      summary: Presign many uploads (max 100)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  maxItems: 100
                  items:
                    type: object
                    required: [path]
                    properties:
                      path: { type: string }
                      contentType: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploads:
                    type: array
                    items: { $ref: "#/components/schemas/UploadPresign" }
  /v1/projects/{id}/uploads/confirm:
    post:
      summary: Confirm one upload (required before deploy)
      description: Safe to retry. Deploy only sees confirmed staging files.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: { type: string }
                contentType: { type: string }
      responses:
        "200":
          description: Confirmed file
          content:
            application/json:
              schema:
                type: object
                required: [file]
                properties:
                  file: { $ref: "#/components/schemas/StagingFile" }
  /v1/projects/{id}/uploads/confirm-batch:
    post:
      summary: Confirm many uploads
      description: |
        Confirms each path independently so a partial PUT can be resumed.
        HTTP 200 even when some files fail — inspect `errors`.
        Body: `{ "files": [StagingFile], "errors": [ { "path", "error" } ] }`.
        `errors` is always present (empty array on full success). Retry only the failed paths.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items:
                    type: object
                    required: [path]
                    properties:
                      path: { type: string }
                      contentType: { type: string }
      responses:
        "200":
          description: Per-file confirm results (partial failure possible)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConfirmBatchResponse"
  /v1/projects/{id}/deployments:
    post:
      summary: Deploy project
      description: |
        Returns deployment with `phase`, `checksResults` (when ready), and `checksUrl`.
        Poll `GET …/deployments/latest` until status is `live` or `failed`.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  deployment: { $ref: "#/components/schemas/Deployment" }
  /v1/projects/{id}/deployments/latest:
    get:
      summary: Latest deployment status (poll this until live)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deployment: { $ref: "#/components/schemas/Deployment" }
        "404":
          $ref: "#/components/responses/Error"
  /v1/projects/{id}/checks:
    get:
      summary: Quality check results for latest (or specified) deployment
      description: |
        When `checksResults.skipped` is true, `passed` is null (checks did not run).
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deploymentId: { type: string, format: uuid }
                  status: { type: string }
                  phase: { type: string }
                  checksResults: { $ref: "#/components/schemas/ChecksResults" }
                  checksUrl: { type: string }
  /v1/projects/{id}/forms:
    get:
      summary: List Novence forms for a project
      description: |
        Optional platform forms. Sites may instead use BYO backends (Formspree,
        Web3forms, etc.) with no Novence form resource.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: OK
    post:
      summary: Create a Novence form
      description: Requires verified email. Returns `submit.edgePath` and `submit.publicApiUrl`.
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                slug: { type: string }
                fields:
                  type: array
                  items:
                    type: object
                    required: [name, type]
                    properties:
                      name: { type: string }
                      type:
                        type: string
                        enum: [text, email, tel, url, textarea, number, select, checkbox, hidden]
                      required: { type: boolean }
                      label: { type: string }
                      options:
                        type: array
                        items: { type: string }
                notifyEmail: { type: string, format: email, nullable: true }
                honeypotField: { type: string }
      responses:
        "201":
          description: Created
  /v1/projects/{id}/forms/{formId}:
    get:
      summary: Get form (includes submit URL hints)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
    patch:
      summary: Update form schema / notify / status
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK
    delete:
      summary: Soft-delete form
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deleted
  /v1/projects/{id}/forms/{formId}/submissions:
    get:
      summary: List form submissions (paginated)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: OK
  /v1/projects/{id}/forms/{formId}/submissions/{submissionId}:
    delete:
      summary: Delete one submission (PII)
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: submissionId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deleted
  /v1/public/forms/{formId}/submissions:
    post:
      summary: Public form submit (no API key)
      description: |
        Accepts `application/json` or `application/x-www-form-urlencoded`.
        Same-origin alternative: `POST /__forms/{formId}` on the live site host
        (edge Worker proxies here). Honeypot hits return 204 silently.
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "201":
          description: Stored
        "204":
          description: Honeypot / silent drop
        "402":
          description: Quota exceeded
        "410":
          description: Form missing or disabled
  /v1/public/share-gate/challenge:
    post:
      summary: Request a share-gate OTP (no API key)
      description: |
        Worker fills `suffix` from the request host. Unknown emails return the
        same `{ sent: true }` as invited ones. Does not send mail unless the
        address is on that project's list.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [suffix, email]
              properties:
                suffix: { type: string }
                email: { type: string, format: email }
      responses:
        "200":
          description: Always `{ sent: true }` when the request is well-formed
        "429":
          $ref: "#/components/responses/Error"
  /v1/public/share-gate/verify:
    post:
      summary: Verify a share-gate OTP (no API key)
      description: |
        Returns `{ token }` for the Worker to set `novence_share` on the site host.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [suffix, email, code]
              properties:
                suffix: { type: string }
                email: { type: string, format: email }
                code: { type: string }
      responses:
        "200":
          description: Opaque session id
          content:
            application/json:
              schema:
                type: object
                required: [token]
                properties:
                  token: { type: string }
        "400":
          $ref: "#/components/responses/Error"
        "429":
          $ref: "#/components/responses/Error"
  /v1/api-keys:
    get:
      summary: List API keys for the account
      description: |
        Never returns the key itself or its hash — `keyPrefix` identifies a row.
        `current` marks the key this request is authenticated with; revoking it
        locks you out (see DELETE). Readable with a `mgmt_` session
        (`account:read`) as well as an `nv_` key.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: projectId
          in: query
          description: Only keys scoped to this project
          schema: { type: string, format: uuid }
        - name: includeRevoked
          in: query
          description: Include revoked keys (hidden by default; rows are never deleted)
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
  /v1/api-keys/{id}:
    delete:
      summary: Revoke an API key
      description: |
        Idempotent. Refuses with 409 when `id` is the key authenticating the
        request — pass `force=true` to override, after which recovery needs
        `POST /v1/auth/reissue-key` with an email OTP. Requires an `nv_` key;
        management sessions cannot revoke.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: force
          in: query
          description: Allow revoking the key this request is authenticated with
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  revoked: { type: boolean }
        "409":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
  /v1/billing/quotas:
    get:
      summary: Quotas and usage
      description: |
        Prefer `usage.storage_bytes` over `storage_gb` for exact accounting.
        `quotas.checkRuns` is included **check minutes** per month (same unit as
        `usage.check_minutes`).
        `quotas.formSubmissionsPerMonth` / `usage.form_submissions` apply only to
        Novence Forms (BYO Formspree/Web3forms do not count).

        Plan matrix (verified Free / Pro / Scale):
        - Free $0: 1 project, 1 GB storage, 10 GB bandwidth, 20 deploys, 30 check
          minutes, **1 custom domain**, **50 form submissions**. Other Free
          overages hard-blocked (402).
        - Pro $29: 20 projects, 50 GB, 200 GB bandwidth, 2,000 deploys, 4,000 check
          minutes, 10 custom domains, 2,000 form submissions (soft overage
          $0.005/submission past included).
        - Scale $149: 125 projects, 250 GB, 1.5 TB bandwidth, 20,000 deploys,
          40,000 check minutes, 100 custom domains, 20,000 form submissions
          (soft overage $0.005/submission).

        Bandwidth overage meter is $0.08/GB (S3→Cloudflare origin pull COGS
        ~$0.09/GB; Workers visitor egress is $0). Unverified accounts keep
        `customDomains: 0` and `formSubmissionsPerMonth: 0`.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
  /v1/account:
    get:
      summary: Account snapshot
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
  /v1/account/console-kit:
    get:
      summary: Local HTML console kit
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
  /v1/account/sessions:
    post:
      summary: Mint mgmt_ session (30m, account:read + billing:portal)
      security: [{ bearerAuth: [] }]
      responses:
        "201":
          description: Created
  /v1/account/sessions/request:
    post:
      summary: Request OTP for management session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: OTP sent
  /v1/account/sessions/verify:
    post:
      summary: Verify OTP and mint management session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string }
      responses:
        "201":
          description: Created
  /v1/account/sessions/current:
    delete:
      summary: Revoke current management session
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Revoked
  /v1/billing/checkout:
    post:
      summary: Stripe Checkout session (Pro / Scale)
      description: |
        Returns a Stripe Checkout URL for a Pro ($29) or Scale subscription.
        Requires a verified email (403 otherwise). Use after a 2nd project or a
        402 — never after the first live URL. Prefer MCP `checkout` when on MCP.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                plan:
                  type: string
                  enum: [pro, scale]
                  default: pro
                successUrl: { type: string, format: uri }
                cancelUrl: { type: string, format: uri }
      responses:
        "200":
          description: Checkout session
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri, nullable: true }
                  id: { type: string }
        "401":
          $ref: "#/components/responses/Error"
        "403":
          description: Email not verified
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string }
  /v1/billing/portal:
    post:
      summary: Stripe Customer Portal
      description: Manage an existing subscription. Not first-time subscribe — use `/v1/billing/checkout` or `/v1/billing/mpp`.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                returnUrl: { type: string, format: uri }
      responses:
        "200":
          description: Portal URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
  /v1/billing/mpp:
    get:
      summary: Subscribe via MPP SPT
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Subscribed
        "402":
          description: Payment required
    post:
      summary: Subscribe via MPP SPT
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: Subscribed
        "402":
          description: Payment required
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (nv_…) or management token (mgmt_…)
  parameters:
    ProjectId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Error:
      description: JSON error
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: { type: string }
    QuotaExceeded:
      description: |
        Quota wall. `error` is the human string. `wall` is `verify` (OTP only —
        Free already covers this cap; no Checkout / $29) or `upgrade` (Checkout
        or MPP). An unverified 2nd project is `upgrade` with verify-then-Pro
        next actions.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/QuotaExceeded"
    RateLimited:
      description: Rate limited — Retry-After is seconds until the rolling hour window resets
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the client may retry
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: { type: string }
              retryAfter: { type: integer, description: Same value as the Retry-After header }
  schemas:
    QuotaExceeded:
      type: object
      required: [error, wall, next]
      properties:
        error: { type: string }
        wall:
          type: string
          enum: [verify, upgrade]
          description: |
            verify — OTP only (Free already covers this cap; no Checkout / $29).
            upgrade — Checkout or MPP. Unverified 2nd project is verify-then-Pro.
        next:
          type: object
          required: [mcp, rest]
          properties:
            mcp:
              type: array
              items: { type: string }
              description: MCP tool names (e.g. verify_email, checkout, mpp_upgrade)
            rest:
              type: array
              items: { type: string }
              description: REST paths (e.g. POST /v1/auth/verify-email)
    Project:
      type: object
      required: [id, name, suffix, url, status]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        suffix: { type: string, description: Subdomain label for https://{suffix}.novence.ai/ }
        url: { type: string, format: uri }
        status:
          type: string
          description: Project lifecycle (active/deleted/suspended) — NOT deploy status
        randomSuffix:
          type: string
          deprecated: true
          description: Alias of suffix (deprecated)
        customDomain: { type: string, nullable: true }
        customDomainStatus: { type: string }
        analyticsEnabled:
          { type: boolean, description: "Cookieless edge analytics. Default false. Enable via PATCH." }
        shareGateEmails:
          type: array
          items: { type: string, format: email }
          description: |
            Invited emails for the optional share gate (max 5). Empty means the
            platform host is public. Custom domains are never gated.
    UploadPresign:
      type: object
      properties:
        path: { type: string }
        uploadUrl: { type: string, format: uri }
        s3Key: { type: string }
        contentType: { type: string }
        expiresIn: { type: integer }
    StagingFile:
      type: object
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        path: { type: string }
        s3Key: { type: string }
        sizeBytes: { type: integer }
        contentType: { type: string }
        etag: { type: string, nullable: true }
    ConfirmBatchResponse:
      type: object
      required: [files, errors]
      properties:
        files:
          type: array
          items: { $ref: "#/components/schemas/StagingFile" }
        errors:
          type: array
          description: Failed paths. Empty on full success. Retry these only.
          items:
            type: object
            required: [path, error]
            properties:
              path: { type: string }
              error: { type: string }
    ChecksResults:
      type: object
      description: |
        Quality-gate payload. When `skipped` is true (unverified accounts),
        `passed` is null — do not treat that as checks having passed.
      properties:
        passed: { type: boolean, nullable: true }
        skipped: { type: boolean }
        reason: { type: string }
    ApiKey:
      type: object
      required: [id, keyPrefix, name, scope, current]
      properties:
        id: { type: string, format: uuid }
        keyPrefix: { type: string, description: First 12 chars, e.g. nv_live_ab12 }
        name: { type: string, description: "account, project, or member:{email}" }
        projectId: { type: string, format: uuid, nullable: true }
        scope:
          type: string
          enum: [account, project]
          description: Account keys have a null projectId
        permissions: { type: object, additionalProperties: { type: boolean } }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        current:
          type: boolean
          description: True for the key authenticating this request
    Deployment:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          description: pending|checking|promoting|live|failed
        phase:
          type: string
          enum: [queued, checking, promoting, live, failed]
        checksResults:
          allOf:
            - $ref: "#/components/schemas/ChecksResults"
          nullable: true
        checksUrl: { type: string }
        errorMessage: { type: string, nullable: true }
        previewUrl: { type: string, format: uri }
        liveUrl: { type: string, format: uri, nullable: true }
    PublishHtmlRequest:
      type: object
      required: [html]
      properties:
        html:
          type: string
          description: Full HTML document stored as /index.html
        title:
          type: string
          description: Project name when creating a project
        projectId:
          type: string
          format: uuid
          description: Existing project. Omit to create one.
        project_id:
          type: string
          format: uuid
          description: Snake_case alias for projectId
        force: { type: boolean }
    PublishHtmlResponse:
      type: object
      required: [url, projectId, created]
      properties:
        url: { type: string, format: uri }
        projectId: { type: string, format: uuid }
        project: { $ref: "#/components/schemas/Project" }
        deployment: { $ref: "#/components/schemas/Deployment" }
        created:
          type: boolean
          description: True when this call created the project
