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.

refactor: review project

Hugo 0f4be8e5 55e57513

+230 -302
+8 -1
app/islands/AutomationForm.css.ts
··· 308 308 export const actionTitle = style({ 309 309 fontSize: fontSize.sm, 310 310 fontWeight: fontWeight.medium, 311 - fontFamily: "monospace", 312 311 color: vars.color.text, 313 312 }); 314 313 ··· 321 320 322 321 export const addActionsRow = style({ 323 322 display: "flex", 323 + flexWrap: "wrap", 324 324 gap: space[2], 325 + }); 326 + 327 + export const addActionBtnDesc = style({ 328 + display: "block", 329 + fontSize: fontSize.xs, 330 + fontWeight: fontWeight.normal, 331 + color: vars.color.textMuted, 325 332 }); 326 333 327 334 export const fetchRow = style({
+35 -23
app/islands/AutomationForm.tsx
··· 1 1 import { useState, useCallback, useRef, useMemo, useEffect } from "hono/jsx"; 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 + import { actionTypeLabels } from "../../lib/automations/labels.js"; 4 5 import RecordFormBuilder from "./RecordFormBuilder.js"; 5 6 import * as s from "./AutomationForm.css.ts"; 6 7 ··· 898 899 </div> 899 900 900 901 <div class={s.fieldGroup}> 901 - <span class={s.label}>Operations</span> 902 + <span class={s.label}>Trigger events</span> 902 903 <div class={s.operationCheckboxes}> 903 - {(["create", "update", "delete"] as const).map((op) => ( 904 + {( 905 + [ 906 + ["create", "Created"], 907 + ["update", "Updated"], 908 + ["delete", "Deleted"], 909 + ] as const 910 + ).map(([op, label]) => ( 904 911 <label key={op} class={s.checkboxLabel}> 905 912 <input 906 913 type="checkbox" ··· 912 919 ) 913 920 } 914 921 /> 915 - {op} 922 + {label} 916 923 </label> 917 924 ))} 918 925 </div> 919 - <span class={s.hint}>Types of commit events this automation responds to</span> 926 + <span class={s.hint}>Run this automation when a record is...</span> 920 927 </div> 921 928 922 929 {allPlaceholders.length > 0 && ( ··· 1020 1027 <div> 1021 1028 <h3>Conditions</h3> 1022 1029 <p class={s.hint}> 1023 - Filter events by field values. All conditions must match (AND). Use{" "} 1024 - <code>{"{{self}}"}</code> as a value to match your own DID. 1030 + Only run this automation when specific conditions are met. All conditions must 1031 + match. Use <code>{"{{self}}"}</code> to match your own account. 1025 1032 </p> 1026 1033 </div> 1027 1034 {conditions.map((cond, i) => ( ··· 1138 1145 {NSID_RE.test(lexicon) && ( 1139 1146 <div class={s.conditionsSection}> 1140 1147 <div> 1141 - <h3>Data Sources</h3> 1148 + <h3> 1149 + Data Sources <span class={s.hint}>(advanced)</span> 1150 + </h3> 1142 1151 <p class={s.hint}> 1143 - Fetch related PDS records before executing actions. Fetched data is available as 1144 - named variables in action templates. 1152 + Load additional records before running actions. For example, fetch the original 1153 + post when processing a like or repost. Loaded data can be used in action templates 1154 + as placeholders. 1145 1155 </p> 1146 1156 </div> 1147 1157 {fetches.map((f, i) => ( ··· 1198 1208 </p> 1199 1209 </div> 1200 1210 1211 + {actions.length === 0 && ( 1212 + <p class={s.hint}>Choose what happens when a matching event is detected.</p> 1213 + )} 1214 + 1201 1215 {actions.map((action, i) => ( 1202 1216 <div key={i} class={s.actionCard}> 1203 1217 <div class={s.actionHeader}> 1204 1218 <span class={s.actionTitle}> 1205 - action{i} 1219 + Action {i + 1} 1206 1220 <span class={s.actionSubtitle}> 1207 - {action.type === "webhook" 1208 - ? "webhook" 1209 - : action.type === "bsky-post" 1210 - ? "bluesky post" 1211 - : action.type === "patch-record" 1212 - ? "patch record" 1213 - : "record"}{" "} 1221 + {actionTypeLabels[action.type] ?? action.type}{" "} 1214 1222 {actions.filter((a, j) => a.type === action.type && j <= i).length} 1215 1223 </span> 1216 1224 </span> ··· 1252 1260 1253 1261 <div class={s.addActionsRow}> 1254 1262 <button type="button" class={s.addBtn} onClick={() => addAction("webhook")}> 1255 - + Add webhook 1256 - </button> 1257 - <button type="button" class={s.addBtn} onClick={() => addAction("record")}> 1258 - + Add record action 1263 + + Send a Webhook 1264 + <span class={s.addActionBtnDesc}>Send event data to an external URL</span> 1259 1265 </button> 1260 1266 <button type="button" class={s.addBtn} onClick={() => addAction("bsky-post")}> 1261 - + Add Bluesky post 1267 + + Post on Bluesky 1268 + <span class={s.addActionBtnDesc}>Publish a post to your Bluesky account</span> 1269 + </button> 1270 + <button type="button" class={s.addBtn} onClick={() => addAction("record")}> 1271 + + Create a Record 1272 + <span class={s.addActionBtnDesc}>Create a new record in any collection</span> 1262 1273 </button> 1263 1274 <button type="button" class={s.addBtn} onClick={() => addAction("patch-record")}> 1264 - + Patch record 1275 + + Update a Record 1276 + <span class={s.addActionBtnDesc}>Modify an existing record</span> 1265 1277 </button> 1266 1278 </div> 1267 1279 </div>
-13
app/islands/Counter.tsx
··· 1 - import { useState } from "hono/jsx"; 2 - 3 - export default function Counter() { 4 - const [count, setCount] = useState(0); 5 - return ( 6 - <div> 7 - <p>Count: {count}</p> 8 - <button type="button" onClick={() => setCount(count + 1)}> 9 - Increment 10 - </button> 11 - </div> 12 - ); 13 - }
+11
app/islands/DeliveryLog.css.ts
··· 177 177 color: vars.color.error, 178 178 }); 179 179 180 + export const alertSuccess = style({ 181 + paddingBlock: space[3], 182 + paddingInline: space[4], 183 + borderRadius: radii.md, 184 + fontSize: fontSize.sm, 185 + borderInlineStart: "3px solid", 186 + backgroundColor: vars.color.successSubtle, 187 + color: vars.color.success, 188 + borderColor: vars.color.success, 189 + }); 190 + 180 191 export const loadMoreWrapper = style({ 181 192 display: "flex", 182 193 justifyContent: "center",
+18 -2
app/islands/DeliveryLog.tsx
··· 29 29 dryRun: boolean; 30 30 initialLogs: LogEntry[]; 31 31 hasMore: boolean; 32 + actionLabels: string[]; 32 33 }; 33 34 34 35 function statusBadgeFor(active: boolean, dryRun: boolean) { ··· 43 44 dryRun, 44 45 initialLogs, 45 46 hasMore: initialHasMore, 47 + actionLabels, 46 48 }: Props) { 47 49 const [isActive, setIsActive] = useState(active); 48 50 const [isDryRun, setIsDryRun] = useState(dryRun); ··· 52 54 const [loadingMore, setLoadingMore] = useState(false); 53 55 const [hasMore, setHasMore] = useState(initialHasMore); 54 56 const [error, setError] = useState(""); 57 + const [success, setSuccess] = useState(""); 55 58 const logsRef = useRef(logs); 56 59 logsRef.current = logs; 57 60 ··· 79 82 const newActive = !isActive; 80 83 setIsActive(newActive); 81 84 updateStatusBadges(newActive, isDryRun); 85 + setSuccess(newActive ? "Automation activated" : "Automation deactivated"); 86 + setTimeout(() => setSuccess(""), 3000); 82 87 } 83 88 } catch { 84 89 setError("Request failed"); ··· 103 108 const newDryRun = !isDryRun; 104 109 setIsDryRun(newDryRun); 105 110 updateStatusBadges(isActive, newDryRun); 111 + setSuccess(newDryRun ? "Dry run enabled" : "Dry run disabled"); 112 + setTimeout(() => setSuccess(""), 3000); 106 113 } 107 114 } catch { 108 115 setError("Request failed"); ··· 169 176 <button type="button" class={s.toggleBtn} onClick={toggleActive} disabled={loading}> 170 177 <Power size={14} /> {isActive ? "Deactivate" : "Activate"} 171 178 </button> 172 - <button type="button" class={s.toggleBtn} onClick={toggleDryRun} disabled={loading}> 179 + <button 180 + type="button" 181 + class={s.toggleBtn} 182 + onClick={toggleDryRun} 183 + disabled={loading} 184 + title="Test mode — logs what would happen without actually running actions" 185 + > 173 186 <FlaskConical size={14} /> {isDryRun ? "Disable Dry Run" : "Enable Dry Run"} 174 187 </button> 175 188 <button type="button" class={s.deleteBtn} onClick={handleDelete} disabled={loading}> ··· 178 191 </div> 179 192 180 193 {error && <div class={s.alertError}>{error}</div>} 194 + {success && <div class={s.alertSuccess}>{success}</div>} 181 195 182 196 <div class={s.logsHeader}> 183 197 <h3>Delivery Logs</h3> ··· 237 251 {new Date(log.createdAt).toLocaleString()} 238 252 </span> 239 253 </td> 240 - <td class={s.td}>{log.actionIndex + 1}</td> 254 + <td class={s.td}> 255 + {actionLabels[log.actionIndex] ?? `Action ${log.actionIndex + 1}`} 256 + </td> 241 257 <td class={s.td}> 242 258 {log.dryRun ? "dry run" : (log.statusCode ?? "\u2014")} 243 259 </td>
+7 -23
app/routes/api/automations/[rkey].ts
··· 28 28 validateBaseRecordUri, 29 29 validateFetchStep, 30 30 } from "@/actions/template.js"; 31 + import { 32 + type ActionInput, 33 + VALID_OPERATORS, 34 + VALID_OPERATIONS, 35 + VALID_BSKY_LABELS, 36 + BCP47_RE, 37 + } from "@/actions/validation.js"; 31 38 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 32 - 33 - type ActionInput = 34 - | { type: "webhook"; callbackUrl: string; comment?: string } 35 - | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 36 - | { 37 - type: "bsky-post"; 38 - textTemplate: string; 39 - langs?: string[]; 40 - labels?: string[]; 41 - comment?: string; 42 - } 43 - | { 44 - type: "patch-record"; 45 - targetCollection: string; 46 - baseRecordUri: string; 47 - recordTemplate: string; 48 - comment?: string; 49 - }; 50 - 51 - const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 52 - const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 53 - const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 54 - const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 55 39 56 40 function findAutomation(did: string, rkey: string) { 57 41 return db.query.automations.findFirst({
+7 -23
app/routes/api/automations/index.ts
··· 27 27 validateBaseRecordUri, 28 28 validateFetchStep, 29 29 } from "@/actions/template.js"; 30 + import { 31 + type ActionInput, 32 + VALID_OPERATORS, 33 + VALID_OPERATIONS, 34 + VALID_BSKY_LABELS, 35 + BCP47_RE, 36 + } from "@/actions/validation.js"; 30 37 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 31 - 32 - type ActionInput = 33 - | { type: "webhook"; callbackUrl: string; comment?: string } 34 - | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 35 - | { 36 - type: "bsky-post"; 37 - textTemplate: string; 38 - langs?: string[]; 39 - labels?: string[]; 40 - comment?: string; 41 - } 42 - | { 43 - type: "patch-record"; 44 - targetCollection: string; 45 - baseRecordUri: string; 46 - recordTemplate: string; 47 - comment?: string; 48 - }; 49 - 50 - const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 51 - const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 52 - const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 53 - const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 54 38 55 39 export const GET = createRoute(async (c) => { 56 40 const user = c.get("user");
+22 -29
app/routes/dashboard/automations/[rkey].tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq, and, desc } from "drizzle-orm"; 3 3 import { ArrowLeft, Copy, ExternalLink, Pencil, Filter, Database, Zap } from "../../../icons.js"; 4 + import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 4 5 import { db } from "@/db/index.js"; 5 6 import { automations, deliveryLogs } from "@/db/schema.js"; 6 7 import { AppShell } from "../../../components/Layout/AppShell/index.js"; ··· 94 95 {auto.operations.map((op, i) => ( 95 96 <> 96 97 {i > 0 && ", "} 97 - <InlineCode>{op}</InlineCode> 98 + {operationLabels[op] ?? op} 98 99 </> 99 100 ))} 100 101 </dd> ··· 106 107 </Badge> 107 108 </span> 108 109 </dd> 109 - <dt>AT URI</dt> 110 - <dd> 111 - <InlineCode>{auto.uri}</InlineCode> 112 - </dd> 113 110 </DescriptionList> 111 + <details> 112 + <summary class={textMuted}>Technical details</summary> 113 + <DescriptionList> 114 + <dt>AT URI</dt> 115 + <dd> 116 + <InlineCode>{auto.uri}</InlineCode> 117 + </dd> 118 + </DescriptionList> 119 + </details> 114 120 </Card> 115 121 116 122 {auto.conditions.length > 0 && ( ··· 120 126 <Filter size={18} /> Conditions 121 127 </h3> 122 128 <ul class={plainList}> 123 - {auto.conditions.map((cond, i) => { 124 - const opLabels: Record<string, string> = { 125 - eq: "equals", 126 - startsWith: "starts with", 127 - endsWith: "ends with", 128 - contains: "contains", 129 - }; 130 - return ( 131 - <li key={i}> 132 - <InlineCode>{cond.field}</InlineCode>{" "} 133 - {opLabels[cond.operator] ?? cond.operator}{" "} 134 - <InlineCode>{cond.value}</InlineCode> 135 - {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 136 - </li> 137 - ); 138 - })} 129 + {auto.conditions.map((cond, i) => ( 130 + <li key={i}> 131 + <InlineCode>{cond.field}</InlineCode>{" "} 132 + {opLabels[cond.operator] ?? cond.operator}{" "} 133 + <InlineCode>{cond.value}</InlineCode> 134 + {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 135 + </li> 136 + ))} 139 137 </ul> 140 138 </Stack> 141 139 </Card> ··· 167 165 <Card key={i} variant="flat"> 168 166 <Stack gap={3}> 169 167 <h4> 170 - <code>action{i}</code>{" "} 168 + Action {i + 1}{" "} 171 169 <span class={textMuted}> 172 - {action.$type === "webhook" 173 - ? "webhook" 174 - : action.$type === "bsky-post" 175 - ? "bluesky post" 176 - : action.$type === "patch-record" 177 - ? "patch record" 178 - : "record"}{" "} 170 + {actionTypeLabels[action.$type] ?? action.$type}{" "} 179 171 {auto.actions.filter((a, j) => a.$type === action.$type && j <= i).length} 180 172 </span> 181 173 {action.$type === "webhook" && ( ··· 257 249 active={auto.active} 258 250 dryRun={auto.dryRun} 259 251 hasMore={hasMore} 252 + actionLabels={auto.actions.map((a) => actionTypeLabels[a.$type] ?? a.$type)} 260 253 initialLogs={logs.map((l) => ({ 261 254 id: l.id, 262 255 actionIndex: l.actionIndex,
+14 -1
app/routes/dashboard/index.tsx
··· 13 13 import { Card } from "../../components/Card/index.js"; 14 14 import { InlineCode } from "../../components/CodeBlock/index.js"; 15 15 import { NsidCode } from "../../components/NsidCode/index.js"; 16 + import { actionTypeLabels } from "@/automations/labels.js"; 16 17 import ThemeToggle from "../../islands/ThemeToggle.js"; 17 18 import { centerTextSm } from "../../styles/utilities.css.js"; 18 19 ··· 76 77 ))} 77 78 </td> 78 79 <td> 79 - {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 80 + {Object.entries( 81 + auto.actions.reduce<Record<string, number>>((acc, a) => { 82 + const label = actionTypeLabels[a.$type] ?? a.$type; 83 + acc[label] = (acc[label] ?? 0) + 1; 84 + return acc; 85 + }, {}), 86 + ).map(([label, count], i) => ( 87 + <> 88 + {i > 0 && ", "} 89 + {count} {label.toLowerCase()} 90 + {count !== 1 ? "s" : ""} 91 + </> 92 + ))} 80 93 </td> 81 94 <td> 82 95 <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}>
+12 -25
app/routes/u/[handle]/[rkey].tsx
··· 3 3 import { ArrowLeft, Copy, Filter, Database, Zap } from "../../../icons.js"; 4 4 import { getSessionUser } from "@/auth/middleware.js"; 5 5 import { resolveHandle } from "@/auth/client.js"; 6 + import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 6 7 import { db } from "@/db/index.js"; 7 8 import { users, automations } from "@/db/schema.js"; 8 9 import { sanitizeActions } from "@/automations/sanitize.js"; ··· 139 140 {auto.operations.map((op, i) => ( 140 141 <> 141 142 {i > 0 && ", "} 142 - <InlineCode>{op}</InlineCode> 143 + {operationLabels[op] ?? op} 143 144 </> 144 145 ))} 145 146 </dd> ··· 157 158 <Filter size={18} /> Conditions 158 159 </h3> 159 160 <ul class={plainList}> 160 - {auto.conditions.map((cond, i) => { 161 - const opLabels: Record<string, string> = { 162 - eq: "equals", 163 - startsWith: "starts with", 164 - endsWith: "ends with", 165 - contains: "contains", 166 - }; 167 - return ( 168 - <li key={i}> 169 - <InlineCode>{cond.field}</InlineCode>{" "} 170 - {opLabels[cond.operator] ?? cond.operator}{" "} 171 - <InlineCode>{cond.value}</InlineCode> 172 - {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 173 - </li> 174 - ); 175 - })} 161 + {auto.conditions.map((cond, i) => ( 162 + <li key={i}> 163 + <InlineCode>{cond.field}</InlineCode>{" "} 164 + {opLabels[cond.operator] ?? cond.operator}{" "} 165 + <InlineCode>{cond.value}</InlineCode> 166 + {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 167 + </li> 168 + ))} 176 169 </ul> 177 170 </Stack> 178 171 </Card> ··· 204 197 <Card key={i} variant="flat"> 205 198 <Stack gap={3}> 206 199 <h4> 207 - <code>action{i}</code>{" "} 200 + Action {i + 1}{" "} 208 201 <span class={textMuted}> 209 - {action.$type === "webhook" 210 - ? "webhook" 211 - : action.$type === "bsky-post" 212 - ? "bluesky post" 213 - : action.$type === "patch-record" 214 - ? "patch record" 215 - : "record"}{" "} 202 + {actionTypeLabels[action.$type] ?? action.$type}{" "} 216 203 {auto.actions.filter((a, j) => a.$type === action.$type && j <= i).length} 217 204 </span> 218 205 {action.$type === "webhook" && (
+2 -32
lib/actions/bsky-post.ts
··· 1 - import { db } from "../db/index.js"; 2 - import { deliveryLogs, type BskyPostAction } from "../db/schema.js"; 1 + import { type BskyPostAction } from "../db/schema.js"; 3 2 import { createArbitraryRecord } from "../automations/pds.js"; 4 3 import { renderTextTemplate, type FetchContext } from "./template.js"; 5 4 import { detectFacets } from "./richtext.js"; 5 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 6 6 import type { ActionResult } from "./executor.js"; 7 7 import type { MatchedEvent } from "../jetstream/consumer.js"; 8 8 9 9 const TARGET_COLLECTION = "app.bsky.feed.post"; 10 - const RETRY_DELAYS = [5_000, 30_000]; 11 10 12 11 async function execute( 13 12 match: MatchedEvent, ··· 63 62 const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 64 63 return { statusCode, error: message }; 65 64 } 66 - } 67 - 68 - async function logDelivery( 69 - automationUri: string, 70 - actionIndex: number, 71 - eventTimeUs: number, 72 - payload: string | null, 73 - statusCode: number, 74 - error: string | null, 75 - attempt: number, 76 - ) { 77 - await db.insert(deliveryLogs).values({ 78 - automationUri, 79 - actionIndex, 80 - eventTimeUs, 81 - payload, 82 - statusCode, 83 - error, 84 - attempt, 85 - createdAt: new Date(), 86 - }); 87 - } 88 - 89 - function isSuccess(code: number): boolean { 90 - return code === 200; 91 - } 92 - 93 - function isRetryable(code: number): boolean { 94 - return code >= 500 || code === 0; 95 65 } 96 66 97 67 function scheduleRetry(
+33
lib/actions/delivery.ts
··· 1 + import { db } from "../db/index.js"; 2 + import { deliveryLogs } from "../db/schema.js"; 3 + 4 + export const RETRY_DELAYS = [5_000, 30_000]; 5 + 6 + export function isSuccess(code: number): boolean { 7 + return code >= 200 && code < 300; 8 + } 9 + 10 + export function isRetryable(code: number): boolean { 11 + return code >= 500 || code === 0; 12 + } 13 + 14 + export async function logDelivery( 15 + automationUri: string, 16 + actionIndex: number, 17 + eventTimeUs: number, 18 + payload: string | null, 19 + statusCode: number, 20 + error: string | null, 21 + attempt: number, 22 + ) { 23 + await db.insert(deliveryLogs).values({ 24 + automationUri, 25 + actionIndex, 26 + eventTimeUs, 27 + payload, 28 + statusCode, 29 + error, 30 + attempt, 31 + createdAt: new Date(), 32 + }); 33 + }
+2 -33
lib/actions/executor.ts
··· 1 - import { db } from "../db/index.js"; 2 - import { deliveryLogs, type RecordAction } from "../db/schema.js"; 1 + import { type RecordAction } from "../db/schema.js"; 3 2 import { createArbitraryRecord } from "../automations/pds.js"; 4 3 import { renderTemplate, type FetchContext } from "./template.js"; 4 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 6 7 7 /** Result returned by every action executor for chaining. */ ··· 13 13 /** CID of the created/updated record (record-producing actions only). */ 14 14 cid?: string; 15 15 }; 16 - 17 - const RETRY_DELAYS = [5_000, 30_000]; 18 16 19 17 async function execute( 20 18 match: MatchedEvent, ··· 42 40 const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 43 41 return { statusCode, error: message }; 44 42 } 45 - } 46 - 47 - async function logDelivery( 48 - automationUri: string, 49 - actionIndex: number, 50 - eventTimeUs: number, 51 - payload: string | null, 52 - statusCode: number, 53 - error: string | null, 54 - attempt: number, 55 - ) { 56 - await db.insert(deliveryLogs).values({ 57 - automationUri, 58 - actionIndex, 59 - eventTimeUs, 60 - payload, 61 - statusCode, 62 - error, 63 - attempt, 64 - createdAt: new Date(), 65 - }); 66 - } 67 - 68 - function isSuccess(code: number): boolean { 69 - return code === 200; 70 - } 71 - 72 - function isRetryable(code: number): boolean { 73 - return code >= 500 || code === 0; 74 43 } 75 44 76 45 function scheduleRetry(
+2 -33
lib/actions/patch-record.ts
··· 1 - import { db } from "../db/index.js"; 2 - import { deliveryLogs, type PatchRecordAction } from "../db/schema.js"; 1 + import { type PatchRecordAction } from "../db/schema.js"; 3 2 import { patchArbitraryRecord } from "../automations/pds.js"; 4 3 import { fetchRecord, parseAtUri } from "../pds/resolver.js"; 5 4 import { renderTemplate, resolveUriTemplate, type FetchContext } from "./template.js"; 5 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 6 6 import type { ActionResult } from "./executor.js"; 7 7 import type { MatchedEvent } from "../jetstream/consumer.js"; 8 - 9 - const RETRY_DELAYS = [5_000, 30_000]; 10 8 11 9 async function execute( 12 10 match: MatchedEvent, ··· 65 63 const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 66 64 return { statusCode, error: message }; 67 65 } 68 - } 69 - 70 - async function logDelivery( 71 - automationUri: string, 72 - actionIndex: number, 73 - eventTimeUs: number, 74 - payload: string | null, 75 - statusCode: number, 76 - error: string | null, 77 - attempt: number, 78 - ) { 79 - await db.insert(deliveryLogs).values({ 80 - automationUri, 81 - actionIndex, 82 - eventTimeUs, 83 - payload, 84 - statusCode, 85 - error, 86 - attempt, 87 - createdAt: new Date(), 88 - }); 89 - } 90 - 91 - function isSuccess(code: number): boolean { 92 - return code === 200; 93 - } 94 - 95 - function isRetryable(code: number): boolean { 96 - return code >= 500 || code === 0; 97 66 } 98 67 99 68 function scheduleRetry(
+22
lib/actions/validation.ts
··· 1 + export type ActionInput = 2 + | { type: "webhook"; callbackUrl: string; comment?: string } 3 + | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 4 + | { 5 + type: "bsky-post"; 6 + textTemplate: string; 7 + langs?: string[]; 8 + labels?: string[]; 9 + comment?: string; 10 + } 11 + | { 12 + type: "patch-record"; 13 + targetCollection: string; 14 + baseRecordUri: string; 15 + recordTemplate: string; 16 + comment?: string; 17 + }; 18 + 19 + export const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 20 + export const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 21 + export const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 22 + export const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
+19
lib/automations/labels.ts
··· 1 + export const opLabels: Record<string, string> = { 2 + eq: "equals", 3 + startsWith: "starts with", 4 + endsWith: "ends with", 5 + contains: "contains", 6 + }; 7 + 8 + export const actionTypeLabels: Record<string, string> = { 9 + webhook: "Webhook", 10 + record: "Create Record", 11 + "bsky-post": "Bluesky Post", 12 + "patch-record": "Update Record", 13 + }; 14 + 15 + export const operationLabels: Record<string, string> = { 16 + create: "Record created", 17 + update: "Record updated", 18 + delete: "Record deleted", 19 + };
+1 -30
lib/favicon.ts
··· 1 1 import { eq } from "drizzle-orm"; 2 2 import { db } from "./db/index.js"; 3 3 import { faviconCache } from "./db/schema.js"; 4 + import { isPrivateIP } from "./url-guard.js"; 4 5 5 6 const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 6 7 const NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day for "no favicon" entries 7 8 const FETCH_TIMEOUT = 3000; 8 9 const MAX_SIZE = 100 * 1024; // 100 KB 9 - 10 - /** Check whether an IPv4 or IPv6 address is in a private/reserved range. */ 11 - function isPrivateIP(ip: string): boolean { 12 - // IPv4-mapped IPv6 — extract the v4 part 13 - if (ip.startsWith("::ffff:")) return isPrivateIP(ip.slice(7)); 14 - 15 - // IPv4 16 - const v4 = ip.split("."); 17 - if (v4.length === 4) { 18 - const [a, b] = [Number(v4[0]), Number(v4[1])]; 19 - if (a === 10) return true; // 10.0.0.0/8 20 - if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 21 - if (a === 192 && b === 168) return true; // 192.168.0.0/16 22 - if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata) 23 - if (a === 127) return true; // 127.0.0.0/8 24 - if (a === 0) return true; // 0.0.0.0/8 25 - if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (CGN / Tailscale) 26 - if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (benchmarking) 27 - if (a >= 240) return true; // 240.0.0.0/4 (reserved) 28 - return false; 29 - } 30 - 31 - // IPv6 32 - const normalized = ip.toLowerCase(); 33 - if (normalized === "::1") return true; // loopback 34 - if (normalized === "::") return true; 35 - if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // ULA 36 - if (normalized.startsWith("fe80")) return true; // link-local 37 - return false; 38 - } 39 10 40 11 /** 41 12 * Resolve a domain (A + AAAA), reject private IPs, return a safe address.
+10
lib/url-guard.test.ts
··· 106 106 resolveAs([], ["fd12::1"]); 107 107 await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError); 108 108 }); 109 + 110 + it("rejects CGN/Tailscale IP 100.64.x.x", async () => { 111 + resolveAs(["100.64.0.1"]); 112 + await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError); 113 + }); 114 + 115 + it("rejects benchmarking IP 198.18.x.x", async () => { 116 + resolveAs(["198.18.0.1"]); 117 + await expect(assertPublicUrl("https://evil.com/hook")).rejects.toThrow(UrlGuardError); 118 + }); 109 119 });
+3 -1
lib/url-guard.ts
··· 58 58 } 59 59 60 60 /** Check if an IP address or hostname falls within private/reserved ranges. */ 61 - function isPrivateIP(ip: string): boolean { 61 + export function isPrivateIP(ip: string): boolean { 62 62 // IPv4 63 63 if (ip.includes(".")) { 64 64 const parts = ip.split(".").map(Number); ··· 71 71 a === 0 || // 0.0.0.0/8 — current network 72 72 a === 10 || // 10.0.0.0/8 73 73 a === 127 || // 127.0.0.0/8 — loopback 74 + (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 — CGN / Tailscale 74 75 (a === 169 && b === 254) || // 169.254.0.0/16 — link-local / cloud metadata 75 76 (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 76 77 (a === 192 && b === 168) || // 192.168.0.0/16 78 + (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 — benchmarking 77 79 a >= 224 // 224+ — multicast & reserved 78 80 ); 79 81 }
+2 -33
lib/webhooks/dispatcher.ts
··· 1 - import { db } from "../db/index.js"; 2 - import { deliveryLogs, type WebhookAction } from "../db/schema.js"; 1 + import { type WebhookAction } from "../db/schema.js"; 3 2 import { sign } from "./signer.js"; 4 3 import { assertPublicUrl } from "../url-guard.js"; 4 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "../actions/delivery.js"; 5 5 import type { ActionResult } from "../actions/executor.js"; 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 7 import type { FetchContext } from "../actions/template.js"; 8 - 9 - const RETRY_DELAYS = [5_000, 30_000]; // 1st retry after 5s, 2nd after 30s 10 8 11 9 type WebhookPayload = { 12 10 automation: string; ··· 81 79 console.error(`Request body was:`, body); 82 80 return { statusCode: 0, error: String(err) }; 83 81 } 84 - } 85 - 86 - async function logDelivery( 87 - automationUri: string, 88 - actionIndex: number, 89 - eventTimeUs: number, 90 - payload: string | null, 91 - statusCode: number, 92 - error: string | null, 93 - attempt: number, 94 - ) { 95 - await db.insert(deliveryLogs).values({ 96 - automationUri, 97 - actionIndex, 98 - eventTimeUs, 99 - payload, 100 - statusCode, 101 - error, 102 - attempt, 103 - createdAt: new Date(), 104 - }); 105 - } 106 - 107 - function isSuccess(code: number): boolean { 108 - return code >= 200 && code < 300; 109 - } 110 - 111 - function isRetryable(code: number): boolean { 112 - return code >= 500 || code === 0; 113 82 } 114 83 115 84 function scheduleRetry(