Language-agnostic contract for Herald — the Archon name service.
Users prove DID ownership via challenge-response, claim a short
@name handle, receive a verifiable credential attesting to their
membership, and are published to a directory served as JSON, IPNS,
LUD-16, WebFinger, and OIDC.
The canonical implementation is services/herald/server/.
Related specs. Herald is a Keymaster client end-to-end. Its challenge-response auth uses Keymaster’s
createChallenge/verifyResponse; its credential issuance usesbindCredential/issueCredential/updateCredential/revokeCredential. When fronted by Drawbridge, Herald’s/.well-known/*and/api/*are reached through Drawbridge’s/.well-known/*and/names/*mounts respectively (see Drawbridge spec §2.1).
Herald is a single-tenant naming authority. One instance owns one namespace and serves:
@name (3-32 chars; names are lowercased before validation and
storage, then matched against [a-z0-9_-]+); the name is recorded on the
Herald’s local user database and a verifiable credential is issued
and stored as an asset DID owned by the Herald’s service identity.name → DID directory is served as
JSON at /api/registry, /directory.json, and /.well-known/names.
Optionally published to IPNS for decentralized resolution.GET /api/name/:name — JSON { name, did }GET /api/member/:name — full resolved DID documentGET /api/name/:name/avatar — PNG/JPEG bytes of the user’s
avatar (asset DID linked from their profile)GET /.well-known/lnurlp/:name + GET /api/lnurlp/:name/callback
— full LUD-16 Lightning address (delegates to the user’s DID’s
Lightning service entry)GET /.well-known/webfinger?resource=acct:name@domain — RFC 7033
WebFinger/oauth/authorize, /oauth/token,
/oauth/userinfo, /oauth/.well-known/jwks.json, plus discovery at
/.well-known/openid-configuration. ES256-signed JWTs.ARCHON_HERALD_OWNER_DID has admin
privilege: list users, delete users, trigger IPNS publication.The service holds either a full Keymaster wallet (standalone mode) or a Keymaster client connection (shared mode); see §4.
Single Express app on ${ARCHON_HERALD_PORT} (default 4230).
Sessions cookie-based via express-session. Routes are split into
several namespaces:
| Method | Path | Notes |
|---|---|---|
GET |
/api/version |
1 (the literal integer). Stable schema version of the API. |
GET |
/api/config |
{ serviceName, serviceDID, serviceDomain, publicUrl, walletUrl }, plus relayAgent when ARCHON_HERALD_SENDGRID_API_KEY is set. relayAgent is the Herald service DID clients can address for dmail/email relay. |
| Method | Path | Notes |
|---|---|---|
GET |
/api/challenge |
Creates a Keymaster challenge, stores it on the session, returns { challenge, challengeURL }. challengeURL is <walletUrl>?challenge=<DID>. |
GET |
/api/login?response=<DID> |
Verifies the response DID via Keymaster verifyResponse({ retries: 10 }). On match, sets session.user = { did } and returns { authenticated: true }. |
POST |
/api/login |
Same as GET but takes { response } in the JSON body. |
POST |
/api/logout |
Destroys the session. Returns { ok: true }. |
GET |
/api/check-auth |
{ isAuthenticated, userDID, isOwner, profile }. |
| Method | Path | Notes |
|---|---|---|
GET |
/api/users |
Authenticated. Returns string[] of all known DIDs. |
GET |
/api/profile/:did |
Authenticated. Returns the user’s profile. |
GET |
/api/profile/:did/name |
Authenticated. Returns { name }. |
PUT |
/api/profile/:did/name |
Owner-of-:did only. Body: { name }. Validates, claims, issues credential. Returns { ok: true, message } (the credential is not included in the response; fetch via GET /api/credential). |
DELETE |
/api/profile/:did/name |
Owner-of-:did only. Releases name + revokes credential. |
GET |
/api/credential |
Authenticated. When the caller has a credential, returns { hasCredential: true, credentialDid, credentialIssuedAt, credential }. When the caller has no credential, returns { hasCredential: false, name, message }. |
For programmatic clients that already hold a verified Keymaster challenge response.
| Method | Path | Notes |
|---|---|---|
PUT |
/api/name |
Body: { name }. Auth: Authorization: Bearer <responseDid> — Herald calls keymaster.verifyResponse(responseDid) itself. Returns { ok, name, did, credentialDid, credentialIssuedAt, credential }. |
DELETE |
/api/name |
Bearer auth. Releases the caller’s name + revokes credential. |
| Method | Path | Notes |
|---|---|---|
GET |
/api/registry |
{ version: 1, updated, names: { "<name>": "<DID>" } }. |
GET |
/directory.json |
Same as /api/registry. Convention for IPNS publication. |
GET |
/api/name/:name |
{ name, did } or 404 { error: "Name not found" }. |
GET |
/api/member/:name |
Full DidCidDocument of the named member, fetched via Keymaster. |
GET |
/api/name/:name/avatar |
Binary image bytes; sets Content-Type to a safe-listed image MIME (image/avif, image/gif, image/jpeg, image/jpg, image/png, image/webp); other types are served as application/octet-stream. |
| Method | Path | Notes |
|---|---|---|
GET |
/.well-known/lnurlp/:name |
Standard LUD-06 metadata: { tag: "payRequest", callback, minSendable, maxSendable, metadata }. minSendable=1000 msats, maxSendable=100000000000 msats (100k sats). Errors as { status: "ERROR", reason }. |
GET |
/api/lnurlp/:name/callback?amount=<msats> |
Resolves the named member’s DID, follows the Lightning service entry, requests an invoice, normalizes the response to LUD-06 { pr, routes }. Onion endpoints routed via ARCHON_HERALD_TOR_PROXY if set. |
| Method | Path | Notes |
|---|---|---|
GET |
/.well-known/names |
Same as /api/registry. |
GET |
/.well-known/names/:name |
Same as /api/name/:name. |
GET |
/.well-known/webfinger?resource=acct:name@domain |
RFC 7033. domain MUST equal ARCHON_HERALD_DOMAIN (when set). Returns a JRD with subject, aliases: [<DID>], and links. The https://w3id.org/did link href is built as https://${SERVICE_DOMAIN}/api/v1/did/${did} — a hardcoded externally-resolvable DID URL that is not served by Herald or Drawbridge directly. |
GET |
/.well-known/openid-configuration |
OIDC discovery; advertises /oauth/authorize, /oauth/token, /oauth/userinfo. The root discovery payload does NOT include jwks_uri; only the /oauth/.well-known/openid-configuration variant advertises the JWKS endpoint. |
/oauth)| Method | Path | Notes |
|---|---|---|
GET |
/oauth/authorize |
Authorization Code with PKCE flow. Triggers Herald login if the user isn’t authenticated, then redirects with ?code=<authcode> to the registered redirect_uri. |
POST |
/oauth/callback |
Internal — completes the authorization code exchange started by /oauth/authorize. |
GET |
/oauth/poll |
Polling endpoint for desktop / native flows. |
POST |
/oauth/token |
Exchange code (or refresh_token) for an access_token + id_token. Form-encoded body. |
GET |
/oauth/userinfo |
Bearer-token-protected. Returns { sub, name, preferred_username, picture, email, email_verified, updated_at }. The /oauth/.well-known/openid-configuration discovery payload advertises scopes_supported: ['openid','profile','email'] and claims_supported: ['sub','name','preferred_username','picture','email','email_verified']. |
GET |
/oauth/.well-known/jwks.json |
The Herald’s ES256 public signing key. |
POST |
/oauth/clients |
Internal client registration — present in the reference but locked down by deployment policy. |
ID tokens are signed with ES256. The signing keypair is generated
on first startup and persisted at
${ARCHON_HERALD_DATA_DIR}/oauth-signing-key.json (as a JSON-encoded
private JWK with kid). kid defaults to
archon-social-signing-key-1. Implementations MUST persist the key —
rotating it invalidates all outstanding sessions.
| Method | Path | Notes |
|---|---|---|
GET |
/api/admin |
Owner. Admin dashboard payload. |
POST |
/api/admin/publish |
Owner. Publishes the current registry to IPNS. Returns { ok, cid, ipns, registry }. |
DELETE |
/api/admin/user/:did |
Owner. Removes the user record + revokes their credential. |
The owner is the single DID in ARCHON_HERALD_OWNER_DID. There is no
finer-grained role system.
morgan('dev') HTTP request logging.express.json() (default 100kB limit).express.urlencoded({ extended: true }) — required for OAuth token
requests which use form encoding.cors() is per-route. Login routes use a per-request corsOptions
closure that whitelists the configured wallet origin and credentials.httpOnly, secure: 'auto' (HTTPS proxies set secure),
sameSite: 'lax'.Error responses are a mix of formats: most route handlers return
application/json { "error": "<message>" } (or { ok: false, message }
for name-claim validation errors), but the isAuthenticated middleware
sends 401 with the plain-text body You need to log in first, the
owner middleware sends 403 with plain-text Owner access required,
and many 500 handlers fall back to res.status(500).send(String(error))
(plain text). Login endpoints return { authenticated: false } on a
non-match (200, not 401) so the wallet can poll cleanly. LUD-16 errors
return { status: "ERROR", reason } per the LUD-06 spec rather than
HTTP error codes.
GET /api/challenge. Herald creates a Keymaster
challenge, stores it on the session, returns
{ challenge, challengeURL }.challengeURL as a QR (or deep link). The user
scans into their Archon wallet.keymaster.createResponse(challenge) and POSTs the
resulting DID back to /api/login.keymaster.verifyResponse(response, { retries: 10 }).
On match, sets session.user = { did: verify.responder }.session.user.did is the
authenticated identity. session.user.did === ARCHON_HERALD_OWNER_DID
grants admin scope.For tools that don’t want sessions, send the verifiable challenge
response as a Bearer token. The token value is the DID of a response
asset produced by keymaster.createResponse(challenge) — Herald passes
the entire bearer value to keymaster.verifyResponse(<token>) without
any did:cid: prefix validation:
Authorization: Bearer <responseDid>
Herald calls keymaster.verifyResponse(<token>) on every request
(no caching). Used by /api/name PUT/DELETE. The response DID is
single-use server-side caching is not part of the spec — clients that
expect to make many calls SHOULD cache the response DID until it
expires.
Access tokens issued by /oauth/token are random opaque strings
backed by an in-memory map (or persistent store in production).
ID tokens are ES256 JWTs. Both expire per the standard
expires_in field returned in the token response (default 3600 s).
Herald operates in one of two mutually-exclusive modes; the boot sequence picks based on env:
Set ARCHON_HERALD_KEYMASTER_URL to the Keymaster’s URL. Herald
constructs a KeymasterClient and calls Keymaster’s HTTP API for
every wallet operation (challenge, response verification, asset
creation, credential issuance / revocation).
This is the recommended deployment mode in a multi-service stack.
Leave ARCHON_HERALD_KEYMASTER_URL empty and set
ARCHON_HERALD_WALLET_PASSPHRASE. Herald instantiates a
Keymaster object backed by a JSON wallet file at
${ARCHON_HERALD_DATA_DIR}/wallet.json and a Gatekeeper HTTP client
at ARCHON_GATEKEEPER_URL. The passphrase decrypts the wallet
in-process (see Keymaster spec §3.1).
This mode is suitable for single-purpose Herald deployments that don’t need the full Keymaster service surface.
On startup Herald ensures a Keymaster ID exists with the name
ARCHON_HERALD_NAME (default name-service). If the wallet doesn’t
have it, Herald creates it (keymaster.createId(name)). The
resulting DID is the Herald’s “service identity” — it owns every
issued credential.
Herald also calls keymaster.setCurrentId(<service name>) at every
credential issue / revoke to ensure the operation is signed by the
right identity, and restores the previous current ID on completion
where possible.
.toLowerCase()) before validation and
storage, then matched against [a-z0-9_-]+. The stored name is
always lowercase; claim Alice and the stored/looked-up name is
alice.Alice, lookup matches alice.{ ok: false, message: "Name already taken" }.When PUT /api/name (or /api/profile/:did/name) succeeds:
credentialDid (renaming):
credentialSubject.name to <newName>@<serviceDomain>.validFrom = now.keymaster.updateCredential(credentialDid, vc).boundCredential = keymaster.bindCredential(<userDid>, { schema:
ARCHON_HERALD_MEMBERSHIP_SCHEMA_DID, validFrom: now, claims: {
name: "<name>@<serviceDomain>" } }).credentialDid = keymaster.issueCredential(boundCredential).{ credentialDid, credentialIssuedAt } on the user record.On DELETE /api/name or rename-displacement:
keymaster.revokeCredential(credentialDid)
delete user.name; delete user.credentialDid; delete user.credentialIssuedAt
Failures during revokeCredential are logged but don’t roll back the
local user-record update. Revoking is idempotent on the Keymaster
side.
If ARCHON_HERALD_MEMBERSHIP_SCHEMA_DID is unset, Herald falls back to
did:cid:bagaaieravnv5onsflewvrz6urhwfjixfnwq7bgc3ejhlrj2nekx75ddhdupq,
a published schema for { name: string }. Operators MAY substitute
their own schema DID; the credential’s credentialSchema.id is
whatever was passed.
User database backs User records keyed by DID. Three implementations:
| Backend | Path | Selector |
|---|---|---|
| JSON file | ${ARCHON_HERALD_DATA_DIR}/db.json |
ARCHON_HERALD_DB=json (default) |
| SQLite | ${ARCHON_HERALD_DATA_DIR}/db.sqlite |
ARCHON_HERALD_DB=sqlite |
| Redis | namespace ${ARCHON_HERALD_NAME}: |
ARCHON_HERALD_DB=redis |
User shape:
{
"firstLogin": "<RFC 3339>",
"lastLogin": "<RFC 3339>",
"logins": <int>,
"name": "<lowercase ASCII>",
"credentialDid": "<DID>",
"credentialIssuedAt": "<RFC 3339>",
// arbitrary additional fields are allowed; readers MUST tolerate them
}
Every backend implements:
interface DatabaseInterface {
init?(): Promise<void>;
close?(): Promise<void>;
getUser(did: string): Promise<User | null>;
setUser(did: string, user: User): Promise<void>;
deleteUser(did: string): Promise<boolean>;
listUsers(): Promise<Record<string, User>>;
findDidByName(name: string): Promise<string | null>;
// Email bridge
setReplyToken(token: string, data: ReplyToken): Promise<void>;
getReplyToken(token: string): Promise<ReplyToken | null>;
deleteExpiredReplyTokens(maxAgeMs: number): Promise<number>;
setEmailMapping(dmailDid: string, mapping: EmailMapping): Promise<void>;
getEmailMapping(dmailDid: string): Promise<EmailMapping | null>;
}
findDidByName is a case-insensitive lookup; implementations MAY
normalize to lowercase at index time. Concurrency: writes MUST be
atomic from the point of view of findDidByName — uniqueness checks
are a load-modify-save pattern that needs serialization (the JSON
backend uses an async-promise lock per AbstractBase).
POST /api/admin/publish (owner-only) builds the registry, pins it
to IPFS via ${ARCHON_HERALD_IPFS_API_URL} (default
http://localhost:5001/api/v0), and updates the IPNS record under the
key ARCHON_HERALD_IPNS_KEY_NAME (default ARCHON_HERALD_NAME).
The IPNS key is created on startup if missing. The published JSON is
identical to /directory.json — clients can fetch it via any IPFS
gateway at ipns://<key-id>/.
ARCHON_HERALD_SESSION_SECRET is set and not a placeholder.initServiceIdentity() — ensure the service ID exists; log the
service DID.ensureIpnsKeyExists() — generate the IPNS key if missing./oauth.| Variable | Default | Meaning |
|---|---|---|
ARCHON_HERALD_PORT |
4230 |
HTTP listen port. |
ARCHON_HERALD_NAME |
name-service |
Service identity name (Keymaster ID). Owns issued credentials. |
ARCHON_HERALD_DOMAIN |
empty | Domain for credential subjects (<name>@<domain>) and WebFinger validation. |
ARCHON_HERALD_DB |
json |
json / sqlite / redis. |
ARCHON_HERALD_DATA_DIR |
/app/server/data |
Filesystem root for JSON / SQLite / OAuth signing key. |
ARCHON_HERALD_SESSION_SECRET |
unset (required) | Secret for Express sessions. MUST NOT be a placeholder string. |
ARCHON_HERALD_OWNER_DID |
empty | Single owner DID with admin scope. |
ARCHON_HERALD_KEYMASTER_URL |
empty | When set, runs in shared mode. |
ARCHON_HERALD_WALLET_PASSPHRASE |
empty | Required for standalone mode; ignored in shared mode. |
ARCHON_HERALD_WALLET_URL |
https://wallet.archon.technology |
URL embedded in challengeURL so wallets know where to load. |
ARCHON_HERALD_IPFS_API_URL |
http://localhost:5001/api/v0 |
Kubo HTTP API for IPNS publication. |
ARCHON_HERALD_IPNS_KEY_NAME |
name-service (the value of ARCHON_HERALD_NAME) |
IPNS key name. |
ARCHON_HERALD_MEMBERSHIP_SCHEMA_DID |
did:cid:bagaaieravnv5o... |
Schema DID for issued membership credentials. |
ARCHON_HERALD_TOR_PROXY |
empty | SOCKS5 proxy host:port for .onion Lightning lookups. |
ARCHON_HERALD_JWT_KEY_PATH |
${DATA_DIR}/oauth-signing-key.json |
Persisted ES256 OAuth signing key. |
ARCHON_GATEKEEPER_URL |
http://localhost:4224 |
Used in standalone mode. |
ARCHON_DRAWBRIDGE_PORT |
4222 |
Used to compute PUBLIC_URL. |
ARCHON_DRAWBRIDGE_PUBLIC_HOST |
http://localhost:${DRAWBRIDGE_PORT} |
External canonical URL of the Drawbridge that fronts this Herald; used to build PUBLIC_URL = <host>/names. |
ARCHON_ADMIN_API_KEY (or ARCHON_HERALD_ADMIN_API_KEY) |
empty | Used by the Keymaster client when in shared mode. |
PUBLIC_URL = ${ARCHON_DRAWBRIDGE_PUBLIC_HOST}/names — used in:
iss claim.Drawbridge proxies /names/* → Herald’s /api/*, so external callers
hit https://your-domain.example/names/api/login etc.
No SIGTERM / SIGINT handler — those signals terminate the Express
server directly. Herald does install process.on('uncaughtException')
and process.on('unhandledRejection') handlers that log the error and
let the process continue. The OAuth signing key and IPNS key persist
on disk.
morgan('dev') for HTTP requests; otherwise console.log /
console.error. No structured logger by default. Notable startup
lines:
<service-name>: <serviceDID>Owner: <ownerDid> (or warning if unset)<service-name> using keymaster at <url> (shared mode)<service-name> using gatekeeper at <url> (standalone mode)<service-name> using wallet at <walletUrl><service-name> listening on port <port>Validation: end-to-end against a running Herald + Drawbridge stack. There is no dedicated unit test suite; the React frontend at apps/herald-client/ and the scripts/ directory exercise the contract.
A conformant third implementation MUST:
createChallenge/verifyResponse API.bindCredential/issueCredential/updateCredential/revokeCredential.DatabaseInterface shape in
§6, with atomic findDidByName.ARCHON_HERALD_SESSION_SECRET is empty or a
known placeholder (change-me, change-me-to-a-random-string).