openapi: 3.0.3
info:
  title: Galaxy Edge Gateway Public REST API
  version: v1
  description: |
    This specification documents the implemented `galaxy/gateway` v1 public
    REST surface.

    At the edge this surface is served same-origin under one host: the edge
    Caddy routes `/api/*` and `/healthz` to the public REST listener, so those
    paths are reached at `https://<host>/healthz` and
    `https://<host>/api/v1/public/auth/*`. The authenticated Connect/gRPC-Web
    surface lives behind the same host at `/rpc/*` and is not part of this
    REST contract. `/readyz` is an in-process readiness probe served on the
    listener itself and is not exposed through the edge.

    Implemented endpoints:
    - `GET /healthz`
    - `GET /readyz`
    - `POST /api/v1/public/auth/send-email-code`
    - `POST /api/v1/public/auth/confirm-email-code`

    This specification intentionally excludes the private operational admin
    listener and its `GET /metrics` endpoint. That endpoint is documented in
    `README.md` because it is not part of the public REST contract.

    Common runtime behavior:
    - requests are unauthenticated;
    - unknown routes return `404` with the JSON error envelope;
    - unsupported methods on implemented routes and browser-shaped public paths
      return `405` with the same JSON error envelope and an `Allow` header;
    - request classification happens before route handling and depends on the
      incoming method, path, and selected headers;
    - the only stable public route classes are `public_auth`,
      `browser_bootstrap`, `browser_asset`, and `public_misc`;
    - any unsupported or empty classifier result is normalized to
      `public_misc`;
    - public REST policy derives its base bucket namespace from the normalized
      class as `public_rest/class=<class>`;
    - per-IP public REST rate limits use only `RemoteAddr`; `X-Forwarded-For`
      and `Forwarded` are intentionally ignored;
    - `public_auth` additionally applies normalized identity buckets by
      `email` for `send-email-code` and by `challenge_id` for
      `confirm-email-code`;
    - oversized request bodies are rejected with `413 request_too_large`;
    - public REST rate limits reject with `429 rate_limited` and a
      `Retry-After` header;
    - public auth routes delegate through `AuthServiceClient`;
    - the default `cmd/gateway` wiring keeps the auth routes mounted and
      returns `503 service_unavailable` until a concrete upstream auth adapter
      is configured;
    - injected public auth adapters may also project client-safe `4xx/5xx`
      `AuthServiceError` envelopes, which the gateway preserves after
      normalizing blank or invalid fields.
servers:
  - url: http://localhost:8080
    description: |
      Example local public REST listener. The actual address is configured by
      `GATEWAY_PUBLIC_HTTP_ADDR`.
tags:
  - name: Probes
    description: Unauthenticated public probe endpoints served by the gateway.
  - name: PublicAuth
    description: |
      Unauthenticated public auth endpoints delegated to the Auth / Session
      Service through `AuthServiceClient`.
