The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: self-hosted OAuth metadata + granular scopes, drop OAUTH_CLIENT_ID

- metadata.ts: shared buildClientMetadata(appUrl) + OAUTH_SCOPE constant.
Single source of truth for both the metadata route and NodeOAuthClient.
- OAUTH_SCOPE: replace transition:generic with granular read-only scopes
(atproto + rpc:getFollows + rpc:searchPosts). Understory never writes.
- /oauth/client-metadata.json route: serves metadata to PDS servers.
- client.ts: derives client_id from APP_URL (no OAUTH_CLIENT_ID env var).
- login/route.ts: uses OAUTH_SCOPE constant instead of hardcoded scope.
- Eliminates the cimd-service.fly.dev dependency for auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+87 -17
+13
src/app/oauth/client-metadata.json/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { buildClientMetadata } from "@/lib/auth/metadata"; 3 + 4 + export async function GET() { 5 + const appUrl = process.env.APP_URL; 6 + if (!appUrl) { 7 + return NextResponse.json( 8 + { error: "APP_URL not configured" }, 9 + { status: 500 }, 10 + ); 11 + } 12 + return NextResponse.json(buildClientMetadata(appUrl)); 13 + }
+2 -1
src/app/oauth/login/route.ts
··· 1 1 import { NextRequest, NextResponse } from "next/server"; 2 2 import { getOAuthClient } from "@/lib/auth/client"; 3 + import { OAUTH_SCOPE } from "@/lib/auth/metadata"; 3 4 4 5 export async function POST(request: NextRequest) { 5 6 try { ··· 15 16 16 17 const client = getOAuthClient(); 17 18 const url = await client.authorize(handle, { 18 - scope: "atproto transition:generic", 19 + scope: OAUTH_SCOPE, 19 20 }); 20 21 21 22 return NextResponse.json({ redirect: url.toString() });
+5 -16
src/lib/auth/client.ts
··· 3 3 NodeSavedState, 4 4 NodeSavedSession, 5 5 } from "@atproto/oauth-client-node"; 6 + import { buildClientMetadata } from "./metadata"; 6 7 7 8 const stateStore = new Map<string, NodeSavedState>(); 8 9 const sessionStore = new Map<string, NodeSavedSession>(); 9 10 10 11 function createClient(): NodeOAuthClient { 11 - const clientId = process.env.OAUTH_CLIENT_ID; 12 12 const appUrl = process.env.APP_URL; 13 13 14 - if (!clientId || !appUrl) { 14 + if (!appUrl) { 15 15 throw new Error( 16 - "Missing OAUTH_CLIENT_ID or APP_URL environment variables. " + 17 - "See docs/superpowers/specs/2026-04-06-oauth.md for setup instructions.", 16 + "Missing APP_URL environment variable. " + 17 + "Set to your app's public URL (e.g., https://understory.watch).", 18 18 ); 19 19 } 20 20 21 21 return new NodeOAuthClient({ 22 - clientMetadata: { 23 - client_id: clientId, 24 - client_name: "Understory (Development)", 25 - client_uri: appUrl, 26 - redirect_uris: [`${appUrl}/oauth/callback`], 27 - grant_types: ["authorization_code", "refresh_token"], 28 - response_types: ["code"], 29 - scope: "atproto transition:generic", 30 - application_type: "web", 31 - dpop_bound_access_tokens: true, 32 - token_endpoint_auth_method: "none", 33 - }, 22 + clientMetadata: buildClientMetadata(appUrl), 34 23 stateStore: { 35 24 async get(key: string) { 36 25 return stateStore.get(key);
+67
src/lib/auth/metadata.ts
··· 1 + /** 2 + * Shared AT Protocol OAuth client metadata builder. 3 + * 4 + * Used by both the client-metadata.json route handler (serves the metadata 5 + * to PDS servers during the OAuth flow) and the NodeOAuthClient constructor 6 + * (uses the metadata locally for the authorization handshake). Defined once 7 + * here so the two can never drift — drift causes cryptic OAuth mismatch errors. 8 + */ 9 + 10 + export const CLIENT_METADATA_PATH = "/oauth/client-metadata.json"; 11 + 12 + /** 13 + * Granular OAuth scopes for Understory. Replaces the overpermissioned 14 + * `transition:generic` transitional scope with the minimum permissions 15 + * the app actually needs: 16 + * 17 + * - `atproto` — required for all AT Protocol OAuth sessions (identity) 18 + * - `rpc:app.bsky.graph.getFollows?aud=*` — read the user's follows 19 + * - `rpc:app.bsky.feed.searchPosts?aud=*` — search for conference posts 20 + * 21 + * Understory never writes records, so no write scopes are requested. 22 + * The `?aud=*` suffix allows the call to be proxied to any AppView service. 23 + */ 24 + export const OAUTH_SCOPE = [ 25 + "atproto", 26 + "rpc:app.bsky.graph.getFollows?aud=*", 27 + "rpc:app.bsky.feed.searchPosts?aud=*", 28 + ].join(" "); 29 + 30 + // SDK-required literal union types for tuple-validated fields. 31 + type GrantType = 32 + | "authorization_code" 33 + | "implicit" 34 + | "refresh_token" 35 + | "password" 36 + | "client_credentials" 37 + | "urn:ietf:params:oauth:grant-type:jwt-bearer" 38 + | "urn:ietf:params:oauth:grant-type:saml2-bearer"; 39 + 40 + type ResponseType = 41 + | "code" 42 + | "none" 43 + | "token" 44 + | "code id_token token" 45 + | "code id_token" 46 + | "code token" 47 + | "id_token token" 48 + | "id_token"; 49 + 50 + export function buildClientMetadata(appUrl: string) { 51 + const clientId = `${appUrl}${CLIENT_METADATA_PATH}`; 52 + return { 53 + client_id: clientId, 54 + client_name: "Understory", 55 + client_uri: appUrl, 56 + redirect_uris: [`${appUrl}/oauth/callback`] as [string, ...string[]], 57 + grant_types: ["authorization_code", "refresh_token"] as [ 58 + GrantType, 59 + ...GrantType[], 60 + ], 61 + response_types: ["code"] as [ResponseType, ...ResponseType[]], 62 + scope: OAUTH_SCOPE, 63 + application_type: "web" as const, 64 + dpop_bound_access_tokens: true, 65 + token_endpoint_auth_method: "none" as const, 66 + }; 67 + }