openapi: 3.1.0
info:
  title: Pixelbar spacestate API
  version: 0.5.0
  description: |
    SpaceAPI document and open/closed state for Pixelbar.

    The `/api/v1` endpoints are the supported API. The **legacy** endpoints
    (`/`, `/index.php`, `/spacestate.php`, `/togglestate.php`, `/togglebaker.php`)
    are a temporary compatibility shim for hardware and consumers that have not
    migrated; do not build new integrations on them.

    ## Authentication

    Reading is open to everyone: the current state, the SpaceAPI document and
    the health check need no credentials. Two kinds of credential exist for
    everything else, and both are issued by the Pixelbar board — ask them if
    you need one.

    ### API key

    A bearer token, used for reading history and for changing the state. Keys
    carry a role: `read` covers history, `write` covers history and state
    changes.

    ```http
    POST /api/v1/state HTTP/1.1
    Host: spacestate.pixelbar.nl
    Authorization: Bearer <your-api-key>
    Content-Type: application/json

    {
      "state": "open",
      "user": "your-name",
      "method": "api"
    }
    ```

    A missing, unknown or under-privileged key gets
    `401 {"error":"unauthorized"}`.

    ### Legacy device token

    The deprecated toggle endpoints take a per-device token in the query
    string instead, and accept it only from that device's registered IP
    address. These exist for hardware that predates the API key scheme; build
    new integrations against `POST /api/v1/state`.

    ```
    https://spacestate.pixelbar.nl/togglestate.php
      ?token=<your-device-token>
      &user=your-name
      &state=open
    ```

    A bad token, or a good token from an unexpected address, redirects to
    <https://www.pixelbar.nl/> rather than returning an error status.
servers:
  - url: https://spacestate.pixelbar.nl
    description: Production
  - url: http://localhost:3000
    description: Local development (`just dev`)
security: []
tags:
  - name: State
    description: Whether the space is open or closed right now, and changing it.
  - name: History
    description: Every past open/close, newest first, as a paged list.
  - name: SpaceAPI
    description: >-
      The space's SpaceAPI document — the shared format hackerspace status
      tools read to show whether we're open.
  - name: Operations
    description: Health checks for the service itself.
  - name: Legacy
    description: >-
      The pre-rewrite URLs, kept working for hardware and consumers that
      haven't migrated. Deprecated — don't build against these.

