openapi: 3.1.0
info:
  title: Selgeo Public API
  version: "1.0.0"
  description: |
    Three unauthenticated endpoints consumed by the marketing landing
    (selgeo.com, Cloudflare Pages SSG, separate repository).

    - `POST /api/public/waitlist` — email capture, gated by a honeypot + time-to-submit anti-spam check.
    - `GET  /api/public/waitlist/waitlist-count` — live counter for the landing.
    - `POST /api/public/contact` — contact-form message capture, same anti-spam gate.

    All three endpoints are anonymous: no API key, no bearer token, no cookies,
    no credentials in CORS. Privacy footprint is documented in the Selgeo
    public privacy policy.

servers:
  - url: https://app.selgeo.com
    description: Production
  - url: https://staging.selgeo.com
    description: Staging

paths:
  /api/public/waitlist:
    post:
      summary: Add an email to the Selgeo waitlist
      operationId: postWaitlist
      tags: [Waitlist]
      description: |
        Persists the normalised (lowercased + trimmed) email as a new
        `WaitlistSignup` row. The response is byte-identical for both a
        new signup and a duplicate submission — the caller cannot
        distinguish them.

        Anti-spam gate: the landing page renders a hidden `website`
        honeypot and captures `elapsedMs` (form-mount-to-submit, in
        milliseconds). The server rejects the request with 400
        `ANTI_SPAM_FAILED` when the honeypot is non-empty or
        `elapsedMs` is outside `[1500, 1_800_000]`. No third-party
        service is called during signup.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WaitlistSignupRequest"
            examples:
              valid:
                summary: Valid signup
                value:
                  email: "alice@example.com"
                  website: ""
                  elapsedMs: 2000
      responses:
        "201":
          description: |
            Created. Empty body. Returned both for new signups and for
            duplicate submissions of an email already on the waitlist
            (per FR-009 indistinguishability).
        "400":
          description: Validation or anti-spam failure
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                invalidEmail:
                  value:
                    statusCode: 400
                    message: "INVALID_EMAIL"
                    error: "Bad Request"
                    code: "INVALID_EMAIL"
                antiSpamFailed:
                  value:
                    statusCode: 400
                    message: "Anti-spam verification failed"
                    error: "Bad Request"
                    code: "ANTI_SPAM_FAILED"
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller should retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                statusCode: 429
                message: "Too many requests"
                error: "Too Many Requests"
                code: "RATE_LIMITED"

  /api/public/waitlist/waitlist-count:
    get:
      summary: Get the displayed waitlist counter
      operationId: getWaitlistCount
      tags: [Waitlist]
      description: |
        Returns the displayed counter used by the landing to show
        "X of Y spots taken". Server computes
        `taken = min(BASE + active_signup_count, total)` at read time;
        `total` is a configuration constant (50 in v1).

        Response is cached internally for 30–60 s; `Cache-Control: public, max-age=30`
        aligns edge/browser caching with server freshness.

        No additional fields are exposed. Adding fields is a breaking
        change under the constitution's API versioning rules.
      responses:
        "200":
          description: Current counter state
          headers:
            Cache-Control:
              schema: { type: string }
              example: "public, max-age=30"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WaitlistCountResponse"
              examples:
                normal:
                  summary: 27 of 50 taken
                  value: { taken: 27, total: 50 }
                full:
                  summary: Displayed cap reached
                  value: { taken: 50, total: 50 }

  /api/public/contact:
    post:
      summary: Submit a contact-form message
      operationId: postContact
      tags: [Contact]
      description: |
        Delivers a message from the landing-page contact form to the
        Selgeo team (buffered in `contact_outbox`, drained server-side to
        email). The response is an empty 201. Same anti-spam gate as the
        waitlist endpoint (hidden `website` honeypot + `elapsedMs`), and
        it shares the waitlist per-IP rate limit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ContactFormRequest"
            examples:
              valid:
                summary: Valid message
                value:
                  name: "Alice Smith"
                  email: "alice@example.com"
                  message: "Tell me more about partner payouts."
                  website: ""
                  elapsedMs: 4000
      responses:
        "201":
          description: Created. Empty body. Message accepted.
        "400":
          description: Validation or anti-spam failure
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                invalidRequest:
                  value:
                    statusCode: 400
                    message: "INVALID_REQUEST"
                    error: "Bad Request"
                    code: "INVALID_REQUEST"
                antiSpamFailed:
                  value:
                    statusCode: 400
                    message: "Anti-spam verification failed"
                    error: "Bad Request"
                    code: "ANTI_SPAM_FAILED"
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the caller should retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                statusCode: 429
                message: "Too many requests"
                error: "Too Many Requests"
                code: "RATE_LIMITED"

