server: The apps sign in at their own PDS, as grain-serve's public client
MergedThis adds /oauth/app-client-metadata.json, a native public-client document with no key, DPoP-bound, redirecting to the reversed-host scheme. It also adds app_scheme, app_scope and app_calls helpers, and oauth::Client::public.
I couldn't run anything, so I read the code instead. identity::service_did exists. The new document's scope is REGISTERED plus the rpc scope, and the_apps_client_is_a_native_public_one checks that every word of app_scope is registered. The 501 case is covered by extending a_server_with_no_login_says_so. The new route sits next to the web's, and the new Web.did field is private and filled at construction.
I found nothing that must change. The two caveats you list are real. An https public URL is needed in development, and the app pulls can't ship until this is live at grain.social.
The new commit fixes what the end-to-end test found. app_calls now escapes % as %25 before # as %23, so a did:web with a port (%3A) survives the PDS's URL-decoding of the audience as one DID. The order of the two replacements is right. A unit test pins the escaped port form, and the grain.social scope is unchanged.
I couldn't run anything. The check, harness, rust and test runs are still queued, so wait for them to pass before merging. I found nothing that must change.
The new commit stops Web from holding its own copy of the DID. app_client_metadata now reads app.did instead, so the end-to-end test can run the server as did:web:localhost%3A<port> over http, which a PDS accepts for proxying. The test gets a local_site helper for that.
The expect is safe. In lib.rs, did is Some whenever public_url is set, and web is only built when both public_url and index are set. A server with no login still gets the 501 from web(&app)? before the expect runs.
I couldn't run anything. The check, harness, rust and test runs for this head are still queued, so wait for them to pass before merging. I found nothing that must change.
Carried forward: the change this pull makes at 5be6e69b488174e146ae15a1764fb277998ea8fd is the one reviewed at 53f53b154aedce766375e40cc945a2b5399cbdc6, so that review stands. What is new comes from its base.
The new head 2db06b7 is a merge of feature/rust into the branch. The one conflict was in crates/grain-serve/tests/web/mod.rs. The pull's own change is the same one I approved at 53f53b1 and 5be6e69. That is the native public-client metadata, app_scope, app_calls with its % and # escaping, Client::public, and the local_site end-to-end test. My earlier approval stands.
I didn't read the 1,647-line diff of the merge itself, and I couldn't run anything. The check, harness, rust and test runs for this head are still queued, so wait for them to pass. That matters most for the merge-resolved test file. I found nothing that must change.
- server: The apps sign in at their own PDS, as grain-serve's public client
- main

What changes
The apps' client metadata. grain-serve serves
/oauth/app-client-metadata.json, the client ID the iOS and Android apps log in with at the account's own PDS:application_type: native,token_endpoint_auth_method: none,dpop_bound_access_tokens: true, no key. It sends them back tosocial.grain:/oauth/callback, the public URL's host reversed as atproto asks of a native client's redirect (login::app_scheme). Its scope is the web'sREGISTEREDplusrpc:*?aud=<server DID>%23grain_appview(login::app_calls, fromApp::did), so it covers anything a login here asks for, and an app's call to grain-serve. A server with no public URL and index answers it 501, as the web's metadata.What an app asks for.
login::app_scope(did)isoauth::SCOPEand thatrpcscope: the app writes its records and blobs straight to its PDS, then calls grain-serve,social.grain.sync.indexRecordsincluded, through the PDS withatproto-proxy: did:web:grain.social#grain_appview. grain-serve already takes those calls: the PDS signs a service token withaudthe baredid:web, whichviewer::viewerchecks, and the DID document already names#grain_appview. The audience is a query value, which a PDS URL-decodes, so%is escaped as%25before#as%23: adid:web:localhost%3A<port>stays one DID. Forgrain.socialthe scope isrpc:*?aud=did:web:grain.social%23grain_appview.oauth::Client::public(id), a public client named by its metadata document, for the test below.Why
grain-serve holds no
/oauth/paror/oauth/tokenof its own, so the apps can no longer sign in through it. The decided design is knotless's: clients are atproto OAuth public clients of the account's own authorization server, and call the appview with service auth. This mirrorscrates/knotless/src/oauth.rs(PKCE + DPoP public client,Client::loopback) andcrates/knotless/src/auth.rs(service tokens to the server). It differs in two places, and why:http://localhost?..., which needs no hosted document. A phone cannot receive a redirect at127.0.0.1, and atproto asks a native client for an https client ID whose host, reversed, is its redirect scheme, so grain-serve hosts that document. It is a sibling of the web's/oauth-client-metadata.json, as knotless-serve's web document is/oauth/web-client-metadata.json.getServiceAuthfor each call, scopedrpc:<lxm>?aud=*, method by method. The apps go throughatproto-proxy, as Bluesky's apps call their appview. That takes one scope for every grain method, and no token per method. The reference PDS checks a proxied call againstrpcwithaudasdid#service(pipethrough.js), andgetServiceAuthchecks the bareaudit is asked for. Sorpc:*?aud=did:web:grain.social%23grain_appviewlets proxying through, and is the only wildcardrpcscope atproto allows (rpc:*?aud=*is refused).How it was checked
the_apps_client_is_a_native_public_one: the document, its redirect, and every word ofapp_scoperegistered in its scope.an_app_logs_in_at_its_pds_and_calls_through_it, against the reference PDS: PAR as the public client, approval coming back to<scheme>:/oauth/callback, the code redeemed with PKCE and DPoP, asocial.grain.graph.followwritten to the PDS, thenindexRecordsandgetPreferencesthrough the PDS'satproto-proxy. The server there isdid:web:localhost%3A<port>over http (web::local_site): atproto takes adid:webwith a port only forlocalhost(@atproto/did), so a PDS drops anrpcscope namingdid:web:client.grain.internal%3A<port>, which is what the first two heads' runs failed on.app_scheme,app_scopeand the escaped port;a_server_with_no_login_says_socovers the 501.check,harness,rust,testandlexiconspass at5be6e69.Shortcomings
127.0.0.1has to be pointed at a tunnel, or log in against production's PDSes with grain-serve's URL set to the tunnel. A server on a port other than atlocalhostis no DID anrpcscope can name.🤖 Generated with Claude Code