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 flow

Hugo c3d85c53 d232ae75

+65 -10
+5
app/islands/AutomationForm.tsx
··· 2 2 import type { RecordSchema } from "../../lib/lexicons/schema-types.js"; 3 3 import { isRecordProducingAction, type Action, type FetchStep } from "../../lib/db/schema.js"; 4 4 import { ACTION_CATALOGUE, type AddableActionId } from "../../lib/automations/action-catalogue.js"; 5 + import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 5 6 import RecordFormBuilder from "./RecordFormBuilder.js"; 6 7 import { ActionHeader } from "../components/ActionHeader/index.js"; 7 8 import { actionIcon } from "../styles/action-header.css.ts"; ··· 998 999 body: previewPayload, 999 1000 }); 1000 1001 const data = await res.json(); 1002 + if (!res.ok && data.error === SCOPE_INSUFFICIENT) { 1003 + redirectToScopeUpgrade(data.requiredScope); 1004 + return; 1005 + } 1001 1006 if (!res.ok) { 1002 1007 setError(data.error || `Failed to ${isEdit ? "update" : "create"} automation`); 1003 1008 } else {
+5 -6
app/islands/DeliveryLog.tsx
··· 8 8 ChevronDown, 9 9 CircleAlert, 10 10 } from "../icons.js"; 11 + import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 11 12 import * as s from "./DeliveryLog.css.ts"; 12 13 import { variant as badgeVariant } from "../components/Badge/styles.css.ts"; 13 14 ··· 77 78 }); 78 79 if (!res.ok) { 79 80 const data = await res.json(); 80 - if (data.error === "scope_insufficient") { 81 - const returnTo = window.location.pathname; 82 - window.location.href = `/auth/login?scope=${encodeURIComponent(data.requiredScope)}&returnTo=${encodeURIComponent(returnTo)}`; 81 + if (data.error === SCOPE_INSUFFICIENT) { 82 + redirectToScopeUpgrade(data.requiredScope); 83 83 return; 84 84 } 85 85 setError(data.error || "Failed to update"); ··· 108 108 }); 109 109 if (!res.ok) { 110 110 const data = await res.json(); 111 - if (data.error === "scope_insufficient") { 112 - const returnTo = window.location.pathname; 113 - window.location.href = `/auth/login?scope=${encodeURIComponent(data.requiredScope)}&returnTo=${encodeURIComponent(returnTo)}`; 111 + if (data.error === SCOPE_INSUFFICIENT) { 112 + redirectToScopeUpgrade(data.requiredScope); 114 113 return; 115 114 } 116 115 setError(data.error || "Failed to update");
+2 -1
app/routes/api/automations/[rkey].ts
··· 41 41 import { AUTOMATION_LIMITS } from "@/automations/limits.js"; 42 42 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 43 43 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 44 + import { SCOPE_INSUFFICIENT } from "@/auth/scope-errors.js"; 44 45 45 46 function findAutomation(did: string, rkey: string) { 46 47 return db.query.automations.findFirst({ ··· 496 497 // Block disabling dry run when scope doesn't cover action collections 497 498 if (!dryRun && !scopeCoversActions(user.scope, localActions)) { 498 499 return c.json( 499 - { error: "scope_insufficient", requiredScope: computeRequiredScope(localActions) }, 500 + { error: SCOPE_INSUFFICIENT, requiredScope: computeRequiredScope(localActions) }, 500 501 403, 501 502 ); 502 503 }
+10
app/routes/api/automations/index.ts
··· 38 38 validateBookmarkInput, 39 39 } from "@/actions/validation.js"; 40 40 import { AUTOMATION_LIMITS } from "@/automations/limits.js"; 41 + import { computeRequiredScope, scopeCoversActions } from "@/auth/client.js"; 42 + import { SCOPE_INSUFFICIENT } from "@/auth/scope-errors.js"; 41 43 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 42 44 43 45 export const GET = createRoute(async (c) => { ··· 379 381 // Write record to PDS 380 382 const active = body.active !== false; 381 383 const dryRun = body.dryRun !== false; 384 + 385 + if (!dryRun && !scopeCoversActions(user.scope, localActions)) { 386 + return c.json( 387 + { error: SCOPE_INSUFFICIENT, requiredScope: computeRequiredScope(localActions) }, 388 + 403, 389 + ); 390 + } 391 + 382 392 const now = new Date(); 383 393 let uri: string; 384 394 let rkey: string;
+9 -1
app/routes/auth/login.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { 3 + computeScopeForDid, 3 4 getOAuthClient, 4 5 resolveHandle, 5 6 rewritePdsUrl, ··· 100 101 const client = await getOAuthClient(); 101 102 102 103 const scopeParam = c.req.query("scope"); 103 - const scope = scopeParam?.includes("transition:generic") ? OAUTH_SCOPE_FULL : OAUTH_SCOPE_BASIC; 104 + let scope: string; 105 + if (scopeParam?.includes("transition:generic")) { 106 + scope = OAUTH_SCOPE_FULL; 107 + } else if (scopeParam) { 108 + scope = OAUTH_SCOPE_BASIC; 109 + } else { 110 + scope = await computeScopeForDid(did); 111 + } 104 112 const returnTo = c.req.query("returnTo"); 105 113 const state = returnTo ? JSON.stringify({ handle: input, returnTo }) : input; 106 114
+15 -2
app/routes/auth/signout.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { deleteCookie } from "hono/cookie"; 2 + import { deleteCookie, getSignedCookie } from "hono/cookie"; 3 + import { getOAuthClient } from "@/auth/client.js"; 3 4 import { COOKIE_NAME } from "@/auth/middleware.js"; 5 + import { config } from "@/config.js"; 4 6 5 - export const POST = createRoute((c) => { 7 + export const POST = createRoute(async (c) => { 8 + const did = await getSignedCookie(c, config.cookieSecret, COOKIE_NAME); 9 + 10 + if (did) { 11 + try { 12 + const client = await getOAuthClient(); 13 + await client.revoke(did); 14 + } catch (err) { 15 + console.error("OAuth revoke failed during signout:", err); 16 + } 17 + } 18 + 6 19 deleteCookie(c, COOKIE_NAME, { path: "/" }); 7 20 return c.redirect("/"); 8 21 });
+11
lib/auth/client.ts
··· 8 8 const require = createRequire(import.meta.url); 9 9 const { JoseKey, NodeOAuthClient, requestLocalLock } = 10 10 require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node"); 11 + import { eq } from "drizzle-orm"; 11 12 import { config } from "../config.js"; 13 + import { db } from "../db/index.js"; 14 + import { automations } from "../db/schema.js"; 12 15 import { 13 16 resolveDidToHandle as pdsResolveDidToHandle, 14 17 resolveHandle as pdsResolveHandle, ··· 50 53 /** Compute the scope string needed for a set of actions (for display and re-auth). */ 51 54 export function computeRequiredScope(actions: ActionLike[]): string { 52 55 return actionsNeedFullScope(actions) ? OAUTH_SCOPE_FULL : OAUTH_SCOPE_BASIC; 56 + } 57 + 58 + /** Infer scope from the DID's existing automations; BASIC if none exist. */ 59 + export async function computeScopeForDid(did: string): Promise<string> { 60 + const rows = await db.query.automations.findMany({ where: eq(automations.did, did) }); 61 + const allActions = rows.flatMap((r) => r.actions); 62 + if (allActions.length === 0) return OAUTH_SCOPE_BASIC; 63 + return computeRequiredScope(allActions); 53 64 } 54 65 55 66 const pdsUrl = config.pdsUrl;
+8
lib/auth/scope-errors.ts
··· 1 + export const SCOPE_INSUFFICIENT = "scope_insufficient"; 2 + 3 + /** Client-side: redirect to /auth/login to upgrade scope, preserving the current URL. */ 4 + export function redirectToScopeUpgrade(requiredScope: string): void { 5 + const returnTo = window.location.pathname + window.location.search; 6 + const params = new URLSearchParams({ scope: requiredScope, returnTo }); 7 + window.location.href = `/auth/login?${params}`; 8 + }