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: various improvements

Hugo 62932f81 916f1dcc

+690 -103
+18 -14
app/routes/api/automations/[rkey].ts
··· 38 38 validateWebhookHeaders, 39 39 validateBookmarkInput, 40 40 } from "@/actions/validation.js"; 41 + import { AUTOMATION_LIMITS } from "@/automations/limits.js"; 41 42 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 42 43 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 43 44 ··· 123 124 if (!name || typeof name !== "string" || !name.trim()) { 124 125 return c.json({ error: "Name is required" }, 400); 125 126 } 126 - if (name.length > 128) { 127 - return c.json({ error: "Name must be 128 characters or less" }, 400); 127 + if (name.length > AUTOMATION_LIMITS.name) { 128 + return c.json({ error: `Name must be ${AUTOMATION_LIMITS.name} characters or less` }, 400); 128 129 } 129 130 const description = body.description !== undefined ? body.description : auto.description; 130 - if (description && description.length > 1024) { 131 - return c.json({ error: "Description must be 1024 characters or less" }, 400); 131 + if (description && description.length > AUTOMATION_LIMITS.description) { 132 + return c.json( 133 + { error: `Description must be ${AUTOMATION_LIMITS.description} characters or less` }, 134 + 400, 135 + ); 132 136 } 133 137 134 138 let operations = body.operations ?? auto.operations; 135 139 if (!Array.isArray(operations) || operations.length === 0) { 136 140 return c.json({ error: "At least one operation is required" }, 400); 137 141 } 138 - if (operations.length > 3) { 139 - return c.json({ error: "Maximum 3 operations allowed" }, 400); 142 + if (operations.length > AUTOMATION_LIMITS.operations) { 143 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.operations} operations allowed` }, 400); 140 144 } 141 145 operations = [...new Set(operations)]; 142 146 for (const op of operations) { ··· 158 162 ...(cond.comment ? { comment: cond.comment } : {}), 159 163 })) 160 164 : auto.conditions; 161 - if (conditions.length > 20) { 162 - return c.json({ error: "Maximum 20 conditions allowed" }, 400); 165 + if (conditions.length > AUTOMATION_LIMITS.conditions) { 166 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.conditions} conditions allowed` }, 400); 163 167 } 164 168 for (const cond of conditions) { 165 169 if (!VALID_OPERATORS.has(cond.operator)) { ··· 174 178 let pdsFetches: PdsFetchStep[] | null = null; 175 179 176 180 if (body.fetches) { 177 - if (body.fetches.length > 5) { 178 - return c.json({ error: "Maximum 5 fetch steps allowed" }, 400); 181 + if (body.fetches.length > AUTOMATION_LIMITS.fetches) { 182 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.fetches} fetch steps allowed` }, 400); 179 183 } 180 184 const newLocalFetches: FetchStep[] = []; 181 185 const newPdsFetches: PdsFetchStep[] = []; ··· 211 215 if (body.actions.length === 0) { 212 216 return c.json({ error: "At least one action is required" }, 400); 213 217 } 214 - if (body.actions.length > 10) { 215 - return c.json({ error: "Maximum 10 actions allowed" }, 400); 218 + if (body.actions.length > AUTOMATION_LIMITS.actions) { 219 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.actions} actions allowed` }, 400); 216 220 } 217 221 218 222 const newLocalActions: Action[] = []; ··· 307 311 if (!textValidation.valid) { 308 312 return c.json({ error: textValidation.error }, 400); 309 313 } 310 - if (input.langs && input.langs.length > 3) { 311 - return c.json({ error: "Maximum 3 languages allowed" }, 400); 314 + if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 315 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 312 316 } 313 317 if (input.langs?.some((l) => !BCP47_RE.test(l))) { 314 318 return c.json(
+2 -10
app/routes/api/automations/_middleware.ts
··· 1 - import { createMiddleware } from "hono/factory"; 2 - import { getSessionUser } from "@/auth/middleware.js"; 1 + import { requireApiAuth } from "@/auth/middleware.js"; 3 2 4 - export default [ 5 - createMiddleware(async (c, next) => { 6 - const user = await getSessionUser(c); 7 - if (!user) return c.json({ error: "Unauthorized" }, 401); 8 - c.set("user", user); 9 - return next(); 10 - }), 11 - ]; 3 + export default [requireApiAuth];
+23 -16
app/routes/api/automations/index.ts
··· 37 37 validateWebhookHeaders, 38 38 validateBookmarkInput, 39 39 } from "@/actions/validation.js"; 40 + import { AUTOMATION_LIMITS } from "@/automations/limits.js"; 40 41 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 41 42 42 43 export const GET = createRoute(async (c) => { ··· 89 90 if (!body.name || typeof body.name !== "string" || !body.name.trim()) { 90 91 return c.json({ error: "Name is required" }, 400); 91 92 } 92 - if (body.name.length > 128) { 93 - return c.json({ error: "Name must be 128 characters or less" }, 400); 93 + if (body.name.length > AUTOMATION_LIMITS.name) { 94 + return c.json({ error: `Name must be ${AUTOMATION_LIMITS.name} characters or less` }, 400); 94 95 } 95 - if (body.description && body.description.length > 1024) { 96 - return c.json({ error: "Description must be 1024 characters or less" }, 400); 96 + if (body.description && body.description.length > AUTOMATION_LIMITS.description) { 97 + return c.json( 98 + { error: `Description must be ${AUTOMATION_LIMITS.description} characters or less` }, 99 + 400, 100 + ); 97 101 } 98 102 99 103 // Validate lexicon NSID ··· 108 112 if (!Array.isArray(body.operations) || body.operations.length === 0) { 109 113 return c.json({ error: "At least one operation is required" }, 400); 110 114 } 111 - if (body.operations.length > 3) { 112 - return c.json({ error: "Maximum 3 operations allowed" }, 400); 115 + if (body.operations.length > AUTOMATION_LIMITS.operations) { 116 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.operations} operations allowed` }, 400); 113 117 } 114 118 const operations = [...new Set(body.operations)]; 115 119 for (const op of operations) { ··· 125 129 if (!body.actions || body.actions.length === 0) { 126 130 return c.json({ error: "At least one action is required" }, 400); 127 131 } 128 - if (body.actions.length > 10) { 129 - return c.json({ error: "Maximum 10 actions allowed" }, 400); 132 + if (body.actions.length > AUTOMATION_LIMITS.actions) { 133 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.actions} actions allowed` }, 400); 130 134 } 131 135 132 136 // Normalize and validate conditions ··· 139 143 value: cond.value, 140 144 ...(cond.comment ? { comment: cond.comment } : {}), 141 145 })); 142 - if (conditions.length > 20) { 143 - return c.json({ error: "Maximum 20 conditions allowed" }, 400); 146 + if (conditions.length > AUTOMATION_LIMITS.conditions) { 147 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.conditions} conditions allowed` }, 400); 144 148 } 145 149 for (const cond of conditions) { 146 150 if (!VALID_OPERATORS.has(cond.operator)) { ··· 153 157 const pdsFetches: PdsFetchStep[] = []; 154 158 155 159 if (body.fetches && body.fetches.length > 0) { 156 - if (body.fetches.length > 5) { 157 - return c.json({ error: "Maximum 5 fetch steps allowed" }, 400); 160 + if (body.fetches.length > AUTOMATION_LIMITS.fetches) { 161 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.fetches} fetch steps allowed` }, 400); 158 162 } 159 163 const seenNames = new Set<string>(); 160 164 for (const f of body.fetches) { ··· 261 265 if (!textValidation.valid) { 262 266 return c.json({ error: textValidation.error }, 400); 263 267 } 264 - if (input.langs && input.langs.length > 3) { 265 - return c.json({ error: "Maximum 3 languages allowed" }, 400); 268 + if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 269 + return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 266 270 } 267 271 if (input.langs?.some((l) => !BCP47_RE.test(l))) { 268 272 return c.json( ··· 420 424 // Rollback PDS record if local indexing fails 421 425 try { 422 426 await deleteRecord(user.did, rkey); 423 - } catch { 424 - /* best-effort */ 427 + } catch (rollbackErr) { 428 + console.error( 429 + `Failed to rollback PDS record ${rkey} after local index failure:`, 430 + rollbackErr, 431 + ); 425 432 } 426 433 console.error("Failed to index automation locally:", err); 427 434 return c.json({ error: "Failed to save automation" }, 500);
+2 -10
app/routes/api/secrets/_middleware.ts
··· 1 - import { createMiddleware } from "hono/factory"; 2 - import { getSessionUser } from "@/auth/middleware.js"; 1 + import { requireApiAuth } from "@/auth/middleware.js"; 3 2 4 - export default [ 5 - createMiddleware(async (c, next) => { 6 - const user = await getSessionUser(c); 7 - if (!user) return c.json({ error: "Unauthorized" }, 401); 8 - c.set("user", user); 9 - return next(); 10 - }), 11 - ]; 3 + export default [requireApiAuth];
+1 -1
lib/actions/bookmark.test.ts
··· 93 93 const target = record.target as { sourceHash: string }; 94 94 expect(target.sourceHash).toMatch(/^[a-f0-9]{64}$/); 95 95 const generator = record.generator as { name: string; homepage: string; id: string }; 96 - expect(generator.name).toBe("Airglow | Test Automation"); 96 + expect(generator.name).toBe("Airglow"); 97 97 expect(generator.homepage).toMatch(/^https?:\/\//); 98 98 expect(generator.id).toContain("/u/"); 99 99 expect(generator.id).toContain("/abc123");
+12 -14
lib/actions/validation.ts
··· 1 - import { SECRET_NAME_RE } from "../secrets/store.js"; 1 + import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js"; 2 + import { AUTOMATION_LIMITS, BOOKMARK_LIMITS } from "../automations/limits.js"; 2 3 import { validateTextTemplate } from "./template.js"; 3 4 4 5 export type ActionInput = ··· 46 47 ]); 47 48 48 49 const HEADER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/; 49 - const SECRET_REF_RE = /\{\{secret:([^}]+)\}\}/g; 50 50 const ANY_PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g; 51 51 52 52 /** Validate custom headers for a webhook action. */ ··· 55 55 ): { valid: true } | { valid: false; error: string } { 56 56 const entries = Object.entries(headers); 57 57 58 - if (entries.length > 20) { 59 - return { valid: false, error: "Maximum 20 custom headers allowed" }; 58 + if (entries.length > AUTOMATION_LIMITS.webhookHeaders) { 59 + return { 60 + valid: false, 61 + error: `Maximum ${AUTOMATION_LIMITS.webhookHeaders} custom headers allowed`, 62 + }; 60 63 } 61 64 62 65 for (const [key, value] of entries) { ··· 75 78 if (typeof value !== "string") { 76 79 return { valid: false, error: `Header "${key}" value must be a string` }; 77 80 } 78 - if (value.length > 2048) { 79 - return { valid: false, error: `Header "${key}" value must be 2048 chars or less` }; 81 + if (value.length > AUTOMATION_LIMITS.webhookHeaderValue) { 82 + return { 83 + valid: false, 84 + error: `Header "${key}" value must be ${AUTOMATION_LIMITS.webhookHeaderValue} chars or less`, 85 + }; 80 86 } 81 87 82 88 // Only {{secret:name}} references are allowed in headers — no other placeholders ··· 103 109 104 110 return { valid: true }; 105 111 } 106 - 107 - export const BOOKMARK_LIMITS = { 108 - targetSource: 2048, 109 - targetTitle: 500, 110 - bodyValue: 10000, 111 - tag: 64, 112 - maxTags: 10, 113 - } as const; 114 112 115 113 type BookmarkInput = { 116 114 targetSource: string;
+9
lib/auth/middleware.ts
··· 1 1 import type { Context, Next } from "hono"; 2 2 import { getSignedCookie } from "hono/cookie"; 3 + import { createMiddleware } from "hono/factory"; 3 4 import { eq } from "drizzle-orm"; 4 5 import { config } from "../config.js"; 5 6 import { db } from "../db/index.js"; ··· 34 35 c.set("user", user); 35 36 return next(); 36 37 } 38 + 39 + /** Middleware that requires authentication for API routes — returns 401 JSON instead of redirecting. */ 40 + export const requireApiAuth = createMiddleware(async (c, next) => { 41 + const user = await getSessionUser(c); 42 + if (!user) return c.json({ error: "Unauthorized" }, 401); 43 + c.set("user", user); 44 + return next(); 45 + });
+19
lib/automations/limits.ts
··· 1 + export const AUTOMATION_LIMITS = { 2 + name: 128, 3 + description: 1024, 4 + operations: 3, 5 + actions: 10, 6 + conditions: 20, 7 + fetches: 5, 8 + bskyLangs: 3, 9 + webhookHeaders: 20, 10 + webhookHeaderValue: 2048, 11 + } as const; 12 + 13 + export const BOOKMARK_LIMITS = { 14 + targetSource: 2048, 15 + targetTitle: 500, 16 + bodyValue: 10000, 17 + tag: 64, 18 + maxTags: 10, 19 + } as const;
+2
lib/db/migrations/0005_robust_invaders.sql
··· 1 + CREATE INDEX `automations_did_idx` ON `automations` (`did`);--> statement-breakpoint 2 + CREATE INDEX `delivery_logs_automation_uri_id_idx` ON `delivery_logs` (`automation_uri`,`id`);
+550
lib/db/migrations/meta/0005_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "19fc8f74-84f5-4519-9612-b206e36e8133", 5 + "prevId": "00a1b843-a8c9-42da-be5f-23dbc64261c9", 6 + "tables": { 7 + "automations": { 8 + "name": "automations", 9 + "columns": { 10 + "uri": { 11 + "name": "uri", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "rkey": { 25 + "name": "rkey", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "name": { 32 + "name": "name", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "description": { 39 + "name": "description", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "lexicon": { 46 + "name": "lexicon", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'[\"create\"]'" 59 + }, 60 + "actions": { 61 + "name": "actions", 62 + "type": "text", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false, 66 + "default": "'[]'" 67 + }, 68 + "fetches": { 69 + "name": "fetches", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "autoincrement": false, 74 + "default": "'[]'" 75 + }, 76 + "conditions": { 77 + "name": "conditions", 78 + "type": "text", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false, 82 + "default": "'[]'" 83 + }, 84 + "active": { 85 + "name": "active", 86 + "type": "integer", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false, 90 + "default": false 91 + }, 92 + "dry_run": { 93 + "name": "dry_run", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 100 + "indexed_at": { 101 + "name": "indexed_at", 102 + "type": "integer", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false 106 + } 107 + }, 108 + "indexes": { 109 + "automations_did_idx": { 110 + "name": "automations_did_idx", 111 + "columns": [ 112 + "did" 113 + ], 114 + "isUnique": false 115 + } 116 + }, 117 + "foreignKeys": {}, 118 + "compositePrimaryKeys": {}, 119 + "uniqueConstraints": {}, 120 + "checkConstraints": {} 121 + }, 122 + "delivery_logs": { 123 + "name": "delivery_logs", 124 + "columns": { 125 + "id": { 126 + "name": "id", 127 + "type": "integer", 128 + "primaryKey": true, 129 + "notNull": true, 130 + "autoincrement": true 131 + }, 132 + "automation_uri": { 133 + "name": "automation_uri", 134 + "type": "text", 135 + "primaryKey": false, 136 + "notNull": true, 137 + "autoincrement": false 138 + }, 139 + "action_index": { 140 + "name": "action_index", 141 + "type": "integer", 142 + "primaryKey": false, 143 + "notNull": true, 144 + "autoincrement": false, 145 + "default": 0 146 + }, 147 + "event_time_us": { 148 + "name": "event_time_us", 149 + "type": "integer", 150 + "primaryKey": false, 151 + "notNull": true, 152 + "autoincrement": false 153 + }, 154 + "payload": { 155 + "name": "payload", 156 + "type": "text", 157 + "primaryKey": false, 158 + "notNull": false, 159 + "autoincrement": false 160 + }, 161 + "status_code": { 162 + "name": "status_code", 163 + "type": "integer", 164 + "primaryKey": false, 165 + "notNull": false, 166 + "autoincrement": false 167 + }, 168 + "message": { 169 + "name": "message", 170 + "type": "text", 171 + "primaryKey": false, 172 + "notNull": false, 173 + "autoincrement": false 174 + }, 175 + "error": { 176 + "name": "error", 177 + "type": "text", 178 + "primaryKey": false, 179 + "notNull": false, 180 + "autoincrement": false 181 + }, 182 + "dry_run": { 183 + "name": "dry_run", 184 + "type": "integer", 185 + "primaryKey": false, 186 + "notNull": true, 187 + "autoincrement": false, 188 + "default": false 189 + }, 190 + "attempt": { 191 + "name": "attempt", 192 + "type": "integer", 193 + "primaryKey": false, 194 + "notNull": true, 195 + "autoincrement": false, 196 + "default": 1 197 + }, 198 + "created_at": { 199 + "name": "created_at", 200 + "type": "integer", 201 + "primaryKey": false, 202 + "notNull": true, 203 + "autoincrement": false 204 + } 205 + }, 206 + "indexes": { 207 + "delivery_logs_automation_uri_id_idx": { 208 + "name": "delivery_logs_automation_uri_id_idx", 209 + "columns": [ 210 + "automation_uri", 211 + "id" 212 + ], 213 + "isUnique": false 214 + } 215 + }, 216 + "foreignKeys": { 217 + "delivery_logs_automation_uri_automations_uri_fk": { 218 + "name": "delivery_logs_automation_uri_automations_uri_fk", 219 + "tableFrom": "delivery_logs", 220 + "tableTo": "automations", 221 + "columnsFrom": [ 222 + "automation_uri" 223 + ], 224 + "columnsTo": [ 225 + "uri" 226 + ], 227 + "onDelete": "cascade", 228 + "onUpdate": "no action" 229 + } 230 + }, 231 + "compositePrimaryKeys": {}, 232 + "uniqueConstraints": {}, 233 + "checkConstraints": {} 234 + }, 235 + "favicon_cache": { 236 + "name": "favicon_cache", 237 + "columns": { 238 + "domain": { 239 + "name": "domain", 240 + "type": "text", 241 + "primaryKey": true, 242 + "notNull": true, 243 + "autoincrement": false 244 + }, 245 + "data": { 246 + "name": "data", 247 + "type": "text", 248 + "primaryKey": false, 249 + "notNull": true, 250 + "autoincrement": false 251 + }, 252 + "content_type": { 253 + "name": "content_type", 254 + "type": "text", 255 + "primaryKey": false, 256 + "notNull": true, 257 + "autoincrement": false 258 + }, 259 + "fetched_at": { 260 + "name": "fetched_at", 261 + "type": "integer", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + } 266 + }, 267 + "indexes": {}, 268 + "foreignKeys": {}, 269 + "compositePrimaryKeys": {}, 270 + "uniqueConstraints": {}, 271 + "checkConstraints": {} 272 + }, 273 + "lexicon_cache": { 274 + "name": "lexicon_cache", 275 + "columns": { 276 + "nsid": { 277 + "name": "nsid", 278 + "type": "text", 279 + "primaryKey": true, 280 + "notNull": true, 281 + "autoincrement": false 282 + }, 283 + "schema": { 284 + "name": "schema", 285 + "type": "text", 286 + "primaryKey": false, 287 + "notNull": true, 288 + "autoincrement": false 289 + }, 290 + "fetched_at": { 291 + "name": "fetched_at", 292 + "type": "integer", 293 + "primaryKey": false, 294 + "notNull": true, 295 + "autoincrement": false 296 + } 297 + }, 298 + "indexes": {}, 299 + "foreignKeys": {}, 300 + "compositePrimaryKeys": {}, 301 + "uniqueConstraints": {}, 302 + "checkConstraints": {} 303 + }, 304 + "oauth_sessions": { 305 + "name": "oauth_sessions", 306 + "columns": { 307 + "key": { 308 + "name": "key", 309 + "type": "text", 310 + "primaryKey": true, 311 + "notNull": true, 312 + "autoincrement": false 313 + }, 314 + "value": { 315 + "name": "value", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true, 319 + "autoincrement": false 320 + }, 321 + "expires_at": { 322 + "name": "expires_at", 323 + "type": "integer", 324 + "primaryKey": false, 325 + "notNull": false, 326 + "autoincrement": false 327 + } 328 + }, 329 + "indexes": {}, 330 + "foreignKeys": {}, 331 + "compositePrimaryKeys": {}, 332 + "uniqueConstraints": {}, 333 + "checkConstraints": {} 334 + }, 335 + "oauth_states": { 336 + "name": "oauth_states", 337 + "columns": { 338 + "key": { 339 + "name": "key", 340 + "type": "text", 341 + "primaryKey": true, 342 + "notNull": true, 343 + "autoincrement": false 344 + }, 345 + "value": { 346 + "name": "value", 347 + "type": "text", 348 + "primaryKey": false, 349 + "notNull": true, 350 + "autoincrement": false 351 + }, 352 + "expires_at": { 353 + "name": "expires_at", 354 + "type": "integer", 355 + "primaryKey": false, 356 + "notNull": false, 357 + "autoincrement": false 358 + } 359 + }, 360 + "indexes": {}, 361 + "foreignKeys": {}, 362 + "compositePrimaryKeys": {}, 363 + "uniqueConstraints": {}, 364 + "checkConstraints": {} 365 + }, 366 + "secret_events": { 367 + "name": "secret_events", 368 + "columns": { 369 + "id": { 370 + "name": "id", 371 + "type": "integer", 372 + "primaryKey": true, 373 + "notNull": true, 374 + "autoincrement": true 375 + }, 376 + "did": { 377 + "name": "did", 378 + "type": "text", 379 + "primaryKey": false, 380 + "notNull": true, 381 + "autoincrement": false 382 + }, 383 + "name": { 384 + "name": "name", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true, 388 + "autoincrement": false 389 + }, 390 + "action": { 391 + "name": "action", 392 + "type": "text", 393 + "primaryKey": false, 394 + "notNull": true, 395 + "autoincrement": false 396 + }, 397 + "created_at": { 398 + "name": "created_at", 399 + "type": "integer", 400 + "primaryKey": false, 401 + "notNull": true, 402 + "autoincrement": false 403 + } 404 + }, 405 + "indexes": {}, 406 + "foreignKeys": {}, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "checkConstraints": {} 410 + }, 411 + "user_secrets": { 412 + "name": "user_secrets", 413 + "columns": { 414 + "id": { 415 + "name": "id", 416 + "type": "integer", 417 + "primaryKey": true, 418 + "notNull": true, 419 + "autoincrement": true 420 + }, 421 + "did": { 422 + "name": "did", 423 + "type": "text", 424 + "primaryKey": false, 425 + "notNull": true, 426 + "autoincrement": false 427 + }, 428 + "name": { 429 + "name": "name", 430 + "type": "text", 431 + "primaryKey": false, 432 + "notNull": true, 433 + "autoincrement": false 434 + }, 435 + "encrypted_value": { 436 + "name": "encrypted_value", 437 + "type": "blob", 438 + "primaryKey": false, 439 + "notNull": true, 440 + "autoincrement": false 441 + }, 442 + "created_at": { 443 + "name": "created_at", 444 + "type": "integer", 445 + "primaryKey": false, 446 + "notNull": true, 447 + "autoincrement": false 448 + }, 449 + "updated_at": { 450 + "name": "updated_at", 451 + "type": "integer", 452 + "primaryKey": false, 453 + "notNull": true, 454 + "autoincrement": false 455 + } 456 + }, 457 + "indexes": { 458 + "user_secrets_did_name_unique": { 459 + "name": "user_secrets_did_name_unique", 460 + "columns": [ 461 + "did", 462 + "name" 463 + ], 464 + "isUnique": true 465 + } 466 + }, 467 + "foreignKeys": { 468 + "user_secrets_did_users_did_fk": { 469 + "name": "user_secrets_did_users_did_fk", 470 + "tableFrom": "user_secrets", 471 + "tableTo": "users", 472 + "columnsFrom": [ 473 + "did" 474 + ], 475 + "columnsTo": [ 476 + "did" 477 + ], 478 + "onDelete": "cascade", 479 + "onUpdate": "no action" 480 + } 481 + }, 482 + "compositePrimaryKeys": {}, 483 + "uniqueConstraints": {}, 484 + "checkConstraints": {} 485 + }, 486 + "users": { 487 + "name": "users", 488 + "columns": { 489 + "id": { 490 + "name": "id", 491 + "type": "integer", 492 + "primaryKey": true, 493 + "notNull": true, 494 + "autoincrement": true 495 + }, 496 + "did": { 497 + "name": "did", 498 + "type": "text", 499 + "primaryKey": false, 500 + "notNull": true, 501 + "autoincrement": false 502 + }, 503 + "handle": { 504 + "name": "handle", 505 + "type": "text", 506 + "primaryKey": false, 507 + "notNull": true, 508 + "autoincrement": false 509 + }, 510 + "scope": { 511 + "name": "scope", 512 + "type": "text", 513 + "primaryKey": false, 514 + "notNull": false, 515 + "autoincrement": false 516 + }, 517 + "created_at": { 518 + "name": "created_at", 519 + "type": "integer", 520 + "primaryKey": false, 521 + "notNull": true, 522 + "autoincrement": false 523 + } 524 + }, 525 + "indexes": { 526 + "users_did_unique": { 527 + "name": "users_did_unique", 528 + "columns": [ 529 + "did" 530 + ], 531 + "isUnique": true 532 + } 533 + }, 534 + "foreignKeys": {}, 535 + "compositePrimaryKeys": {}, 536 + "uniqueConstraints": {}, 537 + "checkConstraints": {} 538 + } 539 + }, 540 + "views": {}, 541 + "enums": {}, 542 + "_meta": { 543 + "schemas": {}, 544 + "tables": {}, 545 + "columns": {} 546 + }, 547 + "internal": { 548 + "indexes": {} 549 + } 550 + }
+7
lib/db/migrations/meta/_journal.json
··· 36 36 "when": 1776417811155, 37 37 "tag": "0004_certain_marvel_boy", 38 38 "breakpoints": true 39 + }, 40 + { 41 + "idx": 5, 42 + "version": "6", 43 + "when": 1776761204110, 44 + "tag": "0005_robust_invaders", 45 + "breakpoints": true 39 46 } 40 47 ] 41 48 }
+42 -34
lib/db/schema.ts
··· 1 - import { sqliteTable, text, integer, blob, uniqueIndex } from "drizzle-orm/sqlite-core"; 1 + import { sqliteTable, text, integer, blob, index, uniqueIndex } from "drizzle-orm/sqlite-core"; 2 2 3 3 export const users = sqliteTable("users", { 4 4 id: integer("id").primaryKey({ autoIncrement: true }), ··· 71 71 72 72 // Local index of run.airglow.automation records living on user PDS. 73 73 // Source of truth is the PDS; this is a cache for fast Jetstream matching. 74 - export const automations = sqliteTable("automations", { 75 - uri: text("uri").primaryKey(), // at://did/run.airglow.automation/rkey 76 - did: text("did").notNull(), 77 - rkey: text("rkey").notNull(), 78 - name: text("name").notNull(), 79 - description: text("description"), 80 - lexicon: text("lexicon").notNull(), // NSID being watched 81 - operations: text("operation", { mode: "json" }).notNull().$type<string[]>().default(["create"]), 82 - actions: text("actions", { mode: "json" }).notNull().$type<Action[]>().default([]), 83 - fetches: text("fetches", { mode: "json" }).notNull().$type<FetchStep[]>().default([]), 84 - conditions: text("conditions", { mode: "json" }) 85 - .notNull() 86 - .$type<Array<{ field: string; operator: string; value: string; comment?: string }>>() 87 - .default([]), 88 - active: integer("active", { mode: "boolean" }).notNull().default(false), 89 - dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 90 - indexedAt: integer("indexed_at", { mode: "timestamp_ms" }).notNull(), 91 - }); 74 + export const automations = sqliteTable( 75 + "automations", 76 + { 77 + uri: text("uri").primaryKey(), // at://did/run.airglow.automation/rkey 78 + did: text("did").notNull(), 79 + rkey: text("rkey").notNull(), 80 + name: text("name").notNull(), 81 + description: text("description"), 82 + lexicon: text("lexicon").notNull(), // NSID being watched 83 + operations: text("operation", { mode: "json" }).notNull().$type<string[]>().default(["create"]), 84 + actions: text("actions", { mode: "json" }).notNull().$type<Action[]>().default([]), 85 + fetches: text("fetches", { mode: "json" }).notNull().$type<FetchStep[]>().default([]), 86 + conditions: text("conditions", { mode: "json" }) 87 + .notNull() 88 + .$type<Array<{ field: string; operator: string; value: string; comment?: string }>>() 89 + .default([]), 90 + active: integer("active", { mode: "boolean" }).notNull().default(false), 91 + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 92 + indexedAt: integer("indexed_at", { mode: "timestamp_ms" }).notNull(), 93 + }, 94 + (table) => [index("automations_did_idx").on(table.did)], 95 + ); 92 96 93 - export const deliveryLogs = sqliteTable("delivery_logs", { 94 - id: integer("id").primaryKey({ autoIncrement: true }), 95 - automationUri: text("automation_uri") 96 - .notNull() 97 - .references(() => automations.uri, { onDelete: "cascade" }), 98 - actionIndex: integer("action_index").notNull().default(0), 99 - eventTimeUs: integer("event_time_us").notNull(), 100 - payload: text("payload"), // JSON, stored for failed deliveries and dry runs 101 - statusCode: integer("status_code"), 102 - message: text("message"), 103 - error: text("error"), 104 - dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 105 - attempt: integer("attempt").notNull().default(1), 106 - createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), 107 - }); 97 + export const deliveryLogs = sqliteTable( 98 + "delivery_logs", 99 + { 100 + id: integer("id").primaryKey({ autoIncrement: true }), 101 + automationUri: text("automation_uri") 102 + .notNull() 103 + .references(() => automations.uri, { onDelete: "cascade" }), 104 + actionIndex: integer("action_index").notNull().default(0), 105 + eventTimeUs: integer("event_time_us").notNull(), 106 + payload: text("payload"), // JSON, stored for failed deliveries and dry runs 107 + statusCode: integer("status_code"), 108 + message: text("message"), // human-readable summary for dry-run log entries 109 + error: text("error"), 110 + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 111 + attempt: integer("attempt").notNull().default(1), 112 + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), 113 + }, 114 + (table) => [index("delivery_logs_automation_uri_id_idx").on(table.automationUri, table.id)], 115 + ); 108 116 109 117 export const oauthSessions = sqliteTable("oauth_sessions", { 110 118 key: text("key").primaryKey(),
+1
lib/secrets/store.ts
··· 5 5 import { logSecretEvent } from "./audit.js"; 6 6 7 7 export const SECRET_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; 8 + export const SECRET_REF_RE = /\{\{secret:([^}]+)\}\}/g; 8 9 const MAX_SECRETS_PER_USER = 50; 9 10 const MAX_VALUE_BYTES = 8192; 10 11
+2 -4
lib/webhooks/dispatcher.ts
··· 2 2 import { sign } from "./signer.js"; 3 3 import { assertPublicUrl } from "../url-guard.js"; 4 4 import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "../actions/delivery.js"; 5 - import { resolve as resolveSecrets } from "../secrets/store.js"; 5 + import { resolve as resolveSecrets, SECRET_REF_RE } from "../secrets/store.js"; 6 6 import { config } from "../config.js"; 7 7 import type { ActionResult } from "../actions/executor.js"; 8 8 import type { MatchedEvent } from "../jetstream/consumer.js"; 9 9 import type { FetchContext } from "../actions/template.js"; 10 - 11 - const SECRET_REF_RE = /\{\{secret:([^}]+)\}\}/g; 12 10 13 11 type WebhookPayload = { 14 12 automation: string; ··· 82 80 }); 83 81 return { statusCode: res.status }; 84 82 } catch (err) { 85 - console.error(`Webhook delivery error to ${callbackUrl} for ${automationUri}`); 83 + console.error(`Webhook delivery error to ${callbackUrl} for ${automationUri}:`, err); 86 84 return { statusCode: 0, error: String(err) }; 87 85 } 88 86 }