# 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:`, 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/.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:` 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://`), for GET: | path | answer | |---|---| | `/.well-known/did.json` | the DID document: `id` `did:web:`, `#atproto` the app's key (Multikey), `#atproto_pds` the origin | | `/.well-known/atproto-did` | the DID, as text | | `/.well-known/` | 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:` (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: 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://`** (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:")` when `ATLIBS_ORIGIN` is set. - **A static host** (`atlibs stage --target spa --origin https:///`): 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)