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.

feat: better oauth scope management

Hugo e8a3d669 111269ca

+58 -38
+2 -2
app/islands/DeliveryLog.tsx
··· 79 79 const data = await res.json(); 80 80 if (data.error === "scope_insufficient") { 81 81 const returnTo = window.location.pathname; 82 - window.location.href = `/auth/login?scope=full&returnTo=${encodeURIComponent(returnTo)}`; 82 + window.location.href = `/auth/login?scope=${encodeURIComponent(data.requiredScope)}&returnTo=${encodeURIComponent(returnTo)}`; 83 83 return; 84 84 } 85 85 setError(data.error || "Failed to update"); ··· 110 110 const data = await res.json(); 111 111 if (data.error === "scope_insufficient") { 112 112 const returnTo = window.location.pathname; 113 - window.location.href = `/auth/login?scope=full&returnTo=${encodeURIComponent(returnTo)}`; 113 + window.location.href = `/auth/login?scope=${encodeURIComponent(data.requiredScope)}&returnTo=${encodeURIComponent(returnTo)}`; 114 114 return; 115 115 } 116 116 setError(data.error || "Failed to update");
+1 -1
app/routes/api/automations/[rkey].test.ts
··· 52 52 const TEST_USER = { 53 53 did: "did:plc:testuser", 54 54 handle: "test.bsky.social", 55 - scope: "atproto transition:generic", 55 + scope: "atproto repo:run.airglow.automation", 56 56 }; 57 57 const TEST_AUTO = { 58 58 uri: "at://did:plc:testuser/run.airglow.automation/rk1",
+5 -8
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 { actionsRequireFullScope, scopeCoversFullWrite } from "@/auth/client.js"; 40 + import { scopeCoversActions, computeRequiredScope, mergeScopes } from "@/auth/client.js"; 41 41 42 42 function findAutomation(did: string, rkey: string) { 43 43 return db.query.automations.findFirst({ ··· 450 450 }); 451 451 } 452 452 453 - // Block disabling dry run when scope doesn't cover write actions 454 - if ( 455 - !dryRun && 456 - actionsRequireFullScope(localActions.map((a) => a.$type)) && 457 - !scopeCoversFullWrite(user.scope) 458 - ) { 459 - return c.json({ error: "scope_insufficient", requiredScope: "full" }, 403); 453 + // Block disabling dry run when scope doesn't cover action collections 454 + if (!dryRun && !scopeCoversActions(user.scope, localActions)) { 455 + const requiredScope = mergeScopes(user.scope, computeRequiredScope(localActions)); 456 + return c.json({ error: "scope_insufficient", requiredScope }, 403); 460 457 } 461 458 462 459 // Update on PDS
+1 -1
app/routes/api/automations/[rkey]/logs.test.ts
··· 25 25 const TEST_USER = { 26 26 did: "did:plc:testuser", 27 27 handle: "test.bsky.social", 28 - scope: "atproto transition:generic", 28 + scope: "atproto repo:run.airglow.automation", 29 29 }; 30 30 const TEST_AUTO = { 31 31 uri: "at://did:plc:testuser/run.airglow.automation/rk1",
+1 -1
app/routes/api/automations/index.test.ts
··· 51 51 const TEST_USER = { 52 52 did: "did:plc:testuser", 53 53 handle: "test.bsky.social", 54 - scope: "atproto transition:generic", 54 + scope: "atproto repo:run.airglow.automation", 55 55 }; 56 56 57 57 function createTestApp() {
+1 -1
app/routes/auth/callback.tsx
··· 87 87 maxAge: 60 * 60 * 24 * 30, // 30 days 88 88 }); 89 89 90 - const destination = returnTo?.startsWith("/") ? returnTo : "/dashboard"; 90 + const destination = returnTo && /^\/[^/]/.test(returnTo) ? returnTo : "/dashboard"; 91 91 return c.redirect(destination); 92 92 } catch (err) { 93 93 console.error("OAuth callback error:", err);
+2 -8
app/routes/auth/login.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { 3 - getOAuthClient, 4 - resolveHandle, 5 - rewritePdsUrl, 6 - OAUTH_SCOPE_BASIC, 7 - OAUTH_SCOPE_FULL, 8 - } from "@/auth/client.js"; 2 + import { getOAuthClient, resolveHandle, rewritePdsUrl, OAUTH_SCOPE_BASIC } from "@/auth/client.js"; 9 3 import { getSessionUser } from "@/auth/middleware.js"; 10 4 import { AppShell } from "../../components/Layout/AppShell/index.js"; 11 5 import { Header } from "../../components/Layout/Header/index.js"; ··· 100 94 const client = await getOAuthClient(); 101 95 102 96 const scopeParam = c.req.query("scope"); 103 - const scope = scopeParam === "full" ? OAUTH_SCOPE_FULL : OAUTH_SCOPE_BASIC; 97 + const scope = scopeParam ?? OAUTH_SCOPE_BASIC; 104 98 const returnTo = c.req.query("returnTo"); 105 99 const state = returnTo ? JSON.stringify({ handle: input, returnTo }) : input; 106 100
+6 -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 { actionsRequireFullScope, scopeCoversFullWrite } from "@/auth/client.js"; 5 + import { scopeCoversActions, computeRequiredScope, mergeScopes } 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"; ··· 56 56 const hasMore = rawLogs.length > 50; 57 57 const logs = rawLogs.slice(0, 50); 58 58 59 - const needsScopeUpgrade = 60 - actionsRequireFullScope(auto.actions.map((a) => a.$type)) && !scopeCoversFullWrite(user.scope); 59 + const needsScopeUpgrade = !scopeCoversActions(user.scope, auto.actions); 60 + const upgradeScope = needsScopeUpgrade 61 + ? mergeScopes(user.scope, computeRequiredScope(auto.actions)) 62 + : null; 61 63 62 64 return c.render( 63 65 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> ··· 93 95 This automation includes actions that write to your account. To disable dry run mode, 94 96 you need to{" "} 95 97 <a 96 - href={`/auth/login?scope=full&handle=${encodeURIComponent(user.handle)}&returnTo=${encodeURIComponent(`/dashboard/automations/${rkey}`)}`} 98 + href={`/auth/login?scope=${encodeURIComponent(upgradeScope!)}&handle=${encodeURIComponent(user.handle)}&returnTo=${encodeURIComponent(`/dashboard/automations/${rkey}`)}`} 97 99 > 98 100 re-authorize with broader permissions 99 101 </a>
+39 -12
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 transition:generic repo:run.airglow.automation"; 22 - /** Tier 1: minimal scope for sign-in and webhook-only automations. */ 21 + export const OAUTH_SCOPE_MAX = "atproto repo:*"; 22 + /** Default scope for sign-in — only run.airglow.automation records. */ 23 23 export const OAUTH_SCOPE_BASIC = "atproto repo:run.airglow.automation"; 24 - /** Tier 2: full write access for bsky-post, record, patch-record actions. */ 25 - export const OAUTH_SCOPE_FULL = OAUTH_SCOPE_MAX; 24 + 25 + type ActionLike = { $type: string; targetCollection?: string }; 26 26 27 - const FULL_SCOPE_ACTION_TYPES = new Set(["record", "bsky-post", "patch-record"]); 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(" ")}`; 51 + } 28 52 29 - /** Returns true if any action type requires full (transition:generic) scope. */ 30 - export function actionsRequireFullScope(actionTypes: string[]): boolean { 31 - return actionTypes.some((t) => FULL_SCOPE_ACTION_TYPES.has(t)); 53 + /** Check if the granted scope covers all collections needed by actions. */ 54 + 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)); 32 58 } 33 59 34 - /** Returns true if the granted scope covers full write access. */ 35 - export function scopeCoversFullWrite(scope: string | null): boolean { 36 - if (!scope) return false; 37 - return scope.split(" ").includes("transition:generic"); 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(" "); 38 65 } 39 66 40 67 const pdsUrl = config.pdsUrl;