Skip to content

Bridge project scope and Work Center feeds

The Bridge is a local execution boundary. Its reviewed bridge:transport credential authenticates bridge_identity_id, not a selected project. A node may work for more than one HeyAira project only when each project has an explicit active binding.

This contract keeps the account, project and node boundaries separate:

  • project_id is never selected from a Bridge read request to grant access;
  • the Bridge access token identifies the node;
  • a project filter is accepted only after the server verifies an active node-to-project binding;
  • binding requires a project owner/admin with bridge:bindings:write, plus control of the Bridge through enrollment-project administration or a transport-token co-signature within the same account;
  • unbinding requires bridge:bindings:write and an owner/admin role only in the authenticated target project, so its data owner can always revoke the delegation;
  • contributors cannot receive the administrative binding scope;
  • an unbound or revoked project fails closed with project_not_bound or a not-found response, and no task, memory, receipt or source data is returned.

Persistence and migration

Migration 017_bridge_node_project_bindings.sql adds the explicit binding relation:

(bridge_identity_id, project_id) -> created_at, created_by_subject, source, revoked_at

Existing reviewed bridge_identities rows are backfilled as bridge-enrollment-backfill. New enrollment proofs create the initial binding in the same transaction that activates the identity and issues its token. The migration is additive and does not delete or rewrite project data.

The paired node is still required

/bridge/v1/pair is older than enrollment, but the Bridge still needs it. Pairing creates the bridge_nodes row, and only the /pair credential can send heartbeats, which keep that row's status, health, capabilities and public key current. Every task send (workflow_send, workflow_dispatch_catalog) joins the enrolled identity to that row (same project, bridge_nodes.node_id equal to the identity's device_id, status connected). Without a connected paired node the send fails with bridge_offline; a node key that does not match the enrolled key fails with bridge_device_binding_mismatch. Capacity, the unclaimed-offer alert and the publication alerts read the same row.

For the read feeds below, the /pair credential stays limited to its original single project; it cannot acquire more projects.

Binding management

The authenticated project is derived from the project token. The request body does not contain a target project, so a caller cannot switch tenants by changing JSON. Creating or restoring a binding is a durable delegation and therefore requires all three checks:

  1. the credential carries bridge:bindings:write;
  2. its subject is an owner or admin of the authenticated target project;
  3. either that same subject is an owner or admin of the Bridge identity's original enrollment project, or the caller co-signs with a valid bridge:transport token of that exact identity and the target and enrollment projects belong to the same account.

The third check proves that the caller controls the Bridge identity rather than merely knowing a node UUID. OAuth subjects are project-token-specific; the co-signature lets the same account owner bind another project without sharing a subject across projects. The token must be active, unexpired and unrevoked, and its identity must be active. A foreign identity is returned as not found, and a rejected request does not create or restore a binding. Limited OAuth grants such as memory:read cannot manage bindings even when their subject has an administrative project role.

POST /bridge/v1/nodes/{node_id}/projects
Authorization: Bearer <project-owner-or-admin-token-with-bridge:bindings:write>
X-HeyAira-Bridge-Authorization: Bearer <identity-bridge:transport-token>

The co-signature header is optional for the existing same-subject path. It never replaces the target project's scope or owner/admin checks. A missing or invalid co-signature on the alternative path returns 404 not_found without creating or restoring a binding.

For POST, {node_id} accepts an identity UUID (bridge_identities.id) or the enrolled device's bridge_id. An exact identity UUID wins even if another identity uses that UUID as its bridge_id; there is no fallback after that identity fails authorization. Otherwise bridge_id must match exactly one identity globally, including revoked identities. Multiple matches return 404 not_found; use the identity UUID to disambiguate. The response always contains the resolved identity UUID as node_id and the bound project metadata. DELETE continues to accept only the identity UUID. To revoke only that project's access:

