Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306import 'server-only'import { Agent } from '@atproto/api'import { NodeOAuthClient, JoseKey, buildAtprotoLoopbackClientId } from '@atproto/oauth-client-node'import type { NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node'
// Granular OAuth scopes — no transition:generic.//// Progressive authorization: login requests BASE_SCOPES only. The list scopes// are requested on demand via /api/oauth/upgrade when the user first touches a// list feature (share-as-list, List Editor, subscribe). Client metadata always// declares FULL_SCOPES — the spec requires each authorization request's scope// to be a subset of the metadata scope, so metadata must carry the union.//// atproto has no incremental-grant merging: each grant stands alone and the// new session replaces the old one (keyed by DID). Upgrade therefore requests// the full union, never just the delta.export const BASE_SCOPE_LIST = [ 'atproto', 'repo:app.bsky.graph.block?action=create&action=delete', 'repo:app.flockoff.blockEvent?action=create&action=update',]
export const LIST_SCOPE_LIST = [ 'repo:app.bsky.graph.list?action=create&action=delete', 'repo:app.bsky.graph.listitem?action=create', 'repo:app.bsky.graph.listblock?action=create&action=delete',]
export const FULL_SCOPE_LIST = [...BASE_SCOPE_LIST, ...LIST_SCOPE_LIST]
export const BASE_SCOPES = BASE_SCOPE_LIST.join(' ')export const FULL_SCOPES = FULL_SCOPE_LIST.join(' ')
// ─── Persistent Singleton Pattern ─────────────────────────────────────────────// Next.js HMR re-evaluates modules on every hot reload, resetting module-level// variables to their initial values. This clears the OAuth session store mid-// session, causing `client.restore()` to throw "session deleted by another// process" even though the user is still logged in.//// Anchoring to `globalThis` solves this: HMR recreates modules but never// recreates the global object, so stores and the client promise survive reloads.// In production (no HMR), globalThis has no practical effect.//// This is the same pattern Next.js recommends for Prisma / MongoDB clients.
declare global { // eslint-disable-next-line no-var var __oauthStateStore: Map<string, NodeSavedState> | undefined // eslint-disable-next-line no-var var __oauthSessionStore: Map<string, NodeSavedSession> | undefined // eslint-disable-next-line no-var var __oauthClientPromise: Promise<NodeOAuthClient> | undefined}
// ─── In-process token refresh lock ────────────────────────────────────────────// Prevents concurrent token refresh races: if multiple simultaneous requests// all hit an expired token, only one refresh runs — the rest wait and reuse// the new token. Without this, parallel block job calls can trigger simultaneous// refreshes, causing the auth server to revoke all credentials as a replay attack.//// This Map-based mutex is correct for single-process deployments (Railway).// For multi-instance deployments, swap for a Redis-backed lock.const _lockMap = new Map<string, Promise<unknown>>()// eslint-disable-next-line @typescript-eslint/no-explicit-anyasync function requestLock<T>(key: string, fn: () => any): Promise<T> { const previous = _lockMap.get(key) ?? Promise.resolve() const next: Promise<T> = previous.then(() => Promise.resolve(fn())).finally(() => { if (_lockMap.get(key) === next) _lockMap.delete(key) }) _lockMap.set(key, next) return next}
const stateStore: Map<string, NodeSavedState> = (globalThis.__oauthStateStore ??= new Map())
const sessionStore: Map<string, NodeSavedSession> = (globalThis.__oauthSessionStore ??= new Map())
const stores = { stateStore: { get: async (key: string) => stateStore.get(key), set: async (key: string, value: NodeSavedState) => { stateStore.set(key, value) }, del: async (key: string) => { stateStore.delete(key) }, }, sessionStore: { get: async (key: string) => sessionStore.get(key), set: async (key: string, value: NodeSavedSession) => { sessionStore.set(key, value) }, del: async (key: string) => { sessionStore.delete(key) }, }, // Reliable DNS TXT record resolution for custom domain handles. // Windows system DNS can fail silently on TXT lookups; these fallbacks // ensure handles like tilth.social resolve correctly in all environments. fallbackNameservers: ['1.1.1.1', '8.8.8.8'],}
export function getOAuthClient(): Promise<NodeOAuthClient> { if (!globalThis.__oauthClientPromise) { globalThis.__oauthClientPromise = createClient() } return globalThis.__oauthClientPromise}
/** * Restore an OAuth session and wrap it in an @atproto/api Agent. * * `client.restore()` returns an OAuthSession (implements FetchHandlerObject) * but NOT an Agent — it doesn't have the .com.atproto.* / .app.bsky.* * namespace proxies. Wrapping in new Agent() provides those namespaces while * the OAuthSession continues to handle token refresh transparently. */export async function restoreAgent(did: string): Promise<Agent> { const client = await getOAuthClient() const session = await client.restore(did) return new Agent(session)}
/** * Restore the raw OAuthSession for token introspection. The Agent wrapper is * the wrong abstraction for reading token state — use this for scope checks. */export async function restoreOAuthSession(did: string) { const client = await getOAuthClient() return client.restore(did)}
// ─── Repo-scope semantics ─────────────────────────────────────────────────────// Granted scopes are compared by repo-scope *semantics*, not raw string// equality. This matters for legacy grants: sessions created before the scope// trim hold `repo:app.bsky.graph.listitem?action=create&action=delete`, which// is a strict superset of the now-required `?action=create` — those users must// not be re-prompted to upgrade. A granted scope satisfies a required one when// the collection matches and its action set is a superset of the required set.
type RepoScope = { collection: string; actions: Set<string> }
function parseRepoScope(token: string): RepoScope | null { if (!token.startsWith('repo:')) return null const [prefix, query] = token.split('?', 2) const collection = prefix.slice('repo:'.length) const actions = new Set<string>() if (query) { for (const pair of query.split('&')) { const [key, value] = pair.split('=', 2) if (key === 'action' && value) actions.add(value) } } return { collection, actions }}
function repoScopeSatisfies(granted: RepoScope, required: RepoScope): boolean { if (granted.collection !== required.collection) return false // A repo scope with no explicit action params grants all actions if (granted.actions.size === 0) return true for (const action of required.actions) { if (!granted.actions.has(action)) return false } return true}
/** * Check whether the session's granted scopes cover every list scope. * Tokens are parsed and compared per repo-scope semantics (collection match + * action superset) — never raw string equality, which would false-negative on * legacy grants with broader action sets, and never substring matching, which * could false-positive on overlapping scope names. */export async function hasListScopes(did: string): Promise<boolean> { const oauthSession = await restoreOAuthSession(did) const { scope } = await oauthSession.getTokenInfo() const grantedRepoScopes = String(scope) .split(/\s+/) .map(parseRepoScope) .filter((s): s is RepoScope => s !== null) return LIST_SCOPE_LIST.every((requiredToken) => { const required = parseRepoScope(requiredToken) if (!required) return false return grantedRepoScopes.some((granted) => repoScopeSatisfies(granted, required)) })}
function isLocalhost(url: string): boolean { return url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1')}
// ─── Node.js 24 / undici v7 fetch workaround ──────────────────────────────────// Node.js 24.14.1 ships undici v7.24.4, which has a bug (fixed in v7.24.8) where// fetch(requestObject) throws "expected non-null body source" when the PDS returns// a 401 use_dpop_nonce response. What happens://// 1. @atproto/xrpc builds: init = { body: Uint8Array, duplex: 'half', ... }// 2. OAuthSession.fetchHandler wraps it in new Request(url, init)// → Uint8Array is stored as a stream-backed ReadableStream (source = null)// 3. globalThis.fetch(request) is called. PDS responds with 401 use_dpop_nonce.// 4. undici v7.24.4 checks body.source before returning the 401 response.// source is null (stream-backed) → throws makeNetworkError('expected non-null// body source') instead of returning the 401 to dpopFetchWrapper.// 5. dpopFetchWrapper never receives the 401, so it never does the nonce retry.//// Fix: buffer the request body into a plain Uint8Array before calling// globalThis.fetch. When undici receives a Uint8Array directly (not a stream),// body.source is non-null, the 401 is correctly returned, and dpopFetchWrapper// can do the nonce retry on the second attempt.//// duplex: 'half' is intentionally omitted from the rebuilt init — it was the// trigger that caused new Request() to produce a stream-backed body in the first// place. Plain Uint8Array bodies do not require it.//// Remove this wrapper once Node.js ships an undici version >= 7.24.8.// Track at: node -e "console.log(process.versions.undici)"async function robustFetch( input: RequestInfo | URL, init?: RequestInit): Promise<Response> { // (url, init) form — metadata GETs, token exchange, etc. Pass straight through. if (!(input instanceof Request)) { return globalThis.fetch(input, init) }
// (request) form — from bindFetch inside dpopFetchWrapper. // Buffer the body so undici sees a Uint8Array (non-null source) rather than a // stream-backed ReadableStream (null source). const req = input const body = req.body ? new Uint8Array(await req.arrayBuffer()) : null
return globalThis.fetch(req.url, { method: req.method, headers: req.headers, body, redirect: req.redirect, signal: req.signal, // duplex intentionally omitted — body is a buffered Uint8Array, not a stream })}
async function createClient(): Promise<NodeOAuthClient> { const appUrl = process.env.NEXT_PUBLIC_APP_URL if (!appUrl) throw new Error('NEXT_PUBLIC_APP_URL is not set')
// ── Development: loopback public client ───────────────────────────────────── // AT Protocol OAuth requires https:// (no port) for production client_ids. // For local dev, the spec provides a special "http://localhost" loopback mode // where port numbers are ignored in redirect_uri matching. if (isLocalhost(appUrl)) { const port = new URL(appUrl).port || '3000'
// Port-free URI for client_id registration (port is ignored in matching) const loopbackCallbackUri = 'http://127.0.0.1/api/oauth/callback' // Port-included URI for actual redirects to reach our dev server const actualCallbackUri = `http://127.0.0.1:${port}/api/oauth/callback`
const clientId = buildAtprotoLoopbackClientId({ redirect_uris: [loopbackCallbackUri], scope: FULL_SCOPES, })
console.log('[oauth] Development mode — loopback client') console.log('[oauth] client_id:', clientId)
return new NodeOAuthClient({ clientMetadata: { client_id: clientId, redirect_uris: [actualCallbackUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'native', token_endpoint_auth_method: 'none', // public client dpop_bound_access_tokens: true, scope: FULL_SCOPES, }, fetch: robustFetch, requestLock, ...stores, }) }
// ── Production: confidential client with ES256 key ────────────────────────── const privateKey = process.env.OAUTH_PRIVATE_KEY if (!privateKey) throw new Error('OAUTH_PRIVATE_KEY is not set')
const keyset = await Promise.all([ JoseKey.fromImportable(privateKey, 'key1'), ])
return new NodeOAuthClient({ clientMetadata: { client_id: `${appUrl}/oauth-client-metadata.json`, client_name: 'FlockOff', client_uri: appUrl, redirect_uris: [`${appUrl}/api/oauth/callback`], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', dpop_bound_access_tokens: true, scope: FULL_SCOPES, jwks_uri: `${appUrl}/jwks.json`, }, keyset, fetch: robustFetch, requestLock, ...stores, })}