paths:
  /api/v1/state:
    get:
      tags: [State]
      summary: Get the current space state
      operationId: getState
      responses:
        "200":
          description: Current state.
          headers:
            Access-Control-Allow-Origin: { schema: { type: string, const: "*" } }
            Cache-Control: { schema: { type: string, const: no-cache } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CurrentState" }
              example: { state: open, since: "2026-10-16T18:04:12.000Z" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [State]
      summary: Change the space state
      description: Requires an API key with the `write` role.
      operationId: setState
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StateChangeRequest" }
            example: { state: closed, user: alice, method: web }
      responses:
        "200":
          description: State changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateChangeResponse" }
        "400":
          description: Body failed validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: The space is already in the requested state; nothing was recorded.
          content:
            application/json:
              schema:
                type: object
                required: [error, state]
                properties:
                  error: { type: string, const: already_in_state }
                  state: { $ref: "#/components/schemas/SpaceState" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/history:
    get:
      tags: [History]
      summary: List state changes, newest first
      description: Requires an API key with the `read` or `write` role.
      operationId: getHistory
      security:
        - bearerAuth: []
      parameters:
        - name: from
          in: query
          description: Only entries at or after this time.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only entries at or before this time.
          schema: { type: string, format: date-time }
        - name: device
          in: query
          description: Only entries from this legacy device shortname.
          schema: { type: string }
        - name: limit
          in: query
          description: Page size. Values above 200 are clamped to 200.
          schema: { type: integer, minimum: 1, default: 50, maximum: 200 }
        - name: continuationToken
          in: query
          description: Opaque token from a previous page's `continuationToken`.
          schema: { type: string }
      responses:
        "200":
          description: A page of history.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HistoryPage" }
        "400":
          description: Query failed validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/spaceapi:
    get:
      tags: [SpaceAPI]
      summary: SpaceAPI 0.13 document
      description: |
        The configured SpaceAPI document with the live `open` / `state.open`
        fields filled in from current state. See https://spaceapi.io/docs/.
      operationId: getSpaceApi
      responses:
        "200":
          description: SpaceAPI document.
          headers:
            Access-Control-Allow-Origin: { schema: { type: string, const: "*" } }
            Cache-Control: { schema: { type: string, const: no-cache } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceApiDocument" }
        "500": { $ref: "#/components/responses/InternalError" }

  /healthz:
    get:
      tags: [Operations]
      summary: Liveness probe
      description: Process-only; does not touch storage.
      operationId: healthz
      responses:
        "200":
          description: Alive.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }

  /:
    get:
      tags: [Legacy]
      deprecated: true
      summary: SpaceAPI document (legacy address)
      description: Same response as `/api/v1/spaceapi`; the old public address of the document.
      operationId: legacySpaceApiRoot
      responses:
        "200":
          description: SpaceAPI document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceApiDocument" }
  /index.php:
    get:
      tags: [Legacy]
      deprecated: true
      summary: SpaceAPI document (legacy address)
      description: Same response as `/api/v1/spaceapi`.
      operationId: legacySpaceApiIndex
      responses:
        "200":
          description: SpaceAPI document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpaceApiDocument" }
  /spacestate.php:
    get:
      tags: [Legacy]
      deprecated: true
      summary: Current state (legacy shape)
      operationId: legacySpaceState
      responses:
        "200":
          description: Current state.
          content:
            application/json:
              schema:
                type: object
                required: [state]
                properties:
                  state: { $ref: "#/components/schemas/SpaceState" }
  /togglestate.php:
    get:
      tags: [Legacy]
      deprecated: true
      security:
        - legacyToken: []
      summary: Change state (legacy token + IP auth)
      description: |
        Authenticated by a per-device `token` **and** the caller's IP address
        (both must match a configured legacy device). Parameters always come
        from the query string; on POST any request body is ignored.

        Invalid parameters and failed authentication both respond with a
        redirect to https://www.pixelbar.nl/ rather than an error status.
      operationId: legacyToggleStateGet
      parameters:
        - name: token
          in: query
          required: true
          description: The device's token, issued by the Pixelbar board.
          schema: { type: string, minLength: 1 }
        - name: user
          in: query
          required: true
          schema: { type: string, minLength: 1, examples: [your-name] }
        - { name: state, in: query, required: true, schema: { $ref: "#/components/schemas/SpaceState" } }
      responses:
        "200":
          description: |
            Plain text. `SUCCESS` if the state changed, otherwise
            `ALLREADY TOGGLED TO THIS STATE` (sic — the typo is preserved on purpose).
          content:
            text/plain:
              schema:
                type: string
                enum: [SUCCESS, ALLREADY TOGGLED TO THIS STATE]
                examples: [SUCCESS]
        "302":
          description: Invalid parameters or unknown token/IP.
          headers:
            Location: { schema: { type: string, const: "https://www.pixelbar.nl/" } }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Legacy]
      deprecated: true
      security:
        - legacyToken: []
      summary: Change state (legacy token + IP auth)
      description: |
        Authenticated by a per-device `token` **and** the caller's IP address
        (both must match a configured legacy device). Parameters always come
        from the query string; on POST any request body is ignored.

        Invalid parameters and failed authentication both respond with a
        redirect to https://www.pixelbar.nl/ rather than an error status.
      operationId: legacyToggleStatePost
      parameters:
        - name: token
          in: query
          required: true
          description: The device's token, issued by the Pixelbar board.
          schema: { type: string, minLength: 1 }
        - name: user
          in: query
          required: true
          schema: { type: string, minLength: 1, examples: [your-name] }
        - { name: state, in: query, required: true, schema: { $ref: "#/components/schemas/SpaceState" } }
      responses:
        "200":
          description: |
            Plain text. `SUCCESS` if the state changed, otherwise
            `ALLREADY TOGGLED TO THIS STATE` (sic — the typo is preserved on purpose).
          content:
            text/plain:
              schema:
                type: string
                enum: [SUCCESS, ALLREADY TOGGLED TO THIS STATE]
                examples: [SUCCESS]
        "302":
          description: Invalid parameters or unknown token/IP.
          headers:
            Location: { schema: { type: string, const: "https://www.pixelbar.nl/" } }
        "500": { $ref: "#/components/responses/InternalError" }
  /togglebaker.php:
    get:
      tags: [Legacy]
      deprecated: true
      security:
        - legacyToken: []
      summary: Change state (legacy, alias of /togglestate.php)
      description: |
        Authenticated by a per-device `token` **and** the caller's IP address
        (both must match a configured legacy device). Parameters always come
        from the query string; on POST any request body is ignored.

        Invalid parameters and failed authentication both respond with a
        redirect to https://www.pixelbar.nl/ rather than an error status.
      operationId: legacyToggleBakerGet
      parameters:
        - name: token
          in: query
          required: true
          description: The device's token, issued by the Pixelbar board.
          schema: { type: string, minLength: 1 }
        - name: user
          in: query
          required: true
          schema: { type: string, minLength: 1, examples: [your-name] }
        - { name: state, in: query, required: true, schema: { $ref: "#/components/schemas/SpaceState" } }
      responses:
        "200":
          description: |
            Plain text. `SUCCESS` if the state changed, otherwise
            `ALLREADY TOGGLED TO THIS STATE` (sic — the typo is preserved on purpose).
          content:
            text/plain:
              schema:
                type: string
                enum: [SUCCESS, ALLREADY TOGGLED TO THIS STATE]
                examples: [SUCCESS]
        "302":
          description: Invalid parameters or unknown token/IP.
          headers:
            Location: { schema: { type: string, const: "https://www.pixelbar.nl/" } }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Legacy]
      deprecated: true
      security:
        - legacyToken: []
      summary: Change state (legacy, alias of /togglestate.php)
      description: |
        Authenticated by a per-device `token` **and** the caller's IP address
        (both must match a configured legacy device). Parameters always come
        from the query string; on POST any request body is ignored.

        Invalid parameters and failed authentication both respond with a
        redirect to https://www.pixelbar.nl/ rather than an error status.
      operationId: legacyToggleBakerPost
      parameters:
        - name: token
          in: query
          required: true
          description: The device's token, issued by the Pixelbar board.
          schema: { type: string, minLength: 1 }
        - name: user
          in: query
          required: true
          schema: { type: string, minLength: 1, examples: [your-name] }
        - { name: state, in: query, required: true, schema: { $ref: "#/components/schemas/SpaceState" } }
      responses:
        "200":
          description: |
            Plain text. `SUCCESS` if the state changed, otherwise
            `ALLREADY TOGGLED TO THIS STATE` (sic — the typo is preserved on purpose).
          content:
            text/plain:
              schema:
                type: string
                enum: [SUCCESS, ALLREADY TOGGLED TO THIS STATE]
                examples: [SUCCESS]
        "302":
          description: Invalid parameters or unknown token/IP.
          headers:
            Location: { schema: { type: string, const: "https://www.pixelbar.nl/" } }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        An API key issued by the Pixelbar board. A `read` key can fetch
        history; a `write` key can also change the state.
    legacyToken:
      type: apiKey
      in: query
      name: token
      description: >-
        A per-device token issued by the Pixelbar board, accepted only from
        that device's registered IP address. Failures redirect to
        https://www.pixelbar.nl/ instead of returning 401.
  responses:
    Unauthorized:
      description: Missing, unknown, or insufficiently privileged API key.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: { type: string, const: unauthorized }
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: { type: string, const: internal_error }
  schemas:
    SpaceState:
      type: string
      enum: [open, closed]
      examples: [open]
    ChangeSource:
      type: string
      description: "`api` for `/api/v1`, `legacy` for the compatibility shim."
      enum: [api, legacy]
      examples: [api]
    CurrentState:
      type: object
      required: [state, since]
      properties:
        state: { $ref: "#/components/schemas/SpaceState" }
        since:
          type: string
          format: date-time
          description: When the state last changed.
          examples: ["2026-10-18T19:04:11.000Z"]
    StateChangeRequest:
      type: object
      required: [state, user, method]
      properties:
        state: { $ref: "#/components/schemas/SpaceState" }
        user:
          type: string
          minLength: 1
          description: Who triggered the change.
          examples: [your-name]
        method:
          type: string
          minLength: 1
          description: "How it was triggered (e.g. button, web)."
          examples: [button]
    StateChangeResponse:
      type: object
      required: [state, recordedAt]
      properties:
        state: { $ref: "#/components/schemas/SpaceState" }
        recordedAt:
          type: string
          format: date-time
          examples: ["2026-10-18T19:04:11.000Z"]
    HistoryEntry:
      type: object
      required: [state, user, method, source, changedAt]
      properties:
        state: { $ref: "#/components/schemas/SpaceState" }
        user:
          type: string
          examples: [your-name]
        method:
          type: string
          examples: [button]
        deviceShortname:
          type: string
          description: Set for legacy device changes.
          examples: [frontdoor]
        source: { $ref: "#/components/schemas/ChangeSource" }
        changedAt:
          type: string
          format: date-time
          examples: ["2026-10-18T19:04:11.000Z"]
    HistoryPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/HistoryEntry" }
        continuationToken:
          type: string
          description: Present when more results exist; pass back as `continuationToken`.
          examples: ["1!52!RGF0ZQ--"]
    ValidationError:
      type: object
      required: [error, details]
      properties:
        error: { type: string, enum: [invalid_body, invalid_query] }
        details:
          type: array
          description: Zod issues.
          items: { type: object, additionalProperties: true }
    SpaceApiDocument:
      type: object
      description: |
        SpaceAPI 0.13 document (https://spaceapi.io/docs/). Content comes from
        the `spaceapi.json` config; `open` and `state.open` are live.
      required: [api, space, url, location, state, contact]
      additionalProperties: true
      properties:
        api: { type: string, example: "0.13" }
        space: { type: string, examples: [Pixelbar] }
        url: { type: string, format: uri, examples: ["https://pixelbar.nl"] }
        logo: { type: string, format: uri }
        location: { type: object, additionalProperties: true }
        contact: { type: object, additionalProperties: true }
        state:
          type: object
          additionalProperties: true
          properties:
            open: { type: boolean }
            lastchange: { type: integer, description: Unix timestamp. }
