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_idis 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:writeand 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_boundor 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:
- the credential carries
bridge:bindings:write; - its subject is an owner or admin of the authenticated target project;
- 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:transporttoken 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
blockedwithCI failed on the accepted candidate: …; not sent back: the Mac is no longer bound to the projectand the usual "Task blocked" event. - Accepted work still waiting for its pull request (
publication_stateawaitingorunchecked) stays as it is. The Mac can no longer report a publication for the project (its execution routes answerexecution_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.