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: introduce action registry, migrate semble-save

Hugo a3f175d3 7de7d87d

+350 -134
+11 -24
app/routes/api/automations/[rkey].ts
··· 12 12 type PatchRecordAction, 13 13 type MarginBookmarkAction, 14 14 type FollowAction, 15 - type SembleSaveAction, 16 15 } from "@/db/schema.js"; 16 + import { ACTION_REGISTRY } from "@/actions/registry.js"; 17 17 import { config } from "@/config.js"; 18 18 import { isValidNsid } from "@/lexicons/resolver.js"; 19 19 import { getRecord, putRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; ··· 33 33 validateWebhookHeaders, 34 34 validateMarginBookmarkInput, 35 35 validateFollowInput, 36 - validateSembleSaveInput, 37 36 validateForEachInput, 38 37 resolveWantedDids, 39 38 } from "@/actions/validation.js"; ··· 476 475 ...(input.comment ? { comment: input.comment } : {}), 477 476 }); 478 477 actionResultNames.push(`action${actionIndex + 1}`); 479 - } else if (input.type === "semble-save") { 480 - const sembleSaveValidation = validateSembleSaveInput( 481 - input, 482 - fetchNames, 483 - actionResultNames, 484 - hasItem, 485 - ); 486 - if (!sembleSaveValidation.valid) { 487 - return c.json({ error: sembleSaveValidation.error }, 400); 488 - } 489 - 490 - newLocalActions.push({ 491 - $type: "semble-save", 492 - url: input.url, 493 - ...forEachField, 494 - ...(input.comment ? { comment: input.comment } : {}), 495 - } satisfies SembleSaveAction); 496 - newPdsActions.push({ 497 - $type: "run.airglow.automation#sembleSaveAction", 498 - url: input.url, 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, 499 484 ...forEachField, 500 485 ...(input.comment ? { comment: input.comment } : {}), 501 - }); 502 - actionResultNames.push(`action${actionIndex + 1}`); 486 + }; 487 + newLocalActions.push(local); 488 + newPdsActions.push(def.toPds(local)); 489 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 503 490 } else { 504 491 return c.json({ error: "Invalid action type" }, 400); 505 492 }
+11 -24
app/routes/api/automations/index.ts
··· 11 11 type PatchRecordAction, 12 12 type MarginBookmarkAction, 13 13 type FollowAction, 14 - type SembleSaveAction, 15 14 } from "@/db/schema.js"; 15 + import { ACTION_REGISTRY } from "@/actions/registry.js"; 16 16 import { config } from "@/config.js"; 17 17 import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 18 18 import { verifyCallback } from "@/automations/verify.js"; ··· 31 31 validateWebhookHeaders, 32 32 validateMarginBookmarkInput, 33 33 validateFollowInput, 34 - validateSembleSaveInput, 35 34 validateForEachInput, 36 35 resolveWantedDids, 37 36 } from "@/actions/validation.js"; ··· 393 392 ...(input.comment ? { comment: input.comment } : {}), 394 393 }); 395 394 actionResultNames.push(`action${actionIndex + 1}`); 396 - } else if (input.type === "semble-save") { 397 - const sembleSaveValidation = validateSembleSaveInput( 398 - input, 399 - fetchNames, 400 - actionResultNames, 401 - hasItem, 402 - ); 403 - if (!sembleSaveValidation.valid) { 404 - return c.json({ error: sembleSaveValidation.error }, 400); 405 - } 406 - 407 - localActions.push({ 408 - $type: "semble-save", 409 - url: input.url, 410 - ...forEachField, 411 - ...(input.comment ? { comment: input.comment } : {}), 412 - } satisfies SembleSaveAction); 413 - pdsActions.push({ 414 - $type: "run.airglow.automation#sembleSaveAction", 415 - url: input.url, 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, 416 401 ...forEachField, 417 402 ...(input.comment ? { comment: input.comment } : {}), 418 - }); 419 - actionResultNames.push(`action${actionIndex + 1}`); 403 + }; 404 + localActions.push(local); 405 + pdsActions.push(def.toPds(local)); 406 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 420 407 } else { 421 408 return c.json({ error: "Invalid action type" }, 400); 422 409 }
+107
lib/actions/registry.ts
··· 1 + import type { Action } from "../db/schema.js"; 2 + import type { PdsAction } from "../automations/pds.js"; 3 + import type { ColorKey } from "../automations/follow-targets.js"; 4 + import type { 5 + ActionHandler, 6 + DryRunContext, 7 + DryRunDescription, 8 + ValidationContext, 9 + } from "./types.js"; 10 + 11 + /** $type discriminator shared between the local Action union and the registry 12 + * key. Derived from `Action` so adding a new variant is a type error here. */ 13 + export type ActionType = Action["$type"]; 14 + 15 + /** Catalogue tile metadata that today lives in `action-catalogue.ts`. Mirrored 16 + * here so each registry entry owns its UI footprint. The hand-curated 17 + * ACTION_CATALOGUE array in action-catalogue.ts becomes a derived view in 18 + * Phase 4. */ 19 + export type CatalogueTile = { 20 + label: string; 21 + description: string; 22 + category: "webhook" | "bluesky" | "apps" | "pds"; 23 + /** Lucide icon ref. Type left loose because the icon module is itself 24 + * loosely typed and we don't want to force a pre-Phase-4 import here. */ 25 + icon: unknown; 26 + available: boolean; 27 + /** Override for the tile's data-cat color (e.g. follow-sifa is sifa-blue 28 + * while still living under "Bluesky"). */ 29 + colorKey?: ColorKey; 30 + /** Domain whose favicon is rendered next to the icon. */ 31 + faviconDomain?: string; 32 + }; 33 + 34 + /** Validation outcome for the API POST/PATCH routes. The single function call 35 + * collapses what's currently a per-action if-branch in both routes. PDS 36 + * serialization is a separate concern (see `toPds`) so PATCH can reproject a 37 + * stored action without re-running input validation. */ 38 + export type ValidateResult<TAction extends Action> = 39 + | { ok: true; local: TAction } 40 + | { ok: false; error: string; status?: number }; 41 + 42 + /** Single source of truth per action type. Subsequent phases populate 43 + * `ACTION_REGISTRY` with one of these per `$type`; the dispatchers in 44 + * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts then 45 + * reduce to `ACTION_REGISTRY[type].method(...)` lookups. */ 46 + export type ActionDefinition< 47 + TAction extends Action = Action, 48 + TInput = unknown, 49 + TPdsAction extends PdsAction = PdsAction, 50 + > = { 51 + type: TAction["$type"]; 52 + pdsType: TPdsAction["$type"]; 53 + 54 + /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls 55 + * in API routes. */ 56 + recordProducing: boolean; 57 + 58 + /** Replaces the per-$type checks in `actionsNeedFullScope`. */ 59 + needsFullScope: boolean; 60 + 61 + /** Validate a raw API input and produce the local action shape. Async to 62 + * accommodate webhooks (callback verification). */ 63 + validate(input: TInput, ctx: ValidationContext): Promise<ValidateResult<TAction>>; 64 + 65 + /** Pure local→PDS projection. Called by the API write paths and by 66 + * `pds-serialize.toPdsAction` for stored-action re-serialization. */ 67 + toPds(action: TAction): TPdsAction; 68 + 69 + /** Runtime executor wired into `handlerFor()` today. */ 70 + execute: ActionHandler; 71 + 72 + /** Build the dry-run delivery_logs row content for this action type. */ 73 + dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>; 74 + 75 + /** Catalogue tile shown in the form's "add action" picker. */ 76 + catalogue: CatalogueTile; 77 + 78 + /** Optional override for how the GET /api/automations route serializes a 79 + * stored action — webhooks use this to strip the secret. */ 80 + serializeForApi?(action: TAction): unknown; 81 + }; 82 + 83 + /** Insertion order is the canonical action ordering used everywhere a list of 84 + * action types matters (catalogue tiles, label tables, lexicon refs, drift 85 + * tests). Phase 2 starts populating ACTION_REGISTRY by type. */ 86 + export const ACTION_TYPES: readonly ActionType[] = [ 87 + "webhook", 88 + "bsky-post", 89 + "follow", 90 + "margin-bookmark", 91 + "semble-save", 92 + "record", 93 + "patch-record", 94 + ] as const; 95 + 96 + /** Map of $type → definition. Phase 2 fills this in one entry at a time; 97 + * consumers begin reading from it incrementally while the legacy switches 98 + * remain in place. Empty until then so this scaffolding commit is a pure 99 + * type-level addition. */ 100 + import { sembleSaveDefinition } from "./semble-save.js"; 101 + 102 + /** Map of $type → definition. Filled in incrementally; consumers begin 103 + * reading from it while the legacy switches remain in place as fallback. */ 104 + // 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>>> = { 106 + "semble-save": sembleSaveDefinition, 107 + };
+134 -1
lib/actions/semble-save.ts
··· 1 1 import { type SembleSaveAction } 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 { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 6 import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js"; 7 + import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js"; 8 + import { BookmarkPlus } from "../../app/icons.js"; 9 + import type { ColorKey } from "../automations/follow-targets.js"; 10 + import type { ActionDefinition } from "./registry.js"; 11 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 12 8 13 const TARGET_COLLECTION = "network.cosmik.card"; 14 + 15 + // Allow either a literal http(s):// prefix or a leading {{...}} placeholder 16 + // (so the entire URL can come from event/action data). A literal non-http 17 + // scheme like `javascript:` is rejected here so it can't be persisted to the 18 + // user's PDS; the runtime SSRF guard remains the real security boundary. 19 + const URL_OK_RE = /^(https?:\/\/|\{\{)/i; 9 20 10 21 async function buildRecord( 11 22 match: MatchedEvent, ··· 85 96 execute, 86 97 (action) => JSON.stringify({ url: action.url }), 87 98 ); 99 + 100 + type SembleSaveInput = { 101 + type: "semble-save"; 102 + url: string; 103 + }; 104 + 105 + type PdsSembleSaveAction = { 106 + $type: "run.airglow.automation#sembleSaveAction"; 107 + url: string; 108 + forEach?: SembleSaveAction["forEach"]; 109 + comment?: string; 110 + }; 111 + 112 + async function validate( 113 + input: SembleSaveInput, 114 + ctx: ValidationContext, 115 + ): Promise<{ ok: true; local: SembleSaveAction } | { ok: false; error: string; status?: number }> { 116 + if (!input.url || typeof input.url !== "string" || !input.url.trim()) { 117 + return { ok: false, error: "url is required for semble-save actions" }; 118 + } 119 + if (input.url.length > SEMBLE_SAVE_LIMITS.url) { 120 + return { ok: false, error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less` }; 121 + } 122 + if (!URL_OK_RE.test(input.url)) { 123 + return { 124 + ok: false, 125 + error: "url must start with http://, https://, or a {{placeholder}}", 126 + }; 127 + } 128 + const urlValidation = validateTextTemplate( 129 + input.url, 130 + ctx.fetchNames, 131 + ctx.actionResultNames, 132 + ctx.hasItem, 133 + ); 134 + if (!urlValidation.valid) { 135 + return { ok: false, error: `url: ${urlValidation.error}` }; 136 + } 137 + 138 + // The route adds forEach/comment after this returns — they're orthogonal 139 + // to the action-specific fields and validated centrally. 140 + const local: SembleSaveAction = { $type: "semble-save", url: input.url }; 141 + return { ok: true, local }; 142 + } 143 + 144 + function toPds(action: SembleSaveAction): PdsSembleSaveAction { 145 + return { 146 + $type: "run.airglow.automation#sembleSaveAction", 147 + url: action.url, 148 + ...(action.forEach ? { forEach: action.forEach } : {}), 149 + ...(action.comment ? { comment: action.comment } : {}), 150 + }; 151 + } 152 + 153 + function redactUserinfo(url: string): string { 154 + try { 155 + const u = new URL(url); 156 + if (u.username || u.password) { 157 + u.username = ""; 158 + u.password = ""; 159 + return u.toString(); 160 + } 161 + return url; 162 + } catch { 163 + return url; 164 + } 165 + } 166 + 167 + async function dryRunDescribe( 168 + action: SembleSaveAction, 169 + ctx: DryRunContext, 170 + ): Promise<DryRunDescription> { 171 + try { 172 + const url = ( 173 + await renderTextTemplate( 174 + action.url, 175 + ctx.match.event, 176 + ctx.fetchContext, 177 + ctx.match.automation, 178 + ctx.item, 179 + ) 180 + ).trim(); 181 + // Redact credentials in userinfo before persisting to delivery_logs: 182 + // event-derived URLs may legitimately carry tokens we shouldn't store. 183 + const safeUrl = redactUserinfo(url); 184 + return { 185 + message: `Would save ${safeUrl || "(empty)"} to Semble${ctx.itemSuffix}`, 186 + payload: JSON.stringify({ url: safeUrl, item: ctx.item }), 187 + error: null, 188 + }; 189 + } catch (err) { 190 + return { 191 + message: null, 192 + payload: null, 193 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 194 + }; 195 + } 196 + } 197 + 198 + export const sembleSaveDefinition: ActionDefinition< 199 + SembleSaveAction, 200 + SembleSaveInput, 201 + PdsSembleSaveAction 202 + > = { 203 + type: "semble-save", 204 + pdsType: "run.airglow.automation#sembleSaveAction", 205 + recordProducing: true, 206 + needsFullScope: true, 207 + validate, 208 + toPds, 209 + execute: executeSembleSave, 210 + dryRunDescribe, 211 + catalogue: { 212 + label: "Save on Semble", 213 + description: "Save a URL as a card on Semble", 214 + category: "apps", 215 + icon: BookmarkPlus, 216 + available: true, 217 + colorKey: "cosmik" satisfies ColorKey, 218 + faviconDomain: "semble.so", 219 + }, 220 + };
+37
lib/actions/types.ts
··· 1 + import type { MatchedEvent } from "../jetstream/consumer.js"; 2 + import type { ActionResult } from "./delivery.js"; 3 + import type { FetchContext } from "./template.js"; 4 + 5 + /** Runtime executor signature: handler.ts dispatches to one of these per action. */ 6 + export type ActionHandler = ( 7 + match: MatchedEvent, 8 + actionIndex: number, 9 + fetchContext?: FetchContext, 10 + item?: unknown, 11 + ) => Promise<ActionResult>; 12 + 13 + /** Context passed to per-action validators by the API POST/PATCH routes. 14 + * Names of declared fetches and prior record-producing actions, plus whether 15 + * this action runs inside a forEach (so validators can allow `{{item.*}}`). */ 16 + export type ValidationContext = { 17 + fetchNames: string[]; 18 + actionResultNames: string[]; 19 + hasItem: boolean; 20 + }; 21 + 22 + /** Outcome of `dryRunDescribe`: the three columns the handler writes into a 23 + * delivery_logs row for a dry-run fire. Any field may be null. */ 24 + export type DryRunDescription = { 25 + message: string | null; 26 + payload: string | null; 27 + error: string | null; 28 + }; 29 + 30 + /** Context passed to per-action `dryRunDescribe`: everything `logDryRun` 31 + * currently has in scope plus the per-iteration `item` for forEach. */ 32 + export type DryRunContext = { 33 + match: MatchedEvent; 34 + fetchContext: FetchContext; 35 + item: unknown; 36 + itemSuffix: string; 37 + };
+1 -43
lib/actions/validation.ts
··· 1 1 import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js"; 2 - import { 3 - AUTOMATION_LIMITS, 4 - MARGIN_BOOKMARK_LIMITS, 5 - SEMBLE_SAVE_LIMITS, 6 - } from "../automations/limits.js"; 2 + import { AUTOMATION_LIMITS, MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; 7 3 import { nsidRequiresWantedDids } from "../lexicons/match.js"; 8 4 import { isValidNsid } from "../lexicons/resolver.js"; 9 5 import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js"; ··· 560 556 } 561 557 562 558 return { valid: true, tags }; 563 - } 564 - 565 - type SembleSaveInput = { 566 - url: string; 567 - }; 568 - 569 - // Allow either a literal http(s):// prefix or a leading {{...}} placeholder 570 - // (so the entire URL can come from event/action data). A literal non-http 571 - // scheme like `javascript:` is rejected here so it can't be persisted to the 572 - // user's PDS; the runtime SSRF guard remains the real security boundary. 573 - const SEMBLE_SAVE_URL_OK_RE = /^(https?:\/\/|\{\{)/i; 574 - 575 - export function validateSembleSaveInput( 576 - input: SembleSaveInput, 577 - fetchNames: string[], 578 - actionNames: string[], 579 - hasItem?: boolean, 580 - ): { valid: true } | { valid: false; error: string } { 581 - if (!input.url || typeof input.url !== "string" || !input.url.trim()) { 582 - return { valid: false, error: "url is required for semble-save actions" }; 583 - } 584 - if (input.url.length > SEMBLE_SAVE_LIMITS.url) { 585 - return { 586 - valid: false, 587 - error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less`, 588 - }; 589 - } 590 - if (!SEMBLE_SAVE_URL_OK_RE.test(input.url)) { 591 - return { 592 - valid: false, 593 - error: "url must start with http://, https://, or a {{placeholder}}", 594 - }; 595 - } 596 - const urlValidation = validateTextTemplate(input.url, fetchNames, actionNames, hasItem); 597 - if (!urlValidation.valid) { 598 - return { valid: false, error: `url: ${urlValidation.error}` }; 599 - } 600 - return { valid: true }; 601 559 } 602 560 603 561 const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+10 -5
lib/auth/client.ts
··· 12 12 import { config } from "../config.js"; 13 13 import { db } from "../db/index.js"; 14 14 import { automations } from "../db/schema.js"; 15 + import { ACTION_REGISTRY } from "../actions/registry.js"; 15 16 import { 16 17 resolveDidToHandle as pdsResolveDidToHandle, 17 18 resolveHandle as pdsResolveHandle, ··· 31 32 32 33 /** Returns true if any action writes to a collection beyond run.airglow.automation. */ 33 34 export function actionsNeedFullScope(actions: ActionLike[]): boolean { 34 - return actions.some( 35 - (a) => 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 + const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY]; 39 + if (def) return def.needsFullScope; 40 + return ( 36 41 a.$type === "bsky-post" || 37 42 a.$type === "record" || 38 43 a.$type === "patch-record" || 39 44 a.$type === "margin-bookmark" || 40 - a.$type === "follow" || 41 - a.$type === "semble-save", 42 - ); 45 + a.$type === "follow" 46 + ); 47 + }); 43 48 } 44 49 45 50 /** Check if the granted scope covers all collections needed by actions. */
+10 -8
lib/automations/pds-serialize.ts
··· 1 1 import type { Action, ForEachConfig } from "../db/schema.js"; 2 2 import type { PdsAction, PdsForEachConfig } from "./pds.js"; 3 + import { ACTION_REGISTRY } from "../actions/registry.js"; 3 4 4 5 function toPdsForEach(fe: ForEachConfig | undefined): PdsForEachConfig | undefined { 5 6 if (!fe) return undefined; ··· 12 13 /** Serialize a stored Action into its PDS-record shape. Split from pds.ts so 13 14 * tests that mock the OAuth-backed PDS client don't need to re-stub this. */ 14 15 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 + 15 25 const forEach = toPdsForEach(a.forEach); 16 26 const forEachField = forEach ? { forEach } : {}; 17 27 ··· 58 68 $type: "run.airglow.automation#followAction", 59 69 target: a.target, 60 70 subject: a.subject, 61 - ...forEachField, 62 - ...(a.comment ? { comment: a.comment } : {}), 63 - }; 64 - } 65 - if (a.$type === "semble-save") { 66 - return { 67 - $type: "run.airglow.automation#sembleSaveAction", 68 - url: a.url, 69 71 ...forEachField, 70 72 ...(a.comment ? { comment: a.comment } : {}), 71 73 };
+29 -29
lib/jetstream/handler.ts
··· 6 6 import { executePatchRecord } from "../actions/patch-record.js"; 7 7 import { executeMarginBookmark } from "../actions/margin-bookmark.js"; 8 8 import { executeFollow } from "../actions/follow.js"; 9 - import { executeSembleSave } from "../actions/semble-save.js"; 9 + import { ACTION_REGISTRY } from "../actions/registry.js"; 10 10 import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 11 11 import { resolveFetches } from "../actions/fetcher.js"; 12 12 import { isSuccess } from "../actions/delivery.js"; ··· 24 24 ) => Promise<ActionResult>; 25 25 26 26 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; 27 32 switch (action.$type) { 28 33 case "bsky-post": 29 34 return executeBskyPost; ··· 35 40 return executeMarginBookmark; 36 41 case "follow": 37 42 return executeFollow; 38 - case "semble-save": 39 - return executeSembleSave; 40 43 default: 41 44 return dispatch; 42 45 } ··· 289 292 const item = options?.item; 290 293 const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : ""; 291 294 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 + } 314 + 292 315 if (failedFetches.length > 0) { 293 316 error = `Fetch failed: ${failedFetches.join(", ")}`; 294 317 } else if (action.$type === "webhook") { ··· 363 386 error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 364 387 } 365 388 } else if (action.$type === "semble-save") { 366 - try { 367 - const url = ( 368 - await renderTextTemplate(action.url, match.event, fetchContext, match.automation, item) 369 - ).trim(); 370 - // Redact credentials in userinfo before persisting to delivery_logs: 371 - // event-derived URLs may legitimately carry tokens we shouldn't store. 372 - const safeUrl = redactUserinfo(url); 373 - message = `Would save ${safeUrl || "(empty)"} to Semble${itemSuffix}`; 374 - payload = JSON.stringify({ url: safeUrl, item }); 375 - } catch (err) { 376 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 377 - } 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"); 378 392 } else { 379 393 try { 380 394 const rendered = await renderTemplate( ··· 410 424 411 425 function truncateForLog(s: string, max = 120): string { 412 426 return s.length <= max ? s : s.slice(0, max) + "..."; 413 - } 414 - 415 - function redactUserinfo(url: string): string { 416 - try { 417 - const u = new URL(url); 418 - if (u.username || u.password) { 419 - u.username = ""; 420 - u.password = ""; 421 - return u.toString(); 422 - } 423 - return url; 424 - } catch { 425 - return url; 426 - } 427 427 } 428 428 429 429 /**