Skip to content

Direct Admission

A member joins a namespace by getting one op onto the governance DAG: MemberJoinedAt, carrying the invitation it claims and a credential proving the account is its own. Every peer applies that op the same way regardless of how it arrived.

What differs is who can publish it.

  • POST /admin-api/namespaces/:id/join — the caller is the node that becomes the member. It holds a device key, builds the credential from its own store, signs, applies locally, and publishes to the namespace topic. One call, no third party.
  • POST /admin-api/namespaces/:id/admit — the caller holds only a key. It signs the op itself and hands it to a node the inviter named, which publishes on its behalf.

The second exists because a keyholder — a browser tab, a phone, an agent — has an account, a device certificate it signed offline, and nowhere to publish from. /join cannot serve it, because /join assumes the caller is the node that will become the member.

An open invitation used to be claimable by broadcast: the joiner announced itself on the namespace topic and any ready peer answered. That published the whole invitation, in the clear, to every subscriber of that topic — and because the consumed-invitation row is keyed by account, a thief and the intended joiner both succeeded, silently.

So GroupInvitationFromAdmin carries an admitters list, inside the inviter’s signature. Being named is permission to carry a valid claim, and nothing else.

  1. The credential is signed offline. The account root signs a device certificate naming the device’s signing and KEM keys. No node participates, and nothing is published.

  2. The op is signed by the device key the credential names. This is what makes handing it over safe: every peer checks signer == credential.statement.sign_pk when applying a join, so an admitter cannot substitute a different account.

  3. The admitter checks what it can, then applies before publishing. It verifies the op’s signature, that it is named in the signed admitters list, and that the invitation is genuinely the inviter’s and unexpired. It applies to its own state first, so only an op its own state accepted reaches the network under its name.

  4. Peers fold it normally. The op is an ordinary MemberJoinedAt; nothing about the admit path relaxes what applying one requires — including the endorsement, which every peer verifies for itself rather than trusting the node that relayed it.

It can decline to consent. That is the whole of its power — and it is real power, because without that consent the membership does not exist.

A join is only applied if it carries an AdmitterEndorsement: a signature by an account the invitation named, over the namespace, the joiner and the invitation’s nonce. Every peer re-checks it, resolving the signing key to an account through the device bindings and requiring that account to appear in the invitation’s signed admitters list.

That is what makes admitters an authorization boundary rather than a preference. Before it, the list restricted nothing on the publish path: a joiner signs its own join, so a holder of a valid invitation could publish and every peer would fold it, whether or not any named admitter had ever agreed.

What an admitter still cannot do is admit a different account, change the group, or grant a role, because all of that sits inside a signature it does not hold. The check that stops substitution — signer == credential.statement.sign_pk — lives at apply on every peer, deliberately rather than at the endpoint, because it has to hold for ops the endpoint never sees.

A hostile admitter is therefore a liveness problem, not an authority one, which is why an invitation naming several is worth more than one naming a single node.

The endorsement sits on the envelopeSignedNamespaceOp — not in the op body, and deliberately outside both the joiner’s signature and the op’s id.

That placement is what makes a keyholder admissible at all. A keyholder signs its own join, so anything covered by that signature has to exist before it signs; and an endorsement can only be signed by an account the invitation named, which a keyholder is not. It cannot have one. Nor could the admitting node supply one if the field were inside the signature, because the first thing /admit does is verify that signature — attaching anything it covers would invalidate what was just checked.

Outside the signature, the node that relays a join can attach its own consent to an op it did not author and cannot alter. to_signable does not copy the field, and the op’s id is taken over the signable form, so attaching consent leaves the op’s identity untouched. If it did not, relaying a join would fork history: the relay’s copy and the author’s copy would be different ops.

Leaving it unsigned costs nothing an attacker can use:

  • Stripping it makes the apply refuse the join. Fail closed.
  • Substituting another endorsement that verifies is a no-op, because any endorsement that verifies is over the same namespace, joiner and invitation as the one removed.

An invitation that names nobody is refused rather than treated as open season — an empty list is one no endorsement can satisfy, so minting refuses to produce one and a node asked to act on one declines rather than doing the work and producing a join every peer rejects.

Admins and TEE nodes. A caller that names none gets that set; the inviter is not added to it.

That is a deliberate separation of duties. CAN_INVITE_MEMBERS lets a non-admin member create an invitation, but not see it through — admission stays with admins and TEE nodes, so a delegated inviter cannot complete a membership alone.

The cost is reachability rather than authority: a non-admin inviter mints invitations it cannot itself admit, so the invitee has to reach an admin or TEE node rather than whoever handed it the invitation.

An invitation with an empty admitter list is the one that means “claimable by broadcast”, so minting refuses when the default set comes back empty. Every group has an admin — the last one cannot be removed or demoted away — so an empty result means the node’s own state is inconsistent, and answering that by issuing the least restricted credential the system can express would be the wrong response.

admitters names accounts. Connecting needs a peer id, and a joiner that has synced nothing has no governance state to resolve one from. So SignedGroupOpenInvitation also carries admitter_addrs, outside the signature: libp2p multiaddrs, each including its /p2p/<peer-id> suffix, e.g. /ip4/203.0.113.7/tcp/2528/p2p/12D3KooW….

The peer id has to travel with the address. Resolving an account to a peer needs governance state, and a joiner that has synced nothing has none — that circularity is why the field exists at all.

Unsigned on purpose. A hint is only where to look; who may admit stays inside the signature, so a wrong or stale hint costs a failed connection and nothing more — the admitting node still has to be in the signed list for its admission to count.

A caller can supply them, and otherwise the node resolves them:

case what you get
caller passes admitterAddrs used exactly as given, never merged with the node’s own view — a caller naming an address is usually correcting that view, not extending it
the minting node is itself an admitter its confirmed external addresses, each with its peer id appended, including the relay-circuit form a NAT’d node is reachable on
any other admitter its last known address, resolved through the node’s durable caches

That last row is what makes a delegated invitation usable. CAN_INVITE_MEMBERS lets a member mint invitations it may not admit, so the admitters are other nodes — and a joiner that has synced nothing cannot look up where those are.

Resolution runs account → signing keys of the account’s live devices → peers those keys have been seen on → last known addresses of those peers. The first link is governance state; the other two are node-local caches that persist across restarts and expire after 24 hours, which is also the longest an invitation can live. So a hint can never outlive the credential carrying it.

Best-effort at every link. An admitter not seen recently is simply not hinted — one fewer address for the joiner to try, never a wrong one, because the signed admitters list still decides who may answer. A mint that can hint nobody says so in the log rather than issuing a credential that quietly cannot be redeemed.

Why the field holds addresses and not URLs

Section titled “Why the field holds addresses and not URLs”

It carries libp2p multiaddrs only, and that is a deliberate narrowing rather than a gap.

A multiaddr ends in /p2p/<peer-id>, which libp2p authenticates during the handshake — dial the wrong host and it fails having disclosed nothing. An HTTPS URL has no equivalent: it proves a domain, not a node. An invitation is a bearer credential, so an address that can be wrong and still be talked to is a way to hand a redeemable credential to whatever answers.

A node could not derive its own URL anyway. It terminates no TLS and AuthMode::Proxy is the default, so the address callers should use belongs to a reverse proxy — different host, port and scheme from anything listen describes.

For the hosted tier this is answered outside the invitation. A keyholder with no node writes through delegated execution, which runs via hosted TEE relays, so the client asks whether the namespace has a TEE policy and presents its claim to the known relay endpoint. Asking first is what keeps a self-hosted namespace’s invitation from being handed to a cloud with no business holding it.

A third-party TEE deployment would want its endpoint recorded per node, alongside the attestation record that already names it. Nothing stores that yet.

Which path a joiner takes is decided by one thing: whether it runs a node.

  1. It receives the invitation out of band (the inviter sends it however it likes).
  2. It signs its own MemberJoinedAt op — the invitation authorises the join, but the joiner authors it, so no admitter ever signs on its behalf.
  3. It publishes that op on the namespace topic.
  4. Any node in the signed admitters list that sees it applies it. Every peer re-checks join_op_proves_ownership at apply, so an admitter relays a join, it never grants one.

A joiner that runs a node obtains its endorsement during the join exchange: the peer serving the join signs one if the invitation names it, and answers without one if it does not — usefully, since it can still serve the group key and the governance history. A joiner that gets an unendorsed answer tries the next peer rather than publishing a membership it cannot have authorised, and fails if it reaches no admitter at all. Reaching one is a requirement, not a preference.

admitter_addrs exists to make step 3 reliable when the joiner is not already connected to an admitter, and the joiner now dials them: one pass over the addresses before peer selection, then those peers are tried ahead of whoever discovery happens to have surfaced.

The dial pass is bounded by a single timeout rather than by how many addresses the invitation carries. An address is a snapshot from mint time, so hints that no longer resolve are the ordinary case, and a dial only resolves once the transport gives up — awaiting them one after another let a handful of dead hints spend the whole join before peer selection had been asked once.

Alternative addresses for one peer are still tried in order, because they are routes to the same machine; different machines are dialed concurrently, because they have no reason to wait for each other.

This is the case direct admission exists for: a keyholder with no node, no swarm, and no governance state.

  1. It mints its account root and signs its device certificate offline.
  2. It signs its own MemberJoinedAt, exactly as a node joiner would.
  3. It POSTs that op to /admin-api/namespaces/:namespace_id/admit on a node that is named in admitters.
  4. That node verifies the signature, checks it may admit, attaches its own endorsement, applies the membership locally, and publishes. The keyholder never handles an endorsement itself — it has nothing to sign one with, and needs no extra round trip to obtain one.

Step 3 needs an address, and admitter_addrs is no use here — those are multiaddrs, and there is no swarm to dial one from. This path is answered outside the invitation, on the client.

For the hosted tier that is resolved on the client rather than in the invitation: a keyholder writes through delegated execution, which runs via hosted TEE relays, so the client can ask whether the namespace has a TEE policy and present its claim to the known relay endpoint. Asking first matters — it keeps a self-hosted namespace’s invitation from being handed to a cloud that has no business with it.

MAX_INVITATION_VALIDITY_SECS is 24 hours, clamped where invitations are minted rather than at the API edge, so an internal caller cannot ask for longer.

An invitation is a bearer credential: possession is enough. A long-lived one keeps a leak redeemable for as long as it lasts, and guarantees any address hint shipped with it has gone stale.

/admit sits behind the admin API’s auth guard, so a keyholder presenting a signed op to somebody else’s node still needs a credential on that node. Everything else in the flow works without one — the account, the certificate, and the op are all produced offline — but the last hop is not yet answered for a node the keyholder has no relationship with.

The same gap on the write path is answered: POST .../intents can be served without a node credential, because a warrant is one — it commits to a single context, method and argument set, is single-use, and is refused before execution unless the relay holds CAN_AUTHOR_ON_BEHALF. A join op is a comparable self-contained credential, so the same treatment should extend to /admit; that is not done yet, and until it is, joining somebody else’s node is the step that still needs an out-of-band arrangement while writing through it does not.

The same guard covers the TEE-admission-policy lookup a client would use to decide where to present a claim, so both are unblocked by one piece of work rather than two.