DELETE /bridge/v1/nodes/{node_id}/projects
Authorization: Bearer <project-owner-or-admin-token-with-bridge:bindings:write>

On the enrolled Mac, run heyaira-bridge project bind --project <slug> (or pass https://mcp.heyaira.eu/p/<slug>/mcp). It opens a browser for a one-time OAuth PKCE sign-in to that project's resource with tasks:read bridge:bindings:write, co-signs with the Keychain transport token, and revokes the temporary OAuth access and refresh tokens after the attempt. OAuth tokens are neither printed nor stored. If cleanup cannot be confirmed, the command reports that separately from the binding result.

Unbinding is intentionally asymmetric: the authenticated subject must administer the target project but does not need to administer the node's enrollment project. Revocation only removes the target project's delegation; requiring node control would let a removed administrator keep a binding that the target project's remaining owners could not revoke.

Unbinding also ends that project's work on that Mac in the same transaction: every offered or running execution of the project on the node is cancelled, and a workflow whose active attempt it was becomes blocked with the reason "The project was unbound from the Mac this task was sent to; send it again to a bound Mac". The task becomes blocked with that reason, and the usual "Task blocked" event goes out. The Mac's slot is free at once, instead of being held until the task time limit, and the "Task waiting" alert never fires for it. The response says how many executions were stopped (executions_stopped). Tasks are locked before the binding, as dispatch and revocation do; if the Mac's work keeps changing, the call answers 503 bridge_unbind_busy and can be repeated.

Only offered and running executions are ended in that transaction (and counted in executions_stopped). Two kinds of the project's work on that Mac have no such execution and are left as they are:

  • A CI correction waiting for a slot stays waiting until the server's supervisor (every 15 seconds) looks at it next; it then ends blocked with CI failed on the accepted candidate: …; not sent back: the Mac is no longer bound to the project and the usual "Task blocked" event.
  • Accepted work still waiting for its pull request (publication_state awaiting or unchecked) stays as it is. The Mac can no longer report a publication for the project (its execution routes answer execution_not_found), so work with no pull request reported yet is blocked as accepted but not published by the first supervisor pass after it turns 10 minutes old, with the "Not published" event. A pull request already reported unchecked is still re-checked on GitHub by the server, which needs no binding, and completes the task once GitHub confirms it.

Here node_id is the database UUID of the reviewed enrollment identity (bridge_identities.id). It is not the /pair node UUID and not the client-chosen textual bridge_id.

POST /bridge/v1/nodes/{node_id}/revoke is the global node-revocation route for /pair credentials. Modern transport-token revocation continues through POST /bridge/v1/tokens/revoke; project unbinding is independent and revokes only one binding.

Read feeds

Bridge access tokens can read the following bounded, no-store feeds:

Endpoint Purpose
GET /bridge/v1/projects Active project catalog for this node
GET /bridge/v1/tasks Task index with optional project_id, status, limit, and offset
GET /bridge/v1/tasks/{task_id} Full task plus work sessions, only within an active binding
GET /bridge/v1/memory Memory index with optional project, kind and text filters
GET /bridge/v1/memory/{memory_id} Full memory entry, only within an active binding
GET /bridge/v1/receipts Bounded work-session receipt index
GET /bridge/v1/sources Bounded Git document-reference index

Index responses do not include memory bodies. Detail endpoints perform the same binding check as index endpoints. Limits are clamped to 100 entries per request. Feed responses contain references and durable metadata; repository files remain in Git and are read through the existing pinned document access contract.

Client integration boundary

The Bridge client should call the project catalog after authentication, show the user only projects returned by that catalog, and send an explicit project filter for project-scoped Work Center views. A client must treat a 403 project_not_bound or a 404 detail response as an authorization result, not as permission to retry against another project or infer a project from a task.

These feeds are read-only. Task assignment, leases and server-issued execution plans use the /bridge/v1/executions routes; see task-version execution. Each execution plan carries its own project, so the Bridge never infers one from a feed.