paths:
  /healthz:
    get:
      tags:
        - Probes
      operationId: getHealthz
      summary: Public liveness probe
      description: |
        Returns a deterministic JSON payload confirming that the public REST
        listener is alive and able to answer requests.
      security: []
      x-public-route-classification-note: |
        Typical probe requests are classified as `public_misc`.
        Requests that match browser bootstrap rules, for example because they
        advertise `Accept: text/html`, are classified as `browser_bootstrap`
        before the route handler runs.
      responses:
        "200":
          description: Public REST listener is alive.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthzResponse"
              examples:
                ok:
                  value:
                    status: ok
        "413":
          $ref: "#/components/responses/RequestTooLargeError"
        "429":
          $ref: "#/components/responses/RateLimitedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /readyz:
    get:
      tags:
        - Probes
      operationId: getReadyz
      summary: Public readiness probe
      description: |
        Returns a deterministic JSON payload confirming that the process is
        ready to accept public REST traffic. Readiness is local-process only
        and does not reflect downstream dependencies.
      security: []
      x-public-route-classification-note: |
        Typical probe requests are classified as `public_misc`.
        Requests that match browser bootstrap rules, for example because they
        advertise `Accept: text/html`, are classified as `browser_bootstrap`
        before the route handler runs.
      responses:
        "200":
          description: Public REST listener is ready to accept traffic.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadyzResponse"
              examples:
                ready:
                  value:
                    status: ready
        "413":
          $ref: "#/components/responses/RequestTooLargeError"
        "429":
          $ref: "#/components/responses/RateLimitedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/public/auth/send-email-code:
    post:
      tags:
        - PublicAuth
      operationId: sendEmailCode
      summary: Start a public e-mail login challenge
      description: |
        Accepts a single client e-mail address and delegates the command to the
        Auth / Session Service. The response returns an opaque `challenge_id`
        that must later be confirmed through
        `POST /api/v1/public/auth/confirm-email-code`.

        Callers select the auth-mail locale through the optional
        `locale` field on the JSON body, which takes priority over the
        request `Accept-Language` header. The body field is the canonical
        channel because Safari silently drops JS-set `Accept-Language`
        headers; non-Safari clients can still rely on the header alone.
        Missing or unsupported values fall back to `en`.

        This route is unauthenticated and classified as `public_auth`.
        Public REST anti-abuse applies a per-IP bucket derived from
        `RemoteAddr` and an additional normalized identity bucket derived from
        `email`.

        In the default `cmd/gateway` process wiring the upstream auth adapter
        is intentionally absent, so this route returns `503
        service_unavailable` until a concrete `AuthServiceClient` is injected.
        When an injected adapter returns a client-safe `AuthServiceError`, the
        gateway preserves that projected `4xx/5xx` status and serialized error
        envelope after normalizing blank or invalid fields.
      security: []
      parameters:
        - $ref: "#/components/parameters/AcceptLanguage"
      x-public-route-classification-note: |
        This route is always classified as `public_auth`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendEmailCodeRequest"
            examples:
              default:
                value:
                  email: pilot@example.com
      responses:
        "200":
          description: The login challenge was accepted by the Auth / Session Service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendEmailCodeResponse"
              examples:
                accepted:
                  value:
                    challenge_id: challenge-123
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "413":
          $ref: "#/components/responses/RequestTooLargeError"
        "405":
          $ref: "#/components/responses/MethodNotAllowedError"
        "429":
          $ref: "#/components/responses/RateLimitedError"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/ServiceUnavailableError"
        default:
          $ref: "#/components/responses/ProjectedAuthServiceError"
  /api/v1/public/auth/confirm-email-code:
    post:
      tags:
        - PublicAuth
      operationId: confirmEmailCode
      summary: Confirm a public e-mail login challenge
      description: |
        Completes a previously issued `challenge_id`, sends the verification
        `code`, and registers the standard base64-encoded raw 32-byte Ed25519
        `client_public_key` for the new device session. The caller must also
        supply the client-selected IANA `time_zone`, which the gateway forwards
        unchanged to the Auth / Session Service. The response returns the
        created `device_session_id`.

        This route is unauthenticated and classified as `public_auth`.
        Public REST anti-abuse applies a per-IP bucket derived from
        `RemoteAddr` and an additional normalized identity bucket derived from
        `challenge_id`.

        In the default `cmd/gateway` process wiring the upstream auth adapter
        is intentionally absent, so this route returns `503
        service_unavailable` until a concrete `AuthServiceClient` is injected.
        When an injected adapter returns a client-safe `AuthServiceError`, the
        gateway preserves that projected `4xx/5xx` status and serialized error
        envelope after normalizing blank or invalid fields.
      security: []
      x-public-route-classification-note: |
        This route is always classified as `public_auth`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConfirmEmailCodeRequest"
            examples:
              default:
                value:
                  challenge_id: challenge-123
                  code: "123456"
                  client_public_key: base64-encoded-raw-ed25519-public-key
                  time_zone: Europe/Kaliningrad
      responses:
        "200":
          description: The device session was created by the Auth / Session Service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConfirmEmailCodeResponse"
              examples:
                accepted:
                  value:
                    device_session_id: device-session-123
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "413":
          $ref: "#/components/responses/RequestTooLargeError"
        "405":
          $ref: "#/components/responses/MethodNotAllowedError"
        "429":
          $ref: "#/components/responses/RateLimitedError"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          $ref: "#/components/responses/ServiceUnavailableError"
        default:
          $ref: "#/components/responses/ProjectedAuthServiceError"
