~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
106 lines · 5.8 KB Raw History
# 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)