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: collapse all action dispatchers through the registry

Hugo 96251806 a3f175d3

+998 -990
+21 -281
app/routes/api/automations/[rkey].ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq, and, desc } from "drizzle-orm"; 3 - import { nanoid } from "nanoid"; 4 3 import { db } from "@/db/index.js"; 5 - import { 6 - automations, 7 - deliveryLogs, 8 - type Action, 9 - type WebhookAction, 10 - type RecordAction, 11 - type BskyPostAction, 12 - type PatchRecordAction, 13 - type MarginBookmarkAction, 14 - type FollowAction, 15 - } from "@/db/schema.js"; 4 + import { automations, deliveryLogs, type Action } from "@/db/schema.js"; 16 5 import { ACTION_REGISTRY } from "@/actions/registry.js"; 17 6 import { config } from "@/config.js"; 18 - import { isValidNsid } from "@/lexicons/resolver.js"; 19 7 import { getRecord, putRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; 20 8 import { toPdsAction } from "@/automations/pds-serialize.js"; 21 9 import { verifyCallback } from "@/automations/verify.js"; 22 - import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 23 - import { 24 - validateTemplate, 25 - validateTextTemplate, 26 - validateBaseRecordUri, 27 - } from "@/actions/template.js"; 10 + import { assertPublicUrl } from "@/url-guard.js"; 28 11 import { 29 12 type ActionInput, 30 13 VALID_OPERATIONS, 31 - VALID_BSKY_LABELS, 32 - BCP47_RE, 33 - validateWebhookHeaders, 34 - validateMarginBookmarkInput, 35 - validateFollowInput, 36 14 validateForEachInput, 37 15 resolveWantedDids, 38 16 } from "@/actions/validation.js"; ··· 100 78 description: auto.description, 101 79 lexicon: auto.lexicon, 102 80 operations: auto.operations, 103 - actions: auto.actions.map((a) => 104 - a.$type === "webhook" 105 - ? { 106 - $type: a.$type, 107 - callbackUrl: a.callbackUrl, 108 - ...(a.headers ? { headers: a.headers } : {}), 109 - verified: a.verified ?? false, 110 - comment: a.comment, 111 - ...(a.forEach ? { forEach: a.forEach } : {}), 112 - } 113 - : a, 114 - ), 81 + actions: auto.actions.map((a) => ACTION_REGISTRY[a.$type].serializeForApi?.(a) ?? a), 115 82 fetches: auto.fetches, 116 83 conditions: auto.conditions, 117 84 wantedDids: auto.wantedDids, ··· 245 212 const hasItem = forEach !== undefined; 246 213 const forEachField = forEach ? { forEach } : {}; 247 214 248 - if (input.type === "webhook") { 249 - if (!input.callbackUrl) { 250 - return c.json({ error: "callbackUrl is required for webhook actions" }, 400); 251 - } 252 - try { 253 - await assertPublicUrl(input.callbackUrl); 254 - } catch (err) { 255 - const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 256 - return c.json({ error: message }, 400); 257 - } 258 - 259 - // Validate custom headers if provided 260 - if (input.headers && Object.keys(input.headers).length > 0) { 261 - const headersValidation = validateWebhookHeaders(input.headers); 262 - if (!headersValidation.valid) { 263 - return c.json({ error: headersValidation.error }, 400); 264 - } 265 - } 266 - 267 - // Verify callback (non-blocking — stores verified status) 268 - const verification = await verifyCallback(input.callbackUrl, auto.lexicon); 269 - 270 - // Preserve existing secret if callbackUrl unchanged 271 - const existing = auto.actions.find( 272 - (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl, 273 - ); 274 - const secret = existing?.secret ?? nanoid(32); 275 - const headers = 276 - input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 277 - 278 - newLocalActions.push({ 279 - $type: "webhook", 280 - callbackUrl: input.callbackUrl, 281 - secret, 282 - ...(headers ? { headers } : {}), 283 - verified: verification.ok, 284 - ...forEachField, 285 - ...(input.comment ? { comment: input.comment } : {}), 286 - } satisfies WebhookAction); 287 - newPdsActions.push({ 288 - $type: "run.airglow.automation#webhookAction", 289 - callbackUrl: input.callbackUrl, 290 - ...forEachField, 291 - ...(input.comment ? { comment: input.comment } : {}), 292 - }); 293 - } else if (input.type === "record") { 294 - if (!input.targetCollection) { 295 - return c.json({ error: "targetCollection is required for record actions" }, 400); 296 - } 297 - if (!isValidNsid(input.targetCollection)) { 298 - return c.json({ error: "Invalid target collection NSID" }, 400); 299 - } 300 - if (!input.recordTemplate) { 301 - return c.json({ error: "recordTemplate is required for record actions" }, 400); 302 - } 303 - const templateValidation = validateTemplate( 304 - input.recordTemplate, 305 - fetchNames, 306 - actionResultNames, 307 - hasItem, 308 - ); 309 - if (!templateValidation.valid) { 310 - return c.json({ error: templateValidation.error }, 400); 311 - } 312 - 313 - newLocalActions.push({ 314 - $type: "record", 315 - targetCollection: input.targetCollection, 316 - recordTemplate: input.recordTemplate, 317 - ...forEachField, 318 - ...(input.comment ? { comment: input.comment } : {}), 319 - } satisfies RecordAction); 320 - newPdsActions.push({ 321 - $type: "run.airglow.automation#recordAction", 322 - targetCollection: input.targetCollection, 323 - recordTemplate: input.recordTemplate, 324 - ...forEachField, 325 - ...(input.comment ? { comment: input.comment } : {}), 326 - }); 327 - actionResultNames.push(`action${actionIndex + 1}`); 328 - } else if (input.type === "bsky-post") { 329 - if (!input.textTemplate || !input.textTemplate.trim()) { 330 - return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 331 - } 332 - const textValidation = validateTextTemplate( 333 - input.textTemplate, 334 - fetchNames, 335 - actionResultNames, 336 - hasItem, 337 - ); 338 - if (!textValidation.valid) { 339 - return c.json({ error: textValidation.error }, 400); 340 - } 341 - if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 342 - return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 343 - } 344 - if (input.langs?.some((l) => !BCP47_RE.test(l))) { 345 - return c.json( 346 - { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 347 - 400, 348 - ); 349 - } 350 - if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 351 - return c.json( 352 - { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 353 - 400, 354 - ); 355 - } 356 - 357 - const langs = input.langs?.filter(Boolean); 358 - const labels = input.labels?.filter(Boolean); 359 - 360 - newLocalActions.push({ 361 - $type: "bsky-post", 362 - textTemplate: input.textTemplate, 363 - ...(langs && langs.length > 0 ? { langs } : {}), 364 - ...(labels && labels.length > 0 ? { labels } : {}), 365 - ...forEachField, 366 - ...(input.comment ? { comment: input.comment } : {}), 367 - } satisfies BskyPostAction); 368 - newPdsActions.push({ 369 - $type: "run.airglow.automation#bskyPostAction", 370 - textTemplate: input.textTemplate, 371 - ...(langs && langs.length > 0 ? { langs } : {}), 372 - ...(labels && labels.length > 0 ? { labels } : {}), 373 - ...forEachField, 374 - ...(input.comment ? { comment: input.comment } : {}), 375 - }); 376 - actionResultNames.push(`action${actionIndex + 1}`); 377 - } else if (input.type === "patch-record") { 378 - if (!input.targetCollection) { 379 - return c.json({ error: "targetCollection is required for patch-record actions" }, 400); 380 - } 381 - if (!isValidNsid(input.targetCollection)) { 382 - return c.json({ error: "Invalid target collection NSID" }, 400); 383 - } 384 - if (!input.baseRecordUri) { 385 - return c.json({ error: "baseRecordUri is required for patch-record actions" }, 400); 386 - } 387 - const uriValidation = validateBaseRecordUri( 388 - input.baseRecordUri, 389 - fetchNames, 390 - actionResultNames, 391 - hasItem, 392 - ); 393 - if (!uriValidation.valid) { 394 - return c.json({ error: uriValidation.error }, 400); 395 - } 396 - if (!input.recordTemplate) { 397 - return c.json({ error: "recordTemplate is required for patch-record actions" }, 400); 398 - } 399 - const templateValidation = validateTemplate( 400 - input.recordTemplate, 401 - fetchNames, 402 - actionResultNames, 403 - hasItem, 404 - ); 405 - if (!templateValidation.valid) { 406 - return c.json({ error: templateValidation.error }, 400); 407 - } 408 - 409 - newLocalActions.push({ 410 - $type: "patch-record", 411 - targetCollection: input.targetCollection, 412 - baseRecordUri: input.baseRecordUri, 413 - recordTemplate: input.recordTemplate, 414 - ...forEachField, 415 - ...(input.comment ? { comment: input.comment } : {}), 416 - } satisfies PatchRecordAction); 417 - newPdsActions.push({ 418 - $type: "run.airglow.automation#patchRecordAction", 419 - targetCollection: input.targetCollection, 420 - baseRecordUri: input.baseRecordUri, 421 - recordTemplate: input.recordTemplate, 422 - ...forEachField, 423 - ...(input.comment ? { comment: input.comment } : {}), 424 - }); 425 - actionResultNames.push(`action${actionIndex + 1}`); 426 - } else if (input.type === "margin-bookmark") { 427 - const bookmarkValidation = validateMarginBookmarkInput( 428 - input, 429 - fetchNames, 430 - actionResultNames, 431 - hasItem, 432 - ); 433 - if (!bookmarkValidation.valid) { 434 - return c.json({ error: bookmarkValidation.error }, 400); 435 - } 436 - 437 - const bodyValue = input.bodyValue?.trim() || undefined; 438 - const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 439 - 440 - newLocalActions.push({ 441 - $type: "margin-bookmark", 442 - targetSource: input.targetSource, 443 - ...(bodyValue ? { bodyValue } : {}), 444 - ...(tags ? { tags } : {}), 445 - ...forEachField, 446 - ...(input.comment ? { comment: input.comment } : {}), 447 - } satisfies MarginBookmarkAction); 448 - newPdsActions.push({ 449 - $type: "run.airglow.automation#marginBookmarkAction", 450 - targetSource: input.targetSource, 451 - ...(bodyValue ? { bodyValue } : {}), 452 - ...(tags ? { tags } : {}), 453 - ...forEachField, 454 - ...(input.comment ? { comment: input.comment } : {}), 455 - }); 456 - actionResultNames.push(`action${actionIndex + 1}`); 457 - } else if (input.type === "follow") { 458 - const followValidation = validateFollowInput(input, fetchNames, actionResultNames, hasItem); 459 - if (!followValidation.valid) { 460 - return c.json({ error: followValidation.error }, 400); 461 - } 462 - 463 - newLocalActions.push({ 464 - $type: "follow", 465 - target: input.target, 466 - subject: input.subject, 467 - ...forEachField, 468 - ...(input.comment ? { comment: input.comment } : {}), 469 - } satisfies FollowAction); 470 - newPdsActions.push({ 471 - $type: "run.airglow.automation#followAction", 472 - target: input.target, 473 - subject: input.subject, 474 - ...forEachField, 475 - ...(input.comment ? { comment: input.comment } : {}), 476 - }); 477 - actionResultNames.push(`action${actionIndex + 1}`); 478 - } else if (ACTION_REGISTRY[input.type]) { 479 - const def = ACTION_REGISTRY[input.type]!; 480 - const r = await def.validate(input, { fetchNames, actionResultNames, hasItem }); 481 - if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 482 - const local = { 483 - ...r.local, 484 - ...forEachField, 485 - ...(input.comment ? { comment: input.comment } : {}), 486 - }; 487 - newLocalActions.push(local); 488 - newPdsActions.push(def.toPds(local)); 489 - if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 490 - } else { 491 - return c.json({ error: "Invalid action type" }, 400); 492 - } 215 + const def = ACTION_REGISTRY[input.type]; 216 + if (!def) return c.json({ error: "Invalid action type" }, 400); 217 + const r = await def.validate(input, { 218 + fetchNames, 219 + actionResultNames, 220 + hasItem, 221 + lexicon: auto.lexicon, 222 + existingActions: auto.actions, 223 + }); 224 + if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 225 + const local = { 226 + ...r.local, 227 + ...forEachField, 228 + ...(input.comment ? { comment: input.comment } : {}), 229 + }; 230 + newLocalActions.push(local); 231 + newPdsActions.push(def.toPds(local)); 232 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 493 233 } 494 234 495 235 localActions = newLocalActions;
+20 -274
app/routes/api/automations/index.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq } from "drizzle-orm"; 3 - import { nanoid } from "nanoid"; 4 3 import { db } from "@/db/index.js"; 5 - import { 6 - automations, 7 - type Action, 8 - type WebhookAction, 9 - type RecordAction, 10 - type BskyPostAction, 11 - type PatchRecordAction, 12 - type MarginBookmarkAction, 13 - type FollowAction, 14 - } from "@/db/schema.js"; 4 + import { automations, type Action } from "@/db/schema.js"; 15 5 import { ACTION_REGISTRY } from "@/actions/registry.js"; 16 6 import { config } from "@/config.js"; 17 7 import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 18 - import { verifyCallback } from "@/automations/verify.js"; 19 - import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 20 8 import { createRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; 21 - import { 22 - validateTemplate, 23 - validateTextTemplate, 24 - validateBaseRecordUri, 25 - } from "@/actions/template.js"; 26 9 import { 27 10 type ActionInput, 28 11 VALID_OPERATIONS, 29 - VALID_BSKY_LABELS, 30 - BCP47_RE, 31 - validateWebhookHeaders, 32 - validateMarginBookmarkInput, 33 - validateFollowInput, 34 12 validateForEachInput, 35 13 resolveWantedDids, 36 14 } from "@/actions/validation.js"; ··· 58 36 description: r.description, 59 37 lexicon: r.lexicon, 60 38 operations: r.operations, 61 - actions: r.actions.map((a) => 62 - a.$type === "webhook" 63 - ? { 64 - $type: a.$type, 65 - callbackUrl: a.callbackUrl, 66 - ...(a.headers ? { headers: a.headers } : {}), 67 - verified: a.verified ?? false, 68 - comment: a.comment, 69 - ...(a.forEach ? { forEach: a.forEach } : {}), 70 - } 71 - : a, 72 - ), 39 + actions: r.actions.map((a) => ACTION_REGISTRY[a.$type].serializeForApi?.(a) ?? a), 73 40 fetches: r.fetches, 74 41 conditions: r.conditions, 75 42 wantedDids: r.wantedDids, ··· 168 135 const hasItem = forEach !== undefined; 169 136 const forEachField = forEach ? { forEach } : {}; 170 137 171 - if (input.type === "webhook") { 172 - if (!input.callbackUrl) { 173 - return c.json({ error: "callbackUrl is required for webhook actions" }, 400); 174 - } 175 - try { 176 - await assertPublicUrl(input.callbackUrl); 177 - } catch (err) { 178 - const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 179 - return c.json({ error: message }, 400); 180 - } 181 - 182 - // Validate custom headers if provided 183 - if (input.headers && Object.keys(input.headers).length > 0) { 184 - const headersValidation = validateWebhookHeaders(input.headers); 185 - if (!headersValidation.valid) { 186 - return c.json({ error: headersValidation.error }, 400); 187 - } 188 - } 189 - 190 - const verification = await verifyCallback(input.callbackUrl, body.lexicon); 191 - 192 - const secret = nanoid(32); 193 - const headers = 194 - input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 195 - localActions.push({ 196 - $type: "webhook", 197 - callbackUrl: input.callbackUrl, 198 - secret, 199 - ...(headers ? { headers } : {}), 200 - verified: verification.ok, 201 - ...forEachField, 202 - ...(input.comment ? { comment: input.comment } : {}), 203 - } satisfies WebhookAction); 204 - pdsActions.push({ 205 - $type: "run.airglow.automation#webhookAction", 206 - callbackUrl: input.callbackUrl, 207 - ...forEachField, 208 - ...(input.comment ? { comment: input.comment } : {}), 209 - }); 210 - } else if (input.type === "record") { 211 - if (!input.targetCollection) { 212 - return c.json({ error: "targetCollection is required for record actions" }, 400); 213 - } 214 - if (!isValidNsid(input.targetCollection)) { 215 - return c.json({ error: "Invalid target collection NSID" }, 400); 216 - } 217 - if (!input.recordTemplate) { 218 - return c.json({ error: "recordTemplate is required for record actions" }, 400); 219 - } 220 - const templateValidation = validateTemplate( 221 - input.recordTemplate, 222 - fetchNames, 223 - actionResultNames, 224 - hasItem, 225 - ); 226 - if (!templateValidation.valid) { 227 - return c.json({ error: templateValidation.error }, 400); 228 - } 229 - 230 - localActions.push({ 231 - $type: "record", 232 - targetCollection: input.targetCollection, 233 - recordTemplate: input.recordTemplate, 234 - ...forEachField, 235 - ...(input.comment ? { comment: input.comment } : {}), 236 - } satisfies RecordAction); 237 - pdsActions.push({ 238 - $type: "run.airglow.automation#recordAction", 239 - targetCollection: input.targetCollection, 240 - recordTemplate: input.recordTemplate, 241 - ...forEachField, 242 - ...(input.comment ? { comment: input.comment } : {}), 243 - }); 244 - actionResultNames.push(`action${actionIndex + 1}`); 245 - } else if (input.type === "bsky-post") { 246 - if (!input.textTemplate || !input.textTemplate.trim()) { 247 - return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 248 - } 249 - const textValidation = validateTextTemplate( 250 - input.textTemplate, 251 - fetchNames, 252 - actionResultNames, 253 - hasItem, 254 - ); 255 - if (!textValidation.valid) { 256 - return c.json({ error: textValidation.error }, 400); 257 - } 258 - if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 259 - return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 260 - } 261 - if (input.langs?.some((l) => !BCP47_RE.test(l))) { 262 - return c.json( 263 - { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 264 - 400, 265 - ); 266 - } 267 - if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 268 - return c.json( 269 - { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 270 - 400, 271 - ); 272 - } 273 - 274 - const langs = input.langs?.filter(Boolean); 275 - const labels = input.labels?.filter(Boolean); 276 - 277 - localActions.push({ 278 - $type: "bsky-post", 279 - textTemplate: input.textTemplate, 280 - ...(langs && langs.length > 0 ? { langs } : {}), 281 - ...(labels && labels.length > 0 ? { labels } : {}), 282 - ...forEachField, 283 - ...(input.comment ? { comment: input.comment } : {}), 284 - } satisfies BskyPostAction); 285 - pdsActions.push({ 286 - $type: "run.airglow.automation#bskyPostAction", 287 - textTemplate: input.textTemplate, 288 - ...(langs && langs.length > 0 ? { langs } : {}), 289 - ...(labels && labels.length > 0 ? { labels } : {}), 290 - ...forEachField, 291 - ...(input.comment ? { comment: input.comment } : {}), 292 - }); 293 - actionResultNames.push(`action${actionIndex + 1}`); 294 - } else if (input.type === "patch-record") { 295 - if (!input.targetCollection) { 296 - return c.json({ error: "targetCollection is required for patch-record actions" }, 400); 297 - } 298 - if (!isValidNsid(input.targetCollection)) { 299 - return c.json({ error: "Invalid target collection NSID" }, 400); 300 - } 301 - if (!input.baseRecordUri) { 302 - return c.json({ error: "baseRecordUri is required for patch-record actions" }, 400); 303 - } 304 - const uriValidation = validateBaseRecordUri( 305 - input.baseRecordUri, 306 - fetchNames, 307 - actionResultNames, 308 - hasItem, 309 - ); 310 - if (!uriValidation.valid) { 311 - return c.json({ error: uriValidation.error }, 400); 312 - } 313 - if (!input.recordTemplate) { 314 - return c.json({ error: "recordTemplate is required for patch-record actions" }, 400); 315 - } 316 - const templateValidation = validateTemplate( 317 - input.recordTemplate, 318 - fetchNames, 319 - actionResultNames, 320 - hasItem, 321 - ); 322 - if (!templateValidation.valid) { 323 - return c.json({ error: templateValidation.error }, 400); 324 - } 325 - 326 - localActions.push({ 327 - $type: "patch-record", 328 - targetCollection: input.targetCollection, 329 - baseRecordUri: input.baseRecordUri, 330 - recordTemplate: input.recordTemplate, 331 - ...forEachField, 332 - ...(input.comment ? { comment: input.comment } : {}), 333 - } satisfies PatchRecordAction); 334 - pdsActions.push({ 335 - $type: "run.airglow.automation#patchRecordAction", 336 - targetCollection: input.targetCollection, 337 - baseRecordUri: input.baseRecordUri, 338 - recordTemplate: input.recordTemplate, 339 - ...forEachField, 340 - ...(input.comment ? { comment: input.comment } : {}), 341 - }); 342 - actionResultNames.push(`action${actionIndex + 1}`); 343 - } else if (input.type === "margin-bookmark") { 344 - const bookmarkValidation = validateMarginBookmarkInput( 345 - input, 346 - fetchNames, 347 - actionResultNames, 348 - hasItem, 349 - ); 350 - if (!bookmarkValidation.valid) { 351 - return c.json({ error: bookmarkValidation.error }, 400); 352 - } 353 - 354 - const bodyValue = input.bodyValue?.trim() || undefined; 355 - const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 356 - 357 - localActions.push({ 358 - $type: "margin-bookmark", 359 - targetSource: input.targetSource, 360 - ...(bodyValue ? { bodyValue } : {}), 361 - ...(tags ? { tags } : {}), 362 - ...forEachField, 363 - ...(input.comment ? { comment: input.comment } : {}), 364 - } satisfies MarginBookmarkAction); 365 - pdsActions.push({ 366 - $type: "run.airglow.automation#marginBookmarkAction", 367 - targetSource: input.targetSource, 368 - ...(bodyValue ? { bodyValue } : {}), 369 - ...(tags ? { tags } : {}), 370 - ...forEachField, 371 - ...(input.comment ? { comment: input.comment } : {}), 372 - }); 373 - actionResultNames.push(`action${actionIndex + 1}`); 374 - } else if (input.type === "follow") { 375 - const followValidation = validateFollowInput(input, fetchNames, actionResultNames, hasItem); 376 - if (!followValidation.valid) { 377 - return c.json({ error: followValidation.error }, 400); 378 - } 379 - 380 - localActions.push({ 381 - $type: "follow", 382 - target: input.target, 383 - subject: input.subject, 384 - ...forEachField, 385 - ...(input.comment ? { comment: input.comment } : {}), 386 - } satisfies FollowAction); 387 - pdsActions.push({ 388 - $type: "run.airglow.automation#followAction", 389 - target: input.target, 390 - subject: input.subject, 391 - ...forEachField, 392 - ...(input.comment ? { comment: input.comment } : {}), 393 - }); 394 - actionResultNames.push(`action${actionIndex + 1}`); 395 - } else if (ACTION_REGISTRY[input.type]) { 396 - const def = ACTION_REGISTRY[input.type]!; 397 - const r = await def.validate(input, { fetchNames, actionResultNames, hasItem }); 398 - if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 399 - const local = { 400 - ...r.local, 401 - ...forEachField, 402 - ...(input.comment ? { comment: input.comment } : {}), 403 - }; 404 - localActions.push(local); 405 - pdsActions.push(def.toPds(local)); 406 - if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 407 - } else { 408 - return c.json({ error: "Invalid action type" }, 400); 409 - } 138 + const def = ACTION_REGISTRY[input.type]; 139 + if (!def) return c.json({ error: "Invalid action type" }, 400); 140 + const r = await def.validate(input, { 141 + fetchNames, 142 + actionResultNames, 143 + hasItem, 144 + lexicon: body.lexicon, 145 + existingActions: [], 146 + }); 147 + if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 148 + const local = { 149 + ...r.local, 150 + ...forEachField, 151 + ...(input.comment ? { comment: input.comment } : {}), 152 + }; 153 + localActions.push(local); 154 + pdsActions.push(def.toPds(local)); 155 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 410 156 } 411 157 412 158 // Write record to PDS
+126 -1
lib/actions/bsky-post.ts
··· 1 1 import { type BskyPostAction } from "../db/schema.js"; 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 - import { renderTextTemplate, type FetchContext } from "./template.js"; 3 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 4 4 import { detectFacets } from "./richtext.js"; 5 5 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 + import { AUTOMATION_LIMITS } from "../automations/limits.js"; 8 + import { BCP47_RE, VALID_BSKY_LABELS } from "./validation.js"; 9 + import { MessageSquare } from "../../app/icons.js"; 10 + import type { ActionDefinition } from "./registry.js"; 11 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 12 8 13 const TARGET_COLLECTION = "app.bsky.feed.post"; 9 14 ··· 67 72 execute, 68 73 (action) => JSON.stringify({ textTemplate: action.textTemplate }), 69 74 ); 75 + 76 + type BskyPostInput = { 77 + type: "bsky-post"; 78 + textTemplate: string; 79 + langs?: string[]; 80 + labels?: string[]; 81 + }; 82 + 83 + type PdsBskyPostAction = { 84 + $type: "run.airglow.automation#bskyPostAction"; 85 + textTemplate: string; 86 + langs?: string[]; 87 + labels?: string[]; 88 + forEach?: BskyPostAction["forEach"]; 89 + comment?: string; 90 + }; 91 + 92 + async function validate( 93 + input: BskyPostInput, 94 + ctx: ValidationContext, 95 + ): Promise<{ ok: true; local: BskyPostAction } | { ok: false; error: string; status?: number }> { 96 + if (!input.textTemplate || !input.textTemplate.trim()) { 97 + return { ok: false, error: "textTemplate is required for bsky-post actions" }; 98 + } 99 + const textValidation = validateTextTemplate( 100 + input.textTemplate, 101 + ctx.fetchNames, 102 + ctx.actionResultNames, 103 + ctx.hasItem, 104 + ); 105 + if (!textValidation.valid) { 106 + return { ok: false, error: textValidation.error }; 107 + } 108 + if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 109 + return { ok: false, error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }; 110 + } 111 + if (input.langs?.some((l) => !BCP47_RE.test(l))) { 112 + return { ok: false, error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }; 113 + } 114 + if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 115 + return { 116 + ok: false, 117 + error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media", 118 + }; 119 + } 120 + 121 + const langs = input.langs?.filter(Boolean); 122 + const labels = input.labels?.filter(Boolean); 123 + const local: BskyPostAction = { 124 + $type: "bsky-post", 125 + textTemplate: input.textTemplate, 126 + ...(langs && langs.length > 0 ? { langs } : {}), 127 + ...(labels && labels.length > 0 ? { labels } : {}), 128 + }; 129 + return { ok: true, local }; 130 + } 131 + 132 + function toPds(action: BskyPostAction): PdsBskyPostAction { 133 + return { 134 + $type: "run.airglow.automation#bskyPostAction", 135 + textTemplate: action.textTemplate, 136 + ...(action.langs && action.langs.length > 0 ? { langs: action.langs } : {}), 137 + ...(action.labels && action.labels.length > 0 ? { labels: action.labels } : {}), 138 + ...(action.forEach ? { forEach: action.forEach } : {}), 139 + ...(action.comment ? { comment: action.comment } : {}), 140 + }; 141 + } 142 + 143 + async function dryRunDescribe( 144 + action: BskyPostAction, 145 + ctx: DryRunContext, 146 + ): Promise<DryRunDescription> { 147 + try { 148 + const text = await renderTextTemplate( 149 + action.textTemplate, 150 + ctx.match.event, 151 + ctx.fetchContext, 152 + ctx.match.automation, 153 + ctx.item, 154 + ); 155 + return { 156 + message: `Would post to Bluesky${ctx.itemSuffix}`, 157 + payload: JSON.stringify({ 158 + text, 159 + langs: action.langs, 160 + labels: action.labels, 161 + item: ctx.item, 162 + }), 163 + error: null, 164 + }; 165 + } catch (err) { 166 + return { 167 + message: null, 168 + payload: null, 169 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 170 + }; 171 + } 172 + } 173 + 174 + export const bskyPostDefinition: ActionDefinition< 175 + BskyPostAction, 176 + BskyPostInput, 177 + PdsBskyPostAction 178 + > = { 179 + type: "bsky-post", 180 + pdsType: "run.airglow.automation#bskyPostAction", 181 + recordProducing: true, 182 + needsFullScope: true, 183 + validate, 184 + toPds, 185 + execute: executeBskyPost, 186 + dryRunDescribe, 187 + catalogue: { 188 + label: "Post to Bluesky", 189 + description: "Publish a post to your Bluesky account", 190 + category: "bluesky", 191 + icon: MessageSquare, 192 + available: true, 193 + }, 194 + };
+103 -1
lib/actions/executor.ts
··· 1 1 import { type RecordAction } from "../db/schema.js"; 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 - import { renderTemplate, type FetchContext } from "./template.js"; 3 + import { renderTemplate, validateTemplate, type FetchContext } from "./template.js"; 4 4 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 + import { isValidNsid } from "../lexicons/resolver.js"; 7 + import { FilePlus2 } from "../../app/icons.js"; 8 + import type { ActionDefinition } from "./registry.js"; 9 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 6 10 7 11 export type { ActionResult }; 8 12 ··· 42 46 recordTemplate: action.recordTemplate, 43 47 }), 44 48 ); 49 + 50 + type RecordInput = { 51 + type: "record"; 52 + targetCollection: string; 53 + recordTemplate: string; 54 + }; 55 + 56 + type PdsRecordAction = { 57 + $type: "run.airglow.automation#recordAction"; 58 + targetCollection: string; 59 + recordTemplate: string; 60 + forEach?: RecordAction["forEach"]; 61 + comment?: string; 62 + }; 63 + 64 + async function validate( 65 + input: RecordInput, 66 + ctx: ValidationContext, 67 + ): Promise<{ ok: true; local: RecordAction } | { ok: false; error: string; status?: number }> { 68 + if (!input.targetCollection) { 69 + return { ok: false, error: "targetCollection is required for record actions" }; 70 + } 71 + if (!isValidNsid(input.targetCollection)) { 72 + return { ok: false, error: "Invalid target collection NSID" }; 73 + } 74 + if (!input.recordTemplate) { 75 + return { ok: false, error: "recordTemplate is required for record actions" }; 76 + } 77 + const templateValidation = validateTemplate( 78 + input.recordTemplate, 79 + ctx.fetchNames, 80 + ctx.actionResultNames, 81 + ctx.hasItem, 82 + ); 83 + if (!templateValidation.valid) { 84 + return { ok: false, error: templateValidation.error }; 85 + } 86 + const local: RecordAction = { 87 + $type: "record", 88 + targetCollection: input.targetCollection, 89 + recordTemplate: input.recordTemplate, 90 + }; 91 + return { ok: true, local }; 92 + } 93 + 94 + function toPds(action: RecordAction): PdsRecordAction { 95 + return { 96 + $type: "run.airglow.automation#recordAction", 97 + targetCollection: action.targetCollection, 98 + recordTemplate: action.recordTemplate, 99 + ...(action.forEach ? { forEach: action.forEach } : {}), 100 + ...(action.comment ? { comment: action.comment } : {}), 101 + }; 102 + } 103 + 104 + async function dryRunDescribe( 105 + action: RecordAction, 106 + ctx: DryRunContext, 107 + ): Promise<DryRunDescription> { 108 + try { 109 + const rendered = await renderTemplate( 110 + action.recordTemplate, 111 + ctx.match.event, 112 + ctx.fetchContext, 113 + ctx.match.automation, 114 + ctx.item, 115 + ); 116 + return { 117 + message: `Would create record in ${action.targetCollection}${ctx.itemSuffix}`, 118 + payload: JSON.stringify(ctx.item !== undefined ? { rendered, item: ctx.item } : rendered), 119 + error: null, 120 + }; 121 + } catch (err) { 122 + return { 123 + message: null, 124 + payload: null, 125 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 126 + }; 127 + } 128 + } 129 + 130 + export const recordDefinition: ActionDefinition<RecordAction, RecordInput, PdsRecordAction> = { 131 + type: "record", 132 + pdsType: "run.airglow.automation#recordAction", 133 + recordProducing: true, 134 + needsFullScope: true, 135 + validate, 136 + toPds, 137 + execute: executeAction, 138 + dryRunDescribe, 139 + catalogue: { 140 + label: "Create a record", 141 + description: "Create a new record in any collection", 142 + category: "pds", 143 + icon: FilePlus2, 144 + available: true, 145 + }, 146 + };
+55 -1
lib/actions/follow.test.ts
··· 21 21 resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 22 22 })); 23 23 24 - import { executeFollow } from "./follow.js"; 24 + import { executeFollow, followDefinition } from "./follow.js"; 25 25 import { createArbitraryRecord } from "../automations/pds.js"; 26 26 import { fetchRecord } from "../pds/resolver.js"; 27 27 import { executeSearch } from "./searcher.js"; ··· 294 294 }); 295 295 }); 296 296 }); 297 + 298 + describe("followDefinition.validate", () => { 299 + const ctx = { 300 + fetchNames: [], 301 + actionResultNames: [], 302 + hasItem: false, 303 + lexicon: "app.bsky.feed.post", 304 + existingActions: [], 305 + }; 306 + 307 + it("accepts a literal DID subject for each target", async () => { 308 + for (const target of ["bluesky", "tangled", "sifa"] as const) { 309 + const res = await followDefinition.validate( 310 + { type: "follow", target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 311 + ctx, 312 + ); 313 + expect(res.ok).toBe(true); 314 + } 315 + }); 316 + 317 + it("accepts placeholders in subject (validated at render time)", async () => { 318 + const res = await followDefinition.validate( 319 + { type: "follow", target: "bluesky", subject: "{{event.did}}" }, 320 + ctx, 321 + ); 322 + expect(res.ok).toBe(true); 323 + }); 324 + 325 + it("rejects an unknown target", async () => { 326 + const res = await followDefinition.validate( 327 + { type: "follow", target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 328 + ctx, 329 + ); 330 + expect(res.ok).toBe(false); 331 + if (!res.ok) expect(res.error).toMatch(/target/); 332 + }); 333 + 334 + it("rejects empty subject", async () => { 335 + const res = await followDefinition.validate( 336 + { type: "follow", target: "bluesky", subject: "" }, 337 + ctx, 338 + ); 339 + expect(res.ok).toBe(false); 340 + }); 341 + 342 + it("rejects unknown placeholders in subject", async () => { 343 + const res = await followDefinition.validate( 344 + { type: "follow", target: "bluesky", subject: "{{mystery.field}}" }, 345 + ctx, 346 + ); 347 + expect(res.ok).toBe(false); 348 + if (!res.ok) expect(res.error).toMatch(/subject/); 349 + }); 350 + });
+116 -2
lib/actions/follow.ts
··· 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 3 import { fetchRecord } from "../pds/resolver.js"; 4 4 import { executeSearch } from "./searcher.js"; 5 - import { renderTextTemplate, type FetchContext } from "./template.js"; 5 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 6 6 import { SKIP_STATUS, parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 7 7 import { DID_RE } from "./validation.js"; 8 - import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 8 + import { 9 + FOLLOW_TARGETS, 10 + VALID_FOLLOW_TARGETS, 11 + type FollowTarget, 12 + } from "../automations/follow-targets.js"; 9 13 import type { MatchedEvent } from "../jetstream/consumer.js"; 14 + import type { ActionDefinition } from "./registry.js"; 15 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 10 16 11 17 async function checkProfileExists( 12 18 action: FollowAction, ··· 147 153 subject: action.subject, 148 154 }), 149 155 ); 156 + 157 + type FollowInput = { 158 + type: "follow"; 159 + target: string; 160 + subject: string; 161 + }; 162 + 163 + type PdsFollowAction = { 164 + $type: "run.airglow.automation#followAction"; 165 + target: FollowTarget; 166 + subject: string; 167 + forEach?: FollowAction["forEach"]; 168 + comment?: string; 169 + }; 170 + 171 + const FOLLOW_SUBJECT_MAX = 512; 172 + 173 + async function validate( 174 + input: FollowInput, 175 + ctx: ValidationContext, 176 + ): Promise<{ ok: true; local: FollowAction } | { ok: false; error: string; status?: number }> { 177 + if (!input.target || typeof input.target !== "string") { 178 + return { ok: false, error: "target is required for follow actions" }; 179 + } 180 + if (!VALID_FOLLOW_TARGETS.has(input.target)) { 181 + return { 182 + ok: false, 183 + error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`, 184 + }; 185 + } 186 + if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) { 187 + return { ok: false, error: "subject is required for follow actions" }; 188 + } 189 + if (input.subject.length > FOLLOW_SUBJECT_MAX) { 190 + return { ok: false, error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less` }; 191 + } 192 + const templateCheck = validateTextTemplate( 193 + input.subject, 194 + ctx.fetchNames, 195 + ctx.actionResultNames, 196 + ctx.hasItem, 197 + ); 198 + if (!templateCheck.valid) { 199 + return { ok: false, error: `subject: ${templateCheck.error}` }; 200 + } 201 + 202 + const local: FollowAction = { 203 + $type: "follow", 204 + target: input.target as FollowTarget, 205 + subject: input.subject, 206 + }; 207 + return { ok: true, local }; 208 + } 209 + 210 + function toPds(action: FollowAction): PdsFollowAction { 211 + return { 212 + $type: "run.airglow.automation#followAction", 213 + target: action.target, 214 + subject: action.subject, 215 + ...(action.forEach ? { forEach: action.forEach } : {}), 216 + ...(action.comment ? { comment: action.comment } : {}), 217 + }; 218 + } 219 + 220 + async function dryRunDescribe( 221 + action: FollowAction, 222 + ctx: DryRunContext, 223 + ): Promise<DryRunDescription> { 224 + try { 225 + const subject = ( 226 + await renderTextTemplate( 227 + action.subject, 228 + ctx.match.event, 229 + ctx.fetchContext, 230 + ctx.match.automation, 231 + ctx.item, 232 + ) 233 + ).trim(); 234 + const target = FOLLOW_TARGETS[action.target]; 235 + // The built-in safety checks live inside executeFollow and aren't run in 236 + // dry-run (keeps the preview cheap). Advertise their presence in the 237 + // message so authors know the real run will skip cleanly on both edges. 238 + return { 239 + message: `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${target.appName} profile exists or already following)${ctx.itemSuffix}`, 240 + payload: JSON.stringify({ collection: target.collection, subject, item: ctx.item }), 241 + error: null, 242 + }; 243 + } catch (err) { 244 + return { 245 + message: null, 246 + payload: null, 247 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 248 + }; 249 + } 250 + } 251 + 252 + export const followDefinition: ActionDefinition<FollowAction, FollowInput, PdsFollowAction> = { 253 + type: "follow", 254 + pdsType: "run.airglow.automation#followAction", 255 + recordProducing: true, 256 + needsFullScope: true, 257 + validate, 258 + toPds, 259 + execute: executeFollow, 260 + dryRunDescribe, 261 + // Catalogue tiles are derived per-target from FOLLOW_TARGETS in 262 + // action-catalogue.ts (one tile per Bluesky/Sifa/Tangled), not 1:1 with $type. 263 + };
+205 -1
lib/actions/margin-bookmark.ts
··· 1 1 import { createHash } from "node:crypto"; 2 2 import { type MarginBookmarkAction } from "../db/schema.js"; 3 3 import { createArbitraryRecord } from "../automations/pds.js"; 4 - import { renderTextTemplate, type FetchContext } from "./template.js"; 4 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 5 5 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 7 import { fetchURLMetadata } from "../url-metadata.js"; 8 8 import { config } from "../config.js"; 9 + import { MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; 10 + import { Bookmark } from "../../app/icons.js"; 11 + import type { ActionDefinition } from "./registry.js"; 12 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 9 13 10 14 const TARGET_COLLECTION = "at.margin.note"; 11 15 ··· 134 138 tags: action.tags, 135 139 }), 136 140 ); 141 + 142 + type MarginBookmarkInput = { 143 + type: "margin-bookmark"; 144 + targetSource: string; 145 + bodyValue?: string; 146 + tags?: string[]; 147 + }; 148 + 149 + type PdsMarginBookmarkAction = { 150 + $type: "run.airglow.automation#marginBookmarkAction"; 151 + targetSource: string; 152 + bodyValue?: string; 153 + tags?: string[]; 154 + forEach?: MarginBookmarkAction["forEach"]; 155 + comment?: string; 156 + }; 157 + 158 + // Allow either a literal http(s):// prefix or a leading {{...}} placeholder. 159 + // Mirrors the semble-save form check; the runtime guard inside the executor 160 + // remains the real boundary. 161 + const URL_OK_RE = /^(https?:\/\/|\{\{)/i; 162 + 163 + async function validate( 164 + input: MarginBookmarkInput, 165 + ctx: ValidationContext, 166 + ): Promise< 167 + { ok: true; local: MarginBookmarkAction } | { ok: false; error: string; status?: number } 168 + > { 169 + if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 170 + return { ok: false, error: "targetSource is required for margin-bookmark actions" }; 171 + } 172 + if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) { 173 + return { 174 + ok: false, 175 + error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`, 176 + }; 177 + } 178 + if (!URL_OK_RE.test(input.targetSource)) { 179 + return { 180 + ok: false, 181 + error: "targetSource must start with http://, https://, or a {{placeholder}}", 182 + }; 183 + } 184 + const sourceValidation = validateTextTemplate( 185 + input.targetSource, 186 + ctx.fetchNames, 187 + ctx.actionResultNames, 188 + ctx.hasItem, 189 + ); 190 + if (!sourceValidation.valid) { 191 + return { ok: false, error: `targetSource: ${sourceValidation.error}` }; 192 + } 193 + 194 + if (input.bodyValue !== undefined) { 195 + if (typeof input.bodyValue !== "string") { 196 + return { ok: false, error: "bodyValue must be a string" }; 197 + } 198 + if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) { 199 + return { 200 + ok: false, 201 + error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`, 202 + }; 203 + } 204 + if (input.bodyValue.trim()) { 205 + const bodyValidation = validateTextTemplate( 206 + input.bodyValue, 207 + ctx.fetchNames, 208 + ctx.actionResultNames, 209 + ctx.hasItem, 210 + ); 211 + if (!bodyValidation.valid) { 212 + return { ok: false, error: `bodyValue: ${bodyValidation.error}` }; 213 + } 214 + } 215 + } 216 + 217 + const tags: string[] = []; 218 + if (input.tags !== undefined) { 219 + if (!Array.isArray(input.tags)) { 220 + return { ok: false, error: "tags must be an array of strings" }; 221 + } 222 + if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) { 223 + return { ok: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` }; 224 + } 225 + for (const tag of input.tags) { 226 + if (typeof tag !== "string") { 227 + return { ok: false, error: "Each tag must be a string" }; 228 + } 229 + const trimmed = tag.trim(); 230 + if (!trimmed) continue; 231 + if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) { 232 + return { 233 + ok: false, 234 + error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`, 235 + }; 236 + } 237 + const tagValidation = validateTextTemplate( 238 + trimmed, 239 + ctx.fetchNames, 240 + ctx.actionResultNames, 241 + ctx.hasItem, 242 + ); 243 + if (!tagValidation.valid) { 244 + return { ok: false, error: `tag: ${tagValidation.error}` }; 245 + } 246 + tags.push(trimmed); 247 + } 248 + } 249 + 250 + const bodyValue = input.bodyValue?.trim() || undefined; 251 + const local: MarginBookmarkAction = { 252 + $type: "margin-bookmark", 253 + targetSource: input.targetSource, 254 + ...(bodyValue ? { bodyValue } : {}), 255 + ...(tags.length > 0 ? { tags } : {}), 256 + }; 257 + return { ok: true, local }; 258 + } 259 + 260 + function toPds(action: MarginBookmarkAction): PdsMarginBookmarkAction { 261 + return { 262 + $type: "run.airglow.automation#marginBookmarkAction", 263 + targetSource: action.targetSource, 264 + ...(action.bodyValue ? { bodyValue: action.bodyValue } : {}), 265 + ...(action.tags && action.tags.length > 0 ? { tags: action.tags } : {}), 266 + ...(action.forEach ? { forEach: action.forEach } : {}), 267 + ...(action.comment ? { comment: action.comment } : {}), 268 + }; 269 + } 270 + 271 + async function dryRunDescribe( 272 + action: MarginBookmarkAction, 273 + ctx: DryRunContext, 274 + ): Promise<DryRunDescription> { 275 + try { 276 + const source = await renderTextTemplate( 277 + action.targetSource, 278 + ctx.match.event, 279 + ctx.fetchContext, 280 + ctx.match.automation, 281 + ctx.item, 282 + ); 283 + const body = action.bodyValue 284 + ? await renderTextTemplate( 285 + action.bodyValue, 286 + ctx.match.event, 287 + ctx.fetchContext, 288 + ctx.match.automation, 289 + ctx.item, 290 + ) 291 + : undefined; 292 + const tags: string[] = []; 293 + if (action.tags) { 294 + for (const tag of action.tags) { 295 + const rendered = await renderTextTemplate( 296 + tag, 297 + ctx.match.event, 298 + ctx.fetchContext, 299 + ctx.match.automation, 300 + ctx.item, 301 + ); 302 + if (rendered.trim()) tags.push(rendered.trim()); 303 + } 304 + } 305 + return { 306 + message: `Would bookmark ${source}${ctx.itemSuffix}`, 307 + payload: JSON.stringify({ source, body, tags, item: ctx.item }), 308 + error: null, 309 + }; 310 + } catch (err) { 311 + return { 312 + message: null, 313 + payload: null, 314 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 315 + }; 316 + } 317 + } 318 + 319 + export const marginBookmarkDefinition: ActionDefinition< 320 + MarginBookmarkAction, 321 + MarginBookmarkInput, 322 + PdsMarginBookmarkAction 323 + > = { 324 + type: "margin-bookmark", 325 + pdsType: "run.airglow.automation#marginBookmarkAction", 326 + recordProducing: true, 327 + needsFullScope: true, 328 + validate, 329 + toPds, 330 + execute: executeMarginBookmark, 331 + dryRunDescribe, 332 + catalogue: { 333 + label: "Bookmark on Margin", 334 + description: "Create a bookmark note in Margin.at", 335 + category: "apps", 336 + icon: Bookmark, 337 + available: true, 338 + faviconDomain: "margin.at", 339 + }, 340 + };
+130 -1
lib/actions/patch-record.ts
··· 1 1 import { type PatchRecordAction } from "../db/schema.js"; 2 2 import { patchArbitraryRecord } from "../automations/pds.js"; 3 3 import { fetchRecord, parseAtUri } from "../pds/resolver.js"; 4 - import { renderTemplate, resolveUriTemplate, type FetchContext } from "./template.js"; 4 + import { 5 + renderTemplate, 6 + resolveUriTemplate, 7 + validateTemplate, 8 + validateBaseRecordUri, 9 + type FetchContext, 10 + } from "./template.js"; 5 11 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 12 import type { MatchedEvent } from "../jetstream/consumer.js"; 13 + import { isValidNsid } from "../lexicons/resolver.js"; 14 + import { Pencil } from "../../app/icons.js"; 15 + import type { ActionDefinition } from "./registry.js"; 16 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 17 8 18 async function execute( 9 19 match: MatchedEvent, ··· 93 103 recordTemplate: action.recordTemplate, 94 104 }), 95 105 ); 106 + 107 + type PatchRecordInput = { 108 + type: "patch-record"; 109 + targetCollection: string; 110 + baseRecordUri: string; 111 + recordTemplate: string; 112 + }; 113 + 114 + type PdsPatchRecordAction = { 115 + $type: "run.airglow.automation#patchRecordAction"; 116 + targetCollection: string; 117 + baseRecordUri: string; 118 + recordTemplate: string; 119 + forEach?: PatchRecordAction["forEach"]; 120 + comment?: string; 121 + }; 122 + 123 + async function validate( 124 + input: PatchRecordInput, 125 + ctx: ValidationContext, 126 + ): Promise<{ ok: true; local: PatchRecordAction } | { ok: false; error: string; status?: number }> { 127 + if (!input.targetCollection) { 128 + return { ok: false, error: "targetCollection is required for patch-record actions" }; 129 + } 130 + if (!isValidNsid(input.targetCollection)) { 131 + return { ok: false, error: "Invalid target collection NSID" }; 132 + } 133 + if (!input.baseRecordUri) { 134 + return { ok: false, error: "baseRecordUri is required for patch-record actions" }; 135 + } 136 + const uriValidation = validateBaseRecordUri( 137 + input.baseRecordUri, 138 + ctx.fetchNames, 139 + ctx.actionResultNames, 140 + ctx.hasItem, 141 + ); 142 + if (!uriValidation.valid) { 143 + return { ok: false, error: uriValidation.error }; 144 + } 145 + if (!input.recordTemplate) { 146 + return { ok: false, error: "recordTemplate is required for patch-record actions" }; 147 + } 148 + const templateValidation = validateTemplate( 149 + input.recordTemplate, 150 + ctx.fetchNames, 151 + ctx.actionResultNames, 152 + ctx.hasItem, 153 + ); 154 + if (!templateValidation.valid) { 155 + return { ok: false, error: templateValidation.error }; 156 + } 157 + 158 + const local: PatchRecordAction = { 159 + $type: "patch-record", 160 + targetCollection: input.targetCollection, 161 + baseRecordUri: input.baseRecordUri, 162 + recordTemplate: input.recordTemplate, 163 + }; 164 + return { ok: true, local }; 165 + } 166 + 167 + function toPds(action: PatchRecordAction): PdsPatchRecordAction { 168 + return { 169 + $type: "run.airglow.automation#patchRecordAction", 170 + targetCollection: action.targetCollection, 171 + baseRecordUri: action.baseRecordUri, 172 + recordTemplate: action.recordTemplate, 173 + ...(action.forEach ? { forEach: action.forEach } : {}), 174 + ...(action.comment ? { comment: action.comment } : {}), 175 + }; 176 + } 177 + 178 + async function dryRunDescribe( 179 + action: PatchRecordAction, 180 + ctx: DryRunContext, 181 + ): Promise<DryRunDescription> { 182 + try { 183 + const rendered = await renderTemplate( 184 + action.recordTemplate, 185 + ctx.match.event, 186 + ctx.fetchContext, 187 + ctx.match.automation, 188 + ctx.item, 189 + ); 190 + return { 191 + message: `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${ctx.itemSuffix}`, 192 + payload: JSON.stringify(ctx.item !== undefined ? { rendered, item: ctx.item } : rendered), 193 + error: null, 194 + }; 195 + } catch (err) { 196 + return { 197 + message: null, 198 + payload: null, 199 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 200 + }; 201 + } 202 + } 203 + 204 + export const patchRecordDefinition: ActionDefinition< 205 + PatchRecordAction, 206 + PatchRecordInput, 207 + PdsPatchRecordAction 208 + > = { 209 + type: "patch-record", 210 + pdsType: "run.airglow.automation#patchRecordAction", 211 + recordProducing: true, 212 + needsFullScope: true, 213 + validate, 214 + toPds, 215 + execute: executePatchRecord, 216 + dryRunDescribe, 217 + catalogue: { 218 + label: "Update a record", 219 + description: "Modify fields of an existing record", 220 + category: "pds", 221 + icon: Pencil, 222 + available: true, 223 + }, 224 + };
+20 -5
lib/actions/registry.ts
··· 72 72 /** Build the dry-run delivery_logs row content for this action type. */ 73 73 dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>; 74 74 75 - /** Catalogue tile shown in the form's "add action" picker. */ 76 - catalogue: CatalogueTile; 75 + /** Catalogue tile shown in the form's "add action" picker. Optional 76 + * because some action types map to multiple tiles (e.g. `follow` expands 77 + * into one tile per FOLLOW_TARGETS entry); those keep their tile list 78 + * hand-curated in `action-catalogue.ts`. */ 79 + catalogue?: CatalogueTile; 77 80 78 81 /** Optional override for how the GET /api/automations route serializes a 79 82 * stored action — webhooks use this to strip the secret. */ ··· 98 101 * remain in place. Empty until then so this scaffolding commit is a pure 99 102 * type-level addition. */ 100 103 import { sembleSaveDefinition } from "./semble-save.js"; 104 + import { marginBookmarkDefinition } from "./margin-bookmark.js"; 105 + import { followDefinition } from "./follow.js"; 106 + import { bskyPostDefinition } from "./bsky-post.js"; 107 + import { patchRecordDefinition } from "./patch-record.js"; 108 + import { recordDefinition } from "./executor.js"; 109 + import { webhookDefinition } from "./webhook.js"; 101 110 102 - /** Map of $type → definition. Filled in incrementally; consumers begin 103 - * reading from it while the legacy switches remain in place as fallback. */ 111 + /** Map of $type → definition. Every action type is registered after Phase 3. 112 + * Subsequent dispatchers consume the registry directly and need no fallback. */ 104 113 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site. 105 - export const ACTION_REGISTRY: Partial<Record<ActionType, ActionDefinition<any, any, any>>> = { 114 + export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = { 106 115 "semble-save": sembleSaveDefinition, 116 + "margin-bookmark": marginBookmarkDefinition, 117 + follow: followDefinition, 118 + "bsky-post": bskyPostDefinition, 119 + "patch-record": patchRecordDefinition, 120 + record: recordDefinition, 121 + webhook: webhookDefinition, 107 122 };
+8 -1
lib/actions/types.ts
··· 1 1 import type { MatchedEvent } from "../jetstream/consumer.js"; 2 + import type { Action } from "../db/schema.js"; 2 3 import type { ActionResult } from "./delivery.js"; 3 4 import type { FetchContext } from "./template.js"; 4 5 ··· 12 13 13 14 /** Context passed to per-action validators by the API POST/PATCH routes. 14 15 * Names of declared fetches and prior record-producing actions, plus whether 15 - * this action runs inside a forEach (so validators can allow `{{item.*}}`). */ 16 + * this action runs inside a forEach (so validators can allow `{{item.*}}`), 17 + * the automation's lexicon NSID (used by webhook for callback verification), 18 + * and the actions previously stored on this automation (PATCH only — empty on 19 + * POST). The latter lets webhook preserve an existing secret when its 20 + * callbackUrl is unchanged across the update. */ 16 21 export type ValidationContext = { 17 22 fetchNames: string[]; 18 23 actionResultNames: string[]; 19 24 hasItem: boolean; 25 + lexicon: string; 26 + existingActions: Action[]; 20 27 }; 21 28 22 29 /** Outcome of `dryRunDescribe`: the three columns the handler writes into a
-40
lib/actions/validation.test.ts
··· 3 3 validateWantedDids, 4 4 validateFetchConditionInputs, 5 5 validateFetchSearchStep, 6 - validateFollowInput, 7 6 type FetchConditionInput, 8 7 type FetchSearchInput, 9 8 } from "./validation.js"; ··· 270 269 expect(res.valid).toBe(false); 271 270 }); 272 271 }); 273 - 274 - describe("validateFollowInput", () => { 275 - it("accepts a literal DID subject for each target", () => { 276 - for (const target of ["bluesky", "tangled", "sifa"] as const) { 277 - const res = validateFollowInput( 278 - { target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 279 - [], 280 - [], 281 - ); 282 - expect(res.valid).toBe(true); 283 - } 284 - }); 285 - 286 - it("accepts placeholders in subject (validated at render time)", () => { 287 - const res = validateFollowInput({ target: "bluesky", subject: "{{event.did}}" }, [], []); 288 - expect(res.valid).toBe(true); 289 - }); 290 - 291 - it("rejects an unknown target", () => { 292 - const res = validateFollowInput( 293 - { target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 294 - [], 295 - [], 296 - ); 297 - expect(res.valid).toBe(false); 298 - if (!res.valid) expect(res.error).toMatch(/target/); 299 - }); 300 - 301 - it("rejects empty subject", () => { 302 - const res = validateFollowInput({ target: "bluesky", subject: "" }, [], []); 303 - expect(res.valid).toBe(false); 304 - }); 305 - 306 - it("rejects unknown placeholders in subject", () => { 307 - const res = validateFollowInput({ target: "bluesky", subject: "{{mystery.field}}" }, [], []); 308 - expect(res.valid).toBe(false); 309 - if (!res.valid) expect(res.error).toMatch(/subject/); 310 - }); 311 - });
+3 -145
lib/actions/validation.ts
··· 1 1 import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js"; 2 - import { AUTOMATION_LIMITS, MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; 2 + import { AUTOMATION_LIMITS } from "../automations/limits.js"; 3 3 import { nsidRequiresWantedDids } from "../lexicons/match.js"; 4 4 import { isValidNsid } from "../lexicons/resolver.js"; 5 - import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js"; 5 + import { PLACEHOLDER_RE } from "./template.js"; 6 6 import type { Condition, FetchStepSearch, ForEachConfig } from "../db/schema.js"; 7 - import { 8 - FOLLOW_TARGETS, 9 - VALID_FOLLOW_TARGETS, 10 - type FollowTarget, 11 - } from "../automations/follow-targets.js"; 7 + import { type FollowTarget } from "../automations/follow-targets.js"; 12 8 13 9 export type ForEachInput = { 14 10 path: string; ··· 418 414 ...(step.comment ? { comment: step.comment } : {}), 419 415 }, 420 416 }; 421 - } 422 - 423 - type FollowInput = { 424 - target: string; 425 - subject: string; 426 - }; 427 - 428 - const FOLLOW_SUBJECT_MAX = 512; 429 - 430 - /** Validate a follow action input. The subject supports `{{placeholders}}` 431 - * which are resolved at execution time, so we validate it as a text template, 432 - * not as a literal DID. */ 433 - export function validateFollowInput( 434 - input: FollowInput, 435 - fetchNames: string[], 436 - actionNames: string[], 437 - hasItem?: boolean, 438 - ): { valid: true } | { valid: false; error: string } { 439 - if (!input.target || typeof input.target !== "string") { 440 - return { valid: false, error: "target is required for follow actions" }; 441 - } 442 - if (!VALID_FOLLOW_TARGETS.has(input.target)) { 443 - return { 444 - valid: false, 445 - error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`, 446 - }; 447 - } 448 - if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) { 449 - return { valid: false, error: "subject is required for follow actions" }; 450 - } 451 - if (input.subject.length > FOLLOW_SUBJECT_MAX) { 452 - return { 453 - valid: false, 454 - error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less`, 455 - }; 456 - } 457 - const templateCheck = validateTextTemplate(input.subject, fetchNames, actionNames, hasItem); 458 - if (!templateCheck.valid) { 459 - return { valid: false, error: `subject: ${templateCheck.error}` }; 460 - } 461 - return { valid: true }; 462 - } 463 - 464 - type MarginBookmarkInput = { 465 - targetSource: string; 466 - bodyValue?: string; 467 - tags?: string[]; 468 - }; 469 - 470 - // Allow either a literal http(s):// prefix or a leading {{...}} placeholder. 471 - // Mirrors the semble-save form check; the runtime guard inside the executor 472 - // remains the real boundary. 473 - const MARGIN_BOOKMARK_URL_OK_RE = /^(https?:\/\/|\{\{)/i; 474 - 475 - /** Validate a margin-bookmark action input. Returns the trimmed, filtered tags on success. */ 476 - export function validateMarginBookmarkInput( 477 - input: MarginBookmarkInput, 478 - fetchNames: string[], 479 - actionNames: string[], 480 - hasItem?: boolean, 481 - ): { valid: true; tags: string[] } | { valid: false; error: string } { 482 - if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 483 - return { valid: false, error: "targetSource is required for margin-bookmark actions" }; 484 - } 485 - if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) { 486 - return { 487 - valid: false, 488 - error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`, 489 - }; 490 - } 491 - if (!MARGIN_BOOKMARK_URL_OK_RE.test(input.targetSource)) { 492 - return { 493 - valid: false, 494 - error: "targetSource must start with http://, https://, or a {{placeholder}}", 495 - }; 496 - } 497 - const sourceValidation = validateTextTemplate( 498 - input.targetSource, 499 - fetchNames, 500 - actionNames, 501 - hasItem, 502 - ); 503 - if (!sourceValidation.valid) { 504 - return { valid: false, error: `targetSource: ${sourceValidation.error}` }; 505 - } 506 - 507 - if (input.bodyValue !== undefined) { 508 - if (typeof input.bodyValue !== "string") { 509 - return { valid: false, error: "bodyValue must be a string" }; 510 - } 511 - if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) { 512 - return { 513 - valid: false, 514 - error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`, 515 - }; 516 - } 517 - if (input.bodyValue.trim()) { 518 - const bodyValidation = validateTextTemplate( 519 - input.bodyValue, 520 - fetchNames, 521 - actionNames, 522 - hasItem, 523 - ); 524 - if (!bodyValidation.valid) { 525 - return { valid: false, error: `bodyValue: ${bodyValidation.error}` }; 526 - } 527 - } 528 - } 529 - 530 - const tags: string[] = []; 531 - if (input.tags !== undefined) { 532 - if (!Array.isArray(input.tags)) { 533 - return { valid: false, error: "tags must be an array of strings" }; 534 - } 535 - if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) { 536 - return { valid: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` }; 537 - } 538 - for (const tag of input.tags) { 539 - if (typeof tag !== "string") { 540 - return { valid: false, error: "Each tag must be a string" }; 541 - } 542 - const trimmed = tag.trim(); 543 - if (!trimmed) continue; 544 - if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) { 545 - return { 546 - valid: false, 547 - error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`, 548 - }; 549 - } 550 - const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames, hasItem); 551 - if (!tagValidation.valid) { 552 - return { valid: false, error: `tag: ${tagValidation.error}` }; 553 - } 554 - tags.push(trimmed); 555 - } 556 - } 557 - 558 - return { valid: true, tags }; 559 417 } 560 418 561 419 const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+123
lib/actions/webhook.ts
··· 1 + import { nanoid } from "nanoid"; 2 + import { type WebhookAction } from "../db/schema.js"; 3 + import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 + import { assertPublicUrl, UrlGuardError } from "../url-guard.js"; 5 + import { verifyCallback } from "../automations/verify.js"; 6 + import { Webhook } from "../../app/icons.js"; 7 + import { validateWebhookHeaders } from "./validation.js"; 8 + import type { ActionDefinition } from "./registry.js"; 9 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 10 + 11 + type WebhookInput = { 12 + type: "webhook"; 13 + callbackUrl: string; 14 + headers?: Record<string, string>; 15 + }; 16 + 17 + type PdsWebhookAction = { 18 + $type: "run.airglow.automation#webhookAction"; 19 + callbackUrl: string; 20 + forEach?: WebhookAction["forEach"]; 21 + comment?: string; 22 + }; 23 + 24 + async function validate( 25 + input: WebhookInput, 26 + ctx: ValidationContext, 27 + ): Promise<{ ok: true; local: WebhookAction } | { ok: false; error: string; status?: number }> { 28 + if (!input.callbackUrl) { 29 + return { ok: false, error: "callbackUrl is required for webhook actions" }; 30 + } 31 + try { 32 + await assertPublicUrl(input.callbackUrl); 33 + } catch (err) { 34 + const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 35 + return { ok: false, error: message }; 36 + } 37 + 38 + if (input.headers && Object.keys(input.headers).length > 0) { 39 + const headersValidation = validateWebhookHeaders(input.headers); 40 + if (!headersValidation.valid) { 41 + return { ok: false, error: headersValidation.error }; 42 + } 43 + } 44 + 45 + const verification = await verifyCallback(input.callbackUrl, ctx.lexicon); 46 + 47 + // Preserve the existing secret when the same callbackUrl is already wired up 48 + // on this automation. Without this, a no-op PATCH (e.g. toggling unrelated 49 + // fields) would re-key the webhook and force the user to update the secret 50 + // on their receiver. Matching by URL — the same callback identity — keeps 51 + // editing safe regardless of action reordering. 52 + const existing = ctx.existingActions.find( 53 + (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl, 54 + ); 55 + const secret = existing?.secret ?? nanoid(32); 56 + const headers = 57 + input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 58 + 59 + const local: WebhookAction = { 60 + $type: "webhook", 61 + callbackUrl: input.callbackUrl, 62 + secret, 63 + ...(headers ? { headers } : {}), 64 + verified: verification.ok, 65 + }; 66 + return { ok: true, local }; 67 + } 68 + 69 + function toPds(action: WebhookAction): PdsWebhookAction { 70 + // PDS shape deliberately omits secret, headers, and verified — those are 71 + // server-side state, not part of the user-visible automation record. 72 + return { 73 + $type: "run.airglow.automation#webhookAction", 74 + callbackUrl: action.callbackUrl, 75 + ...(action.forEach ? { forEach: action.forEach } : {}), 76 + ...(action.comment ? { comment: action.comment } : {}), 77 + }; 78 + } 79 + 80 + async function dryRunDescribe( 81 + action: WebhookAction, 82 + ctx: DryRunContext, 83 + ): Promise<DryRunDescription> { 84 + const headerCount = action.headers ? Object.keys(action.headers).length : 0; 85 + const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : ""; 86 + return { 87 + message: `Would POST to ${action.callbackUrl}${headerNote}${ctx.itemSuffix}`, 88 + payload: JSON.stringify(buildPayload(ctx.match, ctx.fetchContext, ctx.item)), 89 + error: null, 90 + }; 91 + } 92 + 93 + /** Strip the server-side secret from the GET response. The verified flag and 94 + * headers stay; secret never leaves the server. */ 95 + function serializeForApi(action: WebhookAction): unknown { 96 + return { 97 + $type: action.$type, 98 + callbackUrl: action.callbackUrl, 99 + ...(action.headers ? { headers: action.headers } : {}), 100 + verified: action.verified ?? false, 101 + comment: action.comment, 102 + ...(action.forEach ? { forEach: action.forEach } : {}), 103 + }; 104 + } 105 + 106 + export const webhookDefinition: ActionDefinition<WebhookAction, WebhookInput, PdsWebhookAction> = { 107 + type: "webhook", 108 + pdsType: "run.airglow.automation#webhookAction", 109 + recordProducing: false, 110 + needsFullScope: false, 111 + validate, 112 + toPds, 113 + execute: dispatch, 114 + dryRunDescribe, 115 + serializeForApi, 116 + catalogue: { 117 + label: "Send a webhook", 118 + description: "POST event data to an external URL", 119 + category: "webhook", 120 + icon: Webhook, 121 + available: true, 122 + }, 123 + };
+1 -10
lib/auth/client.ts
··· 33 33 /** Returns true if any action writes to a collection beyond run.airglow.automation. */ 34 34 export function actionsNeedFullScope(actions: ActionLike[]): boolean { 35 35 return actions.some((a) => { 36 - // Registry-first: registered definitions declare their own scope need. 37 - // Falls back to the legacy hard-coded list for unmigrated types. 38 36 const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY]; 39 - if (def) return def.needsFullScope; 40 - return ( 41 - a.$type === "bsky-post" || 42 - a.$type === "record" || 43 - a.$type === "patch-record" || 44 - a.$type === "margin-bookmark" || 45 - a.$type === "follow" 46 - ); 37 + return def?.needsFullScope ?? false; 47 38 }); 48 39 } 49 40
+3 -76
lib/automations/pds-serialize.ts
··· 1 - import type { Action, ForEachConfig } from "../db/schema.js"; 2 - import type { PdsAction, PdsForEachConfig } from "./pds.js"; 1 + import type { Action } from "../db/schema.js"; 2 + import type { PdsAction } from "./pds.js"; 3 3 import { ACTION_REGISTRY } from "../actions/registry.js"; 4 4 5 - function toPdsForEach(fe: ForEachConfig | undefined): PdsForEachConfig | undefined { 6 - if (!fe) return undefined; 7 - return { 8 - path: fe.path, 9 - ...(fe.conditions && fe.conditions.length > 0 ? { conditions: fe.conditions } : {}), 10 - }; 11 - } 12 - 13 5 /** Serialize a stored Action into its PDS-record shape. Split from pds.ts so 14 6 * tests that mock the OAuth-backed PDS client don't need to re-stub this. */ 15 7 export function toPdsAction(a: Action): PdsAction { 16 - // Registry-first: any registered action type owns its own toPds projection. 17 - const def = ACTION_REGISTRY[a.$type]; 18 - if (def) return def.toPds(a) as PdsAction; 19 - // TS can't infer that `def` being undefined narrows the union, so help it 20 - // along: registered types never reach the legacy switch below. 21 - if (a.$type === "semble-save") { 22 - throw new Error("semble-save action should be handled by the registry"); 23 - } 24 - 25 - const forEach = toPdsForEach(a.forEach); 26 - const forEachField = forEach ? { forEach } : {}; 27 - 28 - if (a.$type === "webhook") { 29 - return { 30 - $type: "run.airglow.automation#webhookAction", 31 - callbackUrl: a.callbackUrl, 32 - ...forEachField, 33 - ...(a.comment ? { comment: a.comment } : {}), 34 - }; 35 - } 36 - if (a.$type === "bsky-post") { 37 - return { 38 - $type: "run.airglow.automation#bskyPostAction", 39 - textTemplate: a.textTemplate, 40 - ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}), 41 - ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}), 42 - ...forEachField, 43 - ...(a.comment ? { comment: a.comment } : {}), 44 - }; 45 - } 46 - if (a.$type === "patch-record") { 47 - return { 48 - $type: "run.airglow.automation#patchRecordAction", 49 - targetCollection: a.targetCollection, 50 - baseRecordUri: a.baseRecordUri, 51 - recordTemplate: a.recordTemplate, 52 - ...forEachField, 53 - ...(a.comment ? { comment: a.comment } : {}), 54 - }; 55 - } 56 - if (a.$type === "margin-bookmark") { 57 - return { 58 - $type: "run.airglow.automation#marginBookmarkAction", 59 - targetSource: a.targetSource, 60 - ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 61 - ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 62 - ...forEachField, 63 - ...(a.comment ? { comment: a.comment } : {}), 64 - }; 65 - } 66 - if (a.$type === "follow") { 67 - return { 68 - $type: "run.airglow.automation#followAction", 69 - target: a.target, 70 - subject: a.subject, 71 - ...forEachField, 72 - ...(a.comment ? { comment: a.comment } : {}), 73 - }; 74 - } 75 - return { 76 - $type: "run.airglow.automation#recordAction", 77 - targetCollection: a.targetCollection, 78 - recordTemplate: a.recordTemplate, 79 - ...forEachField, 80 - ...(a.comment ? { comment: a.comment } : {}), 81 - }; 8 + return ACTION_REGISTRY[a.$type].toPds(a) as PdsAction; 82 9 }
+48 -9
lib/jetstream/handler.test.ts
··· 15 15 buildPayload: vi.fn(() => ({ event: "dry-run" })), 16 16 })); 17 17 18 - vi.mock("@/actions/executor.js", () => ({ 19 - executeAction: vi.fn(), 20 - })); 18 + vi.mock("@/actions/executor.js", () => { 19 + const executeAction = vi.fn(); 20 + const recordDefinition = { 21 + type: "record", 22 + pdsType: "run.airglow.automation#recordAction", 23 + recordProducing: true, 24 + needsFullScope: true, 25 + execute: executeAction, 26 + dryRunDescribe: vi.fn(), 27 + validate: vi.fn(), 28 + toPds: vi.fn(), 29 + catalogue: { label: "", description: "", category: "pds", icon: null, available: true }, 30 + }; 31 + return { executeAction, recordDefinition }; 32 + }); 21 33 22 - vi.mock("@/actions/bsky-post.js", () => ({ 23 - executeBskyPost: vi.fn(), 24 - })); 34 + vi.mock("@/actions/bsky-post.js", () => { 35 + const executeBskyPost = vi.fn(); 36 + // Stub definition so the action registry can resolve `bsky-post` to this 37 + // mock without dragging the real executor and its dependencies into the 38 + // test. Only fields the handler reads are populated. 39 + const bskyPostDefinition = { 40 + type: "bsky-post", 41 + pdsType: "run.airglow.automation#bskyPostAction", 42 + recordProducing: true, 43 + needsFullScope: true, 44 + execute: executeBskyPost, 45 + dryRunDescribe: vi.fn(), 46 + validate: vi.fn(), 47 + toPds: vi.fn(), 48 + catalogue: { label: "", description: "", category: "bluesky", icon: null, available: true }, 49 + }; 50 + return { executeBskyPost, bskyPostDefinition }; 51 + }); 25 52 26 - vi.mock("@/actions/patch-record.js", () => ({ 27 - executePatchRecord: vi.fn(), 28 - })); 53 + vi.mock("@/actions/patch-record.js", () => { 54 + const executePatchRecord = vi.fn(); 55 + const patchRecordDefinition = { 56 + type: "patch-record", 57 + pdsType: "run.airglow.automation#patchRecordAction", 58 + recordProducing: true, 59 + needsFullScope: true, 60 + execute: executePatchRecord, 61 + dryRunDescribe: vi.fn(), 62 + validate: vi.fn(), 63 + toPds: vi.fn(), 64 + catalogue: { label: "", description: "", category: "pds", icon: null, available: true }, 65 + }; 66 + return { executePatchRecord, patchRecordDefinition }; 67 + }); 29 68 30 69 vi.mock("@/actions/fetcher.js", () => ({ 31 70 resolveFetches: vi.fn(),
+16 -142
lib/jetstream/handler.ts
··· 1 1 import { db } from "../db/index.js"; 2 2 import { deliveryLogs, type Action, isRecordProducingAction } from "../db/schema.js"; 3 - import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 - import { executeAction, type ActionResult } from "../actions/executor.js"; 5 - import { executeBskyPost } from "../actions/bsky-post.js"; 6 - import { executePatchRecord } from "../actions/patch-record.js"; 7 - import { executeMarginBookmark } from "../actions/margin-bookmark.js"; 8 - import { executeFollow } from "../actions/follow.js"; 3 + import { type ActionResult } from "../actions/executor.js"; 9 4 import { ACTION_REGISTRY } from "../actions/registry.js"; 10 - import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 11 5 import { resolveFetches } from "../actions/fetcher.js"; 12 6 import { isSuccess } from "../actions/delivery.js"; 13 - import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 7 + import { type FetchContext } from "../actions/template.js"; 14 8 import { parseAtUri } from "../pds/resolver.js"; 15 9 import { collectItems, matchItemConditions } from "./matcher.js"; 16 10 import { notifyAutomationChange, type MatchedEvent } from "./consumer.js"; ··· 24 18 ) => Promise<ActionResult>; 25 19 26 20 function handlerFor(action: Action): ActionHandler { 27 - // Registry-first: any action type registered in ACTION_REGISTRY dispatches 28 - // through its definition. Unregistered types fall through to the legacy 29 - // switch — phases 2-3 migrate one type at a time. 30 - const def = ACTION_REGISTRY[action.$type]; 31 - if (def) return def.execute; 32 - switch (action.$type) { 33 - case "bsky-post": 34 - return executeBskyPost; 35 - case "record": 36 - return executeAction; 37 - case "patch-record": 38 - return executePatchRecord; 39 - case "margin-bookmark": 40 - return executeMarginBookmark; 41 - case "follow": 42 - return executeFollow; 43 - default: 44 - return dispatch; 45 - } 21 + return ACTION_REGISTRY[action.$type].execute; 46 22 } 47 23 48 24 /** ··· 261 237 failedFetches: string[], 262 238 options?: { item?: unknown; forEachEmpty?: boolean; totalItems?: number }, 263 239 ) { 264 - let message: string | null = null; 265 - let error: string | null = null; 266 - let payload: string | null = null; 267 - 268 240 // forEach with no matching item: emit a single explanatory row instead of 269 241 // staying silent — otherwise the user sees nothing and can't tell whether 270 242 // the path or the conditions filtered everything out. 271 243 if (options?.forEachEmpty) { 272 244 const total = options.totalItems ?? 0; 273 - message = 245 + const message = 274 246 total === 0 275 247 ? `Would skip: forEach path "${action.forEach?.path}" resolved to no items` 276 248 : `Would skip: ${total} item(s) found at "${action.forEach?.path}" but none matched the per-item conditions`; ··· 292 264 const item = options?.item; 293 265 const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : ""; 294 266 295 - // Registry-first: if the action type has a registered definition, delegate 296 - // to its dryRunDescribe and skip the legacy if-chain. 297 - const def = ACTION_REGISTRY[action.$type]; 298 - if (def && failedFetches.length === 0) { 299 - const desc = await def.dryRunDescribe(action, { match, fetchContext, item, itemSuffix }); 300 - await db.insert(deliveryLogs).values({ 301 - automationUri: match.automation.uri, 302 - actionIndex, 303 - eventTimeUs: match.event.time_us, 304 - payload: capPayload(desc.payload), 305 - statusCode: null, 306 - message: desc.message, 307 - error: desc.error, 308 - dryRun: true, 309 - attempt: 1, 310 - createdAt: new Date(), 311 - }); 312 - return; 313 - } 267 + let message: string | null = null; 268 + let payload: string | null = null; 269 + let error: string | null = null; 314 270 315 271 if (failedFetches.length > 0) { 316 272 error = `Fetch failed: ${failedFetches.join(", ")}`; 317 - } else if (action.$type === "webhook") { 318 - const headerCount = action.headers ? Object.keys(action.headers).length : 0; 319 - const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : ""; 320 - message = `Would POST to ${action.callbackUrl}${headerNote}${itemSuffix}`; 321 - payload = JSON.stringify(buildPayload(match, fetchContext, item)); 322 - } else if (action.$type === "bsky-post") { 323 - try { 324 - const text = await renderTextTemplate( 325 - action.textTemplate, 326 - match.event, 327 - fetchContext, 328 - match.automation, 329 - item, 330 - ); 331 - message = `Would post to Bluesky${itemSuffix}`; 332 - payload = JSON.stringify({ text, langs: action.langs, labels: action.labels, item }); 333 - } catch (err) { 334 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 335 - } 336 - } else if (action.$type === "follow") { 337 - try { 338 - const subject = ( 339 - await renderTextTemplate(action.subject, match.event, fetchContext, match.automation, item) 340 - ).trim(); 341 - const target = FOLLOW_TARGETS[action.target]; 342 - const collection = target.collection; 343 - const appName = target.appName; 344 - // The built-in safety checks live inside executeFollow and aren't run in 345 - // dry-run (keeps the preview cheap). Advertise their presence in the 346 - // message so authors know the real run will skip cleanly on both edges. 347 - message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)${itemSuffix}`; 348 - payload = JSON.stringify({ collection, subject, item }); 349 - } catch (err) { 350 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 351 - } 352 - } else if (action.$type === "margin-bookmark") { 353 - try { 354 - const source = await renderTextTemplate( 355 - action.targetSource, 356 - match.event, 357 - fetchContext, 358 - match.automation, 359 - item, 360 - ); 361 - const body = action.bodyValue 362 - ? await renderTextTemplate( 363 - action.bodyValue, 364 - match.event, 365 - fetchContext, 366 - match.automation, 367 - item, 368 - ) 369 - : undefined; 370 - const tags: string[] = []; 371 - if (action.tags) { 372 - for (const tag of action.tags) { 373 - const rendered = await renderTextTemplate( 374 - tag, 375 - match.event, 376 - fetchContext, 377 - match.automation, 378 - item, 379 - ); 380 - if (rendered.trim()) tags.push(rendered.trim()); 381 - } 382 - } 383 - message = `Would bookmark ${source}${itemSuffix}`; 384 - payload = JSON.stringify({ source, body, tags, item }); 385 - } catch (err) { 386 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 387 - } 388 - } else if (action.$type === "semble-save") { 389 - // Unreachable: handled by the registry above. Narrows the union for the 390 - // remaining else-branch (record / patch-record). 391 - throw new Error("semble-save action should be handled by the registry"); 392 273 } else { 393 - try { 394 - const rendered = await renderTemplate( 395 - action.recordTemplate, 396 - match.event, 397 - fetchContext, 398 - match.automation, 399 - item, 400 - ); 401 - message = 402 - action.$type === "patch-record" 403 - ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${itemSuffix}` 404 - : `Would create record in ${action.targetCollection}${itemSuffix}`; 405 - payload = JSON.stringify(item !== undefined ? { rendered, item } : rendered); 406 - } catch (err) { 407 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 408 - } 274 + const desc = await ACTION_REGISTRY[action.$type].dryRunDescribe(action, { 275 + match, 276 + fetchContext, 277 + item, 278 + itemSuffix, 279 + }); 280 + message = desc.message; 281 + payload = desc.payload; 282 + error = desc.error; 409 283 } 410 284 411 285 await db.insert(deliveryLogs).values({