openapi: 3.1.0
info:
  title: Bigscreen Auth API
  version: '0'
  summary: Auth + identity surface of apps/api — login, account creation / profile, password reset, OAuth.
  description: |
    The auth surface of [`apps/api/api.ts`](../../apps/api/api.ts).
    Everything to do with identity: account creation, login (email
    + password, Steam, Oculus, magic-link), token verify / renew,
    profile retrieval and update, identity attach / detach, password
    reset, email verification, plus the OAuth 2.0 token endpoint and
    RFC 7517 / RFC 8414 well-known discovery.

    Sister spec: [`public-api`](./scalar/public.html) on the same
    service hosts everything non-auth — media, Beyond scans / orders,
    channels, topics, analytics, reports, eye-tracking, the Shopify
    webhook. Both bind to port `3009` and share the `api` bearer key —
    the split is documentation-only.

    > Distinct from [`apps/admin_api`](./scalar/admin.html) (admin
    > pipeline) and the cloud APIs ([cloud admin](./scalar/cloud.html),
    > [cloud public](./scalar/cloud-public.html)).

    ## Auth at a glance

    Most requests need two headers:

    | Header | Value |
    |--------|-------|
    | `Authorization` | `Bearer <api app key>` |
    | `x-access-token` | A JWT issued by `/auth/login` (or one of the
                         other login flows) — required on routes marked
                         `Auth: required` |

    Bypasses (no `Authorization` bearer needed):

    - `/oauth/token`, `/.well-known/jwks.json`,
      `/.well-known/oauth-authorization-server` — public per
      RFC 7517 / RFC 8414. Authenticate via `client_id` + `client_secret`
      in the request body. Gated on `OAUTH_TOKEN_ENABLED=true`;
      otherwise return `501`.

    Token renewal is via `GET /auth/renew` (or the legacy
    `POST /renew`) with the refresh token in `x-refresh-token` and a
    nonce in `x-nonce`. When the access token has expired, the server
    returns a fresh nonce in the `x-nonce` response header so the
    client can immediately retry against `/auth/renew`.

    ## Spec layout

    Sources are split per handler module under
    `docs/openapi/auth-modules/*.yaml`, joined by `$ref` from this
    root file. The bundled artifact at
    `docs/openapi/auth-api.bundled.yaml` is what the Scalar renderer
    reads — regenerate it with
    `npx -y @redocly/cli@latest bundle docs/openapi/auth-api.yaml -o docs/openapi/auth-api.bundled.yaml`
    after any edit.
servers:
  - url: http://localhost:3009
    description: Local dev (started by `yarn dev` — port `3009` is hard-coded in `apps/api/api.ts`).
  - url: https://api.bigscreenvr.com
    description: Production.
security:
  - apiKey: []
    accessToken: []
tags:
  - name: OAuth
    description: |
      OAuth 2.0 token endpoint plus RFC 7517 / RFC 8414 well-known
      discovery. Mounted before the standard auth middleware — clients
      authenticate via `client_id` + `client_secret`, not the Bigscreen
      bearer key. All three return `501` when
      `OAUTH_TOKEN_ENABLED` is not `true`.
  - name: Auth
    description: |
      Login, logout, access-token renew/verify, magic-link redemption,
      and the `whoami` debug endpoint. Username/password, Steam, and
      Oculus flows all live here.
  - name: Accounts
    description: |
      Account creation, profile retrieval and update, identity attach /
      detach (Steam, Oculus, email/password), email verification,
      per-device status, and feature flags.
  - name: Password
    description: Forgot-password / reset flow plus email-verification resend.
