openapi: 3.1.0
info:
  title: Jamb Signal Debug Logs
  version: "1.0.0"
  description: |
    Self-hosted debug log upload for Jamb's Signal clients — the replacement for
    Signal's `debuglogs.org`, which version-gates on the `User-Agent` and answers every
    Jamb build `204 No Content`.

    Two routes, and the shape of both is Signal's, so the client change is a hostname
    and one deletion:

    - `GET /` hands the client a signed, single-use POST policy for one object in a
      Google Cloud Storage bucket.
    - `GET /{key}` redirects to that object — the link support opens.

    **The archive never passes through this service.** The client posts it straight to
    `storage.googleapis.com` with the policy minted here, so a 30 MB upload costs this
    process one signature.

    ## Authentication

    There is none, deliberately. Three call sites in the iOS client reach the uploader
    through `DebugLogDumper.preLaunch()` — the database recovery screen and both
    launch-failure paths in `AppDelegate` — where the database has not opened and there
    is no ACI and no service password to send. Those are the reports worth the most, so
    the upload route has to answer them. A per-address rate limit stands in for
    authentication.

    A client that *does* have credentials sends the usual `Authorization: Basic`, and
    the ACI in it is recorded on the object as `x-goog-meta-claimed-aci`. **Claimed**,
    because this service holds no account state and cannot verify the password —
    jamb-signal-server owns that. The password is never logged or stored.

    ## Why a key is a credential

    The bucket grants `allUsers` the `legacyObjectReader` role and **not**
    `objectViewer`: the difference is `storage.objects.list`, so the bucket cannot be
    enumerated and a key that is not guessed is a log that cannot be reached. Keys carry
    32 random bytes. This is Signal's own model, and the reason the fetch route below
    takes no credential either.

    Objects are hard-deleted by a bucket lifecycle rule 30 days after upload.
servers:
  - url: https://debuglogs.jamb.ai
    description: >-
      Production. DNS points at the jamb-signal-agents VM (us-east1-b), the same host as
      api.chat.jamb.ai; nginx terminates TLS and proxies to the container on 127.0.0.1:8008.
