App identity
Some services want to know who an app is, not only who its user is. co/core's device pairing reads a
dev.cocore.app.registration record from the app's own repo before it shows an approve screen, and sends
the browser back only to a host that serves /.well-known/cocore-app.json naming the app's DID. An app
built on atlibs gets that identity from its deployment: did:web:<its public host>, with a read-only repo
at the same origin that holds the records it declares.
Declare it
The records go in a session record (records/<session>.json, the same
com.fedfork.exec.oauth.clientMetadata record that declares the OAuth client), under app:
"app": {
"records": [
{ "collection": "dev.cocore.app.registration", "rkey": "self", "value": {
"$type": "dev.cocore.app.registration",
"name": "co/core on atlibs",
"website": "https://atlibs.example",
"returnUrls": ["https://atlibs.example/"],
"createdAt": "2026-11-10T00:00:00.000Z"
} }
],
"wellKnown": { "cocore-app.json": { "did": "did:web:atlibs.example" } }
}
The record is a template, like its clientMetadata: strings on its client_id origin move to the
deployment's origin, and did:web:<that host> becomes the deployment's DID. A page or server that runs
several modules serves the union of their records; two sessions may not declare one collection and rkey
differently. Values are plain JSON (no blobs or links).
What is served
At the public origin (https://<host>), for GET:
| path | answer |
|---|---|
/.well-known/did.json | the DID document: id did:web:<host>, #atproto the app's key (Multikey), #atproto_pds the origin |
/.well-known/atproto-did | the DID, as text |
/.well-known/<file> | each wellKnown file |
/xrpc/com.atproto.repo.getRecord, listRecords, describeRepo | the declared records, each with its CID (DAG-CBOR, sha2-256), repo the DID or the host |
Nothing else. No createAccount (it would be unauthenticated) and no getServiceAuth (it would sign any aud
and lxm with the key the DID document publishes). No writes and no com.atproto.sync.*: the repo has no
commits, so relays cannot crawl it, and a changed declaration changes only the served record and its CID
(a real repo would advance its rev on every rewrite, a no-op commit at every crawler). Services that read a
record with getRecord, as co/core does, need nothing more. The answers carry
access-control-allow-origin: *: they are public reads.
The key is a P-256 did:key the host keeps under app:<origin> (the same store as the modules' did:keys:
ATLIBS_SERVER_STATE). It signs nothing yet, but it is the identity: lose it and the DID document changes
key, which to anyone who cached it is a new identity. Back the state directory up.
A loopback origin has no app identity: a did:web resolves over https, and did:web:127.0.0.1 would name
nothing anyone else can reach.
import { appRepo, appRepoAnswer } from "./javascript/src/oauth/index.js";
const record = {
$type: "com.fedfork.exec.oauth.clientMetadata",
name: "demo",
clientMetadata: { client_id: "https://atlibs.example/oauth/demo/client-metadata.json", scope: "atproto" },
app: {
records: [{ collection: "dev.cocore.app.registration", rkey: "self", value: { $type: "dev.cocore.app.registration", name: "demo", returnUrls: ["https://atlibs.example/"], createdAt: "2026-11-10T00:00:00.000Z" } }],
wellKnown: { "cocore-app.json": { did: "did:web:atlibs.example" } },
},
createdAt: "2026-11-10T00:00:00.000Z",
};
// the host's key: here a placeholder; servers use the did:key they keep under app:<origin>
const repo = await appRepo({ demo: record }, { origin: "https://app.example", publicKeyMultibase: "zDnaeExample" });
console.log(repo.did);
console.log(appRepoAnswer(repo, "/.well-known/cocore-app.json")[2]);
console.log(JSON.parse(appRepoAnswer(repo, "/xrpc/com.atproto.repo.getRecord?repo=did:web:app.example&collection=dev.cocore.app.registration&rkey=self")[2]).value.returnUrls);
did:web:app.example
{"did":"did:web:app.example"}
[ 'https://app.example/' ]
The hosts
atlibs serve --origin https://<host>(orATLIBS_ORIGIN): serves it next to the OAuth client documents when a served module's session record declaresapp(atlibs_host::oauth::AppRepo, for a Rust server of your own:AppRepo::new(records, origin, key)andhandle(&request)).- A Node server:
appRepoHandler(await appRepo(stage, { origin, publicKeyMultibase }))is a fetch-style handler (Request -> Response, undefined for other requests);examples/node-honomounts it with the key fromdidkeyOf(ctx, "app:<origin>")whenATLIBS_ORIGINis set. - A static host (
atlibs stage --target spa --origin https://<host>/): stage writes the files a static host can serve whatever the query string:.well-known/did.json,.well-known/atproto-did, thewellKnownfiles,xrpc/com.atproto.repo.describeRepo, and, when there is exactly one record,xrpc/com.atproto.repo.getRecordandlistRecords(a static file cannot pick a record by collection and rkey).stage.json'sapplists them. The key is the stager's (its state directory), so stage from the machine that keeps it. They belong at the host's root: a page under a path still needs its host to serve/.well-known/...and/xrpc/...from these files, and the files must go out as JSON (content-type: application/json; most hosts guess nothing for an extensionless file, so configure it).
Which to pick: a static host is enough for one registration record and a host you control at its root. More
records, or a page on someone else's host (a shared github.io path), need a server at the origin: atlibs serve, a Node server, or a tunnel to one such as fedproxy's ingress forwarding a public origin to it.
Previous: Server sessions | Next: MCP servers