OAuth and permission sets
scopesFor derives the scopes your model needs, before any session exists.
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 },
allowHttp: true, // only for a local http PDS
})Serve oauth.metadata at /oauth-client-metadata.json, redirect to await oauth.authorize(handle), and on return const { session } = await oauth.callback(params). That session goes straight into createAirspace({ session }).
blob: scopes are narrowed to the MIME patterns your blob fields accept, so a model whose images are all accept: ['image/*'] asks for blob:image/* rather than blob:*/*.
against a local PDS
allowHttp isn't enough on its own: @atproto/oauth-client-node resolves the handle through DNS and the DID through plc.directory, and neither knows about alice.test. Point both at the development network:
const local = {
allowHttp: true,
handleResolver: 'http://localhost:2583', // the PDS serves `resolveHandle`
plcDirectoryUrl: 'http://localhost:41937', // the port `pnpm dev:pds` prints
}permission sets
A published lexicon family can declare its whole OAuth surface as one "type": "permission-set" def, so the consent screen shows one line instead of a scope list. Declare yours next to the records it covers:
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.',
}),
})airspace lexicons emit and airspace lexicons publish handle it like any other def. Everything a set grants has to sit under its own authority; airspace throws at definition time rather than publishing a set that grants less than it says.
On the consuming side, pass the set instead of letting scopesFor derive a scope per collection:
scopesFor({ collections: { projects, categories }, include: [lexicons.authFull] })
// ['atproto', 'include:dev.roe.authFull', 'blob:image/*']Someone else's set works too, by NSID: include: ['site.standard.authFull'] covers every collection under site.standard.. Blob permissions are not part of an include:, so a blob: scope is still emitted alongside.
A set has to be published before anyone can ask for it. The authorization server resolves the NSID at authorize() time, and until the schema is published and _lexicon.<domain> points at your DID, the request fails with invalid_scope: Could not resolve Lexicon for NSID. The same is true of a space: scope. Derive scopes from the collections while developing and switch to include: once the lexicons are published.