Bridge enrollment HTTP contract¶
Bridge enrollment is a separate HTTP boundary from MCP OAuth. It establishes one project-fenced Bridge identity and returns a short-lived opaque token for Bridge transport. It does not create a provider account, choose a model, or grant access to another project.
Ownership and credentials¶
- The server owns the project binding, Bridge identity, approved account references, challenge consumption, token hash and revocation state.
- An operator creates a one-time bootstrap credential with
heyaira-admin create-bridge-enrollment-credentialand binds it to one project plus already-approved account references. - The raw bootstrap credential is written once to a local
0600file. The database stores only its SHA-256 hash. - The Bridge sends its public Ed25519 key fingerprint in the challenge request and proves possession by signing the returned nonce.
- Private keys, raw nonces, signatures, provider credentials and raw Bridge access tokens are never persisted or written to task state, receipts or logs.
The service must have two independent high-entropy configuration values:
HEYAIRA_BRIDGE_ENROLLMENT_SECRET and HEYAIRA_BRIDGE_TOKEN_SECRET. They are
not MCP OAuth secrets and are never put in a request body or response.
HEYAIRA_BRIDGE_ROTATION_TTL_SECONDS bounds the prepare/confirm window and
defaults to 300 seconds.
1. Create a challenge¶
POST /bridge/v1/enrollment/challenges
Authorization: Bearer <one-time-bootstrap-credential>
Idempotency-Key: bridge-enroll-<unique-key>
Content-Type: application/json
{
"project_id": "<project-uuid>",
"bridge_id": "mac-primary",
"device_id": "<device-identifier>",
"display_name": "Developer Mac",
"public_key_fingerprint": "sha256:<64-lowercase-hex-chars>"
}
Successful response (201) contains no bootstrap token and no private data:
{
"challenge_id": "<challenge-uuid>",
"project_id": "<project-uuid>",
"bridge_id": "mac-primary",
"public_key_fingerprint": "sha256:<64-lowercase-hex-chars>",
"nonce_b64": "<url-safe-32-byte-nonce>",
"expires_at": "2026-09-21T12:35:00+00:00"
}
The approved account references are copied from the bootstrap credential by the server. The Bridge cannot add or replace them in this request.
2. Prove possession and activate¶
POST /bridge/v1/enrollment/proofs
Idempotency-Key: bridge-proof-<unique-key>
Content-Type: application/json
{
"project_id": "<project-uuid>",
"bridge_id": "mac-primary",
"challenge_id": "<challenge-uuid>",
"nonce_b64": "<nonce-from-challenge>",
"signature_b64": "<ed25519-signature-over-nonce>",
"public_key_b64": "<ed25519-public-key>"
}
Successful response (200) contains the new Bridge-scoped bearer token. The
token is returned only from this response and is stored by the Bridge in its
local secure store:
{
"project_id": "<project-uuid>",
"bridge_id": "mac-primary",
"access_token": "<opaque-bearer-token>",
"token_type": "Bearer",
"scope": "bridge:transport",
"expires_at": "2026-09-21T12:50:00+00:00"
}
Challenge consumption, identity activation and token-hash insertion commit in
one PostgreSQL transaction. A failed proof leaves the identity pending and the
challenge unconsumed. Activation is idempotent per challenge and device key:
a retried proof for an already consumed challenge (the same or a new
idempotency key, or a concurrent retry) is verified again and returns the
same token, without minting another, while that token is still the Bridge's
active token. After it was rotated, revoked or expired, the retry fails with
enrollment_challenge_consumed, even under the original idempotency key.
Re-enroll after a failed or expired enrollment¶
A challenge expires five minutes after it is created, and creating it consumes the bootstrap credential. If the proof never succeeded (the challenge expired, or the proof failed and the Mac gave up), the operator creates a new one-time bootstrap credential and the Bridge requests a new challenge for the same Bridge ID:
- The Bridge ID is accepted only while its identity never completed
activation: status
pending, no consumed challenge and no token ever issued. The device key may be the same or a new one (for example after the Mac's key was lost); the identity's device ID, display name and key fingerprint are replaced, and its account references come from the new credential. - Every earlier unconsumed challenge of that identity is superseded in the
same transaction. A proof for it, or a replay of its challenge request,
fails with
enrollment_challenge_expired. - An identity that completed activation is refused with
bridge_identity_conflict(409), whatever key is sent, and the new credential is left unused. Use token rotation, or token recovery below. - A revoked identity is refused with
bridge_revoked. - A used bootstrap credential is refused with
bridge_enrollment_credential_used; only the exact replay of its original challenge request (same idempotency key and body) returns that challenge.
3. Rotate a Bridge token¶
Rotation uses an explicit prepare/confirm protocol. A pending successor is not an active Bridge credential and cannot authorize normal Bridge work.
POST /bridge/v1/tokens/rotate/prepare
Authorization: Bearer <bridge-access-token>
Idempotency-Key: bridge-rotate-<unique-key>
Prepare returns rotation_id, a pending successor token and
confirmation_expires_at. The predecessor remains active. An exact retry with
the same predecessor and idempotency key returns the same pending successor,
so a lost response does not lock out the Bridge. A different prepare is
rejected while one successor is pending.
The Bridge stores both credentials and its rotation journal durably before it confirms:
POST /bridge/v1/tokens/rotate/confirm
Authorization: Bearer <pending-successor-token>
Idempotency-Key: bridge-confirm-<unique-key>
Content-Type: application/json
{"rotation_id":"<rotation-uuid>"}
Confirm atomically activates the successor, assigns its normal access-token expiry and revokes the predecessor. Retrying the exact successful confirmation with the successor returns the same non-secret status response. A revoked predecessor can never recover its successor.
The Bridge may explicitly abandon a pending successor with the same request
shape at POST /bridge/v1/tokens/rotate/abort. Expiry or abort revokes only
the pending successor and leaves the predecessor active. Emergency revocation
is separate from routine rotation and also cancels every pending successor for
the Bridge identity.
The retired one-phase POST /bridge/v1/tokens/rotate route fails closed with
bridge_rotation_protocol_upgrade_required; it never issues or revokes a
token. See the normative Bridge protocol.
4. Revoke a Bridge token¶
POST /bridge/v1/tokens/revoke
Authorization: Bearer <bridge-access-token>
Revocation is terminal for that token. A revoked Bridge identity also blocks
the use or rotation of its tokens through the identity status check.
The operator revokes a whole identity — executions, tokens, bindings and the
paired node — with heyaira-admin revoke-bridge-identity
(revoking access).
5. Recover an already-expired token¶
Normal operation rotates the Bridge token before expiry. Recovery exists only
for an older or offline client that has already missed that window. An
operator runs heyaira-admin recover-bridge-token with the exact project and
existing Bridge ID. The command preserves the active identity, atomically
revokes old credentials, and writes one replacement credential to a new local
0600 file. The file is transferred through the authorized administrative
channel and piped to heyaira-bridge token recover.
The client checks project and Bridge binding against its Keychain metadata and
validates the new token through GET /bridge/v1/tokens/status before saving
it. Recovery fails closed when the rotation journal contains a pending
successor or other evidence requiring reconciliation. Raw recovery tokens are
never logged or written to HeyAira task state. The server rejects recovery
while a valid active token exists, so this path cannot replace routine
rotation or emergency revocation.
Stable errors¶
All enrollment errors use this shape and include Cache-Control: no-store:
{
"error": {
"code": "versioned_machine_code",
"message": "short human-readable explanation"
}
}
The contract uses these codes: bridge_enrollment_credential_required,
bridge_enrollment_credential_invalid,
bridge_enrollment_credential_expired,
bridge_enrollment_credential_used,
bridge_enrollment_credential_revoked, project_mismatch,
enrollment_challenge_not_found, enrollment_challenge_mismatch,
enrollment_challenge_expired, enrollment_challenge_consumed,
enrollment_proof_invalid, enrollment_key_mismatch, bridge_not_pending,
bridge_identity_conflict, bridge_persistence_error, bridge_revoked,
bridge_token_invalid, bridge_token_expired, bridge_token_revoked,
bridge_token_pending, bridge_token_still_active,
bridge_rotation_protocol_upgrade_required,
bridge_rotation_in_progress, bridge_rotation_not_found,
bridge_rotation_expired, bridge_rotation_aborted,
bridge_rotation_confirmed, bridge_rotation_invalid_state,
bridge_rotation_predecessor_revoked,
bridge_rotation_predecessor_expired,
idempotency_key_required, and
idempotency_conflict.
Where this sits¶
The server serves this contract (bridge_transport.py on top of
BridgePersistenceAdapter, migrations 013 to 016). Task offers, executions
and leases are a separate contract: see
task-version execution and the
Bridge transport protocol. There are no OpenBao
credential leases; provider credentials stay on the Mac (see
credential broker boundary).