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/Error"
  /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: Resend email verification OTP (15 min TTL)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Sent
  /v1/auth/reissue-key:
    post:
      summary: Re-issue account API key after OTP
      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
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string, format: uuid }
                  apiKey: { type: string }
                  emailVerified: { type: boolean }
  /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 }
  /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"
  /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 always `null` while edge metering is pending —
        do not treat a zero as measured usage. Quotas are account-level
        (shown for bar context); enforcement stays on the account.
      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
                        nullable: true
                        description: Always null until edge metering ships
                  quotas:
                    type: object
                    description: Account plan quotas (context for usage bars)
                  bandwidthMetering:
                    type: string
                    enum: [pending]
        "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: OK
  /v1/projects/{id}/uploads/confirm-batch:
    post:
      summary: Confirm many uploads
      description: Per-file errors returned so a partial upload can be resumed.
      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: OK
  /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
      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: { type: object }
                  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/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/portal:
    post:
      summary: Stripe Customer Portal
      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 }
  schemas:
    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 }
    UploadPresign:
      type: object
      properties:
        path: { type: string }
        uploadUrl: { type: string, format: uri }
        s3Key: { type: string }
        contentType: { type: string }
        expiresIn: { type: integer }
    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: { type: object, nullable: true }
        checksUrl: { type: string }
        errorMessage: { type: string, nullable: true }
        previewUrl: { type: string, format: uri }
        liveUrl: { type: string, format: uri, nullable: true }
