···11+import type { Action } from "../db/schema.js";
22+import type { PdsAction } from "../automations/pds.js";
33+import type { ColorKey } from "../automations/follow-targets.js";
44+import type {
55+ ActionHandler,
66+ DryRunContext,
77+ DryRunDescription,
88+ ValidationContext,
99+} from "./types.js";
1010+1111+/** $type discriminator shared between the local Action union and the registry
1212+ * key. Derived from `Action` so adding a new variant is a type error here. */
1313+export type ActionType = Action["$type"];
1414+1515+/** Catalogue tile metadata that today lives in `action-catalogue.ts`. Mirrored
1616+ * here so each registry entry owns its UI footprint. The hand-curated
1717+ * ACTION_CATALOGUE array in action-catalogue.ts becomes a derived view in
1818+ * Phase 4. */
1919+export type CatalogueTile = {
2020+ label: string;
2121+ description: string;
2222+ category: "webhook" | "bluesky" | "apps" | "pds";
2323+ /** Lucide icon ref. Type left loose because the icon module is itself
2424+ * loosely typed and we don't want to force a pre-Phase-4 import here. */
2525+ icon: unknown;
2626+ available: boolean;
2727+ /** Override for the tile's data-cat color (e.g. follow-sifa is sifa-blue
2828+ * while still living under "Bluesky"). */
2929+ colorKey?: ColorKey;
3030+ /** Domain whose favicon is rendered next to the icon. */
3131+ faviconDomain?: string;
3232+};
3333+3434+/** Validation outcome for the API POST/PATCH routes. The single function call
3535+ * collapses what's currently a per-action if-branch in both routes. PDS
3636+ * serialization is a separate concern (see `toPds`) so PATCH can reproject a
3737+ * stored action without re-running input validation. */
3838+export type ValidateResult<TAction extends Action> =
3939+ | { ok: true; local: TAction }
4040+ | { ok: false; error: string; status?: number };
4141+4242+/** Single source of truth per action type. Subsequent phases populate
4343+ * `ACTION_REGISTRY` with one of these per `$type`; the dispatchers in
4444+ * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts then
4545+ * reduce to `ACTION_REGISTRY[type].method(...)` lookups. */
4646+export type ActionDefinition<
4747+ TAction extends Action = Action,
4848+ TInput = unknown,
4949+ TPdsAction extends PdsAction = PdsAction,
5050+> = {
5151+ type: TAction["$type"];
5252+ pdsType: TPdsAction["$type"];
5353+5454+ /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls
5555+ * in API routes. */
5656+ recordProducing: boolean;
5757+5858+ /** Replaces the per-$type checks in `actionsNeedFullScope`. */
5959+ needsFullScope: boolean;
6060+6161+ /** Validate a raw API input and produce the local action shape. Async to
6262+ * accommodate webhooks (callback verification). */
6363+ validate(input: TInput, ctx: ValidationContext): Promise<ValidateResult<TAction>>;
6464+6565+ /** Pure local→PDS projection. Called by the API write paths and by
6666+ * `pds-serialize.toPdsAction` for stored-action re-serialization. */
6767+ toPds(action: TAction): TPdsAction;
6868+6969+ /** Runtime executor wired into `handlerFor()` today. */
7070+ execute: ActionHandler;
7171+7272+ /** Build the dry-run delivery_logs row content for this action type. */
7373+ dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>;
7474+7575+ /** Catalogue tile shown in the form's "add action" picker. */
7676+ catalogue: CatalogueTile;
7777+7878+ /** Optional override for how the GET /api/automations route serializes a
7979+ * stored action — webhooks use this to strip the secret. */
8080+ serializeForApi?(action: TAction): unknown;
8181+};
8282+8383+/** Insertion order is the canonical action ordering used everywhere a list of
8484+ * action types matters (catalogue tiles, label tables, lexicon refs, drift
8585+ * tests). Phase 2 starts populating ACTION_REGISTRY by type. */
8686+export const ACTION_TYPES: readonly ActionType[] = [
8787+ "webhook",
8888+ "bsky-post",
8989+ "follow",
9090+ "margin-bookmark",
9191+ "semble-save",
9292+ "record",
9393+ "patch-record",
9494+] as const;
9595+9696+/** Map of $type → definition. Phase 2 fills this in one entry at a time;
9797+ * consumers begin reading from it incrementally while the legacy switches
9898+ * remain in place. Empty until then so this scaffolding commit is a pure
9999+ * type-level addition. */
100100+import { sembleSaveDefinition } from "./semble-save.js";
101101+102102+/** Map of $type → definition. Filled in incrementally; consumers begin
103103+ * reading from it while the legacy switches remain in place as fallback. */
104104+// 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>>> = {
106106+ "semble-save": sembleSaveDefinition,
107107+};
+134-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 { BookmarkPlus } from "../../app/icons.js";
99+import type { ColorKey } from "../automations/follow-targets.js";
1010+import type { ActionDefinition } from "./registry.js";
1111+import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
712813const TARGET_COLLECTION = "network.cosmik.card";
1414+1515+// Allow either a literal http(s):// prefix or a leading {{...}} placeholder
1616+// (so the entire URL can come from event/action data). A literal non-http
1717+// scheme like `javascript:` is rejected here so it can't be persisted to the
1818+// user's PDS; the runtime SSRF guard remains the real security boundary.
1919+const URL_OK_RE = /^(https?:\/\/|\{\{)/i;
9201021async function buildRecord(
1122 match: MatchedEvent,
···8596 execute,
8697 (action) => JSON.stringify({ url: action.url }),
8798);
9999+100100+type SembleSaveInput = {
101101+ type: "semble-save";
102102+ url: string;
103103+};
104104+105105+type PdsSembleSaveAction = {
106106+ $type: "run.airglow.automation#sembleSaveAction";
107107+ url: string;
108108+ forEach?: SembleSaveAction["forEach"];
109109+ comment?: string;
110110+};
111111+112112+async function validate(
113113+ input: SembleSaveInput,
114114+ ctx: ValidationContext,
115115+): Promise<{ ok: true; local: SembleSaveAction } | { ok: false; error: string; status?: number }> {
116116+ if (!input.url || typeof input.url !== "string" || !input.url.trim()) {
117117+ return { ok: false, error: "url is required for semble-save actions" };
118118+ }
119119+ if (input.url.length > SEMBLE_SAVE_LIMITS.url) {
120120+ return { ok: false, error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less` };
121121+ }
122122+ if (!URL_OK_RE.test(input.url)) {
123123+ return {
124124+ ok: false,
125125+ error: "url must start with http://, https://, or a {{placeholder}}",
126126+ };
127127+ }
128128+ const urlValidation = validateTextTemplate(
129129+ input.url,
130130+ ctx.fetchNames,
131131+ ctx.actionResultNames,
132132+ ctx.hasItem,
133133+ );
134134+ if (!urlValidation.valid) {
135135+ return { ok: false, error: `url: ${urlValidation.error}` };
136136+ }
137137+138138+ // The route adds forEach/comment after this returns — they're orthogonal
139139+ // to the action-specific fields and validated centrally.
140140+ const local: SembleSaveAction = { $type: "semble-save", url: input.url };
141141+ return { ok: true, local };
142142+}
143143+144144+function toPds(action: SembleSaveAction): PdsSembleSaveAction {
145145+ return {
146146+ $type: "run.airglow.automation#sembleSaveAction",
147147+ url: action.url,
148148+ ...(action.forEach ? { forEach: action.forEach } : {}),
149149+ ...(action.comment ? { comment: action.comment } : {}),
150150+ };
151151+}
152152+153153+function redactUserinfo(url: string): string {
154154+ try {
155155+ const u = new URL(url);
156156+ if (u.username || u.password) {
157157+ u.username = "";
158158+ u.password = "";
159159+ return u.toString();
160160+ }
161161+ return url;
162162+ } catch {
163163+ return url;
164164+ }
165165+}
166166+167167+async function dryRunDescribe(
168168+ action: SembleSaveAction,
169169+ ctx: DryRunContext,
170170+): Promise<DryRunDescription> {
171171+ try {
172172+ const url = (
173173+ await renderTextTemplate(
174174+ action.url,
175175+ ctx.match.event,
176176+ ctx.fetchContext,
177177+ ctx.match.automation,
178178+ ctx.item,
179179+ )
180180+ ).trim();
181181+ // Redact credentials in userinfo before persisting to delivery_logs:
182182+ // event-derived URLs may legitimately carry tokens we shouldn't store.
183183+ const safeUrl = redactUserinfo(url);
184184+ return {
185185+ message: `Would save ${safeUrl || "(empty)"} to Semble${ctx.itemSuffix}`,
186186+ payload: JSON.stringify({ url: safeUrl, item: ctx.item }),
187187+ error: null,
188188+ };
189189+ } catch (err) {
190190+ return {
191191+ message: null,
192192+ payload: null,
193193+ error: `Template error: ${err instanceof Error ? err.message : String(err)}`,
194194+ };
195195+ }
196196+}
197197+198198+export const sembleSaveDefinition: ActionDefinition<
199199+ SembleSaveAction,
200200+ SembleSaveInput,
201201+ PdsSembleSaveAction
202202+> = {
203203+ type: "semble-save",
204204+ pdsType: "run.airglow.automation#sembleSaveAction",
205205+ recordProducing: true,
206206+ needsFullScope: true,
207207+ validate,
208208+ toPds,
209209+ execute: executeSembleSave,
210210+ dryRunDescribe,
211211+ catalogue: {
212212+ label: "Save on Semble",
213213+ description: "Save a URL as a card on Semble",
214214+ category: "apps",
215215+ icon: BookmarkPlus,
216216+ available: true,
217217+ colorKey: "cosmik" satisfies ColorKey,
218218+ faviconDomain: "semble.so",
219219+ },
220220+};
+37
lib/actions/types.ts
···11+import type { MatchedEvent } from "../jetstream/consumer.js";
22+import type { ActionResult } from "./delivery.js";
33+import type { FetchContext } from "./template.js";
44+55+/** Runtime executor signature: handler.ts dispatches to one of these per action. */
66+export type ActionHandler = (
77+ match: MatchedEvent,
88+ actionIndex: number,
99+ fetchContext?: FetchContext,
1010+ item?: unknown,
1111+) => Promise<ActionResult>;
1212+1313+/** Context passed to per-action validators by the API POST/PATCH routes.
1414+ * Names of declared fetches and prior record-producing actions, plus whether
1515+ * this action runs inside a forEach (so validators can allow `{{item.*}}`). */
1616+export type ValidationContext = {
1717+ fetchNames: string[];
1818+ actionResultNames: string[];
1919+ hasItem: boolean;
2020+};
2121+2222+/** Outcome of `dryRunDescribe`: the three columns the handler writes into a
2323+ * delivery_logs row for a dry-run fire. Any field may be null. */
2424+export type DryRunDescription = {
2525+ message: string | null;
2626+ payload: string | null;
2727+ error: string | null;
2828+};
2929+3030+/** Context passed to per-action `dryRunDescribe`: everything `logDryRun`
3131+ * currently has in scope plus the per-iteration `item` for forEach. */
3232+export type DryRunContext = {
3333+ match: MatchedEvent;
3434+ fetchContext: FetchContext;
3535+ item: unknown;
3636+ itemSuffix: string;
3737+};
+1-43
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, MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js";
73import { nsidRequiresWantedDids } from "../lexicons/match.js";
84import { isValidNsid } from "../lexicons/resolver.js";
95import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js";
···560556 }
561557562558 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 };
601559}
602560603561const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+10-5
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) =>
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.
3838+ const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY];
3939+ if (def) return def.needsFullScope;
4040+ return (
3641 a.$type === "bsky-post" ||
3742 a.$type === "record" ||
3843 a.$type === "patch-record" ||
3944 a.$type === "margin-bookmark" ||
4040- a.$type === "follow" ||
4141- a.$type === "semble-save",
4242- );
4545+ a.$type === "follow"
4646+ );
4747+ });
4348}
44494550/** Check if the granted scope covers all collections needed by actions. */
+10-8
lib/automations/pds-serialize.ts
···11import type { Action, ForEachConfig } from "../db/schema.js";
22import type { PdsAction, PdsForEachConfig } from "./pds.js";
33+import { ACTION_REGISTRY } from "../actions/registry.js";
3445function toPdsForEach(fe: ForEachConfig | undefined): PdsForEachConfig | undefined {
56 if (!fe) return undefined;
···1213/** Serialize a stored Action into its PDS-record shape. Split from pds.ts so
1314 * tests that mock the OAuth-backed PDS client don't need to re-stub this. */
1415export 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+1525 const forEach = toPdsForEach(a.forEach);
1626 const forEachField = forEach ? { forEach } : {};
1727···5868 $type: "run.airglow.automation#followAction",
5969 target: a.target,
6070 subject: a.subject,
6161- ...forEachField,
6262- ...(a.comment ? { comment: a.comment } : {}),
6363- };
6464- }
6565- if (a.$type === "semble-save") {
6666- return {
6767- $type: "run.airglow.automation#sembleSaveAction",
6868- url: a.url,
6971 ...forEachField,
7072 ...(a.comment ? { comment: a.comment } : {}),
7173 };
+29-29
lib/jetstream/handler.ts
···66import { executePatchRecord } from "../actions/patch-record.js";
77import { executeMarginBookmark } from "../actions/margin-bookmark.js";
88import { executeFollow } from "../actions/follow.js";
99-import { executeSembleSave } from "../actions/semble-save.js";
99+import { ACTION_REGISTRY } from "../actions/registry.js";
1010import { FOLLOW_TARGETS } from "../automations/follow-targets.js";
1111import { resolveFetches } from "../actions/fetcher.js";
1212import { isSuccess } from "../actions/delivery.js";
···2424) => Promise<ActionResult>;
25252626function handlerFor(action: Action): ActionHandler {
2727+ // Registry-first: any action type registered in ACTION_REGISTRY dispatches
2828+ // through its definition. Unregistered types fall through to the legacy
2929+ // switch — phases 2-3 migrate one type at a time.
3030+ const def = ACTION_REGISTRY[action.$type];
3131+ if (def) return def.execute;
2732 switch (action.$type) {
2833 case "bsky-post":
2934 return executeBskyPost;
···3540 return executeMarginBookmark;
3641 case "follow":
3742 return executeFollow;
3838- case "semble-save":
3939- return executeSembleSave;
4043 default:
4144 return dispatch;
4245 }
···289292 const item = options?.item;
290293 const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : "";
291294295295+ // Registry-first: if the action type has a registered definition, delegate
296296+ // to its dryRunDescribe and skip the legacy if-chain.
297297+ const def = ACTION_REGISTRY[action.$type];
298298+ if (def && failedFetches.length === 0) {
299299+ const desc = await def.dryRunDescribe(action, { match, fetchContext, item, itemSuffix });
300300+ await db.insert(deliveryLogs).values({
301301+ automationUri: match.automation.uri,
302302+ actionIndex,
303303+ eventTimeUs: match.event.time_us,
304304+ payload: capPayload(desc.payload),
305305+ statusCode: null,
306306+ message: desc.message,
307307+ error: desc.error,
308308+ dryRun: true,
309309+ attempt: 1,
310310+ createdAt: new Date(),
311311+ });
312312+ return;
313313+ }
314314+292315 if (failedFetches.length > 0) {
293316 error = `Fetch failed: ${failedFetches.join(", ")}`;
294317 } else if (action.$type === "webhook") {
···363386 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
364387 }
365388 } 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- }
389389+ // Unreachable: handled by the registry above. Narrows the union for the
390390+ // remaining else-branch (record / patch-record).
391391+ throw new Error("semble-save action should be handled by the registry");
378392 } else {
379393 try {
380394 const rendered = await renderTemplate(
···410424411425function truncateForLog(s: string, max = 120): string {
412426 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- }
427427}
428428429429/**