Something went wrong. Try again.
Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279import { type PatchRecordAction } from "../db/schema.js";import { patchArbitraryRecord } from "../automations/pds.js";import { fetchRecord, parseAtUri } from "../pds/resolver.js";import { renderTemplate, renderJsonPatchOps, resolveUriTemplate, validateTemplate, validateBaseRecordUri, type FetchContext,} from "./template.js";import { applyJsonPatch, JsonPatchError, type JsonPatchOp } from "./json-patch.js";import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js";import type { MatchedEvent } from "../jetstream/consumer.js";import { isValidNsid } from "../lexicons/resolver.js";import type { ActionDefinition } from "./registry.js";import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
async function execute( match: MatchedEvent, action: PatchRecordAction, fetchContext?: FetchContext, item?: unknown,): Promise<ActionResult> { const { automation, event } = match; const isDeep = action.patchMode === "deep";
const resolvedUri = resolveUriTemplate( action.baseRecordUri, event, fetchContext, automation.did, item, ); let parsed: { did: string; collection: string; rkey: string }; try { parsed = parseAtUri(resolvedUri); } catch { return { statusCode: 0, error: `Invalid base record URI: ${resolvedUri}` }; }
// The action's PDS write always targets `targetCollection` on the owner's // repo, but the rkey comes from the resolved URI. If the URI's collection // doesn't match `targetCollection`, the user has wired the action against // data they didn't intend to patch — and an attacker who can inject AT URIs // into the iteration source (e.g. crafted facets in a watched post) would // otherwise be able to choose which of the owner's records get clobbered. // Reject as a deterministic 400 so retries don't repeat the same mistake. if (parsed.collection !== action.targetCollection) { return { statusCode: 400, error: `Resolved baseRecordUri collection "${parsed.collection}" does not match targetCollection "${action.targetCollection}"`, }; }
// Fetch base record and render patch template in parallel (they're independent) const renderPromise = isDeep ? renderJsonPatchOps(action.recordTemplate, event, fetchContext, automation, item) : renderTemplate(action.recordTemplate, event, fetchContext, automation, item); const [fetchResult, templateResult] = await Promise.allSettled([ fetchRecord(resolvedUri), renderPromise, ]);
if (fetchResult.status === "rejected") { const err = fetchResult.reason; return { statusCode: 0, error: `Failed to fetch base record: ${err instanceof Error ? err.message : String(err)}`, }; } if (templateResult.status === "rejected") { const err = templateResult.reason; return { statusCode: 0, error: `Template error: ${err instanceof Error ? err.message : String(err)}`, }; }
const baseRecord = fetchResult.value.record; const baseCid = fetchResult.value.cid;
// Strip $type from the base before merging/patching: putArbitraryRecord // re-adds the collection NSID as $type, and a JSON Patch op that targets a // path through $type would silently rewrite the record's type discriminator. const { $type: _, ...baseFields } = baseRecord;
let merged: Record<string, unknown>; if (isDeep) { try { const patched = applyJsonPatch(baseFields, templateResult.value as JsonPatchOp[]); if (patched === null || typeof patched !== "object" || Array.isArray(patched)) { return { statusCode: 0, error: "JSON Patch produced a non-object result" }; } merged = patched as Record<string, unknown>; } catch (err) { const msg = err instanceof JsonPatchError ? err.message : err instanceof Error ? err.message : String(err); return { statusCode: 0, error: `JSON Patch ${msg}` }; } } else { const patch = templateResult.value as Record<string, unknown>; merged = { ...baseFields, ...patch }; }
try { const result = await patchArbitraryRecord( automation.did, parsed.collection, parsed.rkey, merged, baseCid, ); return { statusCode: 200, uri: result.uri, cid: result.cid }; } catch (err) { return parsePdsError(err); }}
/** Execute a patch-record action for a matched event. */export const executePatchRecord = wrapWithDelivery( (match, i) => match.automation.actions[i] as PatchRecordAction, execute, (action) => JSON.stringify({ targetCollection: action.targetCollection, baseRecordUri: action.baseRecordUri, recordTemplate: action.recordTemplate, ...(action.patchMode ? { patchMode: action.patchMode } : {}), }),);
type PatchRecordInput = { type: "patch-record"; targetCollection: string; baseRecordUri: string; recordTemplate: string; patchMode?: "shallow" | "deep";};
type PdsPatchRecordAction = { $type: "run.airglow.automation#patchRecordAction"; targetCollection: string; baseRecordUri: string; recordTemplate: string; patchMode?: "shallow" | "deep"; forEach?: PatchRecordAction["forEach"]; schedule?: PatchRecordAction["schedule"]; comment?: string;};
async function validate( input: PatchRecordInput, ctx: ValidationContext,): Promise<{ ok: true; local: PatchRecordAction } | { ok: false; error: string; status?: number }> { if (!input.targetCollection) { return { ok: false, error: "targetCollection is required for patch-record actions" }; } if (!isValidNsid(input.targetCollection)) { return { ok: false, error: "Invalid target collection NSID" }; } if (!input.baseRecordUri) { return { ok: false, error: "baseRecordUri is required for patch-record actions" }; } const uriValidation = validateBaseRecordUri( input.baseRecordUri, ctx.fetchNames, ctx.actionResultNames, ctx.hasItem, ctx.variableNames, ); if (!uriValidation.valid) { return { ok: false, error: uriValidation.error }; } if (!input.recordTemplate) { return { ok: false, error: "recordTemplate is required for patch-record actions" }; } if ( input.patchMode !== undefined && input.patchMode !== "shallow" && input.patchMode !== "deep" ) { return { ok: false, error: `Invalid patchMode "${String(input.patchMode)}" (expected "shallow" or "deep")`, }; } const templateValidation = validateTemplate( input.recordTemplate, ctx.fetchNames, ctx.actionResultNames, ctx.hasItem, ctx.variableNames, { shape: input.patchMode === "deep" ? "array" : "object" }, ); if (!templateValidation.valid) { return { ok: false, error: templateValidation.error }; }
const local: PatchRecordAction = { $type: "patch-record", targetCollection: input.targetCollection, baseRecordUri: input.baseRecordUri, recordTemplate: input.recordTemplate, ...(input.patchMode === "deep" ? { patchMode: "deep" as const } : {}), }; return { ok: true, local };}
function toPds(action: PatchRecordAction): PdsPatchRecordAction { return { $type: "run.airglow.automation#patchRecordAction", targetCollection: action.targetCollection, baseRecordUri: action.baseRecordUri, recordTemplate: action.recordTemplate, ...(action.patchMode === "deep" ? { patchMode: "deep" as const } : {}), ...(action.forEach ? { forEach: action.forEach } : {}), ...(action.schedule ? { schedule: action.schedule } : {}), ...(action.comment ? { comment: action.comment } : {}), };}
async function dryRunDescribe( action: PatchRecordAction, ctx: DryRunContext,): Promise<DryRunDescription> { const isDeep = action.patchMode === "deep"; try { const rendered = isDeep ? await renderJsonPatchOps( action.recordTemplate, ctx.match.event, ctx.fetchContext, ctx.match.automation, ctx.item, ) : await renderTemplate( action.recordTemplate, ctx.match.event, ctx.fetchContext, ctx.match.automation, ctx.item, ); const mode = isDeep ? " (JSON Patch)" : ""; return { message: `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${mode}${ctx.itemSuffix}`, payload: JSON.stringify(ctx.item !== undefined ? { rendered, item: ctx.item } : rendered), error: null, }; } catch (err) { return { message: null, payload: null, error: `Template error: ${err instanceof Error ? err.message : String(err)}`, }; }}
export const patchRecordDefinition: ActionDefinition< PatchRecordAction, PatchRecordInput, PdsPatchRecordAction> = { type: "patch-record", pdsType: "run.airglow.automation#patchRecordAction", displayLabel: "Update Record", successLabel: "Record updated", recordProducing: true, needsFullScope: true, validate, toPds, execute: executePatchRecord, dryRunDescribe,};