id: auth-types title: Every credential this server accepts, and what each one may do status: open crates: [didbot-serve, didbot-pds] dependsOn: [agent-accounts, pds-writes] exitCriterion: > Every route the router serves names the credential types it accepts, a request carrying none is refused wherever the route needs a principal, and each accepted type is driven end to end by the conformance harness. #
auth-types #
This server accepts more than one kind of credential, and the question this epic answers is which kinds may exist, on what argument, and what a route does when a request carries none. It is deliberately not a policy engine and deliberately not a taxonomy written down twice.
Where the set lives #
Credential in crates/didbot-serve/src/auth.rs is the set, and there is no
copy of it here or anywhere else. A list of credential kinds kept beside the
code is a list that goes stale silently; the enum's variants carry their own
documentation, routes.rs's xrpc_methods! invocations carry which route
takes which, and both are compiled. Adding a kind, or changing what a route
accepts, is an edit to one of those two places and to nothing else.
What this epic decides, which no enum can say:
Which questions a credential kind exists to answer. oauth builds one credential and the server that issues it. This epic is the other question: what this server does about every caller that is not holding one. A server implementing only OAuth still has to answer the client that arrives with an app password, and it has to answer in a way that says so — hence a named-but-disabled scheme answering "recognised, not accepted" rather than a bare 401, which is a decision about who may talk to this server rather than a detail.
That a kind is added only when no existing kind fits the caller. The agent token was the worked example and is now the counter-example. It was added because a session token meant a machine holding a human credential shape — but what made that shape human was the password, not the session. Replace the password with the node attestation node already establishes and the rest of the session fits exactly: rotation, expiry, a refresh family, and reuse detection that revokes the family on replay. All of that is written and none of it exists on the bespoke token.
That there is no operator credential, and that this is load-bearing. A
server learns which DID operates it by reading bot.did.operator out of the
operator's own repository — see handshake — a mechanism
whose whole point is that no operator credential ever reaches a PDS. So a
surface that would need one is not built rather than gated on a stand-in,
account-lifecycle mutations are self-service or nothing, and a closed
disclosure route is closed to everyone alike. Proving control of the
operator DID to this server is oauth's owner sign-in.
That the hook-stamped local identity in credentials is not a credential here and must not become one. It says which agent is making a tool call. It is not presented over the wire and it authenticates nothing to this server.
The write surface, and the credential that unlocked it #
The write surface is no longer open. com.atproto.repo.createRecord,
putRecord, deleteRecord, applyWrites and uploadBlob all require
Credential::AgentToken now, and the authenticated DID is checked against
the repository the request names — ApiError::repo_mismatch (403
RepoMismatch) is what a token from one account gets for trying to write
another's, an invariant rather than a policy knob.
Getting there needed a credential kind that did not exist yet, because none
of the ones that did fit the caller: a harness client writes an agent's
records and memories automatically, with no person at a login screen and
no operator minting an app password for it, so LegacySession was the wrong
vehicle — building it would have meant a machine holding a human
credential shape for a caller that is never human. What it needed instead was the smallest thing
plan/credentials.md's framing already asks for: a bearer token,
minted once at provisioning, bound to one DID, with an expiry and no
rotation. That is didbot_pds::credential — Credential::AgentToken on
the HTTP side — and Provisioner::provision now issues one automatically,
durable in the write-ahead log (pds.layout bumped to 3), returned once as
agentToken in provisionAgent's response body. A client carries it the
same way it already carries the DID, and the conformance suite proves the
whole path: client and server agreeing on a credential
none of them minted by hand.
This is deliberately not LegacySession, not OAuth, and not inter-service
auth, and getSession's own end-to-end proof (below) still stands as
LegacySession's.
The account-admin surface, and the mistake Credential::Public let happen #
The write surface above closed com.atproto.repo.*. It left bot.did.*
— this server's own account-admin surface — open, on the reasoning that
provisionAgent's credential is the ProvisionRequest's attestation claim,
checked inside the registry rather than at the HTTP layer. That reasoning
describes where provisionAgent is going, not where it is: ProvisionRequest
carries no attestation claim, nothing in the registry checks one, and every
account is minted Assurance::SelfAsserted, so the route authenticates
nobody. deleteAgent, setAgentPinned and
setAgentScrobbling inherited Credential::Public alongside it without
inheriting the attestation check, which is a route that destroys an account
answering an unauthenticated POST — a single request, no session, no
enumeration even needed because listAgents handed out every DID for free.
provisionAgent is now its own credential kind, Credential::Attested, so
Public can no longer mean two different things depending on which route
you are reading — and so the one route that is open and should not stay
that way is told apart from the routes that are open on purpose. See
crates/didbot-serve/src/auth.rs's module documentation for the full
taxonomy this section only summarises, and that variant's own doc for what
it enforces today, which is nothing.
Account-lifecycle mutations are always credentialed, never toggleable.
Credential::AgentSelf: the acting
agent's own write credential, checked against the account the request names.
A harness client calls all three automatically — the same shape that argued
(wrongly) for Public before — but by the time one of these fires the
account already has its own write credential, and that is exactly the caller
these routes are reasonable to ask one from. An agent may end or toggle
itself, and that is the whole list: acting on another account would need a
credential proving this deployment's operator, and there is deliberately no
such credential. bot.did.hardDeleteAgent, which must not be self-service,
consequently has no route at all — see agent-accounts.
There is no netizen argument for letting a stranger delete an account, so
this stays hard-locked regardless of the disclosure decision below.
Publish by default: the accountability argument, and why it is a principle rather than a note about a few routes #
The account-admin reads are disclosure, not mutation — they say which
agents a deployment runs and what happened to them — and the decision for
them is not "lock them like the mutations." It is
Credential::Disclosure: public by default,
narrowable per route by deployment configuration, and never widenable past
public. auth::Disclosure carries one bool per route, every field
defaulting true; a deployment may flip a field to false, and nothing may
flip one back past true, because there is nothing wider than public to
widen into — the same narrowing-only rule that governs every configuration
tier below the owner's own repository.
The reasoning is not specific to those routes, and should be read as one this project applies wherever a "should this be public" question of this shape comes up again — not re-derived per route:
- What is being published is the accountability mechanism, not an incidental leak. ownership exists so a stranger can ask whether an agent traces to a responsible human. That question has no answer if the deployment being asked is also the one deciding whether to be asked. Publishing which agents exist and what happened to them — the roster and the ledger — is the same argument this project already makes for publishing what it is doing elsewhere: divergence from what was promised has to be a comparison somebody outside can make, or the promise is unverifiable. A fresh deployment therefore starts open, not closed-until-configured.
- The toggle exists because "private means federation off" is a real, legitimate posture. spaces names it for the deployment as a whole; per-route disclosure is the same choice at a smaller grain, for an operator running agents on behalf of people who would rather not have every account enumerable by a stranger. Per route rather than one switch: closing the ledger while leaving the roster open (or the reverse) is a plausible posture a single flag cannot express.
- Closing a route has to be visible, or the mechanism defeats itself.
The threat this default resists is an operator who cannot be audited, and
the configuration tier that closes a route is a tier an on-box attacker
also reaches — closing
listAgentsis exactly what that attacker would do to hide what a stranger needed to see. This cannot be prevented from the server alone, but it is made legible: a closed route answersDisclosureDisabled, never a 404 and never an empty list, so an outsider can tell "this server runs no agents" from "this server is not telling you" — two different facts, and only one of them is suspicious. An operator credential still reaches a closed route, so closing it narrows who may read it without narrowing who may audit it.
This closes the "state what stays open to everyone" item below for
bot.did.*: public-by-accident is gone, and what remains public does so for
a reason the table now states, not because nobody got to it. It is written
as a decision for now, not a permanent property — the owner has said
this surface will be revisited, likely with finer-grained scoping than
per-route booleans, and plan/spaces.md's alternative posture is exactly
the direction that revisit would move in.
com.atproto.*'s read surface is a separate question this epic does not
answer. com.atproto.sync.* and the read half of com.atproto.repo.* are
public because the protocol requires it: a relay crawls them and an appview
indexes them, and a credential there means no relay indexes these agents and
nothing they write reaches Bluesky, contradicting what this project says it
does. Whether this deployment federates at all is the owner's decision, made
and recorded — Credential::Public on purpose, not an accident, and
plan/spaces.md's "private means federation off" is what a deployment
reaches for if it decides otherwise. /.well-known/did.json,
/.well-known/atproto-did and createSession are public for a third, purely
structural reason: a DID that needs a credential to resolve is not a DID,
and a credential requirement on the route that issues credentials is
circular. See crates/didbot-serve/src/auth.rs's "Why every public route is public" for all
three reasons laid out together.
What a deletion does to a credential #
A credential that outlives the account it authenticates is a credential with
nothing behind it. Registry::delete, Registry::hard_delete and
Registry::soft_delete therefore end every one of them, through one call:
the registry revokes the agent token it holds and then calls the hook
Registry::set_account_revoke_hook registers, which didbot_serve::app_with_repos
wires to the stores that live beside the router — the account's legacy
sessions, its app password, its OAuth grants and its unredeemed
authorization codes.
The hook is the shape Estop::set_revoke_hook already uses, and for the same
reason: the type that knows an account is going has no way to reach the
stores that authenticate it, and a registry that held them would be holding
the router's state. Wired in app_with_repos rather than left to whoever
built an AuthState, so every router this crate builds has it.
Soft delete and hard delete do the same thing here, and a freeze does
not. There is no path from soft-deleted back to active — only a hard
delete moves an account on from it — so a credential left alive there would
authenticate an account that can never write again, and every read a
soft-deleted account still serves is public and needs no credential at all. A
freeze is the reversible state: an account that will be unfrozen keeps what
it holds, or lifting the freeze would mean re-provisioning it.
Done #
Left open, on purpose: inter-service auth, per-principal rate limits, and the caller-facing app-password lexicon methods.