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

johnandersen777.bsky.social/atlibs

0
0
main
302 lines · 11.3 KB Raw History

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

{
	"$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). 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.

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

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
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.

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'))
http://localhost?scope=atproto+repo%3Axyz.statusphere.status&redirect_uri=http%3A%2F%2F127.0.0.1%3A…%2F

examples/web/hello/main.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

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

	// 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

	// 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

// 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

	// 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) })
	}
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))
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.

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

atlibs stage --target spa --origin https://you.example/app/ --name my-page --out both examples/rust/statusphere examples/rust/vibescrobble | jq -c '{clientId, scope}'
{"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 seeDo 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 statusphereThe call is outside the module's scope: add it to its session record, then republish both.
… with no connected sessionSign in first; signed out, only public reads work.
client_id or stage.json 404sServe the staged directory (--out) at --origin, not its parent.
401 on the first write, use_dpop_nonceExpected: the PDS hands out a DPoP nonce on the first request and the client retries with it.

Previous: Run modules in a page | Next: Node servers