paths:
  /oauth/token:
    post:
      tags:
        - OAuth
      operationId: oauthToken
      summary: OAuth 2.0 token endpoint
      description: |
        RFC 6749 token endpoint. Supports `authorization_code` and
        `refresh_token` grants. Authenticates via `client_id` +
        `client_secret` in the request body — does **not** use the
        Bigscreen `apiKey` bearer.

        Returns `501 endpoint_disabled` when
        `OAUTH_TOKEN_ENABLED` is not `true`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
                - grant_type
                - client_id
                - client_secret
              properties:
                grant_type:
                  type: string
                  enum:
                    - authorization_code
                    - refresh_token
                client_id:
                  type: string
                client_secret:
                  type: string
                code:
                  type: string
                  description: Authorization code, when `grant_type=authorization_code`.
                redirect_uri:
                  type: string
                  format: uri
                  description: Original redirect URI, when `grant_type=authorization_code`.
                refresh_token:
                  type: string
                  description: Refresh token, when `grant_type=refresh_token`.
                scope:
                  type: string
                  description: Space-separated scope subset request (refresh_token grant).
      responses:
        '200':
          description: Granted token bundle.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthTokenResponse'
        '400':
          description: Invalid grant / missing parameters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '401':
          description: Invalid `client_id` / `client_secret`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '501':
          description: OAuth disabled (`OAUTH_TOKEN_ENABLED` is not `true`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
  /.well-known/jwks.json:
    get:
      tags:
        - OAuth
      operationId: oauthJwks
      summary: JWKS — OAuth signing keys
      description: |
        RFC 7517 JWK Set — the public keys used to verify access /
        refresh tokens issued by `/oauth/token`. Returns `501` when
        OAuth is disabled.
      security: []
      responses:
        '200':
          description: JWK Set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JWKSResponse'
        '501':
          description: OAuth disabled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
  /.well-known/oauth-authorization-server:
    get:
      tags:
        - OAuth
      operationId: oauthServerMetadata
      summary: OAuth authorization-server metadata
      description: |
        RFC 8414 authorization-server discovery document — describes
        the issuer, supported grant types, JWKS URI, and registered
        endpoints. Returns `501` when OAuth is disabled.
      security: []
      responses:
        '200':
          description: Discovery document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthorizationServerMetadata'
        '501':
          description: OAuth disabled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
  /auth/whoami:
    get:
      tags:
        - Auth
      operationId: whoami
      summary: Bearer / IP debug echo
      description: |
        Returns a debug payload describing how the server resolved the
        caller's IP (express `req.ip`, `x-forwarded-for`, raw socket,
        `forwarded-real-ip`). Useful for verifying the
        `trust proxy` configuration in front of the service.
      security:
        - apiKey: []
      responses:
        '200':
          description: Debug payload.
          content:
            application/json:
              schema:
                type: object
                required:
                  - result
                  - ip
                  - chain
                properties:
                  result:
                    type: string
                    example: successfully authenticated
                  ip:
                    type: string
                  chain:
                    type: object
                    properties:
                      reqIp:
                        type: string
                      reqIps:
                        type: array
                        items:
                          type: string
                      socket:
                        type: string
                      xForwardedFor:
                        type:
                          - string
                          - 'null'
                      forwardedRealIp:
                        type:
                          - string
                          - 'null'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /login:
    post:
      tags:
        - Auth
      operationId: legacyEmailPasswordLogin
      summary: Email / password login (deprecated alias for `/auth/login`)
      description: |
        **Deprecated.** Behaves identically to `POST /auth/login`. Kept
        for legacy clients; new clients should use `/auth/login`.
      deprecated: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailPasswordLoginRequest'
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Bad credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /renew:
    post:
      tags:
        - Auth
      operationId: legacyRenewAccessToken
      summary: Renew access token (deprecated alias for `/auth/renew`)
      description: |
        **Deprecated.** Behaves identically to `GET /auth/renew` — the
        method is `POST` here for legacy reasons. Only the access
        token is rotated — the refresh token is reused.
      deprecated: true
      security:
        - apiKey: []
          refreshToken: []
          nonce: []
      responses:
        '200':
          description: Refreshed access token (in `x-access-token` response header).
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /verify:
    post:
      tags:
        - Auth
      operationId: legacyCheckAccountStatus
      summary: Verify access token (deprecated alias for `/auth/verify`)
      description: |
        **Deprecated.** Behaves identically to `GET /auth/verify` — the
        method is `POST` here for legacy reasons.
      deprecated: true
      responses:
        '200':
          description: Token is valid; account status payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountStatusResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/login:
    post:
      tags:
        - Auth
      operationId: handleEmailPasswordLogin
      summary: Email / password login
      description: |
        Verifies the supplied credentials and emits an access /
        refresh token bundle. Tokens travel via response **headers**
        (`x-access-token`, `x-refresh-token`, `x-timestamp`,
        `x-nonce`) — the response body is a `{ result: "OK" }`
        sentinel.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailPasswordLoginRequest'
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Bad credentials or banned account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/logout:
    get:
      tags:
        - Auth
      operationId: handleLogout
      summary: Invalidate the current access token
      description: |
        Marks the access token as no longer valid. The refresh token
        is also retired — re-login is required to mint a new bundle.
      responses:
        '200':
          description: Logged out.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/oculus/login:
    post:
      tags:
        - Auth
      operationId: handleOculusLogin
      summary: Login via Oculus credentials
      description: |
        Exchanges Oculus credentials for a Bigscreen access bundle.
        Tokens travel via response headers — see `/auth/login`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OculusLoginRequest'
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Invalid Oculus credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/steam/login:
    post:
      tags:
        - Auth
      operationId: handleSteamLogin
      summary: Login via Steam credentials
      description: |
        Exchanges Steam OpenID credentials for a Bigscreen access
        bundle. Tokens travel via response headers — see `/auth/login`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SteamLoginRequest'
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Invalid Steam credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/verify:
    get:
      tags:
        - Auth
      operationId: checkAccountStatus
      summary: Verify access token
      description: |
        Returns `200` when the access token is valid. The presence of
        `200` already implies the account is in an admitted state —
        banned / read-only states surface as `401` / `403`, not in the
        body. The body is a `{ result: "OK" }` sentinel.
      responses:
        '200':
          description: Token valid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountStatusResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/renew:
    get:
      tags:
        - Auth
      operationId: renewAccessToken
      summary: Renew an access token with a refresh token
      description: |
        Exchanges a refresh token + one-time nonce for a fresh access
        token. The nonce is the value returned in the `x-nonce`
        response header on the previous expiry response. **Only the
        access token rotates** — the refresh token is reused. The
        new access token is in the `x-access-token` response header.
      security:
        - apiKey: []
          refreshToken: []
          nonce: []
      responses:
        '200':
          description: Refreshed access token (in `x-access-token` response header).
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/oculus/verify:
    post:
      tags:
        - Auth
      operationId: checkOculusNonce
      summary: Verify an Oculus nonce (non-prod only)
      description: |
        **Non-production only.** Bound when
        `api.get("env") !== "production"` — confirms a supplied Oculus
        nonce is valid against the Oculus API. Used by integration
        tests; not registered on production deployments.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - oculusUserId
                - nonce
              properties:
                oculusUserId:
                  type: string
                nonce:
                  type: string
      responses:
        '200':
          description: Nonce is valid.
          content:
            application/json:
              schema:
                type: object
                required:
                  - result
                properties:
                  result:
                    type: string
                    example: ok
        '401':
          description: Nonce is invalid or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Endpoint not registered (this is a production deployment).
  /auth/token/login/{singleUseLoginToken}:
    parameters:
      - in: path
        name: singleUseLoginToken
        required: true
        schema:
          type: string
    get:
      tags:
        - Auth
      operationId: verifySingleUseLoginToken
      summary: Magic-link login (v1)
      description: |
        Consumes a single-use magic-link token issued by the email
        flow and emits a fresh access bundle. Token is invalidated
        on success. The body additionally carries a `redirectUrl`
        the email-flow client follows.
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Token unknown / consumed / expired.
  /auth/token2/login/{publicToken}:
    parameters:
      - in: path
        name: publicToken
        required: true
        schema:
          type: string
    get:
      tags:
        - Auth
      operationId: verifyLoginToken2
      summary: Magic-link login (v2)
      description: |
        v2 of the magic-link flow — the request carries the *public*
        half of the token; the *private* half is supplied via
        `LoginToken2`-flavored verification. See `LoginToken2` schema.
        Tokens travel via response headers; the body additionally
        carries a `redirectUrl`.
      responses:
        '200':
          description: Logged in. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Token unknown / consumed / expired.
  /auth/account:
    get:
      tags:
        - Accounts
      operationId: getFullUserProfile
      summary: Caller's full profile
      description: |
        Returns the full self-shape of the authenticated account —
        `SanitizedLocalAccount` (admin-only fields stripped). Aliased
        by `/auth/profile`.
      responses:
        '200':
          description: Full profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelfAccount'
        '401':
          $ref: '#/components/responses/Unauthorized'
    put:
      tags:
        - Accounts
      operationId: updateAccount
      summary: Update caller's account
      description: |
        Updates mutable fields on the authenticated account
        (`username`, `email`, marketing preferences, etc.). On success
        returns the same shape as `GET /auth/account`. Validation
        failures return `422` with the validator's failure record.
        Username changes are rate-limited to once per 24h.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccountUpdateRequest'
      responses:
        '200':
          description: Updated profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelfAccount'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: |
            Identity-attach conflict — Steam / Oculus auth verification
            failed, or the identity is already attached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Validation failed (bad email, taken username, banned word, rate-limited rename).
          content:
            application/json:
              schema:
                type: object
                description: Validator failure record (express-validator output).
                additionalProperties: true
    post:
      tags:
        - Accounts
      operationId: createAccountFromEmailPassword
      summary: Create account from email + password
      description: |
        Creates a new Bigscreen account using an email address and
        password. Sends a verification email; the account is created
        in an unverified state. Tokens travel via response headers —
        see `/auth/login`.
      security:
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEmailAccountRequest'
      responses:
        '200':
          description: Account created. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '409':
          description: Email already in use.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Validation failed (bad email, weak password, taken username, banned word).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
  /auth/profile:
    get:
      tags:
        - Accounts
      operationId: getFullUserProfileAlias
      summary: Caller's full profile (alias)
      description: Identical to `GET /auth/account`.
      responses:
        '200':
          description: Full profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelfAccount'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/account/attach:
    post:
      tags:
        - Accounts
      operationId: attachEmailPasswordToExistingAccount
      summary: Attach email / password to current account
      description: |
        Adds an email/password identity to the caller's account when
        it does not already have one (typically used by accounts
        created through Steam or Oculus that later want a password
        login). Sends a verification email.

        **Returns plain text `OK`** — not JSON.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateEmailAccountRequest'
      responses:
        '200':
          description: Attached. Body is the literal string `OK`.
          content:
            text/plain:
              schema:
                type: string
                example: OK
        '401':
          description: Caller already has an email/password identity, or auth failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Email already in use elsewhere.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Validation failed (bad email, weak password).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
  /auth/steam/account:
    post:
      tags:
        - Accounts
      operationId: createAccountFromSteamId
      summary: Create account from Steam credentials
      description: |
        Creates a new Bigscreen account bound to a verified Steam id.
        Tokens travel via response headers — see `/auth/login`.
      security:
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SteamLoginRequest'
      responses:
        '200':
          description: Account created. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Invalid Steam credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Steam id already attached to another account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/oculus/account:
    post:
      tags:
        - Accounts
      operationId: createAccountFromOculusId
      summary: Create account from Oculus credentials
      description: |
        Creates a new Bigscreen account bound to a verified Oculus
        user token. Tokens travel via response headers — see
        `/auth/login`.
      security:
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OculusLoginRequest'
      responses:
        '200':
          description: Account created. Tokens are emitted via response headers — see below.
          headers:
            x-access-token:
              $ref: '#/components/headers/AccessToken'
            x-refresh-token:
              $ref: '#/components/headers/RefreshToken'
            x-timestamp:
              $ref: '#/components/headers/Timestamp'
            x-nonce:
              $ref: '#/components/headers/Nonce'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessTokenBundle'
        '401':
          description: Invalid Oculus credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Oculus id already attached to another account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/steam/attach:
    post:
      tags:
        - Accounts
      operationId: attachSteamAccount
      summary: Attach Steam identity to current account
      description: |
        Binds a verified Steam id to the caller's account. Refused if
        the Steam id is already attached elsewhere or if the caller
        already has a Steam identity.

        **Returns plain text `OK`** — not JSON.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SteamLoginRequest'
      responses:
        '200':
          description: Attached. Body is the literal string `OK`.
          content:
            text/plain:
              schema:
                type: string
                example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Conflict — Steam id in use, or caller already has Steam attached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/steam/detach:
    post:
      tags:
        - Accounts
      operationId: detachSteamAccount
      summary: Detach Steam identity from current account
      description: |
        Removes the Steam identity from the caller's account, but only
        when the identity is verified.

        **Returns plain text `OK`** — not JSON.
      responses:
        '200':
          description: Detached. Body is the literal string `OK`.
          content:
            text/plain:
              schema:
                type: string
                example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Steam identity is not verified, or no Steam identity attached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/email/verify/{token}:
    parameters:
      - in: path
        name: token
        required: true
        schema:
          type: string
    get:
      tags:
        - Accounts
      operationId: checkEmailVerificationTicket
      summary: Confirm email verification
      description: |
        Confirms an email-verification ticket. Marks the account's
        primary email as verified on success. Reached from the magic
        link in the verification email.
      security:
        - apiKey: []
      responses:
        '200':
          description: Email verified.
          content:
            application/json:
              schema:
                type: object
                required:
                  - result
                properties:
                  result:
                    type: string
                    example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Ticket unknown / consumed / expired.
  /auth/email/update/{emailUpdateToken}:
    parameters:
      - in: path
        name: emailUpdateToken
        required: true
        schema:
          type: string
    get:
      tags:
        - Accounts
      operationId: handleConfirmEmailUpdate
      summary: Confirm an email-address change
      description: |
        Confirms a pending email-update ticket. Used when an existing
        account changes its email — the new address must be verified
        before the change applies. The handler also enforces a
        staff-domain guard: privileged accounts cannot change their
        email to a non-staff domain.
      security:
        - apiKey: []
      responses:
        '200':
          description: Email updated.
          content:
            application/json:
              schema:
                type: object
                required:
                  - result
                properties:
                  result:
                    type: string
                    example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Refused by the staff-domain guard.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Ticket unknown / consumed / expired.
  /auth/device/{deviceUniqueIdentifier}:
    parameters:
      - in: path
        name: deviceUniqueIdentifier
        required: true
        schema:
          type: string
    get:
      tags:
        - Accounts
      operationId: getAccountDeviceStatus
      summary: Per-device status for the caller
      description: |
        Returns the status of a device attached to the caller's
        account — registration state, last-seen, ban / restriction
        flags. Reuses `DeviceStatus` from the admin spec.
      responses:
        '200':
          description: Device status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Device not on this account.
  /auth/account/flags:
    get:
      tags:
        - Accounts
      operationId: getFeatureFlags
      summary: Feature flags for the current account
      description: |
        Returns the per-account feature flags (rollout / experiment
        assignments). The VR client and website call this on launch
        to decide which features to expose.
      responses:
        '200':
          description: Feature flags.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FeatureFlags'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/forgot:
    post:
      tags:
        - Password
      operationId: handleForgotPasswordRequest
      summary: Send a password-reset email
      description: |
        Issues a password-reset email to the supplied address. Always
        returns `200` whether or not the address is registered — the
        client must not be able to enumerate accounts by error
        response. In `dev_local` / test environments only, the
        generated `resetPasswordToken` is echoed in the body for
        integration tests.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Reset email queued (or no-op if the address is unknown).
          content:
            application/json:
              schema:
                type: object
                required:
                  - result
                properties:
                  result:
                    type: string
                    example: OK
                  resetPasswordToken:
                    type: string
                    description: |
                      Only present in `dev_local` / test
                      environments — never in production.
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/reset:
    post:
      tags:
        - Password
      operationId: handleResetPassword
      summary: Reset password
      description: |
        Sets a new password on the account behind the supplied
        `token`. Token is consumed on success.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - token
                - password
              properties:
                token:
                  type: string
                  description: |
                    The reset token from the
                    `/auth/forgot` email — same value
                    as the URL-path parameter on
                    `/auth/reset/{resetPasswordToken}`.
                password:
                  type: string
                  format: password
      responses:
        '200':
          description: Password reset.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    example: OK
        '401':
          description: Hash failed or update failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Token unknown / consumed / expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Validation failed (missing token, weak password).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
  /auth/reset/{resetPasswordToken}:
    parameters:
      - in: path
        name: resetPasswordToken
        required: true
        schema:
          type: string
    get:
      tags:
        - Password
      operationId: verifyResetPasswordToken
      summary: Verify a password-reset token
      description: |
        Confirms the supplied token is valid (issued, unconsumed, not
        expired). Used by the reset UI to gate the password form.
      responses:
        '200':
          description: Token is valid.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    example: OK
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Token is unknown, consumed, or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /auth/resend:
    get:
      tags:
        - Password
      operationId: resendEmailVerificationTicket
      summary: Resend the email-verification email
      description: |
        Resends the email-verification message to the address on the
        authenticated account. Required because brand-new accounts may
        misplace the original. In `dev_local` only, the generated
        confirmation token is echoed under `data.emailConfirmToken`
        for integration tests.
      responses:
        '200':
          description: Verification email queued.
          content:
            application/json:
              schema:
                type: object
                required:
                  - message
                properties:
                  message:
                    type: string
                    example: OK
                  data:
                    type: object
                    description: Only present in `dev_local`.
                    properties:
                      emailConfirmToken:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: ApiKey
      description: |
        Bearer key registered for the `api` application. May be a
        64-character app key or, when OAuth is enabled, an OAuth 2.0
        JWT issued by `/oauth/token` — admission shape determines
        which path runs.
    accessToken:
      type: apiKey
      in: header
      name: x-access-token
      description: |
        JWT for the end-user account the request is acting as.
        Required on routes documented as `Auth: required`. Some
        routes are documented as `Auth: optional` — the JWT, if
        present, scopes / personalises the response, but its
        absence is not an error.
    refreshToken:
      type: apiKey
      in: header
      name: x-refresh-token
      description: |
        Refresh token issued alongside the access token. Required
        on `/auth/renew` and the legacy `/renew` to mint a new
        access token.
    nonce:
      type: apiKey
      in: header
      name: x-nonce
      description: |
        One-time nonce returned by the server in the `x-nonce`
        response header when an access token has expired. Required
        on the next `/auth/renew` call.
  responses:
    Unauthorized:
      description: Missing or invalid `Authorization` / `x-access-token`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  headers:
    AccessToken:
      description: Newly-minted access-token JWT. Clients should replace any prior `x-access-token` value with this one.
      schema:
        type: string
    RefreshToken:
      description: Newly-minted refresh token. Required on subsequent `/auth/renew` calls.
      schema:
        type: string
    Timestamp:
      description: Server-side issue timestamp (Unix milliseconds).
      schema:
        type: string
    Nonce:
      description: One-time nonce required on the next `/auth/renew` call.
      schema:
        type: string
  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
        code:
          type: string
          description: Optional machine-readable error code.
        details:
          description: Optional structured details.
      example:
        error: Invalid credentials
        code: InvalidCredentials
    OAuthTokenResponse:
      type: object
      description: |
        RFC 6749 §5.1 token-response payload. Returned by `POST /oauth/token`
        on a successful `authorization_code` or `refresh_token` grant.
      required:
        - access_token
        - token_type
      properties:
        access_token:
          type: string
          description: Signed JWT — the bearer token to use against the API.
        token_type:
          type: string
          description: Always `Bearer` for this server.
          example: Bearer
        expires_in:
          type: integer
          description: Lifetime of `access_token` in seconds from issue.
        refresh_token:
          type: string
          description: |
            Refresh token. Returned on the `authorization_code` grant and,
            depending on policy, on `refresh_token` grants too.
        scope:
          type: string
          description: Space-separated scopes granted on this token.
        id_token:
          type: string
          description: Optional OIDC id-token JWT.
      example:
        access_token: eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0…
        token_type: Bearer
        expires_in: 3600
        refresh_token: rt_01HZW1234567890ABCDEF
        scope: profile entitlements
    OAuthError:
      type: object
      description: |
        RFC 6749 §5.2 error response. Returned by the OAuth endpoints on
        invalid grants, bad credentials, or when the OAuth subsystem is
        disabled (`error: endpoint_disabled`, `501`).
      required:
        - error
      properties:
        error:
          type: string
          description: Machine-readable error code (e.g. `invalid_grant`, `invalid_client`, `endpoint_disabled`).
        error_description:
          type: string
        error_uri:
          type: string
          format: uri
      example:
        error: invalid_grant
        error_description: Authorization code expired.
    JWKSResponse:
      type: object
      description: |
        RFC 7517 JWK Set — the public keys used to verify access / refresh
        tokens issued by `/oauth/token`.
      required:
        - keys
      properties:
        keys:
          type: array
          items:
            type: object
            description: A single JWK. Shape depends on `kty`.
            required:
              - kty
              - kid
            properties:
              kty:
                type: string
                description: Key type — typically `RSA` or `EC`.
              kid:
                type: string
                description: Key id — referenced from the `kid` JWT header.
              use:
                type: string
                description: Key use — typically `sig`.
              alg:
                type: string
              'n':
                type: string
                description: RSA modulus (when `kty=RSA`).
              e:
                type: string
                description: RSA exponent (when `kty=RSA`).
              crv:
                type: string
                description: EC curve (when `kty=EC`).
              x:
                type: string
              'y':
                type: string
            additionalProperties: true
      example:
        keys:
          - kty: RSA
            kid: key-1
            use: sig
            alg: RS256
            'n': 0vx7agoebGcQSuuPiLJX…
            e: AQAB
    AuthorizationServerMetadata:
      type: object
      description: |
        RFC 8414 OAuth 2.0 authorization-server metadata document. Returned
        from `/.well-known/oauth-authorization-server`.
      required:
        - issuer
      properties:
        issuer:
          type: string
          format: uri
        token_endpoint:
          type: string
          format: uri
        jwks_uri:
          type: string
          format: uri
        authorization_endpoint:
          type: string
          format: uri
        grant_types_supported:
          type: array
          items:
            type: string
          example:
            - authorization_code
            - refresh_token
        response_types_supported:
          type: array
          items:
            type: string
        token_endpoint_auth_methods_supported:
          type: array
          items:
            type: string
          example:
            - client_secret_post
            - client_secret_basic
        scopes_supported:
          type: array
          items:
            type: string
        id_token_signing_alg_values_supported:
          type: array
          items:
            type: string
          example:
            - RS256
      example:
        issuer: https://api.bigscreenvr.com
        token_endpoint: https://api.bigscreenvr.com/oauth/token
        jwks_uri: https://api.bigscreenvr.com/.well-known/jwks.json
        grant_types_supported:
          - authorization_code
          - refresh_token
        response_types_supported:
          - code
        token_endpoint_auth_methods_supported:
          - client_secret_post
        scopes_supported:
          - profile
          - entitlements
    EmailPasswordLoginRequest:
      type: object
      description: Email + password login body. Used by `/auth/login` and the legacy `/login`.
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
      example:
        email: alice@example.com
        password: correct horse battery staple
    AccessTokenBundle:
      type: object
      description: |
        Login / token-renewal response **body**. Despite the schema's name,
        the access and refresh tokens themselves are emitted via response
        **headers**, not in the body — see the `headers` block on each
        operation that uses this schema. This body is just the success
        sentinel.

        Affects `/auth/login`, `/login`, `/auth/oculus/login`,
        `/auth/steam/login`, `/auth/account` POST, `/auth/steam/account` POST,
        `/auth/oculus/account` POST, `/auth/token/login/{token}`,
        `/auth/token2/login/{token}`, `/auth/renew`, and `/renew`. The magic-link
        routes additionally emit a `redirectUrl` in the body.

        Header / body contract handled by `AuthApi.sendAuthSuccessResponse`.
      required:
        - result
      properties:
        result:
          type: string
          description: Always `"OK"`.
          example: OK
        redirectUrl:
          type: string
          format: uri
          description: |
            Only present on the magic-link login routes
            (`/auth/token/login/{token}`, `/auth/token2/login/{token}`).
      example:
        result: OK
    AccountStatusResponse:
      type: object
      description: |
        Verify-token response body. Despite the legacy name, the handler
        (`AuthApi.checkAccountStatus`) returns just `{ result: "OK" }` —
        account status is **not** echoed in the body. The presence of a
        `200` already implies the access token is valid and the account is
        in a state that admits it (banned / read-only states are surfaced
        as 401 / 403, not in this body).

        Affects `/auth/verify` and the legacy `/verify` POST.
      required:
        - result
      properties:
        result:
          type: string
          description: Always `"OK"`.
          example: OK
      example:
        result: OK
    OculusLoginRequest:
      type: object
      description: |
        Oculus login body. Used by `/auth/oculus/login`,
        `/auth/oculus/account`, and the non-prod `/auth/oculus/verify`.
      required:
        - oculusUserId
      properties:
        oculusUserId:
          type: string
        accessToken:
          type: string
          description: Oculus access token issued by the Meta SDK.
        nonce:
          type: string
          description: Oculus nonce, when verifying via the nonce flow.
        deviceUniqueIdentifier:
          type: string
          description: Per-headset identifier the client passes for device-binding.
      example:
        oculusUserId: '100012345678901'
        accessToken: OCAccessToken_abcdef123456
        deviceUniqueIdentifier: dev_01HZH98765
    SteamLoginRequest:
      type: object
      description: |
        Steam login body. Used by `/auth/steam/login`, `/auth/steam/account`,
        and `/auth/steam/attach`.
      required:
        - steamId
      properties:
        steamId:
          type: string
        sessionTicket:
          type: string
          description: Steam session ticket / OpenID payload — used to verify the supplied `steamId`.
        deviceUniqueIdentifier:
          type: string
          description: Per-device identifier the client passes for device-binding.
      example:
        steamId: '76561198012345678'
        sessionTicket: stmtkt_abcdef123456
        deviceUniqueIdentifier: dev_01HZH98765
    BigscreenAccountStatus:
      type: string
      description: |
        Account moderation state. Mirrors `BigscreenAccountStatus` in
        [`auth/Schemas.ts`](../../../../auth/Schemas.ts).
        - `OK` — normal.
        - `ReadOnly` — can sign in and view, but mutations are blocked.
        - `Banned` — sign-in blocked.
      enum:
        - OK
        - ReadOnly
        - Banned
      default: OK
      example: OK
    SelfAccount:
      type: object
      description: |
        Self-shape of a Bigscreen account — `SanitizedLocalAccount` from
        [`auth/Schemas.ts`](../../../../auth/Schemas.ts) — returned to the
        authenticated owner from `/auth/account` and `/auth/profile`. The
        raw `BigscreenAccount` Steam / Oculus / EmailPassword subobjects
        are flattened: identities surface as `email`, `steamProfile`, and
        `oculusProfile`. The runtime attaches `id` to the response (the
        underlying Bigscreen account UUID) — not part of the class but
        always present on the wire.

        For the public-facing snapshot returned to other users, see
        [`PublicAccountData`](./PublicAccountData.yaml).
      required:
        - userSessionId
      properties:
        id:
          type: string
          description: Bigscreen account UUID. Attached by the handler at request time, not stored on the class.
        userSessionId:
          type: string
        createdAt:
          type: string
          format: date-time
          description: Account creation date.
        status:
          $ref: '#/components/schemas/BigscreenAccountStatus'
        isVerified:
          type: boolean
        username:
          type: string
        email:
          type: string
          format: email
        steamProfile:
          type: object
          description: Steam identity (when attached). Loose shape — Steam fields surfaced as-is.
          additionalProperties: true
        oculusProfile:
          type: object
          description: '`SanitizedOculusProfile` (when an Oculus identity is attached).'
          properties:
            oculusId:
              type: string
            oculusImageURL:
              type: string
              format: uri
            oculusSmallImageURL:
              type: string
              format: uri
        badges:
          type: array
          items:
            type: string
          description: |
            Derived from `accessPolicies` — each policy name plus
            `verified` when the account is verified.
        storeCountryCode:
          type: string
          description: ISO 3166-1 alpha-2; set after instance creation from the caller's IP.
      example:
        id: acct_01HXJ4Q12345
        userSessionId: sess_01HZP12345
        createdAt: '2026-04-22T10:14:00.000Z'
        status: OK
        isVerified: true
        username: alice
        email: alice@example.com
        steamProfile:
          steamId: '76561198012345678'
        oculusProfile:
          oculusId: '100012345678901'
          oculusImageURL: https://cdn.bigscreenvr.com/avatars/oculus_100012345_lg.png
          oculusSmallImageURL: https://cdn.bigscreenvr.com/avatars/oculus_100012345_sm.png
        badges:
          - BigscreenDefault
          - verified
        storeCountryCode: US
    AccountUpdateRequest:
      type: object
      description: |
        Body for `PUT /auth/account`. Each field is optional — only those
        supplied are updated. Email changes go through the email-update
        flow (`/auth/email/update/{token}`).
      properties:
        username:
          type: string
        email:
          type: string
          format: email
          description: |
            Initiates an email-change flow — confirmation is required via
            `/auth/email/update/{emailUpdateToken}` before the change
            applies.
        marketingOptIn:
          type: boolean
        region:
          type: string
          description: Override the IP-derived region.
      example:
        username: alice2026
        marketingOptIn: false
    CreateEmailAccountRequest:
      type: object
      description: |
        Body for creating an email/password account (`POST /auth/account`)
        or attaching email/password to an existing account
        (`POST /auth/account/attach`).
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
        username:
          type: string
          description: Optional username. Server generates one when omitted.
        marketingOptIn:
          type: boolean
          description: Whether the account opts into marketing emails on creation.
      example:
        email: alice@example.com
        password: correct horse battery staple
        username: alice
        marketingOptIn: true
    DeviceStatus:
      type: object
      description: |
        Status record for a hardware device — identifies whether a particular
        device unique identifier has been disabled (Bigscreen-side ban). Returned
        by `GET /admin/device/{deviceUniqueIdentifier}` and updated by the matching
        PUT.
      required:
        - deviceUniqueIdentifier
        - status
      properties:
        deviceUniqueIdentifier:
          type: string
          description: Hardware-derived id (varies per device family).
        status:
          type: string
          description: '`AuthSchemas.AccessStatusCodes` value — `OK`, `Disabled`, etc.'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        notes:
          type: string
          description: Free-form moderator notes about the device.
        history:
          type: array
          items:
            type: object
            additionalProperties: true
          description: Status-change history for the device.
      example:
        deviceUniqueIdentifier: 8b6c2e3f9e54a01b
        status: OK
        createdAt: '2026-04-22T10:14:00.000Z'
        updatedAt: '2026-04-22T10:14:00.000Z'
        notes: ''
        history: []
    FeatureFlags:
      type: object
      description: |
        Per-account feature-flag payload returned by `/auth/account/flags`.
        Each top-level key is a flag name; the value is the assignment
        (boolean for on/off, string for variant). The shape is open — new
        flags appear without an OpenAPI change.
      additionalProperties: true
      example:
        socialV2: true
        mediaPlayer: v3
        beyondCushionsBeta: false
        arda_oauth_clients_v2: true
