Direct Admission
Two ways in, for two different callers
Section titled “Two ways in, for two different callers”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.
Why invitations name their admitters
Section titled “Why invitations name their admitters”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.
-
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.
-
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_pkwhen applying a join, so an admitter cannot substitute a different account. -
The admitter checks what it can, then applies before publishing. It verifies the op’s signature, that it is named in the signed
admitterslist, 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. -
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.
What an admitter can and cannot do
Section titled “What an admitter can and cannot do”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.
Where the consent rides
Section titled “Where the consent rides”The endorsement sits on the envelope — SignedNamespaceOp — 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.
Who is an admitter by default
Section titled “Who is an admitter by default”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.
Finding an admitter
Section titled “Finding an admitter”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.
Where hints come from
Section titled “Where hints come from”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.
The two joining flows
Section titled “The two joining flows”Which path a joiner takes is decided by one thing: whether it runs a node.
A joiner that runs a node
Section titled “A joiner that runs a node”- It receives the invitation out of band (the inviter sends it however it likes).
- It signs its own
MemberJoinedAtop — the invitation authorises the join, but the joiner authors it, so no admitter ever signs on its behalf. - It publishes that op on the namespace topic.
- Any node in the signed
admitterslist that sees it applies it. Every peer re-checksjoin_op_proves_ownershipat 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.
A joiner holding only a key
Section titled “A joiner holding only a key”This is the case direct admission exists for: a keyholder with no node, no swarm, and no governance state.
- It mints its account root and signs its device certificate offline.
- It signs its own
MemberJoinedAt, exactly as a node joiner would. - It
POSTs that op to/admin-api/namespaces/:namespace_id/admiton a node that is named inadmitters. - 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.
Invitations expire within a day
Section titled “Invitations expire within a day”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.
Known gap
Section titled “Known gap”/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.