diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index 19280fe..cf23d35 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -21,6 +21,13 @@ Before making any changes, ask the user these questions: 3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) +4. **Signup**: Should the app allow users to create new AT Protocol accounts (signup)? + - **`yes`** — Include a signup button/flow + - **`no`** — Login only, no account creation + +5. **Production PDS**: Which PDS should be used for signup in production? (default: `https://selfhosted.social/`) + - Only relevant if signup is enabled. Skip if signup is `no`. + Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. ## Step 1: Install dependencies @@ -44,69 +51,34 @@ Create all of the following files. These go into `src/lib/atproto/` and `src/rou ### `src/lib/atproto/settings.ts` -Fill in `collections` and `blobs` from the user's answers. If no collections were specified, use an empty array. +Fill in `collections` from the user's answers. If no collections were specified, use an empty array. Build `scopes` using `scope` builders from `@atcute/oauth-node-client` — add `scope.blob()`, `scope.rpc()`, etc. as needed. ```ts import { dev } from '$app/environment'; +import { scope } from '@atcute/oauth-node-client'; -type Permissions = { - collections: readonly string[]; - rpc: Record; - blobs: readonly string[]; -}; - -export const permissions = { - // CUSTOMIZE: add the user's collections - collections: [], +// CUSTOMIZE: writable collections +export const collections = [] as const; - // CUSTOMIZE: add any authenticated RPC requests needed - rpc: {}, +export type AllowedCollection = (typeof collections)[number]; - // CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*']) - blobs: [] -} as const satisfies Permissions; +// CUSTOMIZE: OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed +export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; -type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; +// CUSTOMIZE: set to true to allow signup, false for login-only +export const ALLOW_SIGNUP = true; -export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; - -// PDS to use for signup (change to preferred PDS) +// CUSTOMIZE: PDS to use for signup (only relevant if ALLOW_SIGNUP is true) const devPDS = 'https://bsky.social/'; -const prodPDS = 'https://bsky.social/'; +const prodPDS = 'https://selfhosted.social/'; // CUSTOMIZE: change to preferred production PDS export const signUpPDS = dev ? devPDS : prodPDS; export const REDIRECT_PATH = '/oauth/callback'; -export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; -``` - -### `src/lib/atproto/metadata.ts` - -```ts -import { permissions } from './settings'; - -function constructScope() { - const parts: string[] = ['atproto']; - - for (const collection of permissions.collections) { - parts.push('repo:' + collection); - } +// redirect the user back to the page they were on before login +export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; - for (const [key, value] of Object.entries(permissions.rpc ?? {})) { - const lxms = Array.isArray(value) ? value : [value]; - for (const lxm of lxms) { - parts.push('rpc?lxm=' + lxm + '&aud=' + key); - } - } - - if (permissions.blobs.length > 0) { - parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); - } - - return parts.join(' '); -} - -export const scope = constructScope(); +export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; ``` ### `src/lib/atproto/auth.svelte.ts` @@ -115,6 +87,7 @@ export const scope = constructScope(); import { AppBskyActorDefs } from '@atcute/bluesky'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; import { page } from '$app/state'; +import { ALLOW_SIGNUP, REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; export const user = { get profile() { @@ -128,6 +101,12 @@ export const user = { } }; +function saveReturnTo() { + if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { + document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; + } +} + export async function login(handle: string) { if (handle.startsWith('did:')) { if (handle.length < 6) throw new Error('DID must be at least 6 characters'); @@ -143,6 +122,7 @@ export async function login(handle: string) { const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ handle }); + saveReturnTo(); window.location.assign(url); // Wait for navigation (prevents UI flash) @@ -154,8 +134,11 @@ export async function login(handle: string) { } export async function signup() { + if (!ALLOW_SIGNUP) throw new Error('Signup is not enabled'); + const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ signup: true }); + saveReturnTo(); window.location.assign(url); await new Promise((_resolve, reject) => { @@ -677,8 +660,7 @@ import { WellKnownHandleResolver } from '@atcute/identity-resolver'; import { KVStore } from './kv-store'; -import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; -import { scope } from '../metadata'; +import { DOH_RESOLVER, REDIRECT_PATH, scopes } from '../settings'; import { dev } from '$app/environment'; function createActorResolver() { @@ -723,7 +705,7 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { return new OAuthClient({ metadata: { redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], - scope + scope: scopes }, actorResolver, stores @@ -744,7 +726,7 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { metadata: { client_id: site + '/oauth-client-metadata.json', redirect_uris: [site + REDIRECT_PATH], - scope, + scope: scopes, jwks_uri: site + '/oauth/jwks.json' }, keyset: [key], @@ -764,8 +746,7 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import { createOAuthClient } from './oauth'; import { getSignedCookie } from './signed-cookie'; -import { scope } from '../metadata'; -import { signUpPDS } from '../settings'; +import { scopes, signUpPDS } from '../settings'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; export const oauthLogin = command( @@ -785,7 +766,7 @@ export const oauthLogin = command( const { url } = await oauth.authorize({ target, - scope, + scope: scopes.join(' '), prompt: input.signup ? 'create' : undefined }); @@ -812,6 +793,7 @@ export const oauthLogout = command(async () => { } cookies.delete('did', { path: '/' }); + cookies.delete('scope', { path: '/' }); return { ok: true }; }); @@ -823,16 +805,13 @@ export const oauthLogout = command(async () => { import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import * as v from 'valibot'; -import { permissions } from '../settings'; +import { collections } from '../settings'; // Validate collection format and check against allowed list from settings const collectionSchema = v.pipe( v.string(), v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/), - v.check( - (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), - 'Collection not in allowed list' - ) + v.check((c) => collections.includes(c as (typeof collections)[number]), 'Collection not in allowed list') ); // AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) @@ -916,6 +895,7 @@ import type { Did } from '@atcute/lexicons'; import type { OAuthSession } from '@atcute/oauth-node-client'; import { createOAuthClient } from './oauth'; import { getSignedCookie } from './signed-cookie'; +import { scopes } from '../settings'; export type SessionLocals = { session: OAuthSession | null; @@ -938,6 +918,14 @@ export async function restoreSession( return { session: null, client: null, did: null }; } + // If permissions changed since login, invalidate the session + const savedScope = getSignedCookie(cookies, 'scope'); + if (savedScope !== null && savedScope !== scopes.join(' ')) { + cookies.delete('did', { path: '/' }); + cookies.delete('scope', { path: '/' }); + return { session: null, client: null, did: null }; + } + try { const oauth = createOAuthClient(env); const session = await oauth.restore(did); @@ -950,6 +938,7 @@ export async function restoreSession( } catch (e) { console.error('Failed to restore session:', e); cookies.delete('did', { path: '/' }); + cookies.delete('scope', { path: '/' }); return { session: null, client: null, did: null }; } } @@ -1086,6 +1075,7 @@ console.log(`updated ${envPath}`); import { redirect } from '@sveltejs/kit'; import { createOAuthClient } from '$lib/atproto/server/oauth'; import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; +import { scopes } from '$lib/atproto/settings'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; @@ -1097,18 +1087,30 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => { try { const { session } = await oauth.callback(url.searchParams); - setSignedCookie(cookies, 'did', session.did, { + const cookieOpts = { path: '/', httpOnly: true, secure: !dev, - sameSite: 'lax', + sameSite: 'lax' as const, maxAge: 60 * 60 * 24 * 180 // 180 days - }); + }; + + setSignedCookie(cookies, 'did', session.did, cookieOpts); + setSignedCookie(cookies, 'scope', scopes.join(' '), cookieOpts); } catch (e) { console.error('OAuth callback failed:', e); redirect(303, '/?error=auth_failed'); } + const returnTo = cookies.get('oauth_return_to'); + if (returnTo) { + cookies.delete('oauth_return_to', { path: '/' }); + const decoded = decodeURIComponent(returnTo); + if (decoded.startsWith('/') && !decoded.startsWith('//')) { + redirect(303, decoded); + } + } + redirect(303, '/'); }; ``` @@ -1232,7 +1234,9 @@ If a load function already exists, merge the profile data into its return value. ### `src/routes/+layout.svelte` (foxui only) -Only if the user chose `foxui`. Add the login modal to the existing layout: +Only if the user chose `foxui`. Add the login modal to the existing layout. + +If signup is enabled (`ALLOW_SIGNUP = true`): ```svelte + + { + await login(handle); + return true; + }} +/> +``` + To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components: ```svelte diff --git a/AGENT_SETUP_REFERENCE.md b/AGENT_SETUP_REFERENCE.md index 4f78ce6..7262ed7 100644 --- a/AGENT_SETUP_REFERENCE.md +++ b/AGENT_SETUP_REFERENCE.md @@ -21,6 +21,13 @@ Before making any changes, ask the user these questions: 3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`) +4. **Signup**: Should the app allow users to create new AT Protocol accounts (signup)? + - **`yes`** — Include a signup button/flow + - **`no`** — Login only, no account creation + +5. **Production PDS**: Which PDS should be used for signup in production? (default: `https://selfhosted.social/`) + - Only relevant if signup is enabled. Skip if signup is `no`. + Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create. ## Step 1: Install dependencies @@ -44,46 +51,36 @@ Create all of the following files. These go into `src/lib/atproto/` and `src/rou ### `src/lib/atproto/settings.ts` -Fill in `collections` and `blobs` from the user's answers. If no collections were specified, use an empty array. +Fill in `collections` from the user's answers. If no collections were specified, use an empty array. Build `scopes` using `scope` builders from `@atcute/oauth-node-client` — add `scope.blob()`, `scope.rpc()`, etc. as needed. ```ts import { dev } from '$app/environment'; +import { scope } from '@atcute/oauth-node-client'; -type Permissions = { - collections: readonly string[]; - rpc: Record; - blobs: readonly string[]; -}; - -export const permissions = { - // CUSTOMIZE: add the user's collections - collections: [], - - // CUSTOMIZE: add any authenticated RPC requests needed - rpc: {}, +// CUSTOMIZE: writable collections +export const collections = [] as const; - // CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*']) - blobs: [] -} as const satisfies Permissions; +export type AllowedCollection = (typeof collections)[number]; -type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; +// CUSTOMIZE: OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed +export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; -export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; +// CUSTOMIZE: set to true to allow signup, false for login-only +export const ALLOW_SIGNUP = true; -// PDS to use for signup (change to preferred PDS) +// CUSTOMIZE: PDS to use for signup (only relevant if ALLOW_SIGNUP is true) const devPDS = 'https://bsky.social/'; -const prodPDS = 'https://bsky.social/'; +const prodPDS = 'https://selfhosted.social/'; // CUSTOMIZE: change to preferred production PDS export const signUpPDS = dev ? devPDS : prodPDS; export const REDIRECT_PATH = '/oauth/callback'; +// redirect the user back to the page they were on before login +export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; + export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; ``` -### `src/lib/atproto/metadata.ts` - - - ### `src/lib/atproto/auth.svelte.ts` @@ -234,7 +231,9 @@ If a load function already exists, merge the profile data into its return value. ### `src/routes/+layout.svelte` (foxui only) -Only if the user chose `foxui`. Add the login modal to the existing layout: +Only if the user chose `foxui`. Add the login modal to the existing layout. + +If signup is enabled (`ALLOW_SIGNUP = true`): ```svelte + + { + await login(handle); + return true; + }} +/> +``` + To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components: ```svelte diff --git a/package.json b/package.json index 226ad7b..7a38b9e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", - "build:agent-setup": "npx tsx scripts/build-agent-setup.ts" + "build:agent-setup": "npx tsx scripts/build-agent-setup.ts", + "tunnel": "cloudflared tunnel --url http://localhost:5183" }, "devDependencies": { "@atcute/atproto": "^3.1.10", diff --git a/src/lib/atproto/auth.svelte.ts b/src/lib/atproto/auth.svelte.ts index c5a0db8..2859236 100644 --- a/src/lib/atproto/auth.svelte.ts +++ b/src/lib/atproto/auth.svelte.ts @@ -1,7 +1,7 @@ import { AppBskyActorDefs } from '@atcute/bluesky'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; import { page } from '$app/state'; -import { REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; +import { ALLOW_SIGNUP, REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; export const user = { get profile() { @@ -15,6 +15,12 @@ export const user = { } }; +function saveReturnTo() { + if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { + document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; + } +} + export async function login(handle: string) { if (handle.startsWith('did:')) { if (handle.length < 6) throw new Error('DID must be at least 6 characters'); @@ -30,9 +36,7 @@ export async function login(handle: string) { const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ handle }); - if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { - document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; - } + saveReturnTo(); window.location.assign(url); // Wait for navigation (prevents UI flash) @@ -44,11 +48,11 @@ export async function login(handle: string) { } export async function signup() { + if (!ALLOW_SIGNUP) throw new Error('Signup is not enabled'); + const { oauthLogin } = await import('./server/oauth.remote'); const { url } = await oauthLogin({ signup: true }); - if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { - document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; - } + saveReturnTo(); window.location.assign(url); await new Promise((_resolve, reject) => { diff --git a/src/lib/atproto/metadata.ts b/src/lib/atproto/metadata.ts deleted file mode 100644 index b470f8e..0000000 --- a/src/lib/atproto/metadata.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { permissions } from './settings'; - -function constructScope() { - const parts: string[] = ['atproto']; - - for (const collection of permissions.collections) { - parts.push('repo:' + collection); - } - - for (const [key, value] of Object.entries(permissions.rpc ?? {})) { - const lxms = Array.isArray(value) ? value : [value]; - for (const lxm of lxms) { - parts.push('rpc?lxm=' + lxm + '&aud=' + key); - } - } - - if (permissions.blobs.length > 0) { - parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&')); - } - - return parts.join(' '); -} - -export const scope = constructScope(); diff --git a/src/lib/atproto/server/oauth.remote.ts b/src/lib/atproto/server/oauth.remote.ts index 26bc9f7..f087d53 100644 --- a/src/lib/atproto/server/oauth.remote.ts +++ b/src/lib/atproto/server/oauth.remote.ts @@ -3,8 +3,7 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import { createOAuthClient } from './oauth'; import { getSignedCookie } from './signed-cookie'; -import { scope } from '../metadata'; -import { signUpPDS } from '../settings'; +import { scopes, signUpPDS } from '../settings'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; export const oauthLogin = command( @@ -24,7 +23,7 @@ export const oauthLogin = command( const { url } = await oauth.authorize({ target, - scope, + scope: scopes.join(' '), prompt: input.signup ? 'create' : undefined }); diff --git a/src/lib/atproto/server/oauth.ts b/src/lib/atproto/server/oauth.ts index 179ff04..06348fd 100644 --- a/src/lib/atproto/server/oauth.ts +++ b/src/lib/atproto/server/oauth.ts @@ -18,8 +18,7 @@ import { WellKnownHandleResolver } from '@atcute/identity-resolver'; import { KVStore } from './kv-store'; -import { DOH_RESOLVER, REDIRECT_PATH } from '../settings'; -import { scope } from '../metadata'; +import { DOH_RESOLVER, REDIRECT_PATH, scopes } from '../settings'; import { dev } from '$app/environment'; function createActorResolver() { @@ -64,7 +63,7 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { return new OAuthClient({ metadata: { redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`], - scope + scope: scopes }, actorResolver, stores @@ -85,7 +84,7 @@ export function createOAuthClient(env?: App.Platform['env']): OAuthClient { metadata: { client_id: site + '/oauth-client-metadata.json', redirect_uris: [site + REDIRECT_PATH], - scope, + scope: scopes, jwks_uri: site + '/oauth/jwks.json' }, keyset: [key], diff --git a/src/lib/atproto/server/repo.remote.ts b/src/lib/atproto/server/repo.remote.ts index 3ee8d23..c3bff03 100644 --- a/src/lib/atproto/server/repo.remote.ts +++ b/src/lib/atproto/server/repo.remote.ts @@ -1,16 +1,13 @@ import { error } from '@sveltejs/kit'; import { command, getRequestEvent } from '$app/server'; import * as v from 'valibot'; -import { permissions } from '../settings'; +import { collections } from '../settings'; // Validate collection format and check against allowed list from settings const collectionSchema = v.pipe( v.string(), v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/), - v.check( - (c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')), - 'Collection not in allowed list' - ) + v.check((c) => collections.includes(c as (typeof collections)[number]), 'Collection not in allowed list') ); // AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) diff --git a/src/lib/atproto/server/session.ts b/src/lib/atproto/server/session.ts index 8dfd03a..0e91ac0 100644 --- a/src/lib/atproto/server/session.ts +++ b/src/lib/atproto/server/session.ts @@ -4,7 +4,7 @@ import type { Did } from '@atcute/lexicons'; import type { OAuthSession } from '@atcute/oauth-node-client'; import { createOAuthClient } from './oauth'; import { getSignedCookie } from './signed-cookie'; -import { scope } from '../metadata'; +import { scopes } from '../settings'; export type SessionLocals = { session: OAuthSession | null; @@ -29,7 +29,7 @@ export async function restoreSession( // If permissions changed since login, invalidate the session const savedScope = getSignedCookie(cookies, 'scope'); - if (savedScope !== null && savedScope !== scope) { + if (savedScope !== null && savedScope !== scopes.join(' ')) { cookies.delete('did', { path: '/' }); cookies.delete('scope', { path: '/' }); return { session: null, client: null, did: null }; diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index d7dd3bd..e760afe 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -1,26 +1,16 @@ import { dev } from '$app/environment'; +import { scope } from '@atcute/oauth-node-client'; -type Permissions = { - collections: readonly string[]; - rpc: Record; - blobs: readonly string[]; -}; +// writable collections +export const collections = ['xyz.statusphere.status'] as const; -export const permissions = { - // collections you can create/delete/update - collections: ['xyz.statusphere.status'], +export type AllowedCollection = (typeof collections)[number]; - // what types of authenticated proxied requests you can make to services - rpc: {}, +// OAuth scope — add scope.blob(), scope.rpc(), etc. as needed +export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; - // what types of blobs you can upload to a users PDS - blobs: [] -} as const satisfies Permissions; - -// Extract base collection name (before any query params) -type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; - -export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; +// set to false to disable signup +export const ALLOW_SIGNUP = true; // which PDS to use for signup (change to your preferred PDS) const devPDS = 'https://pds.rip/'; diff --git a/src/routes/(oauth)/oauth/callback/+server.ts b/src/routes/(oauth)/oauth/callback/+server.ts index ab50d36..f6a71e8 100644 --- a/src/routes/(oauth)/oauth/callback/+server.ts +++ b/src/routes/(oauth)/oauth/callback/+server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { createOAuthClient } from '$lib/atproto/server/oauth'; import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; -import { scope } from '$lib/atproto/metadata'; +import { scopes } from '$lib/atproto/settings'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; @@ -22,7 +22,7 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => { }; setSignedCookie(cookies, 'did', session.did, cookieOpts); - setSignedCookie(cookies, 'scope', scope, cookieOpts); + setSignedCookie(cookies, 'scope', scopes.join(' '), cookieOpts); } catch (e) { console.error('OAuth callback failed:', e); redirect(303, '/?error=auth_failed');