openapi: 3.1.0
info:
  title: Bridge Protocol API
  version: 0.1.0
  summary: The wire contract for a party — witbitz-native OR external — to join a shared Space across a Bridge.
  description: >
    The machine-readable half of the Bridge Protocol. The SEMANTIC contract — the five invariants
    (confinement is structural · attribution by signature · content-blind platform · source-side egress ·
    governed membership + trustless audit), the key-epoch + signature crypto, and the trust model — lives in
    `docs/bridge-protocol.md` and is NORMATIVE; this spec is only the HTTP shape of the signed-HTTPS binding.


    A `party` is an identity (a keypair) + optionally an agent + human members. A witbitz-native party and an
    external party (someone else's agent + humans, on their own system) are two implementations of THIS
    contract — that symmetry is the interop. The platform is content-blind: every `body` is sealed under the
    current epoch key, which the platform never holds; the platform verifies signatures + capabilities + the
    hash chain on CIPHERTEXT.
  x-status: live
  x-note: >
    LIVE (2026-07-25) + verified end-to-end (found → admit an external party → submit → read → checkpoint,
    content-blind). Engine + binding: agent/bridge{Membership,Log,Space,Handler}.mjs (22/22). AUTH as deployed
    is thin-but-sound — SUBMIT is authenticated by the ENTRY's own signature (verified against the membership
    pubkey), and READ/MEMBERS/CHECKPOINT are content-blind or public; the per-request `partySignature` scheme
    below is the target HARDENING (still x-proposed) and is not required for the guarantees, which hold from
    the key boundary + entry signatures.
  x-semantic-spec: docs/bridge-protocol.md
  contact: { email: hello@witbitz.chat }
  license: { name: 'Proprietary — © Witbitz', url: 'https://witbitz.chat/terms' }

servers:
  - url: https://api.witbitz.chat/v1/bridge
    description: Prod signed-HTTPS binding (live) — branded, stable across redeploys.
  - url: https://0tu71pk4g5.execute-api.eu-central-1.amazonaws.com/bridge
    description: Prod origin (raw execute-api) — same Lambda, without the /v1 base-path mapping.

tags:
  - { name: Space, description: Found a shared space and manage its membership. }
  - { name: Log, description: Read and submit the shared thread. }
  - { name: Audit, description: 'The tamper-evident, server-signed checkpoint.' }

security:
  - partySignature: [] # every op except GET /checkpoint requires a party-key request signature

