OAuth and permission sets
An app password is fine for your own account. To act on someone else's behalf, use OAuth: they log in at their own PDS and grant your app a list of permissions, called scopes. scopesFor builds that list from your model.
import { scopesFor } from 'airspace'
import { createOAuth } from 'airspace/oauth' // optional peer: @atproto/oauth-client-node
const oauth = await createOAuth({
baseUrl: 'https://roe.dev',
redirectPath: '/api/admin/auth/callback',
name: 'roe.dev admin',
scopes: scopesFor({ collections: { projects, categories }, spaces: { workspace } }),
stores: { session: mySessionStore },
})Then wire up three things:
- Serve
oauth.metadataat/oauth-client-metadata.json, which is how the user's PDS identifies your app. - Redirect the user to
await oauth.authorize(handle). - On return, read the session with
await oauth.callback(params)and pass it tocreateAirspace({ session }).
File scopes match the types your blob fields accept, so a model that only accepts image/* asks for blob:image/*, not blob:*/*.
against a local PDS
Handle and account lookups go to DNS and plc.directory, neither of which knows about a local account such as alice.test. Point both at your development network:
const local = {
allowHttp: true,
handleResolver: 'http://localhost:2583', // the PDS resolves its own handles
plcDirectoryUrl: 'http://localhost:41937', // the port `pnpm dev:pds` prints
}permission sets
A consent screen listing one scope per collection is hard to read. A permission set bundles them into a single named permission, so the user sees one line instead.
import { defineLexicons, field, permissions } from 'airspace/lexicon'
export default defineLexicons('dev.roe', {
project: { name: field.text(), cover: field.image().optional() },
projectCategory: { name: field.text() },
authFull: permissions({
collections: ['project', 'projectCategory'],
title: 'Manage projects',
detail: 'Read and write your projects and their categories.',
}),
})Everything in a set must sit under your own namespace, or airspace throws at definition time. To use one, pass it instead of a list of collections:
scopesFor({ collections: { projects, categories }, include: [lexicons.authFull] })
// ['atproto', 'include:dev.roe.authFull', 'blob:image/*']Someone else's set works too, by name: include: ['site.standard.authFull']. File permissions are never part of a set, so a blob: scope is added alongside.
A set must be published before anyone can ask for it, as must a space: scope. Until then, authorize() fails with invalid_scope: Could not resolve Lexicon for NSID. While developing, list your collections instead.