components:
  schemas:
    WaitlistSignupRequest:
      type: object
      required: [email, elapsedMs]
      # FR-006: extra fields are silently stripped by the server, not rejected.
      # `additionalProperties` is left unset (OpenAPI default: true) to reflect
      # the runtime behaviour — clients may send extras without a 400.
      properties:
        email:
          type: string
          format: email
          maxLength: 320
          description: |
            Prospect's email. Normalised to lowercase + trimmed before
            persistence and deduplication.
        website:
          type: string
          maxLength: 0
          default: ""
          description: |
            Honeypot — the landing page renders this input hidden so
            humans never see it. Any non-empty value is a spam signal
            and the request is rejected with `ANTI_SPAM_FAILED`. Not
            persisted.
        elapsedMs:
          type: integer
          minimum: 1500
          maximum: 1800000
          description: |
            Milliseconds between form mount on the landing page and
            submission. Values below 1500 are rejected as scripted;
            values above 1 800 000 (30 min) are rejected as a stale
            pre-rendered form. Not persisted.

    ContactFormRequest:
      type: object
      required: [name, email, message, elapsedMs]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Sender name. Trimmed.
        email:
          type: string
          format: email
          description: Reply-to email. Trimmed.
        message:
          type: string
          minLength: 10
          maxLength: 5000
          description: Message body. Trimmed.
        website:
          type: string
          maxLength: 0
          default: ""
          description: |
            Honeypot — hidden on the landing page. Any non-empty value is
            rejected with `ANTI_SPAM_FAILED`. Not persisted.
        elapsedMs:
          type: number
          minimum: 1500
          maximum: 1800000
          description: |
            Milliseconds between form mount and submission. Out-of-range
            values are rejected with `ANTI_SPAM_FAILED`. Not persisted.

    WaitlistCountResponse:
      type: object
      required: [taken, total]
      additionalProperties: false
      properties:
        taken:
          type: integer
          minimum: 0
          description: |
            `min(BASE + active_signup_count, total)`. BASE is a server
            configuration parameter; active_signup_count is the number
            of rows in the waitlist table.
        total:
          type: integer
          minimum: 1
          description: |
            Configured cap. 50 in v1. Displayed-only — does not affect
            persistence.

    ErrorResponse:
      type: object
      required: [statusCode, message, error, code]
      additionalProperties: false
      properties:
        statusCode:
          type: integer
          example: 400
        message:
          type: string
          description: |
            Human-readable for guard rejections ("Anti-spam verification
            failed", "Too many requests"); for Zod validation failures it is
            the joined machine code(s) (e.g. `INVALID_EMAIL` / `INVALID_REQUEST`).
            Branch on `code`, never on `message`.
          example: "INVALID_EMAIL"
        error:
          type: string
          example: "Bad Request"
        code:
          type: string
          enum: [INVALID_EMAIL, INVALID_REQUEST, ANTI_SPAM_FAILED, RATE_LIMITED]
          description: |
            Machine-readable error code. The landing maps this to a
            localised message — never surface `message` verbatim to
            users.

  responses: {}
  parameters: {}
  securitySchemes: {}

security: []  # Anonymous public API — no security schemes
