Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

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

fix: oauth scope

Hugo d1b52b52 652266f2

+34 -44
+5 -3
app/routes/api/automations/[rkey].ts
··· 37 37 validateWebhookHeaders, 38 38 } from "@/actions/validation.js"; 39 39 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 40 - import { scopeCoversActions, computeRequiredScope, mergeScopes } from "@/auth/client.js"; 40 + import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 41 41 42 42 function findAutomation(did: string, rkey: string) { 43 43 return db.query.automations.findFirst({ ··· 452 452 453 453 // Block disabling dry run when scope doesn't cover action collections 454 454 if (!dryRun && !scopeCoversActions(user.scope, localActions)) { 455 - const requiredScope = mergeScopes(user.scope, computeRequiredScope(localActions)); 456 - return c.json({ error: "scope_insufficient", requiredScope }, 403); 455 + return c.json( 456 + { error: "scope_insufficient", requiredScope: computeRequiredScope(localActions) }, 457 + 403, 458 + ); 457 459 } 458 460 459 461 // Update on PDS
+8 -2
app/routes/auth/login.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { getOAuthClient, resolveHandle, rewritePdsUrl, OAUTH_SCOPE_BASIC } from "@/auth/client.js"; 2 + import { 3 + getOAuthClient, 4 + resolveHandle, 5 + rewritePdsUrl, 6 + OAUTH_SCOPE_BASIC, 7 + OAUTH_SCOPE_FULL, 8 + } from "@/auth/client.js"; 3 9 import { getSessionUser } from "@/auth/middleware.js"; 4 10 import { AppShell } from "../../components/Layout/AppShell/index.js"; 5 11 import { Header } from "../../components/Layout/Header/index.js"; ··· 94 100 const client = await getOAuthClient(); 95 101 96 102 const scopeParam = c.req.query("scope"); 97 - const scope = scopeParam ?? OAUTH_SCOPE_BASIC; 103 + const scope = scopeParam?.includes("transition:generic") ? OAUTH_SCOPE_FULL : OAUTH_SCOPE_BASIC; 98 104 const returnTo = c.req.query("returnTo"); 99 105 const state = returnTo ? JSON.stringify({ handle: input, returnTo }) : input; 100 106
+2 -4
app/routes/dashboard/automations/[rkey].tsx
··· 2 2 import { eq, and, desc } from "drizzle-orm"; 3 3 import { ArrowLeft, Copy, ExternalLink, Pencil, Filter, Database, Zap } from "../../../icons.js"; 4 4 import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 5 - import { scopeCoversActions, computeRequiredScope, mergeScopes } from "@/auth/client.js"; 5 + import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 6 6 import { db } from "@/db/index.js"; 7 7 import { automations, deliveryLogs } from "@/db/schema.js"; 8 8 import { AppShell } from "../../../components/Layout/AppShell/index.js"; ··· 57 57 const logs = rawLogs.slice(0, 50); 58 58 59 59 const needsScopeUpgrade = !scopeCoversActions(user.scope, auto.actions); 60 - const upgradeScope = needsScopeUpgrade 61 - ? mergeScopes(user.scope, computeRequiredScope(auto.actions)) 62 - : null; 60 + const upgradeScope = needsScopeUpgrade ? computeRequiredScope(auto.actions) : null; 63 61 64 62 return c.render( 65 63 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}>
+19 -35
lib/auth/client.ts
··· 18 18 const KEY_PATH = resolve("./data/oauth-key.json"); 19 19 20 20 /** Maximum scope declared in client metadata (superset of all tiers). */ 21 - export const OAUTH_SCOPE_MAX = "atproto repo:*"; 22 - /** Default scope for sign-in — only run.airglow.automation records. */ 21 + export const OAUTH_SCOPE_MAX = "atproto transition:generic repo:run.airglow.automation"; 22 + /** Tier 1: sign-in — only run.airglow.automation records. */ 23 23 export const OAUTH_SCOPE_BASIC = "atproto repo:run.airglow.automation"; 24 + /** Tier 2: full write — broad access for record-producing actions. */ 25 + export const OAUTH_SCOPE_FULL = "atproto transition:generic"; 24 26 25 27 type ActionLike = { $type: string; targetCollection?: string }; 26 28 27 - /** Extract the set of collection NSIDs from repo: tokens in a scope string. */ 28 - export function scopeCollections(scope: string | null): Set<string> { 29 - if (!scope) return new Set(); 30 - const collections = new Set<string>(); 31 - for (const token of scope.split(" ")) { 32 - if (token.startsWith("repo:")) collections.add(token.slice(5)); 33 - } 34 - return collections; 35 - } 36 - 37 - /** Compute the required scope string for a set of actions. */ 38 - export function computeRequiredScope(actions: ActionLike[]): string { 39 - const collections = new Set(["run.airglow.automation"]); 40 - for (const action of actions) { 41 - if (action.$type === "bsky-post") { 42 - collections.add("app.bsky.feed.post"); 43 - } else if ( 44 - (action.$type === "record" || action.$type === "patch-record") && 45 - action.targetCollection 46 - ) { 47 - collections.add(action.targetCollection); 48 - } 49 - } 50 - return `atproto ${[...collections].map((c) => `repo:${c}`).join(" ")}`; 29 + /** Returns true if any action writes to a collection beyond run.airglow.automation. */ 30 + export function actionsNeedFullScope(actions: ActionLike[]): boolean { 31 + return actions.some( 32 + (a) => a.$type === "bsky-post" || a.$type === "record" || a.$type === "patch-record", 33 + ); 51 34 } 52 35 53 36 /** Check if the granted scope covers all collections needed by actions. */ 54 37 export function scopeCoversActions(scope: string | null, actions: ActionLike[]): boolean { 55 - const granted = scopeCollections(scope); 56 - const needed = scopeCollections(computeRequiredScope(actions)); 57 - return [...needed].every((c) => granted.has(c)); 38 + if (!scope) return false; 39 + const tokens = scope.split(" "); 40 + // transition:generic covers everything 41 + if (tokens.includes("transition:generic")) return true; 42 + // Without transition:generic, only webhook-only automations are covered 43 + return !actionsNeedFullScope(actions); 58 44 } 59 45 60 - /** Merge the user's current scope with additional needed scope (preserves existing permissions). */ 61 - export function mergeScopes(currentScope: string | null, requiredScope: string): string { 62 - const tokens = new Set((currentScope ?? "").split(" ").filter(Boolean)); 63 - for (const token of requiredScope.split(" ")) tokens.add(token); 64 - return [...tokens].join(" "); 46 + /** Compute the scope string needed for a set of actions (for display and re-auth). */ 47 + export function computeRequiredScope(actions: ActionLike[]): string { 48 + return actionsNeedFullScope(actions) ? OAUTH_SCOPE_FULL : OAUTH_SCOPE_BASIC; 65 49 } 66 50 67 51 const pdsUrl = config.pdsUrl; ··· 212 196 } else { 213 197 client = new NodeOAuthClient({ 214 198 clientMetadata: { 215 - client_id: `${config.publicUrl}/oauth/client-metadata.json?v=2`, 199 + client_id: `${config.publicUrl}/oauth/client-metadata.json?v=3`, 216 200 client_name: "Airglow", 217 201 client_uri: config.publicUrl, 218 202 redirect_uris: [`${config.publicUrl}/auth/callback`],