components:
  parameters:
    AcceptLanguage:
      name: Accept-Language
      in: header
      required: false
      description: |
        Optional RFC 9110 `Accept-Language` header used by gateway to derive
        the auth-mail locale and first-login preferred-language candidate.
        The first supported BCP 47 tag wins; unsupported or missing values
        fall back to `en`.
      schema:
        type: string
  schemas:
    HealthzResponse:
      type: object
      additionalProperties: false
      required:
        - status
      properties:
        status:
          type: string
          description: Deterministic liveness marker.
          enum:
            - ok
    ReadyzResponse:
      type: object
      additionalProperties: false
      required:
        - status
      properties:
        status:
          type: string
          description: Deterministic readiness marker.
          enum:
            - ready
    SendEmailCodeRequest:
      type: object
      additionalProperties: false
      required:
        - email
      properties:
        email:
          type: string
          description: Single client e-mail address that should receive the login code.
          format: email
        locale:
          type: string
          description: |
            Optional BCP 47 language tag the caller prefers for the
            delivered code. The body field is the canonical channel
            because Safari silently drops JS-set Accept-Language
            headers; when set, it overrides the request
            `Accept-Language` for preferred-language resolution.
            Empty / malformed values fall back to the header, which
            in turn falls back to `en`.
    SendEmailCodeResponse:
      type: object
      additionalProperties: false
      required:
        - challenge_id
      properties:
        challenge_id:
          type: string
          description: Opaque challenge identifier returned by the Auth / Session Service.
    ConfirmEmailCodeRequest:
      type: object
      additionalProperties: false
      required:
        - challenge_id
        - code
        - client_public_key
        - time_zone
      properties:
        challenge_id:
          type: string
          description: Opaque challenge identifier previously returned by send-email-code.
        code:
          type: string
          description: Verification code delivered to the client.
        client_public_key:
          type: string
          description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session.
        time_zone:
          type: string
          description: |
            Client-selected IANA time zone name forwarded to the Auth /
            Session Service as registration context for first-time user
            creation.
    ConfirmEmailCodeResponse:
      type: object
      additionalProperties: false
      required:
        - device_session_id
      properties:
        device_session_id:
          type: string
          description: Stable identifier of the created device session.
    ErrorResponse:
      type: object
      additionalProperties: false
      required:
        - error
      properties:
        error:
          $ref: "#/components/schemas/ErrorBody"
    ErrorBody:
      type: object
      additionalProperties: false
      required:
        - code
        - message
      properties:
        code:
          type: string
          description: |
            Stable gateway-generated or client-safe auth-adapter-projected
            error code. Gateway-generated values include `invalid_request`,
            `not_found`, `method_not_allowed`, `request_too_large`,
            `rate_limited`, `internal_error`, and `service_unavailable`.
        message:
          type: string
          description: Human-readable client-safe error description.
  headers:
    Allow:
      description: Comma-separated list of allowed methods for the target route.
      schema:
        type: string
      example: GET
    Retry-After:
      description: Seconds until the client should retry a rejected rate-limited request.
      schema:
        type: string
      example: "3600"
  responses:
    InvalidRequestError:
      description: Request body or field values are invalid for the target public auth route.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            invalidRequest:
              value:
                error:
                  code: invalid_request
                  message: email must be a single valid email address
    NotFoundError:
      description: Request path is not implemented on the current public REST surface.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            notFound:
              value:
                error:
                  code: not_found
                  message: resource was not found
    MethodNotAllowedError:
      description: Request method is not allowed for an implemented route.
      headers:
        Allow:
          $ref: "#/components/headers/Allow"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            methodNotAllowed:
              value:
                error:
                  code: method_not_allowed
                  message: request method is not allowed for this route
    RequestTooLargeError:
      description: Request body exceeds the configured public REST body limit for the route class.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            requestTooLarge:
              value:
                error:
                  code: request_too_large
                  message: request body exceeds the configured limit
    RateLimitedError:
      description: Request is rejected by the public REST anti-abuse rate limiter.
      headers:
        Retry-After:
          $ref: "#/components/headers/Retry-After"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            rateLimited:
              value:
                error:
                  code: rate_limited
                  message: request rate limit exceeded
    InternalError:
      description: Internal gateway error while processing the request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            internalError:
              value:
                error:
                  code: internal_error
                  message: internal server error
    ServiceUnavailableError:
      description: |
        The public route is mounted, but the configured or default auth adapter
        cannot currently serve the request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            unavailable:
              value:
                error:
                  code: service_unavailable
                  message: auth service is unavailable
    ProjectedAuthServiceError:
      description: |
        Client-safe `4xx/5xx` error envelope projected by an injected public
        auth adapter through `AuthServiceError`. The gateway preserves the
        projected status and serialized envelope after normalizing blank or
        invalid fields.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            projectedRateLimit:
              value:
                error:
                  code: upstream_rate_limited
                  message: too many attempts for this email