paths:
  /spaces:
    post:
      tags: [Space]
      summary: Found a shared Space (JOIN, first party)
      description: >
        The founder uploads the initial membership record — epoch 0, itself as the only member (full caps),
        `sharedMk_0` sealed to its own box key. The platform stores the (public) record + the sealed grants
        (ciphertext); it never sees a key. The request is signed by the founder's key.
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Membership' } } } }
      responses:
        '201': { description: Founded, content: { application/json: { schema: { type: object, required: [space], properties: { space: { $ref: '#/components/schemas/SpaceId' } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
      operationId: foundSpace
      x-status: live

  /spaces/{space}/members:
    parameters: [ { $ref: '#/components/parameters/space' } ]
    get:
      tags: [Space]
      summary: MEMBERS — the public membership record
      description: The parties, their public keys, capabilities, and policies — so any party can verify others' signatures and know who is on the other side. Public metadata only; no keys, no content.
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Membership' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
      operationId: getMembers
      x-status: live
    put:
      tags: [Space]
      summary: ADMIT / REVOKE — apply a membership change
      description: >
        A membership change is party-side and IS an epoch boundary: the caller (who must hold the `admit` cap
        to add, or `revoke` to remove) mints a fresh epoch key, re-seals it to the NEW member set, and uploads
        the resulting record. The platform verifies the caller's capability + signature and stores it. A joiner
        gets no back-history unless the admitter explicitly sealed past epochs to it (`historyPolicy`).
        Revocation gives real forward-secrecy (the removed party is not sealed the new epoch key).
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Membership' } } } }
      responses:
        '200': { description: Applied, content: { application/json: { schema: { $ref: '#/components/schemas/Membership' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
      operationId: changeMembers
      x-status: live

  /spaces/{space}/grant:
    parameters: [ { $ref: '#/components/parameters/space' } ]
    get:
      tags: [Space]
      summary: JOIN — fetch this party's sealed epoch-key grants
      description: >
        Returns the calling party's `keyGrants` (each epoch key sealed to the party's box public key). The
        party opens them locally with its private key to read + submit. The request is signed by the party key;
        the server returns only the caller's own grants.


        NOTE (live binding): this dedicated endpoint is NOT deployed. In the live binding each member's sealed
        `keyGrants` are embedded in the membership record, so a party reads its own grant from `GET .../members`
        (the grants sealed to other parties' box keys reveal nothing readable). This endpoint remains a privacy
        refinement — return only the caller's grants rather than the whole record — hence x-status: proposed.
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, required: [space, party, keyGrants], properties: { space: { $ref: '#/components/schemas/SpaceId' }, party: { $ref: '#/components/schemas/PartyId' }, keyGrants: { $ref: '#/components/schemas/KeyGrants' } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
      operationId: getGrant
      x-status: proposed

  /spaces/{space}/entries:
    parameters: [ { $ref: '#/components/parameters/space' } ]
    get:
      tags: [Log]
      summary: READ — the shared thread since a cursor
      description: >
        Sealed entries with `seq > cursor`. Bodies are ciphertext; the party decrypts locally with the epoch
        key it holds and verifies each `sig`. A read teaches the server nothing about content. Poll-shaped.
      parameters: [ { $ref: '#/components/parameters/cursor' } ]
      responses:
        '200': { description: OK, content: { application/json: { schema: { type: object, required: [entries, epoch], properties: { entries: { type: array, items: { $ref: '#/components/schemas/Entry' } }, epoch: { type: integer } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
      operationId: readEntries
      x-status: live
    post:
      tags: [Log]
      summary: SUBMIT — a signed, sealed entry (a load or a post)
      description: >
        The party seals its content under the current epoch key, signs the entry with its party key, and
        submits. The platform checks — WITHOUT reading the body — that the party is a member, holds `submit`,
        the `epoch` is current, and the signature verifies; then it assigns `seq`/`at`/`prevHash` and appends.
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/EntryDraft' } } } }
      responses:
        '201': { description: Appended, content: { application/json: { schema: { type: object, required: [seq], properties: { seq: { type: integer } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Not a member, lacks the submit cap, wrong epoch, or bad signature', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '429': { $ref: '#/components/responses/TooManyRequests' }
      operationId: submitEntry
      x-status: live

  /spaces/{space}/checkpoint:
    parameters: [ { $ref: '#/components/parameters/space' } ]
    get:
      tags: [Audit]
      summary: CHECKPOINT — the latest server-signed head (PUBLIC)
      description: >
        The server-signed commitment to the current head, for parties to PIN and gossip. Deliberately
        unauthenticated so checkpoints can be compared out-of-band (equivocation detection). A server that
        later reorders or drops entries cannot match a checkpoint a party already pinned.
      security: [] # public
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Checkpoint' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
      operationId: getCheckpoint
      x-status: live

components:
  securitySchemes:
    partySignature:
      type: http
      scheme: signature
      x-status: proposed
      description: >
        An HTTP Message Signature (RFC 9421-style / detached JWS) over the request, made with the party's
        SIGN key. The server verifies it against that party's `signPub` in the membership record — this is the
        attribution + authorization boundary. Tenant/API keys are NOT used here: a party authenticates as
        ITSELF, native or external.


        LIVE BINDING (x-status of the ops is `live`; this per-request scheme is the target HARDENING, still
        proposed): the deployed auth does not require a request signature. Instead SUBMIT is authenticated by
        the ENTRY's OWN signature — each entry carries `sig` over its canonical core, verified against the
        submitting party's `signPub` — and READ / MEMBERS / CHECKPOINT are content-blind or public. The
        guarantees (attribution, confinement, content-blindness) hold from the key boundary + entry signatures;
        the request signature adds request-level replay/authorization hardening on top and is the next step.

  parameters:
    space: { name: space, in: path, required: true, schema: { $ref: '#/components/schemas/SpaceId' } }
    cursor: { name: cursor, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 }, description: return entries with seq greater than this }

  schemas:
    SpaceId: { type: string, description: the shared space id }
    PartyId: { type: string, pattern: '^pty_[0-9a-f]{24}$', description: fingerprint of the party's sign public key }
    Cap: { type: string, enum: [read, submit, admit, revoke] }
    PublicJwk:
      type: object
      description: an EC P-256 public key (JWK)
      required: [kty, crv, x, y]
      properties: { kty: { const: EC }, crv: { const: P-256 }, x: { type: string }, y: { type: string } }
      additionalProperties: true
    Sealed:
      type: object
      description: 'OPAQUE ciphertext (an envelope from agent/envelope.mjs) — the platform carries it, never reads it. Symmetric (sealed under an epoch key) or a public-key box (alg=box).'
      required: [v, alg, iv, ct]
      properties: { v: { type: integer }, alg: { type: string }, iv: { type: string }, ct: { type: string }, epk: { type: object, additionalProperties: true } }
      additionalProperties: true
    KeyGrants:
      type: object
      description: 'map of epoch -> the epoch key sealed to a member''s box key'
      additionalProperties: { $ref: '#/components/schemas/Sealed' }
    Member:
      type: object
      required: [party, boxPub, signPub, caps, keyGrants]
      properties:
        party: { $ref: '#/components/schemas/PartyId' }
        boxPub: { $ref: '#/components/schemas/PublicJwk' }
        signPub: { $ref: '#/components/schemas/PublicJwk' }
        caps: { type: array, items: { $ref: '#/components/schemas/Cap' } }
        admittedBy: { $ref: '#/components/schemas/PartyId' }
        admittedAt: { type: integer, description: the epoch this member was admitted at }
        keyGrants: { $ref: '#/components/schemas/KeyGrants' }
      additionalProperties: false
    Membership:
      type: object
      description: the PUBLIC governance record — parties, public keys, capabilities, sealed grants (ciphertext), policies. No plaintext key, no content.
      required: [space, epoch, members]
      properties:
        space: { $ref: '#/components/schemas/SpaceId' }
        epoch: { type: integer, description: the current epoch }
        members: { type: array, items: { $ref: '#/components/schemas/Member' } }
        admissionPolicy: { type: string, enum: [cap], description: 'who may admit (v1: whoever holds the admit cap; other policies later)' }
        historyPolicy: { type: string, enum: [none], description: 'default: a joiner gets the current epoch only' }
      additionalProperties: false
    EntryDraft:
      type: object
      description: the CLIENT-signed entry (before the server assigns seq/at/prevHash).
      required: [space, party, kind, actor, epoch, body, nonce, sig]
      properties:
        space: { $ref: '#/components/schemas/SpaceId' }
        party: { $ref: '#/components/schemas/PartyId' }
        kind: { type: string, enum: [load, post], description: 'load = a contributed position; post = a message/turn' }
        actor: { type: string, enum: [agent, human] }
        epoch: { type: integer, description: MUST equal the current epoch }
        body: { $ref: '#/components/schemas/Sealed' }
        clientHeadSeq: { type: integer, description: the seq the client last saw (causal context; advisory) }
        nonce: { type: string }
        sig: { type: string, description: party-key signature over the canonical core fields }
      additionalProperties: false
    Entry:
      description: a stored entry = the signed draft + the server's ordering fields.
      allOf:
        - { $ref: '#/components/schemas/EntryDraft' }
        - type: object
          required: [seq, at, prevHash]
          properties: { seq: { type: integer }, at: { type: integer }, prevHash: { type: string, description: hash of the prior stored entry (the append-only chain) } }
    Checkpoint:
      type: object
      description: a server-signed commitment to the head — parties pin + gossip it.
      required: [space, epoch, headSeq, headHash, at, sig]
      properties:
        space: { $ref: '#/components/schemas/SpaceId' }
        epoch: { type: integer }
        headSeq: { type: integer }
        headHash: { type: string }
        at: { type: integer }
        sig: { type: string, description: the platform (server) key signature over the checkpoint }
      additionalProperties: false
    Error:
      type: object
      required: [error]
      properties: { error: { type: string }, message: { type: string } }
      additionalProperties: false

  responses:
    BadRequest: { description: Malformed request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unauthorized: { description: Missing or invalid party signature, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Forbidden: { description: The party lacks the required capability, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: No such space, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    TooManyRequests: { description: 'Per-party rate/size cap exceeded (enforced on metadata, content-blind)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
