···11+import type { ActionType } from "../../../lib/actions/registry.js";
22+import { webhookUiDefinition, type WebhookDraft } from "./webhook.tsx";
33+import { recordUiDefinition, type RecordDraft } from "./record.tsx";
44+import { bskyPostUiDefinition, type BskyPostDraft } from "./bsky-post.tsx";
55+import { patchRecordUiDefinition, type PatchRecordDraft } from "./patch-record.tsx";
66+import { marginBookmarkUiDefinition, type MarginBookmarkDraft } from "./margin-bookmark.tsx";
77+import { sembleSaveUiDefinition, type SembleSaveDraft } from "./semble-save.tsx";
88+import { followUiDefinition, type FollowDraft } from "./follow.tsx";
99+import type { ActionUIDefinition } from "./types.ts";
1010+1111+/** Form-side counterpart to `ACTION_REGISTRY`. Keyed by the same `$type` as
1212+ * the server-side registry, so the two can be paired by the AutomationForm
1313+ * without an extra translation table. Insertion order matches
1414+ * `ACTION_TYPES` from the server registry; the contract test pins them.
1515+ *
1616+ * The `<any, any, any>` index type widens the per-action
1717+ * `TDraft`/`TAction`/`TPublic` triple for the same reason as the server
1818+ * registry: TS can't represent a variadic mapping where each entry has its
1919+ * own type parameters. Each UI definition keeps full precision at its
2020+ * declaration site. */
2121+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above.
2222+export const ACTION_UI_REGISTRY: Record<ActionType, ActionUIDefinition<any, any, any>> = {
2323+ webhook: webhookUiDefinition,
2424+ "bsky-post": bskyPostUiDefinition,
2525+ follow: followUiDefinition,
2626+ "margin-bookmark": marginBookmarkUiDefinition,
2727+ "semble-save": sembleSaveUiDefinition,
2828+ record: recordUiDefinition,
2929+ "patch-record": patchRecordUiDefinition,
3030+};
3131+3232+/** Per-action draft union — replaces the hand-written `ActionDraft` that
3333+ * used to live in AutomationForm.tsx. Adding a new action type here means
3434+ * appending its draft to this union and registering it in
3535+ * `ACTION_UI_REGISTRY`. */
3636+export type ActionDraft =
3737+ | WebhookDraft
3838+ | RecordDraft
3939+ | BskyPostDraft
4040+ | PatchRecordDraft
4141+ | MarginBookmarkDraft
4242+ | SembleSaveDraft
4343+ | FollowDraft;
4444+4545+export type {
4646+ WebhookDraft,
4747+ RecordDraft,
4848+ BskyPostDraft,
4949+ PatchRecordDraft,
5050+ MarginBookmarkDraft,
5151+ SembleSaveDraft,
5252+ FollowDraft,
5353+};
5454+5555+/** Client-safe variant of `lib/actions/registry.isRecordProducingAction`.
5656+ * Importing the server registry from a client island drags drizzle/sqlite
5757+ * through transitive dependencies (executor → dispatcher → db). The
5858+ * `recordProducing` field on each UI definition mirrors the server flag,
5959+ * enforced by a vitest test. */
6060+export function isRecordProducingAction(type: string): boolean {
6161+ return ACTION_UI_REGISTRY[type as ActionType]?.recordProducing ?? false;
6262+}
···11+import type { FC } from "hono/jsx";
22+import type { Action, FollowTarget } from "../../../lib/db/schema.js";
33+import type { ActionInput } from "../../../lib/actions/validation.js";
44+import type { ColorKey } from "../../../lib/automations/follow-targets.js";
55+66+/** Common prop signature for the lucide-style icon components. */
77+type IconProps = { size?: number; class?: string; color?: string; "stroke-width"?: number };
88+export type ActionIcon = FC<IconProps>;
99+1010+/** Picker-tile metadata shown in the form's "+ Add action" picker. Lives on
1111+ * the UI side because it's pure display data — the server has no use for
1212+ * icons or human-readable labels. */
1313+export type CatalogueTile = {
1414+ /** Imperative phrase shown in the picker (e.g. "Post to Bluesky"). */
1515+ label: string;
1616+ description: string;
1717+ category: "webhook" | "bluesky" | "apps" | "pds";
1818+ icon: ActionIcon;
1919+ available: boolean;
2020+ colorKey?: ColorKey;
2121+ faviconDomain?: string;
2222+};
2323+2424+/** Form-side representation of a per-iteration `forEach` config. Mirrors
2525+ * `ForEachConfig` but always materializes the conditions array (the form
2626+ * needs it for incremental editing). */
2727+export type ForEachDraft = {
2828+ path: string;
2929+ conditions: Array<{ field: string; operator: string; value: string; comment: string }>;
3030+};
3131+3232+/** Custom-header row inside a webhook draft. Two-step entry — the user types
3333+ * key and value separately and we only emit the entry once both are
3434+ * non-empty. */
3535+export type HeaderDraft = { key: string; value: string };
3636+3737+/** Props every action-editor block receives. `placeholders` is universally
3838+ * threaded so editors can opt into placeholder autocomplete without each
3939+ * editor declaring its own prop type. */
4040+export type EditorBlockProps<TDraft> = {
4141+ action: TDraft;
4242+ index: number;
4343+ onChange: (a: TDraft) => void;
4444+ placeholders: string[];
4545+};
4646+4747+/** Init payload for `newDraft`. `followTarget` is set when the picker tile is
4848+ * one of the per-target follow tiles (`follow-bluesky` etc.); other actions
4949+ * ignore it. */
5050+export type NewDraftInit = {
5151+ followTarget?: FollowTarget;
5252+};
5353+5454+/** UI counterpart to `ActionDefinition`: editor JSX, draft↔action conversions,
5555+ * and the toInput projection that produces the API payload shape. The form
5656+ * reaches for these via `ACTION_UI_REGISTRY`. */
5757+export type ActionUIDefinition<
5858+ TDraft extends { type: Action["$type"]; comment: string; forEach?: ForEachDraft },
5959+ TAction extends Action,
6060+ /** Sanitized public-profile projection of `TAction`. Defaults to the
6161+ * owner-side type, which is correct for every action whose public shape
6262+ * is identical (i.e. all of them except webhook). Webhook overrides this
6363+ * with `PublicWebhookAction` so its `PublicDisplayBlock` and `HeaderBadge`
6464+ * see the actual sanitized shape (callbackDomain instead of callbackUrl;
6565+ * no secret) the public profile route hands them. */
6666+ TPublic = TAction,
6767+> = {
6868+ type: TAction["$type"];
6969+ /** Mirrors `ActionDefinition.recordProducing` on the server. Duplicated
7070+ * here so client code (form, LexiconFlow) doesn't have to reach into the
7171+ * server registry — which would drag drizzle/sqlite into the client
7272+ * bundle. The mirror is verified by a vitest test. */
7373+ recordProducing: boolean;
7474+ /** Picker-tile metadata. Optional because some action types map to
7575+ * multiple tiles (e.g. follow expands into one tile per FOLLOW_TARGETS
7676+ * entry); those keep their tile list hand-curated in `action-catalogue`. */
7777+ catalogue?: CatalogueTile;
7878+ /** Build an empty draft for "+ Add action" clicks. */
7979+ newDraft: (init: NewDraftInit) => TDraft;
8080+ /** Project a stored Action into the editor's draft shape. */
8181+ fromAction: (action: TAction) => TDraft;
8282+ /** Project a draft back to the API input shape. The route's POST/PATCH
8383+ * passes this directly into the server-side registry's `validate`. Common
8484+ * fields (forEach, comment) are added by the form. */
8585+ toInput: (draft: TDraft) => Omit<ActionInput, "forEach" | "comment">;
8686+ EditorBlock: FC<EditorBlockProps<TDraft>>;
8787+ /** Read-only display rendered as <dt>/<dd> pairs inside a <DescriptionList>
8888+ * on the dashboard's automation-detail page. Used for the OWNER view; the
8989+ * public profile route falls back to `PublicDisplayBlock` when defined. */
9090+ DisplayBlock: FC<{ action: TAction }>;
9191+ /** Public-profile variant of `DisplayBlock`. Webhook supplies one to swap
9292+ * in the sanitized callback-domain row; every other action reuses
9393+ * `DisplayBlock` (so this is left undefined). Receives the public
9494+ * projection (`TPublic`), not `TAction`. */
9595+ PublicDisplayBlock?: FC<{ action: TPublic }>;
9696+ /** Optional badge rendered next to the action header on the detail pages.
9797+ * Webhook uses it for the Verified/Unverified pill; everyone else omits
9898+ * it and the header renders nothing extra. Receives the public projection
9999+ * on the public profile route, the owner shape on the dashboard. The two
100100+ * shapes share enough (`verified`, `comment`) that webhook reads only
101101+ * the common fields. */
102102+ HeaderBadge?: FC<{ action: TPublic | TAction }>;
103103+ /** Domain whose favicon represents this action in `LexiconFlow`. Some
104104+ * action types know their target statically (e.g. `bsky-post` → bsky.app);
105105+ * others derive it from the action data (e.g. `record.targetCollection`
106106+ * via `nsidToDomain`, or follow's per-target FOLLOW_TARGETS lookup).
107107+ * Returning null hides the favicon (the caller already gates on whether
108108+ * the action is record-producing). */
109109+ getFaviconDomain?: (action: TAction) => string | null;
110110+};
···11+import { describe, it, expect } from "vitest";
22+import { ACTION_REGISTRY, ACTION_TYPES } from "./registry.ts";
33+import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts";
44+55+// The form/island side keeps a `recordProducing` mirror on each
66+// ActionUIDefinition because client code can't import the server registry
77+// (it would drag drizzle/sqlite through transitive deps). This test pins the
88+// two halves together so a future change to the server flag isn't silently
99+// out of sync with the client form.
1010+describe("server vs UI registry contract", () => {
1111+ it("recordProducing flags match across both registries", () => {
1212+ for (const type of ACTION_TYPES) {
1313+ expect(ACTION_UI_REGISTRY[type].recordProducing, type).toBe(
1414+ ACTION_REGISTRY[type].recordProducing,
1515+ );
1616+ }
1717+ });
1818+1919+ it("every action $type is present in both registries", () => {
2020+ expect(Object.keys(ACTION_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort());
2121+ expect(Object.keys(ACTION_UI_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort());
2222+ });
2323+2424+ it("UI registry insertion order matches the server registry", () => {
2525+ expect(Object.keys(ACTION_UI_REGISTRY)).toEqual([...ACTION_TYPES]);
2626+ });
2727+});
+123
lib/actions/registry.ts
···11+import type { Action } from "../db/schema.js";
22+import type { PdsAction } from "../automations/pds.js";
33+import type {
44+ ActionHandler,
55+ DryRunContext,
66+ DryRunDescription,
77+ ValidationContext,
88+} from "./types.js";
99+1010+/** $type discriminator shared between the local Action union and the registry
1111+ * key. Derived from `Action` so adding a new variant is a type error here. */
1212+export type ActionType = Action["$type"];
1313+1414+/** Validation outcome for the API POST/PATCH routes. The single function call
1515+ * collapses what's currently a per-action if-branch in both routes. PDS
1616+ * serialization is a separate concern (see `toPds`) so PATCH can reproject a
1717+ * stored action without re-running input validation. */
1818+export type ValidateResult<TAction extends Action> =
1919+ | { ok: true; local: TAction }
2020+ | { ok: false; error: string; status?: number };
2121+2222+/** Server-side single source of truth per action type. The dispatchers in
2323+ * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts reduce
2424+ * to `ACTION_REGISTRY[type].method(...)` lookups.
2525+ *
2626+ * Pure UI metadata (icons, picker tiles) lives on the client side in
2727+ * `app/islands/action-editors`. Keeping it out of the server registry means
2828+ * importing this file from a route or executor doesn't drag the icon
2929+ * components or app-layer code into the server bundle, and conversely the
3030+ * client form bundle doesn't transitively pull drizzle/sqlite. */
3131+export type ActionDefinition<
3232+ TAction extends Action = Action,
3333+ TInput = unknown,
3434+ TPdsAction extends PdsAction = PdsAction,
3535+> = {
3636+ type: TAction["$type"];
3737+ pdsType: TPdsAction["$type"];
3838+3939+ /** Short noun-phrase label used in the dashboard automation list and
4040+ * delivery-log filters (e.g. "Bluesky Post", "Webhook"). */
4141+ displayLabel: string;
4242+4343+ /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls
4444+ * in API routes. Mirrored on the client side via `ActionUIDefinition`
4545+ * to keep the form's `isRecordProducingAction` check off the server registry. */
4646+ recordProducing: boolean;
4747+4848+ /** Replaces the per-$type checks in `actionsNeedFullScope`. */
4949+ needsFullScope: boolean;
5050+5151+ /** Validate a raw API input and produce the local action shape. Async to
5252+ * accommodate webhooks (callback verification). */
5353+ validate(input: TInput, ctx: ValidationContext): Promise<ValidateResult<TAction>>;
5454+5555+ /** Pure local→PDS projection. Called by the API write paths and by
5656+ * `pds-serialize.toPdsAction` for stored-action re-serialization. */
5757+ toPds(action: TAction): TPdsAction;
5858+5959+ /** Runtime executor wired into `handlerFor()` today. */
6060+ execute: ActionHandler;
6161+6262+ /** Build the dry-run delivery_logs row content for this action type. */
6363+ dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>;
6464+6565+ /** Optional override for how the GET /api/automations route serializes a
6666+ * stored action — webhooks use this to strip the secret. */
6767+ serializeForApi?(action: TAction): unknown;
6868+6969+ /** Optional secrets to surface in the POST /api/automations response so the
7070+ * user can copy them once at creation time. Webhook returns its
7171+ * freshly-generated `secret`; everyone else omits this. */
7272+ getCreatedSecrets?(action: TAction): Record<string, string>;
7373+7474+ /** Optional public-profile projection. Returns a shape stripped of any
7575+ * instance-local secrets so it can be safely rendered on `/u/<handle>`
7676+ * pages. Webhook is the only action type that diverges from its owner
7777+ * shape; everyone else's owner shape is already safe to expose, so this
7878+ * stays undefined and `sanitizeActions` falls through to the action as
7979+ * stored. */
8080+ toPublic?(action: TAction): unknown;
8181+};
8282+8383+import { sembleSaveDefinition } from "./semble-save.js";
8484+import { marginBookmarkDefinition } from "./margin-bookmark.js";
8585+import { followDefinition } from "./follow.js";
8686+import { bskyPostDefinition } from "./bsky-post.js";
8787+import { patchRecordDefinition } from "./patch-record.js";
8888+import { recordDefinition } from "./record.js";
8989+import { webhookDefinition } from "./webhook.js";
9090+9191+/** Map of $type → definition. Server-side only — importing this file from a
9292+ * client island will pull drizzle/sqlite through transitive deps. The client
9393+ * form uses `ACTION_UI_REGISTRY` instead (purely UI metadata + JSX).
9494+ *
9595+ * The `Record<ActionType, ActionDefinition<any, any, any>>` index type widens
9696+ * the per-action type parameters because TypeScript can't represent a
9797+ * variadic mapping where each entry has its own `TAction`/`TInput`/`TPdsAction`
9898+ * triple. Each declaration site (e.g. `webhookDefinition`) keeps full
9999+ * precision, so the unsafety is contained to the index lookup itself. */
100100+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above.
101101+export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = {
102102+ webhook: webhookDefinition,
103103+ "bsky-post": bskyPostDefinition,
104104+ follow: followDefinition,
105105+ "margin-bookmark": marginBookmarkDefinition,
106106+ "semble-save": sembleSaveDefinition,
107107+ record: recordDefinition,
108108+ "patch-record": patchRecordDefinition,
109109+};
110110+111111+/** Canonical action ordering used everywhere a list of action types matters
112112+ * (catalogue tiles, label tables, lexicon refs, drift tests). Derived from
113113+ * the registry's insertion order so the two can't drift. */
114114+export const ACTION_TYPES: readonly ActionType[] = Object.keys(
115115+ ACTION_REGISTRY,
116116+) as readonly ActionType[];
117117+118118+/** Server-side `isRecordProducingAction`. The form/island side has its own
119119+ * client-safe copy in `action-editors/registry.ts` driven by the UI
120120+ * definitions; both stay in sync via a vitest contract test. */
121121+export function isRecordProducingAction(type: string): boolean {
122122+ return ACTION_REGISTRY[type as ActionType]?.recordProducing ?? false;
123123+}
+124-1
lib/actions/semble-save.ts
···11import { type SembleSaveAction } from "../db/schema.js";
22import { createArbitraryRecord } from "../automations/pds.js";
33-import { renderTextTemplate, type FetchContext } from "./template.js";
33+import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js";
44import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js";
55import type { MatchedEvent } from "../jetstream/consumer.js";
66import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js";
77+import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js";
88+import type { ActionDefinition } from "./registry.js";
99+import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
710811const TARGET_COLLECTION = "network.cosmik.card";
1212+1313+// Allow either a literal http(s):// prefix or a leading {{...}} placeholder
1414+// (so the entire URL can come from event/action data). A literal non-http
1515+// scheme like `javascript:` is rejected here so it can't be persisted to the
1616+// user's PDS; the runtime SSRF guard remains the real security boundary.
1717+const URL_OK_RE = /^(https?:\/\/|\{\{)/i;
9181019async function buildRecord(
1120 match: MatchedEvent,
···8594 execute,
8695 (action) => JSON.stringify({ url: action.url }),
8796);
9797+9898+type SembleSaveInput = {
9999+ type: "semble-save";
100100+ url: string;
101101+};
102102+103103+type PdsSembleSaveAction = {
104104+ $type: "run.airglow.automation#sembleSaveAction";
105105+ url: string;
106106+ forEach?: SembleSaveAction["forEach"];
107107+ comment?: string;
108108+};
109109+110110+async function validate(
111111+ input: SembleSaveInput,
112112+ ctx: ValidationContext,
113113+): Promise<{ ok: true; local: SembleSaveAction } | { ok: false; error: string; status?: number }> {
114114+ if (!input.url || typeof input.url !== "string" || !input.url.trim()) {
115115+ return { ok: false, error: "url is required for semble-save actions" };
116116+ }
117117+ if (input.url.length > SEMBLE_SAVE_LIMITS.url) {
118118+ return { ok: false, error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less` };
119119+ }
120120+ if (!URL_OK_RE.test(input.url)) {
121121+ return {
122122+ ok: false,
123123+ error: "url must start with http://, https://, or a {{placeholder}}",
124124+ };
125125+ }
126126+ const urlValidation = validateTextTemplate(
127127+ input.url,
128128+ ctx.fetchNames,
129129+ ctx.actionResultNames,
130130+ ctx.hasItem,
131131+ );
132132+ if (!urlValidation.valid) {
133133+ return { ok: false, error: `url: ${urlValidation.error}` };
134134+ }
135135+136136+ // The route adds forEach/comment after this returns — they're orthogonal
137137+ // to the action-specific fields and validated centrally.
138138+ const local: SembleSaveAction = { $type: "semble-save", url: input.url };
139139+ return { ok: true, local };
140140+}
141141+142142+function toPds(action: SembleSaveAction): PdsSembleSaveAction {
143143+ return {
144144+ $type: "run.airglow.automation#sembleSaveAction",
145145+ url: action.url,
146146+ ...(action.forEach ? { forEach: action.forEach } : {}),
147147+ ...(action.comment ? { comment: action.comment } : {}),
148148+ };
149149+}
150150+151151+function redactUserinfo(url: string): string {
152152+ try {
153153+ const u = new URL(url);
154154+ if (u.username || u.password) {
155155+ u.username = "";
156156+ u.password = "";
157157+ return u.toString();
158158+ }
159159+ return url;
160160+ } catch {
161161+ return url;
162162+ }
163163+}
164164+165165+async function dryRunDescribe(
166166+ action: SembleSaveAction,
167167+ ctx: DryRunContext,
168168+): Promise<DryRunDescription> {
169169+ try {
170170+ const url = (
171171+ await renderTextTemplate(
172172+ action.url,
173173+ ctx.match.event,
174174+ ctx.fetchContext,
175175+ ctx.match.automation,
176176+ ctx.item,
177177+ )
178178+ ).trim();
179179+ // Redact credentials in userinfo before persisting to delivery_logs:
180180+ // event-derived URLs may legitimately carry tokens we shouldn't store.
181181+ const safeUrl = redactUserinfo(url);
182182+ return {
183183+ message: `Would save ${safeUrl || "(empty)"} to Semble${ctx.itemSuffix}`,
184184+ payload: JSON.stringify({ url: safeUrl, item: ctx.item }),
185185+ error: null,
186186+ };
187187+ } catch (err) {
188188+ return {
189189+ message: null,
190190+ payload: null,
191191+ error: `Template error: ${err instanceof Error ? err.message : String(err)}`,
192192+ };
193193+ }
194194+}
195195+196196+export const sembleSaveDefinition: ActionDefinition<
197197+ SembleSaveAction,
198198+ SembleSaveInput,
199199+ PdsSembleSaveAction
200200+> = {
201201+ type: "semble-save",
202202+ pdsType: "run.airglow.automation#sembleSaveAction",
203203+ displayLabel: "Save on Semble",
204204+ recordProducing: true,
205205+ needsFullScope: true,
206206+ validate,
207207+ toPds,
208208+ execute: executeSembleSave,
209209+ dryRunDescribe,
210210+};
+44
lib/actions/types.ts
···11+import type { MatchedEvent } from "../jetstream/consumer.js";
22+import type { Action } from "../db/schema.js";
33+import type { ActionResult } from "./delivery.js";
44+import type { FetchContext } from "./template.js";
55+66+/** Runtime executor signature: handler.ts dispatches to one of these per action. */
77+export type ActionHandler = (
88+ match: MatchedEvent,
99+ actionIndex: number,
1010+ fetchContext?: FetchContext,
1111+ item?: unknown,
1212+) => Promise<ActionResult>;
1313+1414+/** Context passed to per-action validators by the API POST/PATCH routes.
1515+ * Names of declared fetches and prior record-producing actions, plus whether
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. */
2121+export type ValidationContext = {
2222+ fetchNames: string[];
2323+ actionResultNames: string[];
2424+ hasItem: boolean;
2525+ lexicon: string;
2626+ existingActions: Action[];
2727+};
2828+2929+/** Outcome of `dryRunDescribe`: the three columns the handler writes into a
3030+ * delivery_logs row for a dry-run fire. Any field may be null. */
3131+export type DryRunDescription = {
3232+ message: string | null;
3333+ payload: string | null;
3434+ error: string | null;
3535+};
3636+3737+/** Context passed to per-action `dryRunDescribe`: everything `logDryRun`
3838+ * currently has in scope plus the per-iteration `item` for forEach. */
3939+export type DryRunContext = {
4040+ match: MatchedEvent;
4141+ fetchContext: FetchContext;
4242+ item: unknown;
4343+ itemSuffix: string;
4444+};
-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-187
lib/actions/validation.ts
···11import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js";
22-import {
33- AUTOMATION_LIMITS,
44- MARGIN_BOOKMARK_LIMITS,
55- SEMBLE_SAVE_LIMITS,
66-} from "../automations/limits.js";
22+import { AUTOMATION_LIMITS } from "../automations/limits.js";
73import { nsidRequiresWantedDids } from "../lexicons/match.js";
84import { isValidNsid } from "../lexicons/resolver.js";
99-import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js";
55+import { PLACEHOLDER_RE } from "./template.js";
106import type { Condition, FetchStepSearch, ForEachConfig } from "../db/schema.js";
1111-import {
1212- FOLLOW_TARGETS,
1313- VALID_FOLLOW_TARGETS,
1414- type FollowTarget,
1515-} from "../automations/follow-targets.js";
77+import { type FollowTarget } from "../automations/follow-targets.js";
168179export type ForEachInput = {
1810 path: string;
···422414 ...(step.comment ? { comment: step.comment } : {}),
423415 },
424416 };
425425-}
426426-427427-type FollowInput = {
428428- target: string;
429429- subject: string;
430430-};
431431-432432-const FOLLOW_SUBJECT_MAX = 512;
433433-434434-/** Validate a follow action input. The subject supports `{{placeholders}}`
435435- * which are resolved at execution time, so we validate it as a text template,
436436- * not as a literal DID. */
437437-export function validateFollowInput(
438438- input: FollowInput,
439439- fetchNames: string[],
440440- actionNames: string[],
441441- hasItem?: boolean,
442442-): { valid: true } | { valid: false; error: string } {
443443- if (!input.target || typeof input.target !== "string") {
444444- return { valid: false, error: "target is required for follow actions" };
445445- }
446446- if (!VALID_FOLLOW_TARGETS.has(input.target)) {
447447- return {
448448- valid: false,
449449- error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`,
450450- };
451451- }
452452- if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) {
453453- return { valid: false, error: "subject is required for follow actions" };
454454- }
455455- if (input.subject.length > FOLLOW_SUBJECT_MAX) {
456456- return {
457457- valid: false,
458458- error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less`,
459459- };
460460- }
461461- const templateCheck = validateTextTemplate(input.subject, fetchNames, actionNames, hasItem);
462462- if (!templateCheck.valid) {
463463- return { valid: false, error: `subject: ${templateCheck.error}` };
464464- }
465465- return { valid: true };
466466-}
467467-468468-type MarginBookmarkInput = {
469469- targetSource: string;
470470- bodyValue?: string;
471471- tags?: string[];
472472-};
473473-474474-// Allow either a literal http(s):// prefix or a leading {{...}} placeholder.
475475-// Mirrors the semble-save form check; the runtime guard inside the executor
476476-// remains the real boundary.
477477-const MARGIN_BOOKMARK_URL_OK_RE = /^(https?:\/\/|\{\{)/i;
478478-479479-/** Validate a margin-bookmark action input. Returns the trimmed, filtered tags on success. */
480480-export function validateMarginBookmarkInput(
481481- input: MarginBookmarkInput,
482482- fetchNames: string[],
483483- actionNames: string[],
484484- hasItem?: boolean,
485485-): { valid: true; tags: string[] } | { valid: false; error: string } {
486486- if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) {
487487- return { valid: false, error: "targetSource is required for margin-bookmark actions" };
488488- }
489489- if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) {
490490- return {
491491- valid: false,
492492- error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`,
493493- };
494494- }
495495- if (!MARGIN_BOOKMARK_URL_OK_RE.test(input.targetSource)) {
496496- return {
497497- valid: false,
498498- error: "targetSource must start with http://, https://, or a {{placeholder}}",
499499- };
500500- }
501501- const sourceValidation = validateTextTemplate(
502502- input.targetSource,
503503- fetchNames,
504504- actionNames,
505505- hasItem,
506506- );
507507- if (!sourceValidation.valid) {
508508- return { valid: false, error: `targetSource: ${sourceValidation.error}` };
509509- }
510510-511511- if (input.bodyValue !== undefined) {
512512- if (typeof input.bodyValue !== "string") {
513513- return { valid: false, error: "bodyValue must be a string" };
514514- }
515515- if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) {
516516- return {
517517- valid: false,
518518- error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`,
519519- };
520520- }
521521- if (input.bodyValue.trim()) {
522522- const bodyValidation = validateTextTemplate(
523523- input.bodyValue,
524524- fetchNames,
525525- actionNames,
526526- hasItem,
527527- );
528528- if (!bodyValidation.valid) {
529529- return { valid: false, error: `bodyValue: ${bodyValidation.error}` };
530530- }
531531- }
532532- }
533533-534534- const tags: string[] = [];
535535- if (input.tags !== undefined) {
536536- if (!Array.isArray(input.tags)) {
537537- return { valid: false, error: "tags must be an array of strings" };
538538- }
539539- if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) {
540540- return { valid: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` };
541541- }
542542- for (const tag of input.tags) {
543543- if (typeof tag !== "string") {
544544- return { valid: false, error: "Each tag must be a string" };
545545- }
546546- const trimmed = tag.trim();
547547- if (!trimmed) continue;
548548- if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) {
549549- return {
550550- valid: false,
551551- error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`,
552552- };
553553- }
554554- const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames, hasItem);
555555- if (!tagValidation.valid) {
556556- return { valid: false, error: `tag: ${tagValidation.error}` };
557557- }
558558- tags.push(trimmed);
559559- }
560560- }
561561-562562- return { valid: true, tags };
563563-}
564564-565565-type SembleSaveInput = {
566566- url: string;
567567-};
568568-569569-// Allow either a literal http(s):// prefix or a leading {{...}} placeholder
570570-// (so the entire URL can come from event/action data). A literal non-http
571571-// scheme like `javascript:` is rejected here so it can't be persisted to the
572572-// user's PDS; the runtime SSRF guard remains the real security boundary.
573573-const SEMBLE_SAVE_URL_OK_RE = /^(https?:\/\/|\{\{)/i;
574574-575575-export function validateSembleSaveInput(
576576- input: SembleSaveInput,
577577- fetchNames: string[],
578578- actionNames: string[],
579579- hasItem?: boolean,
580580-): { valid: true } | { valid: false; error: string } {
581581- if (!input.url || typeof input.url !== "string" || !input.url.trim()) {
582582- return { valid: false, error: "url is required for semble-save actions" };
583583- }
584584- if (input.url.length > SEMBLE_SAVE_LIMITS.url) {
585585- return {
586586- valid: false,
587587- error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less`,
588588- };
589589- }
590590- if (!SEMBLE_SAVE_URL_OK_RE.test(input.url)) {
591591- return {
592592- valid: false,
593593- error: "url must start with http://, https://, or a {{placeholder}}",
594594- };
595595- }
596596- const urlValidation = validateTextTemplate(input.url, fetchNames, actionNames, hasItem);
597597- if (!urlValidation.valid) {
598598- return { valid: false, error: `url: ${urlValidation.error}` };
599599- }
600600- return { valid: true };
601417}
602418603419const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+161
lib/actions/webhook.ts
···11+import { nanoid } from "nanoid";
22+import { type WebhookAction, type ForEachConfig } 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 { validateWebhookHeaders } from "./validation.js";
77+import type { ActionDefinition } from "./registry.js";
88+import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
99+1010+type WebhookInput = {
1111+ type: "webhook";
1212+ callbackUrl: string;
1313+ headers?: Record<string, string>;
1414+};
1515+1616+type PdsWebhookAction = {
1717+ $type: "run.airglow.automation#webhookAction";
1818+ callbackUrl: string;
1919+ forEach?: WebhookAction["forEach"];
2020+ comment?: string;
2121+};
2222+2323+/** Public-profile projection of a webhook action: the secret and full
2424+ * callback URL are stripped, only the host domain leaks out, and the header
2525+ * values are replaced with just the names. Webhook is the only action type
2626+ * with a non-identity public projection (every other action's owner shape
2727+ * is already safe to expose), which is why the registry's `toPublic` is
2828+ * optional. */
2929+export type PublicWebhookAction = {
3030+ $type: "webhook";
3131+ callbackDomain: string;
3232+ headerNames?: string[];
3333+ verified?: boolean;
3434+ comment?: string;
3535+ forEach?: ForEachConfig;
3636+};
3737+3838+async function validate(
3939+ input: WebhookInput,
4040+ ctx: ValidationContext,
4141+): Promise<{ ok: true; local: WebhookAction } | { ok: false; error: string; status?: number }> {
4242+ if (!input.callbackUrl) {
4343+ return { ok: false, error: "callbackUrl is required for webhook actions" };
4444+ }
4545+ try {
4646+ await assertPublicUrl(input.callbackUrl);
4747+ } catch (err) {
4848+ const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL";
4949+ return { ok: false, error: message };
5050+ }
5151+5252+ if (input.headers && Object.keys(input.headers).length > 0) {
5353+ const headersValidation = validateWebhookHeaders(input.headers);
5454+ if (!headersValidation.valid) {
5555+ return { ok: false, error: headersValidation.error };
5656+ }
5757+ }
5858+5959+ const verification = await verifyCallback(input.callbackUrl, ctx.lexicon);
6060+6161+ // Preserve the existing secret when the same callbackUrl is already wired up
6262+ // on this automation. Without this, a no-op PATCH (e.g. toggling unrelated
6363+ // fields) would re-key the webhook and force the user to update the secret
6464+ // on their receiver. Matching by URL — the same callback identity — keeps
6565+ // editing safe regardless of action reordering.
6666+ const existing = ctx.existingActions.find(
6767+ (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl,
6868+ );
6969+ const secret = existing?.secret ?? nanoid(32);
7070+ const headers =
7171+ input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined;
7272+7373+ const local: WebhookAction = {
7474+ $type: "webhook",
7575+ callbackUrl: input.callbackUrl,
7676+ secret,
7777+ ...(headers ? { headers } : {}),
7878+ verified: verification.ok,
7979+ };
8080+ return { ok: true, local };
8181+}
8282+8383+function toPds(action: WebhookAction): PdsWebhookAction {
8484+ // PDS shape deliberately omits secret, headers, and verified — those are
8585+ // server-side state, not part of the user-visible automation record.
8686+ return {
8787+ $type: "run.airglow.automation#webhookAction",
8888+ callbackUrl: action.callbackUrl,
8989+ ...(action.forEach ? { forEach: action.forEach } : {}),
9090+ ...(action.comment ? { comment: action.comment } : {}),
9191+ };
9292+}
9393+9494+async function dryRunDescribe(
9595+ action: WebhookAction,
9696+ ctx: DryRunContext,
9797+): Promise<DryRunDescription> {
9898+ const headerCount = action.headers ? Object.keys(action.headers).length : 0;
9999+ const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : "";
100100+ return {
101101+ message: `Would POST to ${action.callbackUrl}${headerNote}${ctx.itemSuffix}`,
102102+ payload: JSON.stringify(buildPayload(ctx.match, ctx.fetchContext, ctx.item)),
103103+ error: null,
104104+ };
105105+}
106106+107107+/** Strip the server-side secret from the GET response. The verified flag and
108108+ * headers stay; secret never leaves the server. */
109109+function serializeForApi(action: WebhookAction): unknown {
110110+ return {
111111+ $type: action.$type,
112112+ callbackUrl: action.callbackUrl,
113113+ ...(action.headers ? { headers: action.headers } : {}),
114114+ verified: action.verified ?? false,
115115+ comment: action.comment,
116116+ ...(action.forEach ? { forEach: action.forEach } : {}),
117117+ };
118118+}
119119+120120+/** Surface the freshly-generated secret in the POST /api/automations response
121121+ * so the user can copy it once. Subsequent GETs strip it (see
122122+ * `serializeForApi`). */
123123+function getCreatedSecrets(action: WebhookAction): Record<string, string> {
124124+ return { secret: action.secret };
125125+}
126126+127127+/** Project a webhook action into its public-profile shape. Drops the secret
128128+ * and full callback URL, keeps only the callback host. Used by
129129+ * `sanitizeActions` on the public profile route. */
130130+function toPublic(action: WebhookAction): PublicWebhookAction {
131131+ let callbackDomain: string;
132132+ try {
133133+ callbackDomain = new URL(action.callbackUrl).hostname;
134134+ } catch {
135135+ callbackDomain = "unknown";
136136+ }
137137+ const headerNames = action.headers ? Object.keys(action.headers) : undefined;
138138+ return {
139139+ $type: "webhook",
140140+ callbackDomain,
141141+ ...(headerNames && headerNames.length > 0 ? { headerNames } : {}),
142142+ verified: action.verified,
143143+ comment: action.comment,
144144+ ...(action.forEach ? { forEach: action.forEach } : {}),
145145+ };
146146+}
147147+148148+export const webhookDefinition: ActionDefinition<WebhookAction, WebhookInput, PdsWebhookAction> = {
149149+ type: "webhook",
150150+ pdsType: "run.airglow.automation#webhookAction",
151151+ displayLabel: "Webhook",
152152+ recordProducing: false,
153153+ needsFullScope: false,
154154+ validate,
155155+ toPds,
156156+ execute: dispatch,
157157+ dryRunDescribe,
158158+ serializeForApi,
159159+ getCreatedSecrets,
160160+ toPublic,
161161+};
+5-9
lib/auth/client.ts
···1212import { config } from "../config.js";
1313import { db } from "../db/index.js";
1414import { automations } from "../db/schema.js";
1515+import { ACTION_REGISTRY } from "../actions/registry.js";
1516import {
1617 resolveDidToHandle as pdsResolveDidToHandle,
1718 resolveHandle as pdsResolveHandle,
···31323233/** Returns true if any action writes to a collection beyond run.airglow.automation. */
3334export function actionsNeedFullScope(actions: ActionLike[]): boolean {
3434- return actions.some(
3535- (a) =>
3636- a.$type === "bsky-post" ||
3737- a.$type === "record" ||
3838- a.$type === "patch-record" ||
3939- a.$type === "margin-bookmark" ||
4040- a.$type === "follow" ||
4141- a.$type === "semble-save",
4242- );
3535+ return actions.some((a) => {
3636+ const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY];
3737+ return def?.needsFullScope ?? false;
3838+ });
4339}
44404541/** Check if the granted scope covers all collections needed by actions. */
+66-90
lib/automations/action-catalogue.ts
···11-import {
22- Bookmark,
33- BookmarkPlus,
44- FilePlus2,
55- Heart,
66- MessageSquare,
77- Pencil,
88- Trash2,
99- UserPlus,
1010- Webhook,
1111-} from "../../app/icons.js";
11+import { Heart, Trash2, UserPlus } from "../../app/icons.js";
22+import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts";
33+import type { ActionIcon } from "../../app/islands/action-editors/types.ts";
124import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js";
135146export type AddableActionId =
···2012 | "semble-save"
2113 | `follow-${FollowTarget}`;
22141515+type Tile = {
1616+ id: string;
1717+ label: string;
1818+ description: string;
1919+ icon: ActionIcon;
2020+ available: boolean;
2121+ colorKey?: ColorKey;
2222+ faviconDomain?: string;
2323+};
2424+2325type ActionInfo = {
2426 label: string;
2525- icon: (typeof ACTION_CATALOGUE)[number]["actions"][number]["icon"];
2626- catId: (typeof ACTION_CATALOGUE)[number]["id"];
2727+ icon: Tile["icon"];
2828+ catId: "webhook" | "bluesky" | "apps" | "pds";
2729 /** Optional override for the icon-tile color key (data-cat). Lets follow-sifa
2830 * use a sifa-blue and follow-tangled a grey while still grouping under the
2931 * Bluesky/Apps categories. */
3032 colorKey?: ColorKey;
3131- /** Domain used to render the per-app favicon next to the icon (margin-bookmark, follow). */
3333+ /** Domain used to render the per-app favicon next to the icon. */
3234 faviconDomain?: string;
3335};
34363737+/** Tile entry derived from a registered action's `catalogue` metadata. */
3838+function tileFromRegistry(type: keyof typeof ACTION_UI_REGISTRY): Tile {
3939+ const def = ACTION_UI_REGISTRY[type]!;
4040+ const cat = def.catalogue!;
4141+ return {
4242+ id: type,
4343+ label: cat.label,
4444+ description: cat.description,
4545+ icon: cat.icon,
4646+ available: cat.available,
4747+ ...(cat.colorKey ? { colorKey: cat.colorKey } : {}),
4848+ ...(cat.faviconDomain ? { faviconDomain: cat.faviconDomain } : {}),
4949+ };
5050+}
5151+3552/** Build the catalogue tile for a given follow target. Insertion order in
3653 * `FOLLOW_TARGETS` controls the order tiles appear within their category. */
3737-function followTileFor(target: FollowTarget) {
5454+function followTileFor(target: FollowTarget): Tile {
3855 const t = FOLLOW_TARGETS[target];
3956 return {
4040- id: `follow-${target}` as const,
5757+ id: `follow-${target}`,
4158 label: t.label,
4259 description: t.description,
4360 icon: UserPlus,
···4764 };
4865}
49665050-const followTilesByCat: Record<"bluesky" | "apps", ReturnType<typeof followTileFor>[]> = {
5151- bluesky: [],
5252- apps: [],
5353-};
6767+const followTilesByCat: Record<"bluesky" | "apps", Tile[]> = { bluesky: [], apps: [] };
5468for (const target of Object.keys(FOLLOW_TARGETS) as FollowTarget[]) {
5569 followTilesByCat[FOLLOW_TARGETS[target].catId].push(followTileFor(target));
5670}
57717272+/** "Coming soon" placeholders that have no executor yet. Kept here so the
7373+ * picker can preview the planned shape of each category. */
7474+const COMING_SOON: Record<"bluesky" | "pds", Tile[]> = {
7575+ bluesky: [
7676+ {
7777+ id: "bsky-like",
7878+ label: "Like a post",
7979+ description: "Like a Bluesky post on your behalf",
8080+ icon: Heart,
8181+ available: false,
8282+ },
8383+ ],
8484+ pds: [
8585+ {
8686+ id: "delete-record",
8787+ label: "Delete a record",
8888+ description: "Remove a record from a collection",
8989+ icon: Trash2,
9090+ available: false,
9191+ },
9292+ ],
9393+};
9494+5895export const ACTION_CATALOGUE = [
5996 {
6060- id: "webhook",
9797+ id: "webhook" as const,
6198 label: "Webhooks",
6299 description: "Send event data to your own server",
6363- actions: [
6464- {
6565- id: "webhook",
6666- label: "Send a webhook",
6767- description: "POST event data to an external URL",
6868- icon: Webhook,
6969- available: true,
7070- },
7171- ],
100100+ actions: [tileFromRegistry("webhook")],
72101 },
73102 {
7474- id: "bluesky",
103103+ id: "bluesky" as const,
75104 label: "Bluesky",
76105 description: "High-level Bluesky interactions",
7777- actions: [
7878- {
7979- id: "bsky-post",
8080- label: "Post to Bluesky",
8181- description: "Publish a post to your Bluesky account",
8282- icon: MessageSquare,
8383- available: true,
8484- },
8585- ...followTilesByCat.bluesky,
8686- {
8787- id: "bsky-like",
8888- label: "Like a post",
8989- description: "Like a Bluesky post on your behalf",
9090- icon: Heart,
9191- available: false,
9292- },
9393- ],
106106+ actions: [tileFromRegistry("bsky-post"), ...followTilesByCat.bluesky, ...COMING_SOON.bluesky],
94107 },
95108 {
9696- id: "apps",
109109+ id: "apps" as const,
97110 label: "Apps",
98111 description: "Quick actions for specific AT Protocol apps",
99112 actions: [
100100- {
101101- id: "margin-bookmark",
102102- label: "Bookmark on Margin",
103103- description: "Create a bookmark note in Margin.at",
104104- icon: Bookmark,
105105- available: true,
106106- faviconDomain: "margin.at",
107107- },
108108- {
109109- id: "semble-save",
110110- label: "Save on Semble",
111111- description: "Save a URL as a card on Semble",
112112- icon: BookmarkPlus,
113113- available: true,
114114- colorKey: "cosmik" as ColorKey,
115115- faviconDomain: "semble.so",
116116- },
113113+ tileFromRegistry("margin-bookmark"),
114114+ tileFromRegistry("semble-save"),
117115 ...followTilesByCat.apps,
118116 ],
119117 },
120118 {
121121- id: "pds",
119119+ id: "pds" as const,
122120 label: "PDS records",
123121 description: "Low-level lexicon record operations",
124124- actions: [
125125- {
126126- id: "record",
127127- label: "Create a record",
128128- description: "Create a new record in any collection",
129129- icon: FilePlus2,
130130- available: true,
131131- },
132132- {
133133- id: "patch-record",
134134- label: "Update a record",
135135- description: "Modify fields of an existing record",
136136- icon: Pencil,
137137- available: true,
138138- },
139139- {
140140- id: "delete-record",
141141- label: "Delete a record",
142142- description: "Remove a record from a collection",
143143- icon: Trash2,
144144- available: false,
145145- },
146146- ],
122122+ actions: [tileFromRegistry("record"), tileFromRegistry("patch-record"), ...COMING_SOON.pds],
147123 },
148124];
149125
+7-8
lib/automations/labels.ts
···1616 "not-exists": "is missing",
1717};
18181919-export const actionTypeLabels: Record<string, string> = {
2020- webhook: "Webhook",
2121- record: "Create Record",
2222- "bsky-post": "Bluesky Post",
2323- "patch-record": "Update Record",
2424- "margin-bookmark": "Bookmark on Margin",
2525- follow: "Follow",
2626-};
1919+import { ACTION_REGISTRY } from "../actions/registry.js";
2020+2121+/** Short noun-phrase label shown next to existing automations and in delivery
2222+ * logs. Pulled from each action's registry definition. */
2323+export const actionTypeLabels: Record<string, string> = Object.fromEntries(
2424+ Object.entries(ACTION_REGISTRY).map(([t, def]) => [t, def.displayLabel]),
2525+);
27262827export const operationLabels: Record<string, string> = {
2928 create: "Record created",
···11import { db } from "../db/index.js";
22-import { deliveryLogs, type Action, isRecordProducingAction } from "../db/schema.js";
33-import { dispatch, buildPayload } from "../webhooks/dispatcher.js";
44-import { executeAction, type ActionResult } from "../actions/executor.js";
55-import { executeBskyPost } from "../actions/bsky-post.js";
66-import { executePatchRecord } from "../actions/patch-record.js";
77-import { executeMarginBookmark } from "../actions/margin-bookmark.js";
88-import { executeFollow } from "../actions/follow.js";
99-import { executeSembleSave } from "../actions/semble-save.js";
1010-import { FOLLOW_TARGETS } from "../automations/follow-targets.js";
22+import { deliveryLogs, type Action } from "../db/schema.js";
33+import { type ActionResult } from "../actions/delivery.js";
44+import { ACTION_REGISTRY, isRecordProducingAction } from "../actions/registry.js";
115import { resolveFetches } from "../actions/fetcher.js";
126import { isSuccess } from "../actions/delivery.js";
1313-import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js";
77+import { type FetchContext } from "../actions/template.js";
148import { parseAtUri } from "../pds/resolver.js";
159import { collectItems, matchItemConditions } from "./matcher.js";
1610import { notifyAutomationChange, type MatchedEvent } from "./consumer.js";
···2418) => Promise<ActionResult>;
25192620function handlerFor(action: Action): ActionHandler {
2727- switch (action.$type) {
2828- case "bsky-post":
2929- return executeBskyPost;
3030- case "record":
3131- return executeAction;
3232- case "patch-record":
3333- return executePatchRecord;
3434- case "margin-bookmark":
3535- return executeMarginBookmark;
3636- case "follow":
3737- return executeFollow;
3838- case "semble-save":
3939- return executeSembleSave;
4040- default:
4141- return dispatch;
4242- }
2121+ return ACTION_REGISTRY[action.$type].execute;
4322}
44234524/**
···258237 failedFetches: string[],
259238 options?: { item?: unknown; forEachEmpty?: boolean; totalItems?: number },
260239) {
261261- let message: string | null = null;
262262- let error: string | null = null;
263263- let payload: string | null = null;
264264-265240 // forEach with no matching item: emit a single explanatory row instead of
266241 // staying silent — otherwise the user sees nothing and can't tell whether
267242 // the path or the conditions filtered everything out.
268243 if (options?.forEachEmpty) {
269244 const total = options.totalItems ?? 0;
270270- message =
245245+ const message =
271246 total === 0
272247 ? `Would skip: forEach path "${action.forEach?.path}" resolved to no items`
273248 : `Would skip: ${total} item(s) found at "${action.forEach?.path}" but none matched the per-item conditions`;
···289264 const item = options?.item;
290265 const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : "";
291266267267+ let message: string | null = null;
268268+ let payload: string | null = null;
269269+ let error: string | null = null;
270270+292271 if (failedFetches.length > 0) {
293272 error = `Fetch failed: ${failedFetches.join(", ")}`;
294294- } else if (action.$type === "webhook") {
295295- const headerCount = action.headers ? Object.keys(action.headers).length : 0;
296296- const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : "";
297297- message = `Would POST to ${action.callbackUrl}${headerNote}${itemSuffix}`;
298298- payload = JSON.stringify(buildPayload(match, fetchContext, item));
299299- } else if (action.$type === "bsky-post") {
300300- try {
301301- const text = await renderTextTemplate(
302302- action.textTemplate,
303303- match.event,
304304- fetchContext,
305305- match.automation,
306306- item,
307307- );
308308- message = `Would post to Bluesky${itemSuffix}`;
309309- payload = JSON.stringify({ text, langs: action.langs, labels: action.labels, item });
310310- } catch (err) {
311311- error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
312312- }
313313- } else if (action.$type === "follow") {
314314- try {
315315- const subject = (
316316- await renderTextTemplate(action.subject, match.event, fetchContext, match.automation, item)
317317- ).trim();
318318- const target = FOLLOW_TARGETS[action.target];
319319- const collection = target.collection;
320320- const appName = target.appName;
321321- // The built-in safety checks live inside executeFollow and aren't run in
322322- // dry-run (keeps the preview cheap). Advertise their presence in the
323323- // message so authors know the real run will skip cleanly on both edges.
324324- message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)${itemSuffix}`;
325325- payload = JSON.stringify({ collection, subject, item });
326326- } catch (err) {
327327- error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
328328- }
329329- } else if (action.$type === "margin-bookmark") {
330330- try {
331331- const source = await renderTextTemplate(
332332- action.targetSource,
333333- match.event,
334334- fetchContext,
335335- match.automation,
336336- item,
337337- );
338338- const body = action.bodyValue
339339- ? await renderTextTemplate(
340340- action.bodyValue,
341341- match.event,
342342- fetchContext,
343343- match.automation,
344344- item,
345345- )
346346- : undefined;
347347- const tags: string[] = [];
348348- if (action.tags) {
349349- for (const tag of action.tags) {
350350- const rendered = await renderTextTemplate(
351351- tag,
352352- match.event,
353353- fetchContext,
354354- match.automation,
355355- item,
356356- );
357357- if (rendered.trim()) tags.push(rendered.trim());
358358- }
359359- }
360360- message = `Would bookmark ${source}${itemSuffix}`;
361361- payload = JSON.stringify({ source, body, tags, item });
362362- } catch (err) {
363363- error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
364364- }
365365- } else if (action.$type === "semble-save") {
366366- try {
367367- const url = (
368368- await renderTextTemplate(action.url, match.event, fetchContext, match.automation, item)
369369- ).trim();
370370- // Redact credentials in userinfo before persisting to delivery_logs:
371371- // event-derived URLs may legitimately carry tokens we shouldn't store.
372372- const safeUrl = redactUserinfo(url);
373373- message = `Would save ${safeUrl || "(empty)"} to Semble${itemSuffix}`;
374374- payload = JSON.stringify({ url: safeUrl, item });
375375- } catch (err) {
376376- error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
377377- }
378273 } else {
379379- try {
380380- const rendered = await renderTemplate(
381381- action.recordTemplate,
382382- match.event,
383383- fetchContext,
384384- match.automation,
385385- item,
386386- );
387387- message =
388388- action.$type === "patch-record"
389389- ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${itemSuffix}`
390390- : `Would create record in ${action.targetCollection}${itemSuffix}`;
391391- payload = JSON.stringify(item !== undefined ? { rendered, item } : rendered);
392392- } catch (err) {
393393- error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
394394- }
274274+ const desc = await ACTION_REGISTRY[action.$type].dryRunDescribe(action, {
275275+ match,
276276+ fetchContext,
277277+ item,
278278+ itemSuffix,
279279+ });
280280+ message = desc.message;
281281+ payload = desc.payload;
282282+ error = desc.error;
395283 }
396284397285 await db.insert(deliveryLogs).values({
···410298411299function truncateForLog(s: string, max = 120): string {
412300 return s.length <= max ? s : s.slice(0, max) + "...";
413413-}
414414-415415-function redactUserinfo(url: string): string {
416416- try {
417417- const u = new URL(url);
418418- if (u.username || u.password) {
419419- u.username = "";
420420- u.password = "";
421421- return u.toString();
422422- }
423423- return url;
424424- } catch {
425425- return url;
426426- }
427301}
428302429303/**
+1-1
lib/webhooks/dispatcher.ts
···44import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "../actions/delivery.js";
55import { resolve as resolveSecrets, SECRET_REF_RE } from "../secrets/store.js";
66import { config } from "../config.js";
77-import type { ActionResult } from "../actions/executor.js";
77+import type { ActionResult } from "../actions/delivery.js";
88import type { MatchedEvent } from "../jetstream/consumer.js";
99import type { FetchContext } from "../actions/template.js";
1010