~ 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:

"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:

pathanswer
/.well-known/did.jsonthe DID document: id did:web:<host>, #atproto the app's key (Multikey), #atproto_pds the origin
/.well-known/atproto-didthe DID, as text
/.well-known/<file>each wellKnown file
/xrpc/com.atproto.repo.getRecord, listRecords, describeRepothe 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> (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 | Next: MCP servers