···7272 /** Build the dry-run delivery_logs row content for this action type. */
7373 dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>;
74747575- /** Catalogue tile shown in the form's "add action" picker. */
7676- catalogue: CatalogueTile;
7575+ /** Catalogue tile shown in the form's "add action" picker. Optional
7676+ * because some action types map to multiple tiles (e.g. `follow` expands
7777+ * into one tile per FOLLOW_TARGETS entry); those keep their tile list
7878+ * hand-curated in `action-catalogue.ts`. */
7979+ catalogue?: CatalogueTile;
77807881 /** Optional override for how the GET /api/automations route serializes a
7982 * stored action — webhooks use this to strip the secret. */
···98101 * remain in place. Empty until then so this scaffolding commit is a pure
99102 * type-level addition. */
100103import { sembleSaveDefinition } from "./semble-save.js";
104104+import { marginBookmarkDefinition } from "./margin-bookmark.js";
105105+import { followDefinition } from "./follow.js";
106106+import { bskyPostDefinition } from "./bsky-post.js";
107107+import { patchRecordDefinition } from "./patch-record.js";
108108+import { recordDefinition } from "./executor.js";
109109+import { webhookDefinition } from "./webhook.js";
101110102102-/** Map of $type → definition. Filled in incrementally; consumers begin
103103- * reading from it while the legacy switches remain in place as fallback. */
111111+/** Map of $type → definition. Every action type is registered after Phase 3.
112112+ * Subsequent dispatchers consume the registry directly and need no fallback. */
104113// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site.
105105-export const ACTION_REGISTRY: Partial<Record<ActionType, ActionDefinition<any, any, any>>> = {
114114+export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = {
106115 "semble-save": sembleSaveDefinition,
116116+ "margin-bookmark": marginBookmarkDefinition,
117117+ follow: followDefinition,
118118+ "bsky-post": bskyPostDefinition,
119119+ "patch-record": patchRecordDefinition,
120120+ record: recordDefinition,
121121+ webhook: webhookDefinition,
107122};
+8-1
lib/actions/types.ts
···11import type { MatchedEvent } from "../jetstream/consumer.js";
22+import type { Action } from "../db/schema.js";
23import type { ActionResult } from "./delivery.js";
34import type { FetchContext } from "./template.js";
45···12131314/** Context passed to per-action validators by the API POST/PATCH routes.
1415 * Names of declared fetches and prior record-producing actions, plus whether
1515- * this action runs inside a forEach (so validators can allow `{{item.*}}`). */
1616+ * this action runs inside a forEach (so validators can allow `{{item.*}}`),
1717+ * the automation's lexicon NSID (used by webhook for callback verification),
1818+ * and the actions previously stored on this automation (PATCH only — empty on
1919+ * POST). The latter lets webhook preserve an existing secret when its
2020+ * callbackUrl is unchanged across the update. */
1621export type ValidationContext = {
1722 fetchNames: string[];
1823 actionResultNames: string[];
1924 hasItem: boolean;
2525+ lexicon: string;
2626+ existingActions: Action[];
2027};
21282229/** Outcome of `dryRunDescribe`: the three columns the handler writes into a
-40
lib/actions/validation.test.ts
···33 validateWantedDids,
44 validateFetchConditionInputs,
55 validateFetchSearchStep,
66- validateFollowInput,
76 type FetchConditionInput,
87 type FetchSearchInput,
98} from "./validation.js";
···270269 expect(res.valid).toBe(false);
271270 });
272271});
273273-274274-describe("validateFollowInput", () => {
275275- it("accepts a literal DID subject for each target", () => {
276276- for (const target of ["bluesky", "tangled", "sifa"] as const) {
277277- const res = validateFollowInput(
278278- { target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" },
279279- [],
280280- [],
281281- );
282282- expect(res.valid).toBe(true);
283283- }
284284- });
285285-286286- it("accepts placeholders in subject (validated at render time)", () => {
287287- const res = validateFollowInput({ target: "bluesky", subject: "{{event.did}}" }, [], []);
288288- expect(res.valid).toBe(true);
289289- });
290290-291291- it("rejects an unknown target", () => {
292292- const res = validateFollowInput(
293293- { target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" },
294294- [],
295295- [],
296296- );
297297- expect(res.valid).toBe(false);
298298- if (!res.valid) expect(res.error).toMatch(/target/);
299299- });
300300-301301- it("rejects empty subject", () => {
302302- const res = validateFollowInput({ target: "bluesky", subject: "" }, [], []);
303303- expect(res.valid).toBe(false);
304304- });
305305-306306- it("rejects unknown placeholders in subject", () => {
307307- const res = validateFollowInput({ target: "bluesky", subject: "{{mystery.field}}" }, [], []);
308308- expect(res.valid).toBe(false);
309309- if (!res.valid) expect(res.error).toMatch(/subject/);
310310- });
311311-});
+3-145
lib/actions/validation.ts
···11import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js";
22-import { AUTOMATION_LIMITS, MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js";
22+import { AUTOMATION_LIMITS } from "../automations/limits.js";
33import { nsidRequiresWantedDids } from "../lexicons/match.js";
44import { isValidNsid } from "../lexicons/resolver.js";
55-import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js";
55+import { PLACEHOLDER_RE } from "./template.js";
66import type { Condition, FetchStepSearch, ForEachConfig } from "../db/schema.js";
77-import {
88- FOLLOW_TARGETS,
99- VALID_FOLLOW_TARGETS,
1010- type FollowTarget,
1111-} from "../automations/follow-targets.js";
77+import { type FollowTarget } from "../automations/follow-targets.js";
128139export type ForEachInput = {
1410 path: string;
···418414 ...(step.comment ? { comment: step.comment } : {}),
419415 },
420416 };
421421-}
422422-423423-type FollowInput = {
424424- target: string;
425425- subject: string;
426426-};
427427-428428-const FOLLOW_SUBJECT_MAX = 512;
429429-430430-/** Validate a follow action input. The subject supports `{{placeholders}}`
431431- * which are resolved at execution time, so we validate it as a text template,
432432- * not as a literal DID. */
433433-export function validateFollowInput(
434434- input: FollowInput,
435435- fetchNames: string[],
436436- actionNames: string[],
437437- hasItem?: boolean,
438438-): { valid: true } | { valid: false; error: string } {
439439- if (!input.target || typeof input.target !== "string") {
440440- return { valid: false, error: "target is required for follow actions" };
441441- }
442442- if (!VALID_FOLLOW_TARGETS.has(input.target)) {
443443- return {
444444- valid: false,
445445- error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`,
446446- };
447447- }
448448- if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) {
449449- return { valid: false, error: "subject is required for follow actions" };
450450- }
451451- if (input.subject.length > FOLLOW_SUBJECT_MAX) {
452452- return {
453453- valid: false,
454454- error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less`,
455455- };
456456- }
457457- const templateCheck = validateTextTemplate(input.subject, fetchNames, actionNames, hasItem);
458458- if (!templateCheck.valid) {
459459- return { valid: false, error: `subject: ${templateCheck.error}` };
460460- }
461461- return { valid: true };
462462-}
463463-464464-type MarginBookmarkInput = {
465465- targetSource: string;
466466- bodyValue?: string;
467467- tags?: string[];
468468-};
469469-470470-// Allow either a literal http(s):// prefix or a leading {{...}} placeholder.
471471-// Mirrors the semble-save form check; the runtime guard inside the executor
472472-// remains the real boundary.
473473-const MARGIN_BOOKMARK_URL_OK_RE = /^(https?:\/\/|\{\{)/i;
474474-475475-/** Validate a margin-bookmark action input. Returns the trimmed, filtered tags on success. */
476476-export function validateMarginBookmarkInput(
477477- input: MarginBookmarkInput,
478478- fetchNames: string[],
479479- actionNames: string[],
480480- hasItem?: boolean,
481481-): { valid: true; tags: string[] } | { valid: false; error: string } {
482482- if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) {
483483- return { valid: false, error: "targetSource is required for margin-bookmark actions" };
484484- }
485485- if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) {
486486- return {
487487- valid: false,
488488- error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`,
489489- };
490490- }
491491- if (!MARGIN_BOOKMARK_URL_OK_RE.test(input.targetSource)) {
492492- return {
493493- valid: false,
494494- error: "targetSource must start with http://, https://, or a {{placeholder}}",
495495- };
496496- }
497497- const sourceValidation = validateTextTemplate(
498498- input.targetSource,
499499- fetchNames,
500500- actionNames,
501501- hasItem,
502502- );
503503- if (!sourceValidation.valid) {
504504- return { valid: false, error: `targetSource: ${sourceValidation.error}` };
505505- }
506506-507507- if (input.bodyValue !== undefined) {
508508- if (typeof input.bodyValue !== "string") {
509509- return { valid: false, error: "bodyValue must be a string" };
510510- }
511511- if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) {
512512- return {
513513- valid: false,
514514- error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`,
515515- };
516516- }
517517- if (input.bodyValue.trim()) {
518518- const bodyValidation = validateTextTemplate(
519519- input.bodyValue,
520520- fetchNames,
521521- actionNames,
522522- hasItem,
523523- );
524524- if (!bodyValidation.valid) {
525525- return { valid: false, error: `bodyValue: ${bodyValidation.error}` };
526526- }
527527- }
528528- }
529529-530530- const tags: string[] = [];
531531- if (input.tags !== undefined) {
532532- if (!Array.isArray(input.tags)) {
533533- return { valid: false, error: "tags must be an array of strings" };
534534- }
535535- if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) {
536536- return { valid: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` };
537537- }
538538- for (const tag of input.tags) {
539539- if (typeof tag !== "string") {
540540- return { valid: false, error: "Each tag must be a string" };
541541- }
542542- const trimmed = tag.trim();
543543- if (!trimmed) continue;
544544- if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) {
545545- return {
546546- valid: false,
547547- error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`,
548548- };
549549- }
550550- const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames, hasItem);
551551- if (!tagValidation.valid) {
552552- return { valid: false, error: `tag: ${tagValidation.error}` };
553553- }
554554- tags.push(trimmed);
555555- }
556556- }
557557-558558- return { valid: true, tags };
559417}
560418561419const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+123
lib/actions/webhook.ts
···11+import { nanoid } from "nanoid";
22+import { type WebhookAction } from "../db/schema.js";
33+import { dispatch, buildPayload } from "../webhooks/dispatcher.js";
44+import { assertPublicUrl, UrlGuardError } from "../url-guard.js";
55+import { verifyCallback } from "../automations/verify.js";
66+import { Webhook } from "../../app/icons.js";
77+import { validateWebhookHeaders } from "./validation.js";
88+import type { ActionDefinition } from "./registry.js";
99+import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
1010+1111+type WebhookInput = {
1212+ type: "webhook";
1313+ callbackUrl: string;
1414+ headers?: Record<string, string>;
1515+};
1616+1717+type PdsWebhookAction = {
1818+ $type: "run.airglow.automation#webhookAction";
1919+ callbackUrl: string;
2020+ forEach?: WebhookAction["forEach"];
2121+ comment?: string;
2222+};
2323+2424+async function validate(
2525+ input: WebhookInput,
2626+ ctx: ValidationContext,
2727+): Promise<{ ok: true; local: WebhookAction } | { ok: false; error: string; status?: number }> {
2828+ if (!input.callbackUrl) {
2929+ return { ok: false, error: "callbackUrl is required for webhook actions" };
3030+ }
3131+ try {
3232+ await assertPublicUrl(input.callbackUrl);
3333+ } catch (err) {
3434+ const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL";
3535+ return { ok: false, error: message };
3636+ }
3737+3838+ if (input.headers && Object.keys(input.headers).length > 0) {
3939+ const headersValidation = validateWebhookHeaders(input.headers);
4040+ if (!headersValidation.valid) {
4141+ return { ok: false, error: headersValidation.error };
4242+ }
4343+ }
4444+4545+ const verification = await verifyCallback(input.callbackUrl, ctx.lexicon);
4646+4747+ // Preserve the existing secret when the same callbackUrl is already wired up
4848+ // on this automation. Without this, a no-op PATCH (e.g. toggling unrelated
4949+ // fields) would re-key the webhook and force the user to update the secret
5050+ // on their receiver. Matching by URL — the same callback identity — keeps
5151+ // editing safe regardless of action reordering.
5252+ const existing = ctx.existingActions.find(
5353+ (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl,
5454+ );
5555+ const secret = existing?.secret ?? nanoid(32);
5656+ const headers =
5757+ input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined;
5858+5959+ const local: WebhookAction = {
6060+ $type: "webhook",
6161+ callbackUrl: input.callbackUrl,
6262+ secret,
6363+ ...(headers ? { headers } : {}),
6464+ verified: verification.ok,
6565+ };
6666+ return { ok: true, local };
6767+}
6868+6969+function toPds(action: WebhookAction): PdsWebhookAction {
7070+ // PDS shape deliberately omits secret, headers, and verified — those are
7171+ // server-side state, not part of the user-visible automation record.
7272+ return {
7373+ $type: "run.airglow.automation#webhookAction",
7474+ callbackUrl: action.callbackUrl,
7575+ ...(action.forEach ? { forEach: action.forEach } : {}),
7676+ ...(action.comment ? { comment: action.comment } : {}),
7777+ };
7878+}
7979+8080+async function dryRunDescribe(
8181+ action: WebhookAction,
8282+ ctx: DryRunContext,
8383+): Promise<DryRunDescription> {
8484+ const headerCount = action.headers ? Object.keys(action.headers).length : 0;
8585+ const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : "";
8686+ return {
8787+ message: `Would POST to ${action.callbackUrl}${headerNote}${ctx.itemSuffix}`,
8888+ payload: JSON.stringify(buildPayload(ctx.match, ctx.fetchContext, ctx.item)),
8989+ error: null,
9090+ };
9191+}
9292+9393+/** Strip the server-side secret from the GET response. The verified flag and
9494+ * headers stay; secret never leaves the server. */
9595+function serializeForApi(action: WebhookAction): unknown {
9696+ return {
9797+ $type: action.$type,
9898+ callbackUrl: action.callbackUrl,
9999+ ...(action.headers ? { headers: action.headers } : {}),
100100+ verified: action.verified ?? false,
101101+ comment: action.comment,
102102+ ...(action.forEach ? { forEach: action.forEach } : {}),
103103+ };
104104+}
105105+106106+export const webhookDefinition: ActionDefinition<WebhookAction, WebhookInput, PdsWebhookAction> = {
107107+ type: "webhook",
108108+ pdsType: "run.airglow.automation#webhookAction",
109109+ recordProducing: false,
110110+ needsFullScope: false,
111111+ validate,
112112+ toPds,
113113+ execute: dispatch,
114114+ dryRunDescribe,
115115+ serializeForApi,
116116+ catalogue: {
117117+ label: "Send a webhook",
118118+ description: "POST event data to an external URL",
119119+ category: "webhook",
120120+ icon: Webhook,
121121+ available: true,
122122+ },
123123+};
+1-10
lib/auth/client.ts
···3333/** Returns true if any action writes to a collection beyond run.airglow.automation. */
3434export function actionsNeedFullScope(actions: ActionLike[]): boolean {
3535 return actions.some((a) => {
3636- // Registry-first: registered definitions declare their own scope need.
3737- // Falls back to the legacy hard-coded list for unmigrated types.
3836 const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY];
3939- if (def) return def.needsFullScope;
4040- return (
4141- a.$type === "bsky-post" ||
4242- a.$type === "record" ||
4343- a.$type === "patch-record" ||
4444- a.$type === "margin-bookmark" ||
4545- a.$type === "follow"
4646- );
3737+ return def?.needsFullScope ?? false;
4738 });
4839}
4940
+3-76
lib/automations/pds-serialize.ts
···11-import type { Action, ForEachConfig } from "../db/schema.js";
22-import type { PdsAction, PdsForEachConfig } from "./pds.js";
11+import type { Action } from "../db/schema.js";
22+import type { PdsAction } from "./pds.js";
33import { ACTION_REGISTRY } from "../actions/registry.js";
4455-function toPdsForEach(fe: ForEachConfig | undefined): PdsForEachConfig | undefined {
66- if (!fe) return undefined;
77- return {
88- path: fe.path,
99- ...(fe.conditions && fe.conditions.length > 0 ? { conditions: fe.conditions } : {}),
1010- };
1111-}
1212-135/** Serialize a stored Action into its PDS-record shape. Split from pds.ts so
146 * tests that mock the OAuth-backed PDS client don't need to re-stub this. */
157export function toPdsAction(a: Action): PdsAction {
1616- // Registry-first: any registered action type owns its own toPds projection.
1717- const def = ACTION_REGISTRY[a.$type];
1818- if (def) return def.toPds(a) as PdsAction;
1919- // TS can't infer that `def` being undefined narrows the union, so help it
2020- // along: registered types never reach the legacy switch below.
2121- if (a.$type === "semble-save") {
2222- throw new Error("semble-save action should be handled by the registry");
2323- }
2424-2525- const forEach = toPdsForEach(a.forEach);
2626- const forEachField = forEach ? { forEach } : {};
2727-2828- if (a.$type === "webhook") {
2929- return {
3030- $type: "run.airglow.automation#webhookAction",
3131- callbackUrl: a.callbackUrl,
3232- ...forEachField,
3333- ...(a.comment ? { comment: a.comment } : {}),
3434- };
3535- }
3636- if (a.$type === "bsky-post") {
3737- return {
3838- $type: "run.airglow.automation#bskyPostAction",
3939- textTemplate: a.textTemplate,
4040- ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}),
4141- ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}),
4242- ...forEachField,
4343- ...(a.comment ? { comment: a.comment } : {}),
4444- };
4545- }
4646- if (a.$type === "patch-record") {
4747- return {
4848- $type: "run.airglow.automation#patchRecordAction",
4949- targetCollection: a.targetCollection,
5050- baseRecordUri: a.baseRecordUri,
5151- recordTemplate: a.recordTemplate,
5252- ...forEachField,
5353- ...(a.comment ? { comment: a.comment } : {}),
5454- };
5555- }
5656- if (a.$type === "margin-bookmark") {
5757- return {
5858- $type: "run.airglow.automation#marginBookmarkAction",
5959- targetSource: a.targetSource,
6060- ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}),
6161- ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}),
6262- ...forEachField,
6363- ...(a.comment ? { comment: a.comment } : {}),
6464- };
6565- }
6666- if (a.$type === "follow") {
6767- return {
6868- $type: "run.airglow.automation#followAction",
6969- target: a.target,
7070- subject: a.subject,
7171- ...forEachField,
7272- ...(a.comment ? { comment: a.comment } : {}),
7373- };
7474- }
7575- return {
7676- $type: "run.airglow.automation#recordAction",
7777- targetCollection: a.targetCollection,
7878- recordTemplate: a.recordTemplate,
7979- ...forEachField,
8080- ...(a.comment ? { comment: a.comment } : {}),
8181- };
88+ return ACTION_REGISTRY[a.$type].toPds(a) as PdsAction;
829}