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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# Sign in from a page
A module that writes names a session: a `com.fedfork.exec.oauth.clientMetadata` record whose scope is
what the module may do. A page signs the user in with `@atproto/oauth-client-browser` and runs each
module under its own session's scope.
## One client record per module
`statusphere`'s module.json says `"session": "statusphere"`; this is that record:
`records/statusphere.json`
```json
{
"$type": "com.fedfork.exec.oauth.clientMetadata",
"name": "Statusphere",
"description": "Set your xyz.statusphere.status emoji status.",
"clientMetadata": {
"client_id": "https://statusphere.example.com/oauth-client-metadata.json",
"client_name": "Statusphere",
"client_uri": "https://statusphere.example.com",
"redirect_uris": [
"https://statusphere.example.com/oauth/callback"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"response_types": [
"code"
],
"scope": "atproto repo:xyz.statusphere.status",
"application_type": "web",
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
},
"scopeGroups": [
{
"key": "status",
"label": "Set your Statusphere status",
"scopes": [
"repo:xyz.statusphere.status"
]
}
],
"createdAt": "2026-11-06T00:00:00.000Z"
}
```
Publish it beside the module ([Publish a module](../modules/publish.md)). The `client_id` and
`redirect_uris` in the record are placeholders: whoever serves the page serves its own.
## Stage the client metadata
An OAuth client is a JSON document at a URL. `atlibs stage --target spa` writes one per session next
to the modules and a `stage.json` the page reads.
```bash run
atlibs stage --target spa --origin https://you.example/app/ --out dist examples/rust/statusphere examples/rust/hello > /dev/null
find dist -type f | LC_ALL=C sort
jq -c '{client_id, redirect_uris, scope}' dist/oauth/statusphere/client-metadata.json
```
```output
dist/modules/hello.json
dist/modules/hello.wasm
dist/modules/statusphere.json
dist/modules/statusphere.wasm
dist/oauth/statusphere/client-metadata.json
dist/stage.json
{"client_id":"https://you.example/app/oauth/statusphere/client-metadata.json","redirect_uris":["https://you.example/app/"],"scope":"atproto repo:xyz.statusphere.status"}
```
Deploy `dist/` at `--origin`. `--oauth-path auth/` moves the clients; with no ref, `stage` takes
everything in `atlibs.lock.json`.
Origin not known at build time? Leave out `--origin` and fill it in at deploy:
```bash run
atlibs stage --target spa --out site examples/rust/statusphere 2>&1 >/dev/null | head -1
ORIGIN=https://you.example/app
find site/stage.json site/oauth -name '*.json' -exec sed -i.bak "s#https://atlibs.invalid#$ORIGIN#g" {} + && find site -name '*.json.bak' -delete
jq -r .clientId site/stage.json
```
```output
no --origin: https://atlibs.invalid is a placeholder; at deploy, with ORIGIN=https://host/page (no trailing slash):
https://you.example/app/oauth/statusphere/client-metadata.json
```
## Sign in
`pageClientId(scope)` picks the client: on `127.0.0.1` the loopback development client for that scope
(no staging needed); deployed, the `clientId` in the `stage.json` beside the page.
```js run browser
import { pageClientId } from 'atlibs/oauth/index.js'
// This page is on 127.0.0.1: the loopback client, its scope and redirect in the client_id itself.
console.log(await pageClientId('atproto repo:xyz.statusphere.status'))
```
```output
http://localhost?scope=atproto+repo%3Axyz.statusphere.status&redirect_uri=http%3A%2F%2F127.0.0.1%3A…%2F
```
`examples/web/hello/main.js`
```js
// On 127.0.0.1 the loopback client; deployed, the client `atlibs stage --target spa` wrote beside the page.
const clientId = pageClientId(requestScope(POLICY, ['statusphere']))
```
`examples/web/hello/main.js`
```js
const cubit = new HelloCubit({
ctx: createPluginContext({ id: 'hello' }),
client: clientId.then((clientId) => BrowserOAuthClient.load({ clientId, handleResolver: 'https://bsky.social' })),
hello: local('hello'),
statusphere: local('statusphere'),
})
```
The cubit restores the session or finishes the redirect, and starts one:
`examples/web/hello/hello-cubit.js`
```js
// Restore the last session, or finish a sign-in redirect.
async init() {
try {
const signedIn = await (await this.#client).init()
this.#session = signedIn?.session
this.emit({ session: signedIn ? 'signed-in' : 'signed-out', did: signedIn?.session.did ?? null })
} catch (error) {
this.emit({ session: 'error', error: error.message })
}
}
```
`examples/web/hello/hello-cubit.js`
```js
// Navigates to the user's authorization server; returns only if that fails.
async login(handle) {
try {
await (await this.#client).signIn(handle)
} catch (error) {
this.emit({ error: error.message })
}
}
```
## Run as the user, held to the module's scope
`sessionXrpc(session)` makes calls through the OAuth session (DPoP, token refresh: a refused token is
renewed once for every tab sharing the session, under its Web Lock). `policyCall` lets
a module call only what its own session's scope allows; anything else fails that call.
`examples/web/hello/hello-cubit.js`
```js
// Which session each module may use, and what that session's scope allows (a stage.json has this shape).
export const POLICY = {
sessions: { statusphere: { scope: 'atproto repo:xyz.statusphere.status' } },
modules: { statusphere: { session: 'statusphere' } },
}
```
`examples/web/hello/hello-cubit.js`
```js
// Signed in: the user's OAuth session, held to statusphere's own scope by policyCall.
async setStatus(status) {
const session = this.#session
const call = policyCall(POLICY, 'statusphere', {
statusphere: hostCall({ ctx: this.#ctx, xrpc: sessionXrpc(session), canHeader: false }),
})
this.emit({ status: { ...IDLE, status: 'busy' } })
this.emit({ status: await this.#run(this.#statusphere, 'set_status', { did: session.did, status }, call) })
}
```
```js run browser
import { policyCall } from 'atlibs/oauth/index.js'
const POLICY = {
sessions: { statusphere: { scope: 'atproto repo:xyz.statusphere.status' } },
modules: { statusphere: { session: 'statusphere' } },
}
// The session's call; here a stand-in that says what it would send.
const call = policyCall(POLICY, 'statusphere', { statusphere: async (nsid, { input }) => `${nsid} ${input.collection}` })
const write = (collection) => call('com.atproto.repo.createRecord', { input: { repo: 'did:plc:alice', collection, record: {} } })
console.log(await write('xyz.statusphere.status'))
console.log(await write('app.bsky.feed.post').catch((e) => e.message))
```
```output
com.atproto.repo.createRecord xyz.statusphere.status
statusphere may not call com.atproto.repo.createRecord with sessions statusphere
```
A `stage.json` has the same `sessions` and `modules` shape, so a deployed page can fetch it and pass
it as the policy.
## A whole page
Sign in, then set a status, with what `atlibs stage --target spa` wrote. Serve the staged directory at
`--origin`: `stage.json` beside the page, `oauth/statusphere/client-metadata.json` at the `client_id`.
```html norun
<!DOCTYPE html>
<meta charset="utf-8">
<script type="importmap">
{
"imports": {
"@atproto/oauth-client-browser": "https://esm.sh/@atproto/oauth-client-browser@0.5.14",
"atlibs/": "./atlibs/"
}
}
</script>
<script type="module" src="./main.js"></script>
<form id="login" hidden><input name="handle" placeholder="you.bsky.social" required><button>Sign in</button></form>
<form id="status" hidden><input name="status" value="🦋" required><button>Set status</button></form>
<output></output>
```
```js run=account browser
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { pageClientId, policyCall, requestScope } from 'atlibs/oauth/index.js'
import { createPluginContext } from 'atlibs/plugin-context/index.js'
import { hostCall, sessionXrpc } from 'atlibs/run-module/host-call.js'
import { localModule, runModule } from 'atlibs/run-module/index.js'
// stage.json: the OAuth client, each session's scope, each module's session and files.
const stage = await fetch(new URL('./stage.json', import.meta.url)).then((r) => r.json())
const { wasm, spec } = stage.modules.statusphere
const statusphere = localModule(new URL(wasm, import.meta.url), new URL(spec, import.meta.url))
const ctx = createPluginContext({ id: 'statusphere-page' })
const clientId = await pageClientId(requestScope(stage, ['statusphere']))
const client = await BrowserOAuthClient.load({ clientId, handleResolver: 'https://bsky.social' })
// Restores the last session, or finishes the sign-in redirect.
const session = (await client.init())?.session
const login = document.querySelector('#login')
const status = document.querySelector('#status')
const out = document.querySelector('output')
login.hidden = Boolean(session)
status.hidden = !session
out.textContent = session ? `signed in as ${session.did}` : ''
// Navigates to the user's authorization server; returns only if that fails.
login.onsubmit = async (e) => {
e.preventDefault()
await client.signIn(new FormData(login).get('handle')).catch((error) => (out.textContent = error.message))
}
// The user's OAuth session, held to statusphere's own scope by policyCall.
status.onsubmit = async (e) => {
e.preventDefault()
const call = policyCall(stage, 'statusphere', {
statusphere: hostCall({ ctx, xrpc: sessionXrpc(session), canHeader: false }),
})
const { result, error } = await runModule(ctx, await statusphere, 'set_status', { did: session.did, status: new FormData(status).get('status') }, call)
out.textContent = JSON.stringify(error ?? result)
}
```
Signed in, **Set status** shows `{"status":"🦋","unchanged":false,"uri":"at://did:plc:…/xyz.statusphere.status/…"}`
(`"unchanged":true` when your latest status already matches).
## Several modules, one sign-in
Sign in once with the union of the scopes: `requestScope(policy, names)`. Each module still runs under
its own session's scope. To publish one client for all of them, stage with `--name`:
```bash run
atlibs stage --target spa --origin https://you.example/app/ --name my-page --out both examples/rust/statusphere examples/rust/vibescrobble | jq -c '{clientId, scope}'
```
```output
{"clientId":"https://you.example/app/oauth/my-page/client-metadata.json","scope":"atproto repo:xyz.statusphere.status repo:com.vibescrobble.scrobble?action=create"}
```
A `features` entry in the policy lends a module another session once the user has granted every
session the feature `requires` (`grants: { atirc: ["requester"] }`): one module can unlock more of
another without either asking for more than its own scope.
## Troubleshooting
| You see | Do this |
|---|---|
| `stage.json is a template: replace https://atlibs.invalid …` | Fill in the origin (above) or stage with `--origin`. |
| `no stage.json next to …` | Deploy the page with `atlibs stage --target spa`, or serve it on 127.0.0.1. |
| `statusphere may not call … with sessions statusphere` | The call is outside the module's scope: add it to its session record, then republish both. |
| `… with no connected session` | Sign in first; signed out, only public reads work. |
| `client_id` or `stage.json` 404s | Serve the staged directory (`--out`) at `--origin`, not its parent. |
| `401` on the first write, `use_dpop_nonce` | Expected: the PDS hands out a DPoP nonce on the first request and the client retries with it. |
Previous: [Run modules in a page](run-modules.md) | Next: [Node servers](../server/node.md)