Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: collapse remaining per-action switches via UI registry

Hugo b3c8e730 bda42715

+249 -200
+5 -9
app/components/LexiconFlow/index.tsx
··· 1 1 import { ArrowRight, Webhook } from "../../icons.ts"; 2 - import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 2 import { type Action } from "../../../lib/db/schema.ts"; 4 - import { isRecordProducingAction } from "../../islands/action-editors/registry.ts"; 5 - import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts"; 3 + import { 4 + ACTION_UI_REGISTRY, 5 + isRecordProducingAction, 6 + } from "../../islands/action-editors/registry.ts"; 6 7 import { Favicon, NsidCode } from "../NsidCode/index.tsx"; 7 8 import * as s from "./styles.css.ts"; 8 9 ··· 11 12 const domains: string[] = []; 12 13 for (const a of actions) { 13 14 if (!isRecordProducingAction(a.$type)) continue; 14 - let domain: string | null = null; 15 - if (a.$type === "bsky-post") domain = "bsky.app"; 16 - else if (a.$type === "margin-bookmark") domain = "margin.at"; 17 - else if (a.$type === "follow") domain = FOLLOW_TARGETS[a.target].faviconDomain; 18 - else if (a.$type === "record" || a.$type === "patch-record") 19 - domain = nsidToDomain(a.targetCollection); 15 + const domain = ACTION_UI_REGISTRY[a.$type].getFaviconDomain?.(a) ?? null; 20 16 if (!domain || seen.has(domain)) continue; 21 17 seen.add(domain); 22 18 domains.push(domain);
+26
app/islands/action-editors/bsky-post.tsx
··· 1 1 import type { BskyPostAction } from "../../../lib/db/schema.js"; 2 2 import { MessageSquare } from "../../icons.ts"; 3 + import { CodeBlock } from "../../components/CodeBlock/index.tsx"; 3 4 import * as s from "../AutomationForm.css.ts"; 4 5 import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 6 ··· 99 100 ); 100 101 } 101 102 103 + function BskyPostDisplayBlock({ action }: { action: BskyPostAction }) { 104 + return ( 105 + <> 106 + <dt>Text Template</dt> 107 + <dd> 108 + <CodeBlock>{action.textTemplate}</CodeBlock> 109 + </dd> 110 + {action.langs && action.langs.length > 0 && ( 111 + <> 112 + <dt>Languages</dt> 113 + <dd>{action.langs.join(", ")}</dd> 114 + </> 115 + )} 116 + {action.labels && action.labels.length > 0 && ( 117 + <> 118 + <dt>Content Warnings</dt> 119 + <dd>{action.labels.join(", ")}</dd> 120 + </> 121 + )} 122 + </> 123 + ); 124 + } 125 + 102 126 export const bskyPostUiDefinition: ActionUIDefinition<BskyPostDraft, BskyPostAction> = { 103 127 type: "bsky-post", 104 128 recordProducing: true, ··· 136 160 }; 137 161 }, 138 162 EditorBlock: BskyPostActionEditor, 163 + DisplayBlock: BskyPostDisplayBlock, 164 + getFaviconDomain: () => "bsky.app", 139 165 };
+22
app/islands/action-editors/follow.tsx
··· 1 1 import type { FollowAction, FollowTarget } from "../../../lib/db/schema.js"; 2 2 import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.js"; 3 + import { InlineCode } from "../../components/CodeBlock/index.tsx"; 4 + import { NsidCode } from "../../components/NsidCode/index.tsx"; 3 5 import * as s from "../AutomationForm.css.ts"; 4 6 import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 7 ··· 52 54 ); 53 55 } 54 56 57 + function FollowDisplayBlock({ action }: { action: FollowAction }) { 58 + const target = FOLLOW_TARGETS[action.target]; 59 + return ( 60 + <> 61 + <dt>App</dt> 62 + <dd>{target?.appName ?? action.target}</dd> 63 + <dt>Collection</dt> 64 + <dd> 65 + <NsidCode>{target?.collection ?? ""}</NsidCode> 66 + </dd> 67 + <dt>Subject DID</dt> 68 + <dd> 69 + <InlineCode>{action.subject}</InlineCode> 70 + </dd> 71 + </> 72 + ); 73 + } 74 + 55 75 export const followUiDefinition: ActionUIDefinition<FollowDraft, FollowAction> = { 56 76 type: "follow", 57 77 recordProducing: true, ··· 69 89 }), 70 90 toInput: (d) => ({ type: "follow", target: d.target, subject: d.subject }), 71 91 EditorBlock: FollowActionEditor, 92 + DisplayBlock: FollowDisplayBlock, 93 + getFaviconDomain: (action) => FOLLOW_TARGETS[action.target].faviconDomain, 72 94 };
+28
app/islands/action-editors/margin-bookmark.tsx
··· 1 1 import type { MarginBookmarkAction } from "../../../lib/db/schema.js"; 2 2 import { Bookmark } from "../../icons.ts"; 3 + import { CodeBlock, InlineCode } from "../../components/CodeBlock/index.tsx"; 3 4 import * as s from "../AutomationForm.css.ts"; 4 5 import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 6 ··· 88 89 ); 89 90 } 90 91 92 + function MarginBookmarkDisplayBlock({ action }: { action: MarginBookmarkAction }) { 93 + return ( 94 + <> 95 + <dt>Page URL</dt> 96 + <dd> 97 + <InlineCode>{action.targetSource}</InlineCode> 98 + </dd> 99 + {action.bodyValue && ( 100 + <> 101 + <dt>Description</dt> 102 + <dd> 103 + <CodeBlock>{action.bodyValue}</CodeBlock> 104 + </dd> 105 + </> 106 + )} 107 + {action.tags && action.tags.length > 0 && ( 108 + <> 109 + <dt>Tags</dt> 110 + <dd>{action.tags.join(", ")}</dd> 111 + </> 112 + )} 113 + </> 114 + ); 115 + } 116 + 91 117 export const marginBookmarkUiDefinition: ActionUIDefinition< 92 118 MarginBookmarkDraft, 93 119 MarginBookmarkAction ··· 131 157 }; 132 158 }, 133 159 EditorBlock: MarginBookmarkActionEditor, 160 + DisplayBlock: MarginBookmarkDisplayBlock, 161 + getFaviconDomain: () => "margin.at", 134 162 };
+24
app/islands/action-editors/patch-record.tsx
··· 1 1 import type { PatchRecordAction } from "../../../lib/db/schema.js"; 2 + import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 2 3 import { Pencil } from "../../icons.ts"; 4 + import { CodeBlock, InlineCode } from "../../components/CodeBlock/index.tsx"; 5 + import { NsidCode } from "../../components/NsidCode/index.tsx"; 3 6 import * as s from "../AutomationForm.css.ts"; 4 7 import RecordFormBuilder from "../RecordFormBuilder.js"; 5 8 import { useNsidSchema } from "./use-nsid-schema.ts"; ··· 125 128 ); 126 129 } 127 130 131 + function PatchRecordDisplayBlock({ action }: { action: PatchRecordAction }) { 132 + return ( 133 + <> 134 + <dt>Target Collection</dt> 135 + <dd> 136 + <NsidCode>{action.targetCollection}</NsidCode> 137 + </dd> 138 + <dt>Base Record URI</dt> 139 + <dd> 140 + <InlineCode>{action.baseRecordUri}</InlineCode> 141 + </dd> 142 + <dt>Patch Template</dt> 143 + <dd> 144 + <CodeBlock>{action.recordTemplate}</CodeBlock> 145 + </dd> 146 + </> 147 + ); 148 + } 149 + 128 150 export const patchRecordUiDefinition: ActionUIDefinition<PatchRecordDraft, PatchRecordAction> = { 129 151 type: "patch-record", 130 152 recordProducing: true, ··· 156 178 recordTemplate: d.recordTemplate, 157 179 }), 158 180 EditorBlock: PatchRecordActionEditor, 181 + DisplayBlock: PatchRecordDisplayBlock, 182 + getFaviconDomain: (action) => nsidToDomain(action.targetCollection), 159 183 };
+20
app/islands/action-editors/record.tsx
··· 1 1 import type { RecordAction } from "../../../lib/db/schema.js"; 2 + import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 2 3 import { FilePlus2 } from "../../icons.ts"; 4 + import { CodeBlock } from "../../components/CodeBlock/index.tsx"; 5 + import { NsidCode } from "../../components/NsidCode/index.tsx"; 3 6 import * as s from "../AutomationForm.css.ts"; 4 7 import RecordFormBuilder from "../RecordFormBuilder.js"; 5 8 import { useNsidSchema } from "./use-nsid-schema.ts"; ··· 101 104 ); 102 105 } 103 106 107 + function RecordDisplayBlock({ action }: { action: RecordAction }) { 108 + return ( 109 + <> 110 + <dt>Target Collection</dt> 111 + <dd> 112 + <NsidCode>{action.targetCollection}</NsidCode> 113 + </dd> 114 + <dt>Record Template</dt> 115 + <dd> 116 + <CodeBlock>{action.recordTemplate}</CodeBlock> 117 + </dd> 118 + </> 119 + ); 120 + } 121 + 104 122 export const recordUiDefinition: ActionUIDefinition<RecordDraft, RecordAction> = { 105 123 type: "record", 106 124 recordProducing: true, ··· 124 142 recordTemplate: d.recordTemplate, 125 143 }), 126 144 EditorBlock: RecordActionEditor, 145 + DisplayBlock: RecordDisplayBlock, 146 + getFaviconDomain: (action) => nsidToDomain(action.targetCollection), 127 147 };
+3 -1
app/islands/action-editors/registry.ts
··· 1 1 import type { ActionType } from "../../../lib/actions/registry.js"; 2 - import { webhookUiDefinition, type WebhookDraft } from "./webhook.tsx"; 2 + import { webhookUiDefinition, WebhookPublicDisplayBlock, type WebhookDraft } from "./webhook.tsx"; 3 + 4 + export { WebhookPublicDisplayBlock }; 3 5 import { recordUiDefinition, type RecordDraft } from "./record.tsx"; 4 6 import { bskyPostUiDefinition, type BskyPostDraft } from "./bsky-post.tsx"; 5 7 import { patchRecordUiDefinition, type PatchRecordDraft } from "./patch-record.tsx";
+14
app/islands/action-editors/semble-save.tsx
··· 1 1 import type { SembleSaveAction } from "../../../lib/db/schema.js"; 2 2 import { BookmarkPlus } from "../../icons.ts"; 3 + import { InlineCode } from "../../components/CodeBlock/index.tsx"; 3 4 import * as s from "../AutomationForm.css.ts"; 4 5 import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 5 6 ··· 43 44 ); 44 45 } 45 46 47 + function SembleSaveDisplayBlock({ action }: { action: SembleSaveAction }) { 48 + return ( 49 + <> 50 + <dt>Page URL</dt> 51 + <dd> 52 + <InlineCode>{action.url}</InlineCode> 53 + </dd> 54 + </> 55 + ); 56 + } 57 + 46 58 export const sembleSaveUiDefinition: ActionUIDefinition<SembleSaveDraft, SembleSaveAction> = { 47 59 type: "semble-save", 48 60 recordProducing: true, ··· 59 71 fromAction: (a) => ({ type: "semble-save", url: a.url, comment: a.comment ?? "" }), 60 72 toInput: (d) => ({ type: "semble-save", url: d.url }), 61 73 EditorBlock: SembleSaveActionEditor, 74 + DisplayBlock: SembleSaveDisplayBlock, 75 + getFaviconDomain: () => "semble.so", 62 76 };
+13
app/islands/action-editors/types.ts
··· 77 77 * fields (forEach, comment) are added by the form. */ 78 78 toInput: (draft: TDraft) => Omit<ActionInput, "forEach" | "comment">; 79 79 EditorBlock: FC<EditorBlockProps<TDraft>>; 80 + /** Read-only display rendered as <dt>/<dd> pairs inside a <DescriptionList> 81 + * on the dashboard's automation-detail page. Used for the OWNER view — 82 + * the public profile route uses sanitized data, so webhook ships a 83 + * separate public block (see `webhookPublicDisplayBlock`); for every 84 + * other action the public view reuses this same component. */ 85 + DisplayBlock: FC<{ action: TAction }>; 86 + /** Domain whose favicon represents this action in `LexiconFlow`. Some 87 + * action types know their target statically (e.g. `bsky-post` → bsky.app); 88 + * others derive it from the action data (e.g. `record.targetCollection` 89 + * via `nsidToDomain`, or follow's per-target FOLLOW_TARGETS lookup). 90 + * Returning null hides the favicon (webhook is record-producing? no, but 91 + * the caller already gates on that). */ 92 + getFaviconDomain?: (action: TAction) => string | null; 80 93 };
+32
app/islands/action-editors/webhook.tsx
··· 1 1 import type { WebhookAction } from "../../../lib/db/schema.js"; 2 2 import { Webhook } from "../../icons.ts"; 3 + import { InlineCode } from "../../components/CodeBlock/index.tsx"; 3 4 import * as s from "../AutomationForm.css.ts"; 4 5 import type { ActionUIDefinition, ForEachDraft, HeaderDraft } from "./types.ts"; 5 6 ··· 99 100 ); 100 101 } 101 102 103 + function WebhookDisplayBlock({ action }: { action: WebhookAction }) { 104 + return ( 105 + <> 106 + <dt>Callback URL</dt> 107 + <dd> 108 + <InlineCode>{action.callbackUrl}</InlineCode> 109 + </dd> 110 + <dt>HMAC Secret</dt> 111 + <dd> 112 + <InlineCode>{action.secret}</InlineCode> 113 + </dd> 114 + </> 115 + ); 116 + } 117 + 118 + /** Public-view counterpart to {@link WebhookDisplayBlock}. The webhook record 119 + * is the only action whose public projection has a different shape 120 + * (`PublicWebhookAction` from `lib/automations/sanitize.ts`): the secret and 121 + * full callback URL are stripped, and only the host domain is shown. */ 122 + export function WebhookPublicDisplayBlock({ action }: { action: { callbackDomain: string } }) { 123 + return ( 124 + <> 125 + <dt>Destination</dt> 126 + <dd> 127 + <InlineCode>{action.callbackDomain}</InlineCode> 128 + </dd> 129 + </> 130 + ); 131 + } 132 + 102 133 export const webhookUiDefinition: ActionUIDefinition<WebhookDraft, WebhookAction> = { 103 134 type: "webhook", 104 135 recordProducing: false, ··· 129 160 }; 130 161 }, 131 162 EditorBlock: WebhookActionEditor, 163 + DisplayBlock: WebhookDisplayBlock, 132 164 };
+6 -100
app/routes/dashboard/automations/[rkey].tsx
··· 13 13 import { getRateLimitCounts } from "@/jetstream/rate-limit.js"; 14 14 import { opLabels, actionTypeLabels, operationLabels } from "@/automations/labels.js"; 15 15 import { actionTypeKey } from "@/automations/action-catalogue.js"; 16 - import { FOLLOW_TARGETS } from "@/automations/follow-targets.js"; 16 + import { ACTION_UI_REGISTRY } from "../../../islands/action-editors/registry.js"; 17 17 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; 18 18 import { db } from "@/db/index.js"; 19 19 import { automations, deliveryLogs } from "@/db/schema.js"; ··· 26 26 import { Button } from "../../../components/Button/index.js"; 27 27 import { Alert } from "../../../components/Alert/index.js"; 28 28 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 29 - import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 29 + import { InlineCode } from "../../../components/CodeBlock/index.js"; 30 30 import { FetchCard } from "../../../components/FetchCard/index.js"; 31 31 import { ForEachSummary } from "../../../components/ForEachSummary/index.js"; 32 32 import { NsidCode } from "../../../components/NsidCode/index.js"; ··· 244 244 </ActionHeader> 245 245 <DescriptionList> 246 246 <ForEachSummary forEach={action.forEach} /> 247 - {action.$type === "webhook" ? ( 248 - <> 249 - <dt>Callback URL</dt> 250 - <dd> 251 - <InlineCode>{action.callbackUrl}</InlineCode> 252 - </dd> 253 - <dt>HMAC Secret</dt> 254 - <dd> 255 - <InlineCode>{action.secret}</InlineCode> 256 - </dd> 257 - </> 258 - ) : action.$type === "bsky-post" ? ( 259 - <> 260 - <dt>Text Template</dt> 261 - <dd> 262 - <CodeBlock>{action.textTemplate}</CodeBlock> 263 - </dd> 264 - {action.langs && action.langs.length > 0 && ( 265 - <> 266 - <dt>Languages</dt> 267 - <dd>{action.langs.join(", ")}</dd> 268 - </> 269 - )} 270 - {action.labels && action.labels.length > 0 && ( 271 - <> 272 - <dt>Content Warnings</dt> 273 - <dd>{action.labels.join(", ")}</dd> 274 - </> 275 - )} 276 - </> 277 - ) : action.$type === "patch-record" ? ( 278 - <> 279 - <dt>Target Collection</dt> 280 - <dd> 281 - <NsidCode>{action.targetCollection}</NsidCode> 282 - </dd> 283 - <dt>Base Record URI</dt> 284 - <dd> 285 - <InlineCode>{action.baseRecordUri}</InlineCode> 286 - </dd> 287 - <dt>Patch Template</dt> 288 - <dd> 289 - <CodeBlock>{action.recordTemplate}</CodeBlock> 290 - </dd> 291 - </> 292 - ) : action.$type === "follow" ? ( 293 - <> 294 - <dt>App</dt> 295 - <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd> 296 - <dt>Collection</dt> 297 - <dd> 298 - <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode> 299 - </dd> 300 - <dt>Subject DID</dt> 301 - <dd> 302 - <InlineCode>{action.subject}</InlineCode> 303 - </dd> 304 - </> 305 - ) : action.$type === "margin-bookmark" ? ( 306 - <> 307 - <dt>Page URL</dt> 308 - <dd> 309 - <InlineCode>{action.targetSource}</InlineCode> 310 - </dd> 311 - {action.bodyValue && ( 312 - <> 313 - <dt>Description</dt> 314 - <dd> 315 - <CodeBlock>{action.bodyValue}</CodeBlock> 316 - </dd> 317 - </> 318 - )} 319 - {action.tags && action.tags.length > 0 && ( 320 - <> 321 - <dt>Tags</dt> 322 - <dd>{action.tags.join(", ")}</dd> 323 - </> 324 - )} 325 - </> 326 - ) : action.$type === "semble-save" ? ( 327 - <> 328 - <dt>Page URL</dt> 329 - <dd> 330 - <InlineCode>{action.url}</InlineCode> 331 - </dd> 332 - </> 333 - ) : ( 334 - <> 335 - <dt>Target Collection</dt> 336 - <dd> 337 - <NsidCode>{action.targetCollection}</NsidCode> 338 - </dd> 339 - <dt>Record Template</dt> 340 - <dd> 341 - <CodeBlock>{action.recordTemplate}</CodeBlock> 342 - </dd> 343 - </> 344 - )} 247 + {(() => { 248 + const Block = ACTION_UI_REGISTRY[action.$type].DisplayBlock; 249 + return <Block action={action} />; 250 + })()} 345 251 </DescriptionList> 346 252 </Stack> 347 253 </Card>
+16 -90
app/routes/u/[handle]/[rkey].tsx
··· 5 5 import { resolveHandle } from "@/auth/client.js"; 6 6 import { opLabels, operationLabels } from "@/automations/labels.js"; 7 7 import { actionTypeKey } from "@/automations/action-catalogue.js"; 8 - import { FOLLOW_TARGETS } from "@/automations/follow-targets.js"; 8 + import { 9 + ACTION_UI_REGISTRY, 10 + WebhookPublicDisplayBlock, 11 + } from "../../../islands/action-editors/registry.js"; 9 12 import { db } from "@/db/index.js"; 10 13 import { users, automations } from "@/db/schema.js"; 11 14 import { sanitizeActions } from "@/automations/sanitize.js"; ··· 17 20 import { Badge } from "../../../components/Badge/index.js"; 18 21 import { Button } from "../../../components/Button/index.js"; 19 22 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 20 - import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 23 + import { InlineCode } from "../../../components/CodeBlock/index.js"; 21 24 import { FetchCard } from "../../../components/FetchCard/index.js"; 22 25 import { ForEachSummary } from "../../../components/ForEachSummary/index.js"; 23 26 import { NsidCode } from "../../../components/NsidCode/index.js"; ··· 231 234 </ActionHeader> 232 235 <DescriptionList> 233 236 <ForEachSummary forEach={action.forEach} /> 234 - {action.$type === "webhook" ? ( 235 - <> 236 - <dt>Destination</dt> 237 - <dd> 238 - <InlineCode>{action.callbackDomain}</InlineCode> 239 - </dd> 240 - </> 241 - ) : action.$type === "bsky-post" ? ( 242 - <> 243 - <dt>Text Template</dt> 244 - <dd> 245 - <CodeBlock>{action.textTemplate}</CodeBlock> 246 - </dd> 247 - {action.langs && action.langs.length > 0 && ( 248 - <> 249 - <dt>Languages</dt> 250 - <dd>{action.langs.join(", ")}</dd> 251 - </> 252 - )} 253 - </> 254 - ) : action.$type === "patch-record" ? ( 255 - <> 256 - <dt>Target Collection</dt> 257 - <dd> 258 - <NsidCode>{action.targetCollection}</NsidCode> 259 - </dd> 260 - <dt>Base Record URI</dt> 261 - <dd> 262 - <InlineCode>{action.baseRecordUri}</InlineCode> 263 - </dd> 264 - <dt>Patch Template</dt> 265 - <dd> 266 - <CodeBlock>{action.recordTemplate}</CodeBlock> 267 - </dd> 268 - </> 269 - ) : action.$type === "follow" ? ( 270 - <> 271 - <dt>App</dt> 272 - <dd>{FOLLOW_TARGETS[action.target]?.appName ?? action.target}</dd> 273 - <dt>Collection</dt> 274 - <dd> 275 - <NsidCode>{FOLLOW_TARGETS[action.target]?.collection}</NsidCode> 276 - </dd> 277 - <dt>Subject DID</dt> 278 - <dd> 279 - <InlineCode>{action.subject}</InlineCode> 280 - </dd> 281 - </> 282 - ) : action.$type === "margin-bookmark" ? ( 283 - <> 284 - <dt>Page URL</dt> 285 - <dd> 286 - <InlineCode>{action.targetSource}</InlineCode> 287 - </dd> 288 - {action.bodyValue && ( 289 - <> 290 - <dt>Description</dt> 291 - <dd> 292 - <CodeBlock>{action.bodyValue}</CodeBlock> 293 - </dd> 294 - </> 295 - )} 296 - {action.tags && action.tags.length > 0 && ( 297 - <> 298 - <dt>Tags</dt> 299 - <dd>{action.tags.join(", ")}</dd> 300 - </> 301 - )} 302 - </> 303 - ) : action.$type === "semble-save" ? ( 304 - <> 305 - <dt>Page URL</dt> 306 - <dd> 307 - <InlineCode>{action.url}</InlineCode> 308 - </dd> 309 - </> 310 - ) : ( 311 - <> 312 - <dt>Target Collection</dt> 313 - <dd> 314 - <NsidCode>{action.targetCollection}</NsidCode> 315 - </dd> 316 - <dt>Record Template</dt> 317 - <dd> 318 - <CodeBlock>{action.recordTemplate}</CodeBlock> 319 - </dd> 320 - </> 321 - )} 237 + {(() => { 238 + // Webhook is the only action whose public projection 239 + // diverges (callbackDomain only, no secret/full URL), 240 + // so it has its own DisplayBlock; every other action 241 + // reuses the owner DisplayBlock from the registry. 242 + if (action.$type === "webhook") { 243 + return <WebhookPublicDisplayBlock action={action} />; 244 + } 245 + const Block = ACTION_UI_REGISTRY[action.$type].DisplayBlock; 246 + return <Block action={action} />; 247 + })()} 322 248 </DescriptionList> 323 249 </Stack> 324 250 </Card>
+40
lib/actions/lexicon-drift.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { readFileSync } from "node:fs"; 3 + import { fileURLToPath } from "node:url"; 4 + import { ACTION_REGISTRY } from "./registry.ts"; 5 + 6 + // Pins the lexicon JSON to the action registry so a new $type added in code 7 + // can't ship without its `#xxxAction` companion in the lexicon (and vice 8 + // versa). Drift here would let an automation be saved locally that the PDS 9 + // would reject the first time it sees it. 10 + describe("automation lexicon ↔ registry parity", () => { 11 + const lexiconPath = fileURLToPath( 12 + new URL("../../lexicons/run/airglow/automation.json", import.meta.url), 13 + ); 14 + const lexicon = JSON.parse(readFileSync(lexiconPath, "utf8")) as { 15 + defs: Record<string, unknown> & { 16 + main: { 17 + record: { 18 + properties: { 19 + actions: { items: { refs: string[] } }; 20 + }; 21 + }; 22 + }; 23 + }; 24 + }; 25 + 26 + // pdsType: "run.airglow.automation#xxxAction" → lexicon ref: "#xxxAction" 27 + const registryRefs = Object.values(ACTION_REGISTRY).map((d) => `#${d.pdsType.split("#")[1]}`); 28 + const lexiconRefs = lexicon.defs.main.record.properties.actions.items.refs; 29 + 30 + it("every registered action has a matching lexicon ref", () => { 31 + expect([...lexiconRefs].sort()).toEqual([...registryRefs].sort()); 32 + }); 33 + 34 + it("every lexicon ref has a corresponding def under the same key", () => { 35 + for (const ref of lexiconRefs) { 36 + const key = ref.replace(/^#/, ""); 37 + expect(lexicon.defs[key], `lexicon ref ${ref} has no def`).toBeTruthy(); 38 + } 39 + }); 40 + });