1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# 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`:
```json norun
"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.
```js run
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);
```
```output
did:web:app.example
{"did":"did:web:app.example"}
[ 'https://app.example/' ]
```
## The hosts
- **`atlibs serve --origin https://<host>`** (or `ATLIBS_ORIGIN`): serves it next to the OAuth client
documents when a served module's session record declares `app` (`atlibs_host::oauth::AppRepo`, for a Rust
server of your own: `AppRepo::new(records, origin, key)` and `handle(&request)`).
- **A Node server**: `appRepoHandler(await appRepo(stage, { origin, publicKeyMultibase }))` is a fetch-style
handler (Request -> Response, undefined for other requests); `examples/node-hono` mounts it with the key
from `didkeyOf(ctx, "app:<origin>")` when `ATLIBS_ORIGIN` is 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`, the
`wellKnown` files, `xrpc/com.atproto.repo.describeRepo`, and, when there is exactly one record,
`xrpc/com.atproto.repo.getRecord` and `listRecords` (a static file cannot pick a record by collection and
rkey). `stage.json`'s `app` lists 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](sessions.md) | Next: [MCP servers](mcp.md)