paths:
  /:
    get:
      summary: Get upload parameters for one debug log
      operationId: getUploadParameters
      description: |
        Mints a V4 POST policy for one freshly generated object key and returns it in
        `debuglogs.org`'s shape: `url` to POST to, and `fields` the multipart form must
        carry. The client adds the file as a part named `file`, which must be **last**.

        The object key is derived from the `User-Agent` (`ios/1.0.10/<64 hex>.zip`) and
        **already carries its extension**. A client must send `key` back exactly as
        given: the policy makes it an exact-match condition, so appending an extension —
        as Signal's client did for `debuglogs.org`, whose S3 policy used `starts-with` —
        invalidates the signature.

        The server chooses the extension, because nothing downstream can correct it —
        and the platform alone cannot tell it. The clients disagree on the log format
        (iOS zips a directory of log files; Android and Desktop each gzip a single
        stream), *and* one client uploads more than one kind of thing: Android posts a raw
        Perfetto trace as `application/octet-stream` and the log as `application/gzip`,
        through this same endpoint.

        So pass `contentType` to say what is about to be POSTed. Absent or unrecognised,
        the platform's log format is used instead.

        The policy permits a `Content-Type` form field of any value, because the client
        appends one; and bounds the body between 1 byte and 30 MB.
      security: []
      parameters:
        - in: query
          name: contentType
          required: false
          description: >-
            The media type the client is about to POST, which names the object. Matched
            against an allowlist and never echoed: anything else falls back to the
            platform's log format. This is a naming hint, not a constraint — the policy
            still permits any `Content-Type` form field, because the client sends one.
          schema:
            type: string
            enum:
              - application/zip
              - application/gzip
              - application/x-gzip
              - application/octet-stream
          example: application/octet-stream
        - in: header
          name: User-Agent
          required: false
          description: >-
            `Signal-iOS/<version> iOS/<sysver>` or the Android/Desktop equivalent. Only
            the platform and the first three version components are used, and an
            unrecognised value files the log under `unknown/unknown/` rather than
            refusing it.
          schema:
            type: string
            examples: ["Signal-iOS/1.0.10.187 iOS/26.0"]
        - in: header
          name: Authorization
          required: false
          description: >-
            `Basic <base64(aci[.deviceId]:servicePassword)>`, when the client has
            credentials. The ACI is recorded on the object as an unverified claim; the
            password is ignored.
          schema:
            type: string
      responses:
        "200":
          description: An upload policy, usable once, for one object.
          headers:
            Cache-Control:
              description: Always `no-store` — a policy is a bearer credential with a deadline.
              schema: { type: string }
            X-Request-Id:
              description: Echoed from the request, or minted. The same id appears in this server's log.
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadParameters'
              example:
                url: https://storage.googleapis.com/jamb-debuglogs/
                fields:
                  key: ios/1.0.10/4f3c2b1a0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b.zip
                  x-goog-date: "20260902T150405Z"
                  x-goog-credential: jamb-debuglogs-sa@jamb-64e70.iam.gserviceaccount.com/20260902/auto/storage/goog4_request
                  x-goog-algorithm: GOOG4-RSA-SHA256
                  policy: eyJjb25kaXRpb25zIjpbWyJjb250ZW50LWxlbmd0aC1yYW5nZSIsMSwzMTQ1NzI4MF0...
                  x-goog-signature: 2f9c8b7a6d5e4f3c2b1a0e9d8c7b6a5f...
        "429":
          description: >-
            Too many requests from this address in the current window. `Retry-After`
            carries the seconds until it resets.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: rate_limited
                message: Too many upload requests from this address; try again later.
  /{key}:
    get:
      summary: Fetch an uploaded debug log
      operationId: getDebugLog
      description: |
        Redirects to the object in the bucket. `302` and never `301`: the permanent form
        is one a browser may never ask about again, and this hostname exists precisely so
        authentication or a private bucket can go in front of it later without breaking a
        link already handed out — including the one a call quality survey ships in
        `proto.debugLogURL`.

        The path must be a key this service minted. Anything else is a `404`, matched
        against the whole string rather than parsed, so nothing from the request can
        reach the `Location` header.

        **Link unfurlers get a page instead of the redirect.** A `User-Agent` from a
        known link-preview fetcher — iMessage's LinkPresentation (`facebookexternalhit`),
        Slackbot, Discordbot, TelegramBot, `WhatsApp/2` (which Signal's clients send),
        LinkedInBot, SkypeUriPreview — is answered `200` with a small HTML page carrying
        Open Graph tags that name the platform and version from the key, so a pasted link
        previews as what it is. Every other caller, browsers included, gets the `302`.
      security: []
      parameters:
        - in: path
          name: key
          required: true
          description: >-
            The three-segment object key, exactly as returned by `GET /`. The extension is
            not tied to the platform — any client may declare any allowlisted media type,
            so `android/…/….bin` (a Perfetto trace) is a key this service really mints.
          schema:
            type: string
            pattern: '^(?:ios|android|desktop|unknown)/(?:unknown|[0-9]+(?:\.[0-9]+){0,2})/[0-9a-f]{64}\.(?:zip|gz|bin)$'
          example: ios/1.0.10/4f3c2b1a0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b.zip
      responses:
        "200":
          description: >-
            A link-preview page, served only to a known unfurler's `User-Agent`.
            `Vary: User-Agent`.
          content:
            text/html:
              schema: { type: string }
              example: |
                <meta property="og:title" content="Jamb debug log · iOS 1.0.10">
                <meta property="og:description" content="Debug log archive (.zip) from Jamb iOS, version 1.0.10.">
        "302":
          description: "The object's location in the bucket. `Vary: User-Agent`."
          headers:
            Location:
              schema: { type: string, format: uri }
        "404":
          description: Not a key this service minted. Says nothing about whether the object exists.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /robots.txt:
    get:
      summary: Crawler policy
      operationId: robotsTxt
      description: >-
        Allows the link unfurlers named under `getDebugLog` by name and disallows
        everything else. A key is the read credential for its log, so a link a search
        or AI crawler follows and indexes is a published log. The preview page also
        carries `<meta name="robots" content="noindex, nofollow">`.
      security: []
      responses:
        "200":
          description: The robots.txt body.
          content:
            text/plain:
              schema: { type: string }
  /apple-touch-icon.png:
    get:
      summary: Link-preview icon
      operationId: icon
      description: >-
        Jamb's logo as a 180x180 PNG on white, linked from the preview page as
        `apple-touch-icon` and `icon` so Messages shows it in place of its generic compass.
        Also served at `/favicon.ico`. Cached for a day.
      security: []
      responses:
        "200":
          $ref: '#/components/responses/Icon'
  /favicon.ico:
    get:
      summary: Link-preview icon (favicon alias)
      operationId: favicon
      description: >-
        The same PNG as `/apple-touch-icon.png`, for browsers and unfurlers that ask for
        `/favicon.ico` unprompted. Served as `image/png` despite the extension.
      security: []
      responses:
        "200":
          $ref: '#/components/responses/Icon'
  /healthz:
    get:
      summary: Liveness probe
      operationId: healthz
      description: >-
        Unauthenticated, like everything else here, and registered ahead of every other
        route. Says nothing about the bucket or the signing key beyond the fact that the
        process is up — a malformed key stops the container at startup instead.
      security: []
      responses:
        "200":
          description: The process is running.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                required: [status]
components:
  responses:
    Icon:
      description: Jamb's logo, 180x180 PNG on white.
      headers:
        Cache-Control:
          description: Always `public, max-age=86400`.
          schema: { type: string }
      content:
        image/png:
          schema: { type: string, format: binary }
  schemas:
    UploadParameters:
      type: object
      description: >-
        Exactly `debuglogs.org`'s response shape. `getUploadParameters` in
        `DebugLogs.swift` reads `url` and `fields` and nothing else; adding a third key
        here would be a second contract to keep.
      properties:
        url:
          type: string
          format: uri
          description: The multipart POST target, with its trailing slash.
        fields:
          type: object
          additionalProperties: { type: string }
          description: >-
            Every form field the POST must carry, `file` excepted. Send them back
            verbatim — each is an exact-match condition in the signed policy, so altering
            any one of them fails the upload as `SignatureDoesNotMatch`.
      required: [url, fields]
    Error:
      type: object
      properties:
        error: { type: string, description: A stable machine-readable code. }
        message: { type: string }
      required: [error, message]
