···11import { ArrowRight, Webhook } from "../../icons.ts";
22import { nsidToDomain } from "../../../lib/lexicons/resolver.ts";
33import { type Action } from "../../../lib/db/schema.ts";
44-import { isRecordProducingAction } from "../../../lib/actions/registry.ts";
44+import { isRecordProducingAction } from "../../islands/action-editors/registry.ts";
55import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts";
66import { Favicon, NsidCode } from "../NsidCode/index.tsx";
77import * as s from "./styles.css.ts";
···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. */
1414+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site.
1515+export const ACTION_UI_REGISTRY: Record<ActionType, ActionUIDefinition<any, any>> = {
1616+ webhook: webhookUiDefinition,
1717+ record: recordUiDefinition,
1818+ "bsky-post": bskyPostUiDefinition,
1919+ "patch-record": patchRecordUiDefinition,
2020+ "margin-bookmark": marginBookmarkUiDefinition,
2121+ "semble-save": sembleSaveUiDefinition,
2222+ follow: followUiDefinition,
2323+};
2424+2525+/** Per-action draft union — replaces the hand-written `ActionDraft` that
2626+ * used to live in AutomationForm.tsx. Adding a new action type here means
2727+ * appending its draft to this union and registering it in
2828+ * `ACTION_UI_REGISTRY`. */
2929+export type ActionDraft =
3030+ | WebhookDraft
3131+ | RecordDraft
3232+ | BskyPostDraft
3333+ | PatchRecordDraft
3434+ | MarginBookmarkDraft
3535+ | SembleSaveDraft
3636+ | FollowDraft;
3737+3838+export type {
3939+ WebhookDraft,
4040+ RecordDraft,
4141+ BskyPostDraft,
4242+ PatchRecordDraft,
4343+ MarginBookmarkDraft,
4444+ SembleSaveDraft,
4545+ FollowDraft,
4646+};
4747+4848+/** Client-safe variant of `lib/actions/registry.isRecordProducingAction`.
4949+ * Importing the server registry from a client island drags drizzle/sqlite
5050+ * through transitive dependencies (executor → dispatcher → db). The
5151+ * `recordProducing` field on each UI definition mirrors the server flag,
5252+ * enforced by a vitest test. */
5353+export function isRecordProducingAction(type: string): boolean {
5454+ return ACTION_UI_REGISTRY[type as ActionType]?.recordProducing ?? false;
5555+}
···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+> = {
6161+ type: TAction["$type"];
6262+ /** Mirrors `ActionDefinition.recordProducing` on the server. Duplicated
6363+ * here so client code (form, LexiconFlow) doesn't have to reach into the
6464+ * server registry — which would drag drizzle/sqlite into the client
6565+ * bundle. The mirror is verified by a vitest test. */
6666+ recordProducing: boolean;
6767+ /** Picker-tile metadata. Optional because some action types map to
6868+ * multiple tiles (e.g. follow expands into one tile per FOLLOW_TARGETS
6969+ * entry); those keep their tile list hand-curated in `action-catalogue`. */
7070+ catalogue?: CatalogueTile;
7171+ /** Build an empty draft for "+ Add action" clicks. */
7272+ newDraft: (init: NewDraftInit) => TDraft;
7373+ /** Project a stored Action into the editor's draft shape. */
7474+ fromAction: (action: TAction) => TDraft;
7575+ /** Project a draft back to the API input shape. The route's POST/PATCH
7676+ * passes this directly into the server-side registry's `validate`. Common
7777+ * fields (forEach, comment) are added by the form. */
7878+ toInput: (draft: TDraft) => Omit<ActionInput, "forEach" | "comment">;
7979+ EditorBlock: FC<EditorBlockProps<TDraft>>;
8080+};
···66import type { MatchedEvent } from "../jetstream/consumer.js";
77import { AUTOMATION_LIMITS } from "../automations/limits.js";
88import { BCP47_RE, VALID_BSKY_LABELS } from "./validation.js";
99-import { MessageSquare } from "../../app/icons.js";
109import type { ActionDefinition } from "./registry.js";
1110import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
1211···185184 toPds,
186185 execute: executeBskyPost,
187186 dryRunDescribe,
188188- catalogue: {
189189- label: "Post to Bluesky",
190190- description: "Publish a post to your Bluesky account",
191191- category: "bluesky",
192192- icon: MessageSquare,
193193- available: true,
194194- },
195187};
-8
lib/actions/executor.ts
···44import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js";
55import type { MatchedEvent } from "../jetstream/consumer.js";
66import { isValidNsid } from "../lexicons/resolver.js";
77-import { FilePlus2 } from "../../app/icons.js";
87import type { ActionDefinition } from "./registry.js";
98import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
109···137136 toPds,
138137 execute: executeAction,
139138 dryRunDescribe,
140140- catalogue: {
141141- label: "Create a record",
142142- description: "Create a new record in any collection",
143143- category: "pds",
144144- icon: FilePlus2,
145145- available: true,
146146- },
147139};
-9
lib/actions/margin-bookmark.ts
···77import { fetchURLMetadata } from "../url-metadata.js";
88import { config } from "../config.js";
99import { MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js";
1010-import { Bookmark } from "../../app/icons.js";
1110import type { ActionDefinition } from "./registry.js";
1211import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
1312···330329 toPds,
331330 execute: executeMarginBookmark,
332331 dryRunDescribe,
333333- catalogue: {
334334- label: "Bookmark on Margin",
335335- description: "Create a bookmark note in Margin.at",
336336- category: "apps",
337337- icon: Bookmark,
338338- available: true,
339339- faviconDomain: "margin.at",
340340- },
341332};
-8
lib/actions/patch-record.ts
···1111import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js";
1212import type { MatchedEvent } from "../jetstream/consumer.js";
1313import { isValidNsid } from "../lexicons/resolver.js";
1414-import { Pencil } from "../../app/icons.js";
1514import type { ActionDefinition } from "./registry.js";
1615import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
1716···215214 toPds,
216215 execute: executePatchRecord,
217216 dryRunDescribe,
218218- catalogue: {
219219- label: "Update a record",
220220- description: "Modify fields of an existing record",
221221- category: "pds",
222222- icon: Pencil,
223223- available: true,
224224- },
225217};
+23
lib/actions/registry-contract.test.ts
···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+});
+19-48
lib/actions/registry.ts
···11-import type { FC } from "hono/jsx";
21import type { Action } from "../db/schema.js";
32import type { PdsAction } from "../automations/pds.js";
44-import type { ColorKey } from "../automations/follow-targets.js";
55-66-/** Common prop signature for the lucide-style icon components in `app/icons.ts`. */
77-type IconProps = { size?: number; class?: string; color?: string; "stroke-width"?: number };
88-export type ActionIcon = FC<IconProps>;
93import type {
104 ActionHandler,
115 DryRunContext,
···1711 * key. Derived from `Action` so adding a new variant is a type error here. */
1812export type ActionType = Action["$type"];
19132020-/** Catalogue tile metadata that today lives in `action-catalogue.ts`. Mirrored
2121- * here so each registry entry owns its UI footprint. The hand-curated
2222- * ACTION_CATALOGUE array in action-catalogue.ts becomes a derived view in
2323- * Phase 4. */
2424-export type CatalogueTile = {
2525- /** Imperative phrase shown in the "add action" picker (e.g. "Post to Bluesky"). */
2626- label: string;
2727- description: string;
2828- category: "webhook" | "bluesky" | "apps" | "pds";
2929- /** Lucide-style icon component (one of the exports from `app/icons.ts`). */
3030- icon: ActionIcon;
3131- available: boolean;
3232- /** Override for the tile's data-cat color (e.g. follow-sifa is sifa-blue
3333- * while still living under "Bluesky"). */
3434- colorKey?: ColorKey;
3535- /** Domain whose favicon is rendered next to the icon. */
3636- faviconDomain?: string;
3737-};
3838-3914/** Validation outcome for the API POST/PATCH routes. The single function call
4015 * collapses what's currently a per-action if-branch in both routes. PDS
4116 * serialization is a separate concern (see `toPds`) so PATCH can reproject a
···4419 | { ok: true; local: TAction }
4520 | { ok: false; error: string; status?: number };
46214747-/** Single source of truth per action type. Subsequent phases populate
4848- * `ACTION_REGISTRY` with one of these per `$type`; the dispatchers in
4949- * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts then
5050- * reduce to `ACTION_REGISTRY[type].method(...)` lookups. */
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. */
5131export type ActionDefinition<
5232 TAction extends Action = Action,
5333 TInput = unknown,
···5737 pdsType: TPdsAction["$type"];
58385939 /** Short noun-phrase label used in the dashboard automation list and
6060- * delivery-log filters (e.g. "Bluesky Post", "Webhook"). Distinct from
6161- * `catalogue.label`, which is the imperative phrase in the picker. */
4040+ * delivery-log filters (e.g. "Bluesky Post", "Webhook"). */
6241 displayLabel: string;
63426443 /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls
6565- * in API routes. */
4444+ * in API routes. Mirrored on the client side via `ActionUIDefinition`
4545+ * to keep the form's `isRecordProducingAction` check off the server registry. */
6646 recordProducing: boolean;
67476848 /** Replaces the per-$type checks in `actionsNeedFullScope`. */
···8262 /** Build the dry-run delivery_logs row content for this action type. */
8363 dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>;
84648585- /** Catalogue tile shown in the form's "add action" picker. Optional
8686- * because some action types map to multiple tiles (e.g. `follow` expands
8787- * into one tile per FOLLOW_TARGETS entry); those keep their tile list
8888- * hand-curated in `action-catalogue.ts`. */
8989- catalogue?: CatalogueTile;
9090-9165 /** Optional override for how the GET /api/automations route serializes a
9266 * stored action — webhooks use this to strip the secret. */
9367 serializeForApi?(action: TAction): unknown;
···95699670/** Insertion order is the canonical action ordering used everywhere a list of
9771 * action types matters (catalogue tiles, label tables, lexicon refs, drift
9898- * tests). Phase 2 starts populating ACTION_REGISTRY by type. */
7272+ * tests). */
9973export const ACTION_TYPES: readonly ActionType[] = [
10074 "webhook",
10175 "bsky-post",
···10680 "patch-record",
10781] as const;
10882109109-/** Map of $type → definition. Phase 2 fills this in one entry at a time;
110110- * consumers begin reading from it incrementally while the legacy switches
111111- * remain in place. Empty until then so this scaffolding commit is a pure
112112- * type-level addition. */
11383import { sembleSaveDefinition } from "./semble-save.js";
11484import { marginBookmarkDefinition } from "./margin-bookmark.js";
11585import { followDefinition } from "./follow.js";
···11888import { recordDefinition } from "./executor.js";
11989import { webhookDefinition } from "./webhook.js";
12090121121-/** Map of $type → definition. Every action type is registered after Phase 3.
122122- * Subsequent dispatchers consume the registry directly and need no fallback. */
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). */
12394// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-action entries are precisely typed at their declaration site.
12495export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = {
12596 "semble-save": sembleSaveDefinition,
···131102 webhook: webhookDefinition,
132103};
133104134134-/** Action types that produce a record result (uri, cid, rkey) for chaining.
135135- * Lookup goes through the registry — adding a new action with
136136- * `recordProducing: true` is enough; no parallel set to maintain. */
105105+/** Server-side `isRecordProducingAction`. The form/island side has its own
106106+ * client-safe copy in `action-editors/registry.ts` driven by the UI
107107+ * definitions; both stay in sync via a vitest contract test. */
137108export function isRecordProducingAction(type: string): boolean {
138109 return ACTION_REGISTRY[type as ActionType]?.recordProducing ?? false;
139110}
-11
lib/actions/semble-save.ts
···55import type { MatchedEvent } from "../jetstream/consumer.js";
66import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js";
77import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js";
88-import { BookmarkPlus } from "../../app/icons.js";
99-import type { ColorKey } from "../automations/follow-targets.js";
108import type { ActionDefinition } from "./registry.js";
119import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
1210···209207 toPds,
210208 execute: executeSembleSave,
211209 dryRunDescribe,
212212- catalogue: {
213213- label: "Save on Semble",
214214- description: "Save a URL as a card on Semble",
215215- category: "apps",
216216- icon: BookmarkPlus,
217217- available: true,
218218- colorKey: "cosmik" satisfies ColorKey,
219219- faviconDomain: "semble.so",
220220- },
221210};
-8
lib/actions/webhook.ts
···33import { dispatch, buildPayload } from "../webhooks/dispatcher.js";
44import { assertPublicUrl, UrlGuardError } from "../url-guard.js";
55import { verifyCallback } from "../automations/verify.js";
66-import { Webhook } from "../../app/icons.js";
76import { validateWebhookHeaders } from "./validation.js";
87import type { ActionDefinition } from "./registry.js";
98import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js";
···114113 execute: dispatch,
115114 dryRunDescribe,
116115 serializeForApi,
117117- catalogue: {
118118- label: "Send a webhook",
119119- description: "POST event data to an external URL",
120120- category: "webhook",
121121- icon: Webhook,
122122- available: true,
123123- },
124116};
+4-3
lib/automations/action-catalogue.ts
···11import { Heart, Trash2, UserPlus } from "../../app/icons.js";
22-import { ACTION_REGISTRY, type ActionIcon } from "../actions/registry.js";
22+import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts";
33+import type { ActionIcon } from "../../app/islands/action-editors/types.ts";
34import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js";
4556export type AddableActionId =
···3435};
35363637/** Tile entry derived from a registered action's `catalogue` metadata. */
3737-function tileFromRegistry(type: keyof typeof ACTION_REGISTRY): Tile {
3838- const def = ACTION_REGISTRY[type]!;
3838+function tileFromRegistry(type: keyof typeof ACTION_UI_REGISTRY): Tile {
3939+ const def = ACTION_UI_REGISTRY[type]!;
3940 const cat = def.catalogue!;
4041 return {
4142 id: type,