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: actions registry pattern

Hugo c0a60865 9b27551c

+2815 -2422
+6 -10
app/components/LexiconFlow/index.tsx
··· 1 1 import { ArrowRight, Webhook } from "../../icons.ts"; 2 - import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 - import { isRecordProducingAction, type Action } from "../../../lib/db/schema.ts"; 4 - import { FOLLOW_TARGETS } from "../../../lib/automations/follow-targets.ts"; 2 + import { type Action } from "../../../lib/db/schema.ts"; 3 + import { 4 + ACTION_UI_REGISTRY, 5 + isRecordProducingAction, 6 + } from "../../islands/action-editors/registry.ts"; 5 7 import { Favicon, NsidCode } from "../NsidCode/index.tsx"; 6 8 import * as s from "./styles.css.ts"; 7 9 ··· 10 12 const domains: string[] = []; 11 13 for (const a of actions) { 12 14 if (!isRecordProducingAction(a.$type)) continue; 13 - let domain: string | null = null; 14 - if (a.$type === "bsky-post") domain = "bsky.app"; 15 - else if (a.$type === "margin-bookmark") domain = "margin.at"; 16 - else if (a.$type === "semble-save") domain = "semble.so"; 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);
+32 -964
app/islands/AutomationForm.tsx
··· 1 1 import { useState, useCallback, useRef, useMemo, useEffect } from "hono/jsx"; 2 2 import type { RecordSchema, SchemaNode } from "../../lib/lexicons/schema-types.js"; 3 3 import { nsidRequiresWantedDids } from "../../lib/lexicons/match.js"; 4 - import { 5 - isRecordProducingAction, 6 - type Action, 7 - type FetchStep, 8 - type FollowTarget, 9 - } from "../../lib/db/schema.js"; 4 + import { type Action, type FetchStep, type FollowTarget } from "../../lib/db/schema.js"; 10 5 import { 11 6 ACTION_CATALOGUE, 12 7 actionTypeKey, 13 8 type AddableActionId, 14 9 } from "../../lib/automations/action-catalogue.js"; 15 - import { FOLLOW_TARGETS } from "../../lib/automations/follow-targets.js"; 16 10 import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 17 - import RecordFormBuilder from "./RecordFormBuilder.js"; 18 11 import { ActionHeader } from "../components/ActionHeader/index.js"; 19 12 import { actionIcon } from "../styles/action-header.css.ts"; 20 13 import * as s from "./AutomationForm.css.ts"; 14 + import { 15 + ACTION_UI_REGISTRY, 16 + isRecordProducingAction, 17 + type ActionDraft, 18 + } from "./action-editors/registry.ts"; 19 + import type { ForEachDraft } from "./action-editors/types.ts"; 21 20 22 21 type Field = { 23 22 path: string; ··· 42 41 comment: string; 43 42 }; 44 43 45 - type ForEachDraft = { 46 - path: string; 47 - conditions: Condition[]; 48 - }; 49 - 50 44 type FetchDraft = { 51 45 kind: "record" | "search"; 52 46 name: string; ··· 58 52 conditions: Condition[]; 59 53 comment: string; 60 54 }; 61 - 62 - type HeaderDraft = { key: string; value: string }; 63 - type WebhookDraft = { 64 - type: "webhook"; 65 - callbackUrl: string; 66 - headers: HeaderDraft[]; 67 - comment: string; 68 - forEach?: ForEachDraft; 69 - }; 70 - type RecordDraft = { 71 - type: "record"; 72 - targetCollection: string; 73 - recordTemplate: string; 74 - comment: string; 75 - forEach?: ForEachDraft; 76 - }; 77 - type BskyPostDraft = { 78 - type: "bsky-post"; 79 - textTemplate: string; 80 - langsText: string; 81 - labels: string[]; 82 - comment: string; 83 - forEach?: ForEachDraft; 84 - }; 85 - type PatchRecordDraft = { 86 - type: "patch-record"; 87 - targetCollection: string; 88 - baseRecordUri: string; 89 - recordTemplate: string; 90 - comment: string; 91 - forEach?: ForEachDraft; 92 - }; 93 - type MarginBookmarkDraft = { 94 - type: "margin-bookmark"; 95 - targetSource: string; 96 - bodyValue: string; 97 - tagsText: string; 98 - comment: string; 99 - forEach?: ForEachDraft; 100 - }; 101 - type FollowDraft = { 102 - type: "follow"; 103 - target: FollowTarget; 104 - subject: string; 105 - comment: string; 106 - forEach?: ForEachDraft; 107 - }; 108 - type SembleSaveDraft = { 109 - type: "semble-save"; 110 - url: string; 111 - comment: string; 112 - forEach?: ForEachDraft; 113 - }; 114 - type ActionDraft = 115 - | WebhookDraft 116 - | RecordDraft 117 - | BskyPostDraft 118 - | PatchRecordDraft 119 - | MarginBookmarkDraft 120 - | FollowDraft 121 - | SembleSaveDraft; 122 55 123 56 export type AutomationInitial = { 124 57 rkey?: string; ··· 311 244 } 312 245 313 246 // --------------------------------------------------------------------------- 314 - // Webhook action editor 315 - // --------------------------------------------------------------------------- 316 - 317 - function WebhookActionEditor({ 318 - action, 319 - index, 320 - onChange, 321 - }: { 322 - action: WebhookDraft; 323 - index: number; 324 - onChange: (a: WebhookDraft) => void; 325 - }) { 326 - const updateHeader = (i: number, key: "key" | "value", val: string) => { 327 - const headers = action.headers.map((h, j) => (j === i ? { ...h, [key]: val } : h)); 328 - onChange({ ...action, headers }); 329 - }; 330 - const addHeader = () => { 331 - onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 332 - }; 333 - const removeHeader = (i: number) => { 334 - onChange({ ...action, headers: action.headers.filter((_, j) => j !== i) }); 335 - }; 336 - 337 - const callbackId = `action-${index}-callback-url`; 338 - const headersGroupId = `action-${index}-headers-group`; 339 - 340 - return ( 341 - <> 342 - <div class={s.fieldGroup}> 343 - <label class={s.label} for={callbackId}> 344 - Callback URL 345 - </label> 346 - <input 347 - id={callbackId} 348 - class={s.input} 349 - type="url" 350 - placeholder="e.g. https://example.com/hooks/events" 351 - value={action.callbackUrl} 352 - onInput={(e: Event) => 353 - onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 354 - } 355 - required 356 - autocomplete="off" 357 - /> 358 - </div> 359 - <div class={s.fieldGroup} role="group" aria-labelledby={headersGroupId}> 360 - <span id={headersGroupId} class={s.label}> 361 - Custom headers 362 - </span> 363 - <span class={s.hint}> 364 - Use <code>{"{{secret:name}}"}</code> to reference stored secrets 365 - </span> 366 - {action.headers.map((header, i) => ( 367 - <div key={i} class={s.conditionRow}> 368 - <div class={s.conditionField}> 369 - <input 370 - class={s.input} 371 - type="text" 372 - placeholder="e.g. Authorization" 373 - value={header.key} 374 - onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 375 - aria-label="Header name" 376 - autocomplete="off" 377 - /> 378 - </div> 379 - <div class={s.conditionValue}> 380 - <input 381 - class={s.input} 382 - type="text" 383 - placeholder="e.g. Bearer {{secret:my-token}}" 384 - value={header.value} 385 - onInput={(e: Event) => 386 - updateHeader(i, "value", (e.target as HTMLInputElement).value) 387 - } 388 - aria-label="Header value" 389 - autocomplete="off" 390 - /> 391 - </div> 392 - <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> 393 - Remove 394 - </button> 395 - </div> 396 - ))} 397 - <button type="button" class={s.addBtn} onClick={addHeader}> 398 - + Add Header 399 - </button> 400 - </div> 401 - </> 402 - ); 403 - } 404 - 405 - // --------------------------------------------------------------------------- 406 - // Shared NSID schema hook for record action editors 407 - // --------------------------------------------------------------------------- 408 - 409 - function useNsidSchema(initialCollection?: string) { 410 - const [targetSchema, setTargetSchema] = useState<RecordSchema | null>(null); 411 - const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); 412 - const [targetSchemaError, setTargetSchemaError] = useState(""); 413 - const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 414 - const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 415 - const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 416 - const abortRef = useRef<AbortController | null>(null); 417 - const lastSuggestPrefix = useRef(""); 418 - const [datalistId] = useState(() => `nsid-${Math.random().toString(36).slice(2, 8)}`); 419 - const initialFetched = useRef(false); 420 - 421 - const fetchTargetSchema = useCallback((nsid: string) => { 422 - if (debounceRef.current) clearTimeout(debounceRef.current); 423 - if (!nsid || !NSID_RE.test(nsid)) { 424 - abortRef.current?.abort(); 425 - setTargetSchema(null); 426 - setTargetSchemaError(""); 427 - return; 428 - } 429 - debounceRef.current = setTimeout(async () => { 430 - abortRef.current?.abort(); 431 - const ctrl = new AbortController(); 432 - abortRef.current = ctrl; 433 - setTargetSchemaLoading(true); 434 - setTargetSchemaError(""); 435 - try { 436 - const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}?schema=record`, { 437 - signal: ctrl.signal, 438 - }); 439 - const data = await res.json(); 440 - if (!res.ok) { 441 - setTargetSchemaError(data.error || "Failed to load schema"); 442 - setTargetSchema(null); 443 - } else { 444 - setTargetSchema(data.record ?? null); 445 - if (!data.record) setTargetSchemaError("No record schema found for this collection"); 446 - } 447 - } catch (err) { 448 - if ((err as Error).name === "AbortError") return; 449 - setTargetSchemaError("Failed to fetch target collection schema"); 450 - setTargetSchema(null); 451 - } finally { 452 - if (abortRef.current === ctrl) { 453 - abortRef.current = null; 454 - setTargetSchemaLoading(false); 455 - } 456 - } 457 - }, 400); 458 - }, []); 459 - 460 - const fetchSuggestions = useCallback((value: string) => { 461 - if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); 462 - const dotIndex = value.lastIndexOf("."); 463 - const prefix = dotIndex > 0 ? value.slice(0, dotIndex + 1) : ""; 464 - if (!prefix || prefix.split(".").filter(Boolean).length < 2) return; 465 - if (prefix === lastSuggestPrefix.current) return; 466 - suggestDebounceRef.current = setTimeout(async () => { 467 - lastSuggestPrefix.current = prefix; 468 - try { 469 - const res = await fetch(`/api/lexicons/suggest?prefix=${encodeURIComponent(prefix)}`); 470 - if (res.ok) { 471 - const data = await res.json(); 472 - setNsidSuggestions(data.suggestions ?? []); 473 - } 474 - } catch { 475 - // ignore 476 - } 477 - }, 300); 478 - }, []); 479 - 480 - if (!initialFetched.current && initialCollection) { 481 - initialFetched.current = true; 482 - fetchTargetSchema(initialCollection); 483 - } 484 - 485 - return { 486 - targetSchema, 487 - targetSchemaLoading, 488 - targetSchemaError, 489 - nsidSuggestions, 490 - datalistId, 491 - fetchTargetSchema, 492 - fetchSuggestions, 493 - }; 494 - } 495 - 496 - // --------------------------------------------------------------------------- 497 - // Record action editor 498 - // --------------------------------------------------------------------------- 499 - 500 - function RecordActionEditor({ 501 - action, 502 - index, 503 - onChange, 504 - placeholders, 505 - }: { 506 - action: RecordDraft; 507 - index: number; 508 - onChange: (a: RecordDraft) => void; 509 - placeholders: string[]; 510 - }) { 511 - const { 512 - targetSchema, 513 - targetSchemaLoading, 514 - targetSchemaError, 515 - nsidSuggestions, 516 - datalistId, 517 - fetchTargetSchema, 518 - fetchSuggestions, 519 - } = useNsidSchema(action.targetCollection); 520 - 521 - const targetId = `action-${index}-target-collection`; 522 - const templateId = `action-${index}-record-template`; 523 - 524 - return ( 525 - <> 526 - <div class={s.fieldGroup}> 527 - <label class={s.label} for={targetId}> 528 - Target Lexicon NSID 529 - </label> 530 - <input 531 - id={targetId} 532 - class={s.input} 533 - type="text" 534 - list={datalistId} 535 - placeholder="e.g. app.bsky.feed.like" 536 - value={action.targetCollection} 537 - onInput={(e: Event) => { 538 - const val = (e.target as HTMLInputElement).value; 539 - onChange({ ...action, targetCollection: val }); 540 - fetchTargetSchema(val); 541 - fetchSuggestions(val); 542 - }} 543 - required 544 - autocomplete="off" 545 - /> 546 - <span class={s.hint}>NSID of the collection to create a record in</span> 547 - <datalist id={datalistId}> 548 - {nsidSuggestions.map((nsid) => ( 549 - <option key={nsid} value={nsid} /> 550 - ))} 551 - </datalist> 552 - </div> 553 - 554 - {(targetSchema || targetSchemaLoading) && ( 555 - <RecordFormBuilder 556 - schema={targetSchema} 557 - loading={targetSchemaLoading} 558 - error={targetSchemaError} 559 - placeholders={placeholders} 560 - initialTemplate={action.recordTemplate || undefined} 561 - onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 562 - /> 563 - )} 564 - 565 - {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 566 - <div class={s.fieldGroup}> 567 - <label class={s.label} for={templateId}> 568 - Record template 569 - </label> 570 - {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 571 - <textarea 572 - id={templateId} 573 - class={s.textarea} 574 - placeholder={ 575 - '{\n "subject": {\n "uri": "{{event.commit.record.subject.uri}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 576 - } 577 - value={action.recordTemplate} 578 - onInput={(e: Event) => 579 - onChange({ 580 - ...action, 581 - recordTemplate: (e.target as HTMLTextAreaElement).value, 582 - }) 583 - } 584 - required 585 - autocomplete="off" 586 - /> 587 - </div> 588 - )} 589 - </> 590 - ); 591 - } 592 - 593 - // --------------------------------------------------------------------------- 594 - // Bluesky Post action editor 595 - // --------------------------------------------------------------------------- 596 - 597 - const BSKY_LABELS = [ 598 - { value: "sexual", label: "Suggestive" }, 599 - { value: "nudity", label: "Nudity" }, 600 - { value: "porn", label: "Adult Content" }, 601 - { value: "graphic-media", label: "Graphic Media" }, 602 - ]; 603 - 604 - function BskyPostActionEditor({ 605 - action, 606 - index, 607 - onChange, 608 - }: { 609 - action: BskyPostDraft; 610 - index: number; 611 - onChange: (a: BskyPostDraft) => void; 612 - }) { 613 - const textId = `action-${index}-bsky-text`; 614 - const langsId = `action-${index}-bsky-langs`; 615 - 616 - return ( 617 - <> 618 - <div class={s.fieldGroup}> 619 - <label class={s.label} for={textId}> 620 - Post text 621 - </label> 622 - <textarea 623 - id={textId} 624 - class={s.textarea} 625 - placeholder={"Write your post here...\nYou can use {{placeholders}}."} 626 - value={action.textTemplate} 627 - onInput={(e: Event) => 628 - onChange({ ...action, textTemplate: (e.target as HTMLTextAreaElement).value }) 629 - } 630 - rows={4} 631 - required 632 - autocomplete="off" 633 - /> 634 - <span class={s.hint}> 635 - Mentions (@handle), links, and #hashtags are detected automatically. 636 - </span> 637 - </div> 638 - 639 - <div class={s.fieldGroup}> 640 - <label class={s.label} for={langsId}> 641 - Languages <span class={s.hint}>(optional, max 3)</span> 642 - </label> 643 - <input 644 - id={langsId} 645 - class={s.input} 646 - type="text" 647 - placeholder="e.g. en, fr, pt" 648 - value={action.langsText} 649 - onInput={(e: Event) => 650 - onChange({ ...action, langsText: (e.target as HTMLInputElement).value }) 651 - } 652 - autocomplete="off" 653 - /> 654 - <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 655 - </div> 656 - 657 - <fieldset class={s.groupFieldset}> 658 - <legend class={s.groupLegend}> 659 - Content warnings <span class={s.hint}>(optional)</span> 660 - </legend> 661 - <div class={s.operationCheckboxes}> 662 - {BSKY_LABELS.map(({ value, label }) => ( 663 - <label key={value} class={s.checkboxLabel}> 664 - <input 665 - type="checkbox" 666 - class={s.checkbox} 667 - checked={action.labels.includes(value)} 668 - onChange={() => { 669 - const labels = action.labels.includes(value) 670 - ? action.labels.filter((l) => l !== value) 671 - : [...action.labels, value]; 672 - onChange({ ...action, labels }); 673 - }} 674 - /> 675 - {label} 676 - </label> 677 - ))} 678 - </div> 679 - </fieldset> 680 - </> 681 - ); 682 - } 683 - 684 - // --------------------------------------------------------------------------- 685 - // Patch Record action editor 686 - // --------------------------------------------------------------------------- 687 - 688 - function PatchRecordActionEditor({ 689 - action, 690 - index, 691 - onChange, 692 - placeholders, 693 - }: { 694 - action: PatchRecordDraft; 695 - index: number; 696 - onChange: (a: PatchRecordDraft) => void; 697 - placeholders: string[]; 698 - }) { 699 - const { 700 - targetSchema, 701 - targetSchemaLoading, 702 - targetSchemaError, 703 - nsidSuggestions, 704 - datalistId, 705 - fetchTargetSchema, 706 - fetchSuggestions, 707 - } = useNsidSchema(action.targetCollection); 708 - 709 - const targetId = `action-${index}-target-collection`; 710 - const baseUriId = `action-${index}-base-record-uri`; 711 - const templateId = `action-${index}-record-template`; 712 - 713 - return ( 714 - <> 715 - <div class={s.fieldGroup}> 716 - <label class={s.label} for={targetId}> 717 - Target Lexicon NSID 718 - </label> 719 - <input 720 - id={targetId} 721 - class={s.input} 722 - type="text" 723 - list={datalistId} 724 - placeholder="e.g. site.standard.document" 725 - value={action.targetCollection} 726 - onInput={(e: Event) => { 727 - const val = (e.target as HTMLInputElement).value; 728 - onChange({ ...action, targetCollection: val }); 729 - fetchTargetSchema(val); 730 - fetchSuggestions(val); 731 - }} 732 - required 733 - autocomplete="off" 734 - /> 735 - <span class={s.hint}>NSID of the collection containing the record to update</span> 736 - <datalist id={datalistId}> 737 - {nsidSuggestions.map((nsid) => ( 738 - <option key={nsid} value={nsid} /> 739 - ))} 740 - </datalist> 741 - </div> 742 - 743 - <div class={s.fieldGroup}> 744 - <label class={s.label} for={baseUriId}> 745 - Base Record URI 746 - </label> 747 - <input 748 - id={baseUriId} 749 - class={s.input} 750 - type="text" 751 - placeholder="e.g. {{event.commit.record.subject.uri}}" 752 - value={action.baseRecordUri} 753 - onInput={(e: Event) => 754 - onChange({ ...action, baseRecordUri: (e.target as HTMLInputElement).value }) 755 - } 756 - required 757 - autocomplete="off" 758 - /> 759 - <span class={s.hint}>AT URI of the record to update. Supports {"{{placeholders}}"}.</span> 760 - </div> 761 - 762 - {(targetSchema || targetSchemaLoading) && ( 763 - <RecordFormBuilder 764 - schema={targetSchema} 765 - loading={targetSchemaLoading} 766 - error={targetSchemaError} 767 - placeholders={placeholders} 768 - initialTemplate={action.recordTemplate || undefined} 769 - onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 770 - patchMode 771 - /> 772 - )} 773 - 774 - {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 775 - <div class={s.fieldGroup}> 776 - <label class={s.label} for={templateId}> 777 - Patch template 778 - </label> 779 - {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 780 - <textarea 781 - id={templateId} 782 - class={s.textarea} 783 - placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 784 - value={action.recordTemplate} 785 - onInput={(e: Event) => 786 - onChange({ 787 - ...action, 788 - recordTemplate: (e.target as HTMLTextAreaElement).value, 789 - }) 790 - } 791 - required 792 - autocomplete="off" 793 - /> 794 - <span class={s.hint}> 795 - Only include the fields you want to change. They will be merged on top of the existing 796 - record. 797 - </span> 798 - </div> 799 - )} 800 - </> 801 - ); 802 - } 803 - 804 - // --------------------------------------------------------------------------- 805 - // Margin bookmark action editor 806 - // --------------------------------------------------------------------------- 807 - 808 - function MarginBookmarkActionEditor({ 809 - action, 810 - index, 811 - onChange, 812 - }: { 813 - action: MarginBookmarkDraft; 814 - index: number; 815 - onChange: (a: MarginBookmarkDraft) => void; 816 - }) { 817 - const urlId = `action-${index}-margin-bookmark-url`; 818 - const bodyId = `action-${index}-margin-bookmark-body`; 819 - const tagsId = `action-${index}-margin-bookmark-tags`; 820 - 821 - return ( 822 - <> 823 - <div class={s.fieldGroup}> 824 - <label class={s.label} for={urlId}> 825 - Page URL 826 - </label> 827 - <input 828 - id={urlId} 829 - class={s.input} 830 - type="text" 831 - placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 832 - value={action.targetSource} 833 - onInput={(e: Event) => 834 - onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 835 - } 836 - required 837 - autocomplete="off" 838 - /> 839 - <span class={s.hint}> 840 - URL of the page to bookmark. Supports {"{{placeholders}}"}. The page title is fetched 841 - automatically. 842 - </span> 843 - </div> 844 - 845 - <div class={s.fieldGroup}> 846 - <label class={s.label} for={bodyId}> 847 - Description <span class={s.hint}>(optional)</span> 848 - </label> 849 - <textarea 850 - id={bodyId} 851 - class={s.textarea} 852 - placeholder="A short note about this bookmark" 853 - value={action.bodyValue} 854 - onInput={(e: Event) => 855 - onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 856 - } 857 - rows={3} 858 - autocomplete="off" 859 - /> 860 - <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 861 - </div> 862 - 863 - <div class={s.fieldGroup}> 864 - <label class={s.label} for={tagsId}> 865 - Tags <span class={s.hint}>(optional, max 10)</span> 866 - </label> 867 - <input 868 - id={tagsId} 869 - class={s.input} 870 - type="text" 871 - placeholder="e.g. reading, research, bluesky" 872 - value={action.tagsText} 873 - onInput={(e: Event) => 874 - onChange({ ...action, tagsText: (e.target as HTMLInputElement).value }) 875 - } 876 - autocomplete="off" 877 - /> 878 - <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 879 - </div> 880 - </> 881 - ); 882 - } 883 - 884 - // --------------------------------------------------------------------------- 885 - // Semble save action editor 886 - // --------------------------------------------------------------------------- 887 - 888 - function SembleSaveActionEditor({ 889 - action, 890 - index, 891 - onChange, 892 - }: { 893 - action: SembleSaveDraft; 894 - index: number; 895 - onChange: (a: SembleSaveDraft) => void; 896 - }) { 897 - const urlId = `action-${index}-semble-url`; 898 - return ( 899 - <div class={s.fieldGroup}> 900 - <label class={s.label} for={urlId}> 901 - Page URL 902 - </label> 903 - <input 904 - id={urlId} 905 - class={s.input} 906 - type="text" 907 - placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 908 - value={action.url} 909 - onInput={(e: Event) => onChange({ ...action, url: (e.target as HTMLInputElement).value })} 910 - required 911 - autocomplete="off" 912 - /> 913 - <span class={s.hint}> 914 - URL of the page to save. Metadata (title, description, image) will be fetched automatically. 915 - Supports {"{{placeholders}}"}. 916 - </span> 917 - </div> 918 - ); 919 - } 920 - 921 - // --------------------------------------------------------------------------- 922 - // Follow (social graph) action editor, shared across bluesky / tangled / sifa 923 - // --------------------------------------------------------------------------- 924 - 925 - function FollowActionEditor({ 926 - action, 927 - index, 928 - onChange, 929 - }: { 930 - action: FollowDraft; 931 - index: number; 932 - onChange: (a: FollowDraft) => void; 933 - }) { 934 - const meta = FOLLOW_TARGETS[action.target]; 935 - const subjectId = `action-${index}-follow-subject`; 936 - return ( 937 - <> 938 - <div class={s.fieldGroup}> 939 - <label class={s.label} for={subjectId}> 940 - Subject DID 941 - </label> 942 - <input 943 - id={subjectId} 944 - class={s.input} 945 - type="text" 946 - placeholder="e.g. did:plc:... or {{event.did}}" 947 - value={action.subject} 948 - onInput={(e: Event) => 949 - onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 950 - } 951 - required 952 - autocomplete="off" 953 - /> 954 - <span class={s.hint}> 955 - DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 956 - {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 957 - <br /> 958 - Automatically checks that the subject has a {meta.appName} profile and that you don't 959 - already follow them. No extra conditions needed. 960 - </span> 961 - </div> 962 - </> 963 - ); 964 - } 965 - 966 - // --------------------------------------------------------------------------- 967 247 // Copy-to-clipboard placeholder 968 248 // --------------------------------------------------------------------------- 969 249 ··· 1236 516 1237 517 function toActionDrafts(actions: Action[]): ActionDraft[] { 1238 518 return actions.map((a) => { 519 + const draft = ACTION_UI_REGISTRY[a.$type].fromAction(a); 1239 520 const forEach = toForEachDraft(a.forEach); 1240 - const forEachField = forEach ? { forEach } : {}; 1241 - if (a.$type === "webhook") { 1242 - const headers: HeaderDraft[] = a.headers 1243 - ? Object.entries(a.headers).map(([key, value]) => ({ key, value })) 1244 - : []; 1245 - return { 1246 - type: "webhook", 1247 - callbackUrl: a.callbackUrl, 1248 - headers, 1249 - comment: a.comment ?? "", 1250 - ...forEachField, 1251 - }; 1252 - } 1253 - if (a.$type === "bsky-post") { 1254 - return { 1255 - type: "bsky-post", 1256 - textTemplate: a.textTemplate, 1257 - langsText: (a.langs ?? []).join(", "), 1258 - labels: a.labels ?? [], 1259 - comment: a.comment ?? "", 1260 - ...forEachField, 1261 - }; 1262 - } 1263 - if (a.$type === "patch-record") { 1264 - return { 1265 - type: "patch-record", 1266 - targetCollection: a.targetCollection, 1267 - baseRecordUri: a.baseRecordUri, 1268 - recordTemplate: a.recordTemplate, 1269 - comment: a.comment ?? "", 1270 - ...forEachField, 1271 - }; 1272 - } 1273 - if (a.$type === "margin-bookmark") { 1274 - return { 1275 - type: "margin-bookmark", 1276 - targetSource: a.targetSource, 1277 - bodyValue: a.bodyValue ?? "", 1278 - tagsText: (a.tags ?? []).join(", "), 1279 - comment: a.comment ?? "", 1280 - ...forEachField, 1281 - }; 1282 - } 1283 - if (a.$type === "semble-save") { 1284 - return { 1285 - type: "semble-save", 1286 - url: a.url, 1287 - comment: a.comment ?? "", 1288 - ...forEachField, 1289 - }; 1290 - } 1291 - if (a.$type === "follow") { 1292 - return { 1293 - type: "follow", 1294 - target: a.target, 1295 - subject: a.subject, 1296 - comment: a.comment ?? "", 1297 - ...forEachField, 1298 - }; 1299 - } 1300 - return { 1301 - type: "record", 1302 - targetCollection: a.targetCollection, 1303 - recordTemplate: a.recordTemplate, 1304 - comment: a.comment ?? "", 1305 - ...forEachField, 1306 - }; 521 + return forEach ? { ...draft, forEach } : draft; 1307 522 }); 1308 523 } 1309 524 ··· 1813 1028 [], 1814 1029 ); 1815 1030 1816 - const addAction = useCallback((type: AddableActionId) => { 1817 - if (type === "webhook") { 1818 - setActions((prev) => [ 1819 - ...prev, 1820 - { type: "webhook", callbackUrl: "", headers: [], comment: "" }, 1821 - ]); 1822 - } else if (type === "bsky-post") { 1823 - setActions((prev) => [ 1824 - ...prev, 1825 - { type: "bsky-post", textTemplate: "", langsText: "", labels: [], comment: "" }, 1826 - ]); 1827 - } else if (type === "patch-record") { 1828 - setActions((prev) => [ 1829 - ...prev, 1830 - { 1831 - type: "patch-record", 1832 - targetCollection: "", 1833 - baseRecordUri: "", 1834 - recordTemplate: "", 1835 - comment: "", 1836 - }, 1837 - ]); 1838 - } else if (type === "margin-bookmark") { 1839 - setActions((prev) => [ 1840 - ...prev, 1841 - { 1842 - type: "margin-bookmark", 1843 - targetSource: "", 1844 - bodyValue: "", 1845 - tagsText: "", 1846 - comment: "", 1847 - }, 1848 - ]); 1849 - } else if (type === "semble-save") { 1850 - setActions((prev) => [...prev, { type: "semble-save", url: "", comment: "" }]); 1851 - } else if (type.startsWith("follow-")) { 1852 - const target = type.slice("follow-".length) as FollowTarget; 1853 - setActions((prev) => [ 1854 - ...prev, 1855 - { type: "follow", target, subject: "{{event.commit.record.subject}}", comment: "" }, 1856 - ]); 1857 - } else { 1858 - setActions((prev) => [ 1859 - ...prev, 1860 - { type: "record", targetCollection: "", recordTemplate: "", comment: "" }, 1861 - ]); 1862 - } 1031 + const addAction = useCallback((id: AddableActionId) => { 1032 + // Tile ids and action $types diverge for follow only — picker exposes one 1033 + // tile per FOLLOW_TARGETS entry, but they all share the `follow` $type. 1034 + const isFollow = id.startsWith("follow-"); 1035 + const $type = (isFollow ? "follow" : id) as keyof typeof ACTION_UI_REGISTRY; 1036 + const followTarget = isFollow ? (id.slice("follow-".length) as FollowTarget) : undefined; 1037 + const draft = ACTION_UI_REGISTRY[$type].newDraft({ followTarget }); 1038 + setActions((prev) => [...prev, draft]); 1863 1039 }, []); 1864 1040 1865 1041 const removeAction = useCallback((index: number) => { ··· 1886 1062 payload.wantedDids = trimmedWantedDids; 1887 1063 } 1888 1064 payload.actions = actions.map((a) => { 1889 - const comment = a.comment ? { comment: a.comment } : {}; 1065 + const input = ACTION_UI_REGISTRY[a.type].toInput(a); 1890 1066 const forEach = forEachToPayload(a.forEach); 1891 - const forEachField = forEach ? { forEach } : {}; 1892 - if (a.type === "webhook") { 1893 - const filtered = a.headers.filter((h) => h.key.trim() && h.value.trim()); 1894 - const headers = 1895 - filtered.length > 0 1896 - ? Object.fromEntries(filtered.map((h) => [h.key.trim(), h.value.trim()])) 1897 - : undefined; 1898 - return { 1899 - type: "webhook", 1900 - callbackUrl: a.callbackUrl, 1901 - ...(headers ? { headers } : {}), 1902 - ...forEachField, 1903 - ...comment, 1904 - }; 1905 - } 1906 - if (a.type === "bsky-post") { 1907 - const langs = a.langsText 1908 - .split(",") 1909 - .map((l) => l.trim()) 1910 - .filter(Boolean); 1911 - return { 1912 - type: "bsky-post", 1913 - textTemplate: a.textTemplate, 1914 - ...(langs.length > 0 ? { langs } : {}), 1915 - ...(a.labels.length > 0 ? { labels: a.labels } : {}), 1916 - ...forEachField, 1917 - ...comment, 1918 - }; 1919 - } 1920 - if (a.type === "patch-record") { 1921 - return { 1922 - type: "patch-record", 1923 - targetCollection: a.targetCollection, 1924 - baseRecordUri: a.baseRecordUri, 1925 - recordTemplate: a.recordTemplate, 1926 - ...forEachField, 1927 - ...comment, 1928 - }; 1929 - } 1930 - if (a.type === "margin-bookmark") { 1931 - const bodyValue = a.bodyValue.trim(); 1932 - const tags = a.tagsText 1933 - .split(",") 1934 - .map((t) => t.trim()) 1935 - .filter(Boolean) 1936 - .slice(0, 10); 1937 - return { 1938 - type: "margin-bookmark", 1939 - targetSource: a.targetSource, 1940 - ...(bodyValue ? { bodyValue } : {}), 1941 - ...(tags.length > 0 ? { tags } : {}), 1942 - ...forEachField, 1943 - ...comment, 1944 - }; 1945 - } 1946 - if (a.type === "semble-save") { 1947 - return { 1948 - type: "semble-save", 1949 - url: a.url, 1950 - ...forEachField, 1951 - ...comment, 1952 - }; 1953 - } 1954 - if (a.type === "follow") { 1955 - return { 1956 - type: "follow", 1957 - target: a.target, 1958 - subject: a.subject, 1959 - ...forEachField, 1960 - ...comment, 1961 - }; 1962 - } 1963 1067 return { 1964 - type: "record", 1965 - targetCollection: a.targetCollection, 1966 - recordTemplate: a.recordTemplate, 1967 - ...forEachField, 1968 - ...comment, 1068 + ...input, 1069 + ...(forEach ? { forEach } : {}), 1070 + ...(a.comment ? { comment: a.comment } : {}), 1969 1071 }; 1970 1072 }); 1971 1073 return JSON.stringify(payload, null, 2); ··· 3006 2108 arrayPathSuggestions={arrayPathSuggestions} 3007 2109 itemFieldsByPath={itemFieldsByPath} 3008 2110 /> 3009 - {action.type === "webhook" ? ( 3010 - <WebhookActionEditor 3011 - action={action} 3012 - index={i} 3013 - onChange={(a) => updateAction(i, a)} 3014 - /> 3015 - ) : action.type === "bsky-post" ? ( 3016 - <BskyPostActionEditor 3017 - action={action} 3018 - index={i} 3019 - onChange={(a) => updateAction(i, a)} 3020 - /> 3021 - ) : action.type === "patch-record" ? ( 3022 - <PatchRecordActionEditor 3023 - action={action} 3024 - index={i} 3025 - onChange={(a) => updateAction(i, a)} 3026 - placeholders={allPlaceholders} 3027 - /> 3028 - ) : action.type === "margin-bookmark" ? ( 3029 - <MarginBookmarkActionEditor 3030 - action={action} 3031 - index={i} 3032 - onChange={(a) => updateAction(i, a)} 3033 - /> 3034 - ) : action.type === "semble-save" ? ( 3035 - <SembleSaveActionEditor 3036 - action={action} 3037 - index={i} 3038 - onChange={(a) => updateAction(i, a)} 3039 - /> 3040 - ) : action.type === "follow" ? ( 3041 - <FollowActionEditor 3042 - action={action} 3043 - index={i} 3044 - onChange={(a) => updateAction(i, a)} 3045 - /> 3046 - ) : ( 3047 - <RecordActionEditor 3048 - action={action} 3049 - index={i} 3050 - onChange={(a) => updateAction(i, a)} 3051 - placeholders={allPlaceholders} 3052 - /> 3053 - )} 2111 + {(() => { 2112 + const EditorBlock = ACTION_UI_REGISTRY[action.type].EditorBlock; 2113 + return ( 2114 + <EditorBlock 2115 + action={action} 2116 + index={i} 2117 + onChange={(a) => updateAction(i, a)} 2118 + placeholders={allPlaceholders} 2119 + /> 2120 + ); 2121 + })()} 3054 2122 <div class={s.fieldGroup}> 3055 2123 <label class={s.label} for={`action-${i}-note`}> 3056 2124 Note <span class={s.hint}>(optional)</span>
+165
app/islands/action-editors/bsky-post.tsx
··· 1 + import type { BskyPostAction } from "../../../lib/db/schema.js"; 2 + import { MessageSquare } from "../../icons.ts"; 3 + import { CodeBlock } from "../../components/CodeBlock/index.tsx"; 4 + import * as s from "../AutomationForm.css.ts"; 5 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 6 + 7 + const BSKY_LABELS = [ 8 + { value: "sexual", label: "Suggestive" }, 9 + { value: "nudity", label: "Nudity" }, 10 + { value: "porn", label: "Porn" }, 11 + { value: "graphic-media", label: "Graphic media" }, 12 + ]; 13 + 14 + export type BskyPostDraft = { 15 + type: "bsky-post"; 16 + textTemplate: string; 17 + langsText: string; 18 + labels: string[]; 19 + comment: string; 20 + forEach?: ForEachDraft; 21 + }; 22 + 23 + function BskyPostActionEditor({ 24 + action, 25 + index, 26 + onChange, 27 + }: { 28 + action: BskyPostDraft; 29 + index: number; 30 + onChange: (a: BskyPostDraft) => void; 31 + }) { 32 + const textId = `action-${index}-bsky-text`; 33 + const langsId = `action-${index}-bsky-langs`; 34 + 35 + return ( 36 + <> 37 + <div class={s.fieldGroup}> 38 + <label class={s.label} for={textId}> 39 + Post text 40 + </label> 41 + <textarea 42 + id={textId} 43 + class={s.textarea} 44 + placeholder={"Write your post here...\nYou can use {{placeholders}}."} 45 + value={action.textTemplate} 46 + onInput={(e: Event) => 47 + onChange({ ...action, textTemplate: (e.target as HTMLTextAreaElement).value }) 48 + } 49 + rows={4} 50 + required 51 + autocomplete="off" 52 + /> 53 + <span class={s.hint}> 54 + Mentions (@handle), links, and #hashtags are detected automatically. 55 + </span> 56 + </div> 57 + 58 + <div class={s.fieldGroup}> 59 + <label class={s.label} for={langsId}> 60 + Languages <span class={s.hint}>(optional, max 3)</span> 61 + </label> 62 + <input 63 + id={langsId} 64 + class={s.input} 65 + type="text" 66 + placeholder="e.g. en, fr, pt" 67 + value={action.langsText} 68 + onInput={(e: Event) => 69 + onChange({ ...action, langsText: (e.target as HTMLInputElement).value }) 70 + } 71 + autocomplete="off" 72 + /> 73 + <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 74 + </div> 75 + 76 + <fieldset class={s.groupFieldset}> 77 + <legend class={s.groupLegend}> 78 + Content warnings <span class={s.hint}>(optional)</span> 79 + </legend> 80 + <div class={s.operationCheckboxes}> 81 + {BSKY_LABELS.map(({ value, label }) => ( 82 + <label key={value} class={s.checkboxLabel}> 83 + <input 84 + type="checkbox" 85 + class={s.checkbox} 86 + checked={action.labels.includes(value)} 87 + onChange={() => { 88 + const labels = action.labels.includes(value) 89 + ? action.labels.filter((l) => l !== value) 90 + : [...action.labels, value]; 91 + onChange({ ...action, labels }); 92 + }} 93 + /> 94 + {label} 95 + </label> 96 + ))} 97 + </div> 98 + </fieldset> 99 + </> 100 + ); 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 + 126 + export const bskyPostUiDefinition: ActionUIDefinition<BskyPostDraft, BskyPostAction> = { 127 + type: "bsky-post", 128 + recordProducing: true, 129 + catalogue: { 130 + label: "Post to Bluesky", 131 + description: "Publish a post to your Bluesky account", 132 + category: "bluesky", 133 + icon: MessageSquare, 134 + available: true, 135 + }, 136 + newDraft: () => ({ 137 + type: "bsky-post", 138 + textTemplate: "", 139 + langsText: "", 140 + labels: [], 141 + comment: "", 142 + }), 143 + fromAction: (a) => ({ 144 + type: "bsky-post", 145 + textTemplate: a.textTemplate, 146 + langsText: (a.langs ?? []).join(", "), 147 + labels: a.labels ?? [], 148 + comment: a.comment ?? "", 149 + }), 150 + toInput: (d) => { 151 + const langs = d.langsText 152 + .split(",") 153 + .map((l) => l.trim()) 154 + .filter(Boolean); 155 + return { 156 + type: "bsky-post", 157 + textTemplate: d.textTemplate, 158 + ...(langs.length > 0 ? { langs } : {}), 159 + ...(d.labels.length > 0 ? { labels: d.labels } : {}), 160 + }; 161 + }, 162 + EditorBlock: BskyPostActionEditor, 163 + DisplayBlock: BskyPostDisplayBlock, 164 + getFaviconDomain: () => "bsky.app", 165 + };
+94
app/islands/action-editors/follow.tsx
··· 1 + import type { FollowAction, FollowTarget } from "../../../lib/db/schema.js"; 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"; 5 + import * as s from "../AutomationForm.css.ts"; 6 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 7 + 8 + export type FollowDraft = { 9 + type: "follow"; 10 + target: FollowTarget; 11 + subject: string; 12 + comment: string; 13 + forEach?: ForEachDraft; 14 + }; 15 + 16 + function FollowActionEditor({ 17 + action, 18 + index, 19 + onChange, 20 + }: { 21 + action: FollowDraft; 22 + index: number; 23 + onChange: (a: FollowDraft) => void; 24 + }) { 25 + const meta = FOLLOW_TARGETS[action.target]; 26 + const subjectId = `action-${index}-follow-subject`; 27 + return ( 28 + <> 29 + <div class={s.fieldGroup}> 30 + <label class={s.label} for={subjectId}> 31 + Subject DID 32 + </label> 33 + <input 34 + id={subjectId} 35 + class={s.input} 36 + type="text" 37 + placeholder="e.g. did:plc:... or {{event.did}}" 38 + value={action.subject} 39 + onInput={(e: Event) => 40 + onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 41 + } 42 + required 43 + autocomplete="off" 44 + /> 45 + <span class={s.hint}> 46 + DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 47 + {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 48 + <br /> 49 + Automatically checks that the subject has a {meta.appName} profile and that you don't 50 + already follow them. No extra conditions needed. 51 + </span> 52 + </div> 53 + </> 54 + ); 55 + } 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 + 75 + export const followUiDefinition: ActionUIDefinition<FollowDraft, FollowAction> = { 76 + type: "follow", 77 + recordProducing: true, 78 + newDraft: ({ followTarget }) => ({ 79 + type: "follow", 80 + target: followTarget ?? "bluesky", 81 + subject: "{{event.commit.record.subject}}", 82 + comment: "", 83 + }), 84 + fromAction: (a) => ({ 85 + type: "follow", 86 + target: a.target, 87 + subject: a.subject, 88 + comment: a.comment ?? "", 89 + }), 90 + toInput: (d) => ({ type: "follow", target: d.target, subject: d.subject }), 91 + EditorBlock: FollowActionEditor, 92 + DisplayBlock: FollowDisplayBlock, 93 + getFaviconDomain: (action) => FOLLOW_TARGETS[action.target].faviconDomain, 94 + };
+162
app/islands/action-editors/margin-bookmark.tsx
··· 1 + import type { MarginBookmarkAction } from "../../../lib/db/schema.js"; 2 + import { Bookmark } from "../../icons.ts"; 3 + import { CodeBlock, InlineCode } from "../../components/CodeBlock/index.tsx"; 4 + import * as s from "../AutomationForm.css.ts"; 5 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 6 + 7 + export type MarginBookmarkDraft = { 8 + type: "margin-bookmark"; 9 + targetSource: string; 10 + bodyValue: string; 11 + tagsText: string; 12 + comment: string; 13 + forEach?: ForEachDraft; 14 + }; 15 + 16 + function MarginBookmarkActionEditor({ 17 + action, 18 + index, 19 + onChange, 20 + }: { 21 + action: MarginBookmarkDraft; 22 + index: number; 23 + onChange: (a: MarginBookmarkDraft) => void; 24 + }) { 25 + const urlId = `action-${index}-margin-bookmark-url`; 26 + const bodyId = `action-${index}-margin-bookmark-body`; 27 + const tagsId = `action-${index}-margin-bookmark-tags`; 28 + 29 + return ( 30 + <> 31 + <div class={s.fieldGroup}> 32 + <label class={s.label} for={urlId}> 33 + Page URL 34 + </label> 35 + <input 36 + id={urlId} 37 + class={s.input} 38 + type="text" 39 + placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 40 + value={action.targetSource} 41 + onInput={(e: Event) => 42 + onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 43 + } 44 + required 45 + autocomplete="off" 46 + /> 47 + <span class={s.hint}> 48 + URL of the page to bookmark. Supports {"{{placeholders}}"}. The page title is fetched 49 + automatically. 50 + </span> 51 + </div> 52 + 53 + <div class={s.fieldGroup}> 54 + <label class={s.label} for={bodyId}> 55 + Description <span class={s.hint}>(optional)</span> 56 + </label> 57 + <textarea 58 + id={bodyId} 59 + class={s.textarea} 60 + placeholder="A short note about this bookmark" 61 + value={action.bodyValue} 62 + onInput={(e: Event) => 63 + onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 64 + } 65 + rows={3} 66 + autocomplete="off" 67 + /> 68 + <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 69 + </div> 70 + 71 + <div class={s.fieldGroup}> 72 + <label class={s.label} for={tagsId}> 73 + Tags <span class={s.hint}>(optional, max 10)</span> 74 + </label> 75 + <input 76 + id={tagsId} 77 + class={s.input} 78 + type="text" 79 + placeholder="e.g. reading, research, bluesky" 80 + value={action.tagsText} 81 + onInput={(e: Event) => 82 + onChange({ ...action, tagsText: (e.target as HTMLInputElement).value }) 83 + } 84 + autocomplete="off" 85 + /> 86 + <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 87 + </div> 88 + </> 89 + ); 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 + 117 + export const marginBookmarkUiDefinition: ActionUIDefinition< 118 + MarginBookmarkDraft, 119 + MarginBookmarkAction 120 + > = { 121 + type: "margin-bookmark", 122 + recordProducing: true, 123 + catalogue: { 124 + label: "Bookmark on Margin", 125 + description: "Create a bookmark note in Margin.at", 126 + category: "apps", 127 + icon: Bookmark, 128 + available: true, 129 + faviconDomain: "margin.at", 130 + }, 131 + newDraft: () => ({ 132 + type: "margin-bookmark", 133 + targetSource: "", 134 + bodyValue: "", 135 + tagsText: "", 136 + comment: "", 137 + }), 138 + fromAction: (a) => ({ 139 + type: "margin-bookmark", 140 + targetSource: a.targetSource, 141 + bodyValue: a.bodyValue ?? "", 142 + tagsText: (a.tags ?? []).join(", "), 143 + comment: a.comment ?? "", 144 + }), 145 + toInput: (d) => { 146 + const bodyValue = d.bodyValue.trim(); 147 + const tags = d.tagsText 148 + .split(",") 149 + .map((t) => t.trim()) 150 + .filter(Boolean) 151 + .slice(0, 10); 152 + return { 153 + type: "margin-bookmark", 154 + targetSource: d.targetSource, 155 + ...(bodyValue ? { bodyValue } : {}), 156 + ...(tags.length > 0 ? { tags } : {}), 157 + }; 158 + }, 159 + EditorBlock: MarginBookmarkActionEditor, 160 + DisplayBlock: MarginBookmarkDisplayBlock, 161 + getFaviconDomain: () => "margin.at", 162 + };
+183
app/islands/action-editors/patch-record.tsx
··· 1 + import type { PatchRecordAction } from "../../../lib/db/schema.js"; 2 + import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 + import { Pencil } from "../../icons.ts"; 4 + import { CodeBlock, InlineCode } from "../../components/CodeBlock/index.tsx"; 5 + import { NsidCode } from "../../components/NsidCode/index.tsx"; 6 + import * as s from "../AutomationForm.css.ts"; 7 + import RecordFormBuilder from "../RecordFormBuilder.js"; 8 + import { useNsidSchema } from "./use-nsid-schema.ts"; 9 + import type { ActionUIDefinition, EditorBlockProps, ForEachDraft } from "./types.ts"; 10 + 11 + export type PatchRecordDraft = { 12 + type: "patch-record"; 13 + targetCollection: string; 14 + baseRecordUri: string; 15 + recordTemplate: string; 16 + comment: string; 17 + forEach?: ForEachDraft; 18 + }; 19 + 20 + function PatchRecordActionEditor({ 21 + action, 22 + index, 23 + onChange, 24 + placeholders, 25 + }: EditorBlockProps<PatchRecordDraft>) { 26 + const { 27 + targetSchema, 28 + targetSchemaLoading, 29 + targetSchemaError, 30 + nsidSuggestions, 31 + datalistId, 32 + fetchTargetSchema, 33 + fetchSuggestions, 34 + } = useNsidSchema(action.targetCollection); 35 + 36 + const targetId = `action-${index}-target-collection`; 37 + const baseUriId = `action-${index}-base-record-uri`; 38 + const templateId = `action-${index}-record-template`; 39 + 40 + return ( 41 + <> 42 + <div class={s.fieldGroup}> 43 + <label class={s.label} for={targetId}> 44 + Target Lexicon NSID 45 + </label> 46 + <input 47 + id={targetId} 48 + class={s.input} 49 + type="text" 50 + list={datalistId} 51 + placeholder="e.g. site.standard.document" 52 + value={action.targetCollection} 53 + onInput={(e: Event) => { 54 + const val = (e.target as HTMLInputElement).value; 55 + onChange({ ...action, targetCollection: val }); 56 + fetchTargetSchema(val); 57 + fetchSuggestions(val); 58 + }} 59 + required 60 + autocomplete="off" 61 + /> 62 + <span class={s.hint}>NSID of the collection containing the record to update</span> 63 + <datalist id={datalistId}> 64 + {nsidSuggestions.map((nsid) => ( 65 + <option key={nsid} value={nsid} /> 66 + ))} 67 + </datalist> 68 + </div> 69 + 70 + <div class={s.fieldGroup}> 71 + <label class={s.label} for={baseUriId}> 72 + Base Record URI 73 + </label> 74 + <input 75 + id={baseUriId} 76 + class={s.input} 77 + type="text" 78 + placeholder="e.g. {{event.commit.record.subject.uri}}" 79 + value={action.baseRecordUri} 80 + onInput={(e: Event) => 81 + onChange({ ...action, baseRecordUri: (e.target as HTMLInputElement).value }) 82 + } 83 + required 84 + autocomplete="off" 85 + /> 86 + <span class={s.hint}>AT URI of the record to update. Supports {"{{placeholders}}"}.</span> 87 + </div> 88 + 89 + {(targetSchema || targetSchemaLoading) && ( 90 + <RecordFormBuilder 91 + schema={targetSchema} 92 + loading={targetSchemaLoading} 93 + error={targetSchemaError} 94 + placeholders={placeholders} 95 + initialTemplate={action.recordTemplate || undefined} 96 + onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 97 + patchMode 98 + /> 99 + )} 100 + 101 + {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 102 + <div class={s.fieldGroup}> 103 + <label class={s.label} for={templateId}> 104 + Patch template 105 + </label> 106 + {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 107 + <textarea 108 + id={templateId} 109 + class={s.textarea} 110 + placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 111 + value={action.recordTemplate} 112 + onInput={(e: Event) => 113 + onChange({ 114 + ...action, 115 + recordTemplate: (e.target as HTMLTextAreaElement).value, 116 + }) 117 + } 118 + required 119 + autocomplete="off" 120 + /> 121 + <span class={s.hint}> 122 + Only include the fields you want to change. They will be merged on top of the existing 123 + record. 124 + </span> 125 + </div> 126 + )} 127 + </> 128 + ); 129 + } 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 + 150 + export const patchRecordUiDefinition: ActionUIDefinition<PatchRecordDraft, PatchRecordAction> = { 151 + type: "patch-record", 152 + recordProducing: true, 153 + catalogue: { 154 + label: "Update a record", 155 + description: "Modify fields of an existing record", 156 + category: "pds", 157 + icon: Pencil, 158 + available: true, 159 + }, 160 + newDraft: () => ({ 161 + type: "patch-record", 162 + targetCollection: "", 163 + baseRecordUri: "", 164 + recordTemplate: "", 165 + comment: "", 166 + }), 167 + fromAction: (a) => ({ 168 + type: "patch-record", 169 + targetCollection: a.targetCollection, 170 + baseRecordUri: a.baseRecordUri, 171 + recordTemplate: a.recordTemplate, 172 + comment: a.comment ?? "", 173 + }), 174 + toInput: (d) => ({ 175 + type: "patch-record", 176 + targetCollection: d.targetCollection, 177 + baseRecordUri: d.baseRecordUri, 178 + recordTemplate: d.recordTemplate, 179 + }), 180 + EditorBlock: PatchRecordActionEditor, 181 + DisplayBlock: PatchRecordDisplayBlock, 182 + getFaviconDomain: (action) => nsidToDomain(action.targetCollection), 183 + };
+147
app/islands/action-editors/record.tsx
··· 1 + import type { RecordAction } from "../../../lib/db/schema.js"; 2 + import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 3 + import { FilePlus2 } from "../../icons.ts"; 4 + import { CodeBlock } from "../../components/CodeBlock/index.tsx"; 5 + import { NsidCode } from "../../components/NsidCode/index.tsx"; 6 + import * as s from "../AutomationForm.css.ts"; 7 + import RecordFormBuilder from "../RecordFormBuilder.js"; 8 + import { useNsidSchema } from "./use-nsid-schema.ts"; 9 + import type { ActionUIDefinition, EditorBlockProps, ForEachDraft } from "./types.ts"; 10 + 11 + export type RecordDraft = { 12 + type: "record"; 13 + targetCollection: string; 14 + recordTemplate: string; 15 + comment: string; 16 + forEach?: ForEachDraft; 17 + }; 18 + 19 + function RecordActionEditor({ 20 + action, 21 + index, 22 + onChange, 23 + placeholders, 24 + }: EditorBlockProps<RecordDraft>) { 25 + const { 26 + targetSchema, 27 + targetSchemaLoading, 28 + targetSchemaError, 29 + nsidSuggestions, 30 + datalistId, 31 + fetchTargetSchema, 32 + fetchSuggestions, 33 + } = useNsidSchema(action.targetCollection); 34 + 35 + const targetId = `action-${index}-target-collection`; 36 + const templateId = `action-${index}-record-template`; 37 + 38 + return ( 39 + <> 40 + <div class={s.fieldGroup}> 41 + <label class={s.label} for={targetId}> 42 + Target Lexicon NSID 43 + </label> 44 + <input 45 + id={targetId} 46 + class={s.input} 47 + type="text" 48 + list={datalistId} 49 + placeholder="e.g. app.bsky.feed.like" 50 + value={action.targetCollection} 51 + onInput={(e: Event) => { 52 + const val = (e.target as HTMLInputElement).value; 53 + onChange({ ...action, targetCollection: val }); 54 + fetchTargetSchema(val); 55 + fetchSuggestions(val); 56 + }} 57 + required 58 + autocomplete="off" 59 + /> 60 + <span class={s.hint}>NSID of the collection to create a record in</span> 61 + <datalist id={datalistId}> 62 + {nsidSuggestions.map((nsid) => ( 63 + <option key={nsid} value={nsid} /> 64 + ))} 65 + </datalist> 66 + </div> 67 + 68 + {(targetSchema || targetSchemaLoading) && ( 69 + <RecordFormBuilder 70 + schema={targetSchema} 71 + loading={targetSchemaLoading} 72 + error={targetSchemaError} 73 + placeholders={placeholders} 74 + initialTemplate={action.recordTemplate || undefined} 75 + onChange={(tpl) => onChange({ ...action, recordTemplate: tpl })} 76 + /> 77 + )} 78 + 79 + {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 80 + <div class={s.fieldGroup}> 81 + <label class={s.label} for={templateId}> 82 + Record template 83 + </label> 84 + {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 85 + <textarea 86 + id={templateId} 87 + class={s.textarea} 88 + placeholder={ 89 + '{\n "subject": {\n "uri": "{{event.commit.record.subject.uri}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 90 + } 91 + value={action.recordTemplate} 92 + onInput={(e: Event) => 93 + onChange({ 94 + ...action, 95 + recordTemplate: (e.target as HTMLTextAreaElement).value, 96 + }) 97 + } 98 + required 99 + autocomplete="off" 100 + /> 101 + </div> 102 + )} 103 + </> 104 + ); 105 + } 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 + 122 + export const recordUiDefinition: ActionUIDefinition<RecordDraft, RecordAction> = { 123 + type: "record", 124 + recordProducing: true, 125 + catalogue: { 126 + label: "Create a record", 127 + description: "Create a new record in any collection", 128 + category: "pds", 129 + icon: FilePlus2, 130 + available: true, 131 + }, 132 + newDraft: () => ({ type: "record", targetCollection: "", recordTemplate: "", comment: "" }), 133 + fromAction: (a) => ({ 134 + type: "record", 135 + targetCollection: a.targetCollection, 136 + recordTemplate: a.recordTemplate, 137 + comment: a.comment ?? "", 138 + }), 139 + toInput: (d) => ({ 140 + type: "record", 141 + targetCollection: d.targetCollection, 142 + recordTemplate: d.recordTemplate, 143 + }), 144 + EditorBlock: RecordActionEditor, 145 + DisplayBlock: RecordDisplayBlock, 146 + getFaviconDomain: (action) => nsidToDomain(action.targetCollection), 147 + };
+62
app/islands/action-editors/registry.ts
··· 1 + import type { ActionType } from "../../../lib/actions/registry.js"; 2 + import { webhookUiDefinition, type WebhookDraft } from "./webhook.tsx"; 3 + import { recordUiDefinition, type RecordDraft } from "./record.tsx"; 4 + import { bskyPostUiDefinition, type BskyPostDraft } from "./bsky-post.tsx"; 5 + import { patchRecordUiDefinition, type PatchRecordDraft } from "./patch-record.tsx"; 6 + import { marginBookmarkUiDefinition, type MarginBookmarkDraft } from "./margin-bookmark.tsx"; 7 + import { sembleSaveUiDefinition, type SembleSaveDraft } from "./semble-save.tsx"; 8 + import { followUiDefinition, type FollowDraft } from "./follow.tsx"; 9 + import type { ActionUIDefinition } from "./types.ts"; 10 + 11 + /** Form-side counterpart to `ACTION_REGISTRY`. Keyed by the same `$type` as 12 + * the server-side registry, so the two can be paired by the AutomationForm 13 + * without an extra translation table. Insertion order matches 14 + * `ACTION_TYPES` from the server registry; the contract test pins them. 15 + * 16 + * The `<any, any, any>` index type widens the per-action 17 + * `TDraft`/`TAction`/`TPublic` triple for the same reason as the server 18 + * registry: TS can't represent a variadic mapping where each entry has its 19 + * own type parameters. Each UI definition keeps full precision at its 20 + * declaration site. */ 21 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above. 22 + export const ACTION_UI_REGISTRY: Record<ActionType, ActionUIDefinition<any, any, any>> = { 23 + webhook: webhookUiDefinition, 24 + "bsky-post": bskyPostUiDefinition, 25 + follow: followUiDefinition, 26 + "margin-bookmark": marginBookmarkUiDefinition, 27 + "semble-save": sembleSaveUiDefinition, 28 + record: recordUiDefinition, 29 + "patch-record": patchRecordUiDefinition, 30 + }; 31 + 32 + /** Per-action draft union — replaces the hand-written `ActionDraft` that 33 + * used to live in AutomationForm.tsx. Adding a new action type here means 34 + * appending its draft to this union and registering it in 35 + * `ACTION_UI_REGISTRY`. */ 36 + export type ActionDraft = 37 + | WebhookDraft 38 + | RecordDraft 39 + | BskyPostDraft 40 + | PatchRecordDraft 41 + | MarginBookmarkDraft 42 + | SembleSaveDraft 43 + | FollowDraft; 44 + 45 + export type { 46 + WebhookDraft, 47 + RecordDraft, 48 + BskyPostDraft, 49 + PatchRecordDraft, 50 + MarginBookmarkDraft, 51 + SembleSaveDraft, 52 + FollowDraft, 53 + }; 54 + 55 + /** Client-safe variant of `lib/actions/registry.isRecordProducingAction`. 56 + * Importing the server registry from a client island drags drizzle/sqlite 57 + * through transitive dependencies (executor → dispatcher → db). The 58 + * `recordProducing` field on each UI definition mirrors the server flag, 59 + * enforced by a vitest test. */ 60 + export function isRecordProducingAction(type: string): boolean { 61 + return ACTION_UI_REGISTRY[type as ActionType]?.recordProducing ?? false; 62 + }
+76
app/islands/action-editors/semble-save.tsx
··· 1 + import type { SembleSaveAction } from "../../../lib/db/schema.js"; 2 + import { BookmarkPlus } from "../../icons.ts"; 3 + import { InlineCode } from "../../components/CodeBlock/index.tsx"; 4 + import * as s from "../AutomationForm.css.ts"; 5 + import type { ActionUIDefinition, ForEachDraft } from "./types.ts"; 6 + 7 + export type SembleSaveDraft = { 8 + type: "semble-save"; 9 + url: string; 10 + comment: string; 11 + forEach?: ForEachDraft; 12 + }; 13 + 14 + function SembleSaveActionEditor({ 15 + action, 16 + index, 17 + onChange, 18 + }: { 19 + action: SembleSaveDraft; 20 + index: number; 21 + onChange: (a: SembleSaveDraft) => void; 22 + }) { 23 + const urlId = `action-${index}-semble-url`; 24 + return ( 25 + <div class={s.fieldGroup}> 26 + <label class={s.label} for={urlId}> 27 + Page URL 28 + </label> 29 + <input 30 + id={urlId} 31 + class={s.input} 32 + type="text" 33 + placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 34 + value={action.url} 35 + onInput={(e: Event) => onChange({ ...action, url: (e.target as HTMLInputElement).value })} 36 + required 37 + autocomplete="off" 38 + /> 39 + <span class={s.hint}> 40 + URL of the page to save. Metadata (title, description, image) will be fetched automatically. 41 + Supports {"{{placeholders}}"}. 42 + </span> 43 + </div> 44 + ); 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 + 58 + export const sembleSaveUiDefinition: ActionUIDefinition<SembleSaveDraft, SembleSaveAction> = { 59 + type: "semble-save", 60 + recordProducing: true, 61 + catalogue: { 62 + label: "Save on Semble", 63 + description: "Save a URL as a card on Semble", 64 + category: "apps", 65 + icon: BookmarkPlus, 66 + available: true, 67 + colorKey: "cosmik", 68 + faviconDomain: "semble.so", 69 + }, 70 + newDraft: () => ({ type: "semble-save", url: "", comment: "" }), 71 + fromAction: (a) => ({ type: "semble-save", url: a.url, comment: a.comment ?? "" }), 72 + toInput: (d) => ({ type: "semble-save", url: d.url }), 73 + EditorBlock: SembleSaveActionEditor, 74 + DisplayBlock: SembleSaveDisplayBlock, 75 + getFaviconDomain: () => "semble.so", 76 + };
+110
app/islands/action-editors/types.ts
··· 1 + import type { FC } from "hono/jsx"; 2 + import type { Action, FollowTarget } from "../../../lib/db/schema.js"; 3 + import type { ActionInput } from "../../../lib/actions/validation.js"; 4 + import type { ColorKey } from "../../../lib/automations/follow-targets.js"; 5 + 6 + /** Common prop signature for the lucide-style icon components. */ 7 + type IconProps = { size?: number; class?: string; color?: string; "stroke-width"?: number }; 8 + export type ActionIcon = FC<IconProps>; 9 + 10 + /** Picker-tile metadata shown in the form's "+ Add action" picker. Lives on 11 + * the UI side because it's pure display data — the server has no use for 12 + * icons or human-readable labels. */ 13 + export type CatalogueTile = { 14 + /** Imperative phrase shown in the picker (e.g. "Post to Bluesky"). */ 15 + label: string; 16 + description: string; 17 + category: "webhook" | "bluesky" | "apps" | "pds"; 18 + icon: ActionIcon; 19 + available: boolean; 20 + colorKey?: ColorKey; 21 + faviconDomain?: string; 22 + }; 23 + 24 + /** Form-side representation of a per-iteration `forEach` config. Mirrors 25 + * `ForEachConfig` but always materializes the conditions array (the form 26 + * needs it for incremental editing). */ 27 + export type ForEachDraft = { 28 + path: string; 29 + conditions: Array<{ field: string; operator: string; value: string; comment: string }>; 30 + }; 31 + 32 + /** Custom-header row inside a webhook draft. Two-step entry — the user types 33 + * key and value separately and we only emit the entry once both are 34 + * non-empty. */ 35 + export type HeaderDraft = { key: string; value: string }; 36 + 37 + /** Props every action-editor block receives. `placeholders` is universally 38 + * threaded so editors can opt into placeholder autocomplete without each 39 + * editor declaring its own prop type. */ 40 + export type EditorBlockProps<TDraft> = { 41 + action: TDraft; 42 + index: number; 43 + onChange: (a: TDraft) => void; 44 + placeholders: string[]; 45 + }; 46 + 47 + /** Init payload for `newDraft`. `followTarget` is set when the picker tile is 48 + * one of the per-target follow tiles (`follow-bluesky` etc.); other actions 49 + * ignore it. */ 50 + export type NewDraftInit = { 51 + followTarget?: FollowTarget; 52 + }; 53 + 54 + /** UI counterpart to `ActionDefinition`: editor JSX, draft↔action conversions, 55 + * and the toInput projection that produces the API payload shape. The form 56 + * reaches for these via `ACTION_UI_REGISTRY`. */ 57 + export type ActionUIDefinition< 58 + TDraft extends { type: Action["$type"]; comment: string; forEach?: ForEachDraft }, 59 + TAction extends Action, 60 + /** Sanitized public-profile projection of `TAction`. Defaults to the 61 + * owner-side type, which is correct for every action whose public shape 62 + * is identical (i.e. all of them except webhook). Webhook overrides this 63 + * with `PublicWebhookAction` so its `PublicDisplayBlock` and `HeaderBadge` 64 + * see the actual sanitized shape (callbackDomain instead of callbackUrl; 65 + * no secret) the public profile route hands them. */ 66 + TPublic = TAction, 67 + > = { 68 + type: TAction["$type"]; 69 + /** Mirrors `ActionDefinition.recordProducing` on the server. Duplicated 70 + * here so client code (form, LexiconFlow) doesn't have to reach into the 71 + * server registry — which would drag drizzle/sqlite into the client 72 + * bundle. The mirror is verified by a vitest test. */ 73 + recordProducing: boolean; 74 + /** Picker-tile metadata. Optional because some action types map to 75 + * multiple tiles (e.g. follow expands into one tile per FOLLOW_TARGETS 76 + * entry); those keep their tile list hand-curated in `action-catalogue`. */ 77 + catalogue?: CatalogueTile; 78 + /** Build an empty draft for "+ Add action" clicks. */ 79 + newDraft: (init: NewDraftInit) => TDraft; 80 + /** Project a stored Action into the editor's draft shape. */ 81 + fromAction: (action: TAction) => TDraft; 82 + /** Project a draft back to the API input shape. The route's POST/PATCH 83 + * passes this directly into the server-side registry's `validate`. Common 84 + * fields (forEach, comment) are added by the form. */ 85 + toInput: (draft: TDraft) => Omit<ActionInput, "forEach" | "comment">; 86 + EditorBlock: FC<EditorBlockProps<TDraft>>; 87 + /** Read-only display rendered as <dt>/<dd> pairs inside a <DescriptionList> 88 + * on the dashboard's automation-detail page. Used for the OWNER view; the 89 + * public profile route falls back to `PublicDisplayBlock` when defined. */ 90 + DisplayBlock: FC<{ action: TAction }>; 91 + /** Public-profile variant of `DisplayBlock`. Webhook supplies one to swap 92 + * in the sanitized callback-domain row; every other action reuses 93 + * `DisplayBlock` (so this is left undefined). Receives the public 94 + * projection (`TPublic`), not `TAction`. */ 95 + PublicDisplayBlock?: FC<{ action: TPublic }>; 96 + /** Optional badge rendered next to the action header on the detail pages. 97 + * Webhook uses it for the Verified/Unverified pill; everyone else omits 98 + * it and the header renders nothing extra. Receives the public projection 99 + * on the public profile route, the owner shape on the dashboard. The two 100 + * shapes share enough (`verified`, `comment`) that webhook reads only 101 + * the common fields. */ 102 + HeaderBadge?: FC<{ action: TPublic | TAction }>; 103 + /** Domain whose favicon represents this action in `LexiconFlow`. Some 104 + * action types know their target statically (e.g. `bsky-post` → bsky.app); 105 + * others derive it from the action data (e.g. `record.targetCollection` 106 + * via `nsidToDomain`, or follow's per-target FOLLOW_TARGETS lookup). 107 + * Returning null hides the favicon (the caller already gates on whether 108 + * the action is record-producing). */ 109 + getFaviconDomain?: (action: TAction) => string | null; 110 + };
+94
app/islands/action-editors/use-nsid-schema.ts
··· 1 + import { useState, useRef, useCallback } from "hono/jsx"; 2 + import type { RecordSchema } from "../../../lib/lexicons/schema-types.js"; 3 + 4 + const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; 5 + 6 + /** Shared hook for record-producing editors that need to load the target 7 + * collection's lexicon schema (record + patch-record). Owns NSID typeahead, 8 + * debounced schema fetch, and a stable datalist id for input suggestions. */ 9 + export function useNsidSchema(initialCollection?: string) { 10 + const [targetSchema, setTargetSchema] = useState<RecordSchema | null>(null); 11 + const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); 12 + const [targetSchemaError, setTargetSchemaError] = useState(""); 13 + const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 14 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 15 + const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 16 + const abortRef = useRef<AbortController | null>(null); 17 + const lastSuggestPrefix = useRef(""); 18 + const [datalistId] = useState(() => `nsid-${Math.random().toString(36).slice(2, 8)}`); 19 + const initialFetched = useRef(false); 20 + 21 + const fetchTargetSchema = useCallback((nsid: string) => { 22 + if (debounceRef.current) clearTimeout(debounceRef.current); 23 + if (!nsid || !NSID_RE.test(nsid)) { 24 + abortRef.current?.abort(); 25 + setTargetSchema(null); 26 + setTargetSchemaError(""); 27 + return; 28 + } 29 + debounceRef.current = setTimeout(async () => { 30 + abortRef.current?.abort(); 31 + const ctrl = new AbortController(); 32 + abortRef.current = ctrl; 33 + setTargetSchemaLoading(true); 34 + setTargetSchemaError(""); 35 + try { 36 + const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}?schema=record`, { 37 + signal: ctrl.signal, 38 + }); 39 + const data = await res.json(); 40 + if (!res.ok) { 41 + setTargetSchemaError(data.error || "Failed to load schema"); 42 + setTargetSchema(null); 43 + } else { 44 + setTargetSchema(data.record ?? null); 45 + if (!data.record) setTargetSchemaError("No record schema found for this collection"); 46 + } 47 + } catch (err) { 48 + if ((err as Error).name === "AbortError") return; 49 + setTargetSchemaError("Failed to fetch target collection schema"); 50 + setTargetSchema(null); 51 + } finally { 52 + if (abortRef.current === ctrl) { 53 + abortRef.current = null; 54 + setTargetSchemaLoading(false); 55 + } 56 + } 57 + }, 400); 58 + }, []); 59 + 60 + const fetchSuggestions = useCallback((value: string) => { 61 + if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); 62 + const dotIndex = value.lastIndexOf("."); 63 + const prefix = dotIndex > 0 ? value.slice(0, dotIndex + 1) : ""; 64 + if (!prefix || prefix.split(".").filter(Boolean).length < 2) return; 65 + if (prefix === lastSuggestPrefix.current) return; 66 + suggestDebounceRef.current = setTimeout(async () => { 67 + lastSuggestPrefix.current = prefix; 68 + try { 69 + const res = await fetch(`/api/lexicons/suggest?prefix=${encodeURIComponent(prefix)}`); 70 + if (res.ok) { 71 + const data = await res.json(); 72 + setNsidSuggestions(data.suggestions ?? []); 73 + } 74 + } catch { 75 + // ignore 76 + } 77 + }, 300); 78 + }, []); 79 + 80 + if (!initialFetched.current && initialCollection) { 81 + initialFetched.current = true; 82 + fetchTargetSchema(initialCollection); 83 + } 84 + 85 + return { 86 + targetSchema, 87 + targetSchemaLoading, 88 + targetSchemaError, 89 + nsidSuggestions, 90 + datalistId, 91 + fetchTargetSchema, 92 + fetchSuggestions, 93 + }; 94 + }
+180
app/islands/action-editors/webhook.tsx
··· 1 + import type { WebhookAction } from "../../../lib/db/schema.js"; 2 + import type { PublicWebhookAction } from "../../../lib/actions/webhook.js"; 3 + import { Webhook } from "../../icons.ts"; 4 + import { InlineCode } from "../../components/CodeBlock/index.tsx"; 5 + import { Badge } from "../../components/Badge/index.js"; 6 + import * as s from "../AutomationForm.css.ts"; 7 + import type { ActionUIDefinition, ForEachDraft, HeaderDraft } from "./types.ts"; 8 + 9 + export type WebhookDraft = { 10 + type: "webhook"; 11 + callbackUrl: string; 12 + headers: HeaderDraft[]; 13 + comment: string; 14 + forEach?: ForEachDraft; 15 + }; 16 + 17 + function WebhookActionEditor({ 18 + action, 19 + index, 20 + onChange, 21 + }: { 22 + action: WebhookDraft; 23 + index: number; 24 + onChange: (a: WebhookDraft) => void; 25 + }) { 26 + const updateHeader = (i: number, key: "key" | "value", val: string) => { 27 + const headers = action.headers.map((h, j) => (j === i ? { ...h, [key]: val } : h)); 28 + onChange({ ...action, headers }); 29 + }; 30 + const addHeader = () => { 31 + onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 32 + }; 33 + const removeHeader = (i: number) => { 34 + onChange({ ...action, headers: action.headers.filter((_, j) => j !== i) }); 35 + }; 36 + 37 + const callbackId = `action-${index}-callback-url`; 38 + const headersGroupId = `action-${index}-headers-group`; 39 + 40 + return ( 41 + <> 42 + <div class={s.fieldGroup}> 43 + <label class={s.label} for={callbackId}> 44 + Callback URL 45 + </label> 46 + <input 47 + id={callbackId} 48 + class={s.input} 49 + type="url" 50 + placeholder="e.g. https://example.com/hooks/events" 51 + value={action.callbackUrl} 52 + onInput={(e: Event) => 53 + onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 54 + } 55 + required 56 + autocomplete="off" 57 + /> 58 + </div> 59 + <div class={s.fieldGroup} role="group" aria-labelledby={headersGroupId}> 60 + <span id={headersGroupId} class={s.label}> 61 + Custom headers 62 + </span> 63 + <span class={s.hint}> 64 + Use <code>{"{{secret:name}}"}</code> to reference stored secrets 65 + </span> 66 + {action.headers.map((header, i) => ( 67 + <div key={i} class={s.conditionRow}> 68 + <div class={s.conditionField}> 69 + <input 70 + class={s.input} 71 + type="text" 72 + placeholder="e.g. Authorization" 73 + value={header.key} 74 + onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 75 + aria-label="Header name" 76 + autocomplete="off" 77 + /> 78 + </div> 79 + <div class={s.conditionValue}> 80 + <input 81 + class={s.input} 82 + type="text" 83 + placeholder="e.g. Bearer {{secret:my-token}}" 84 + value={header.value} 85 + onInput={(e: Event) => 86 + updateHeader(i, "value", (e.target as HTMLInputElement).value) 87 + } 88 + aria-label="Header value" 89 + autocomplete="off" 90 + /> 91 + </div> 92 + <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> 93 + Remove 94 + </button> 95 + </div> 96 + ))} 97 + <button type="button" class={s.addBtn} onClick={addHeader}> 98 + + Add Header 99 + </button> 100 + </div> 101 + </> 102 + ); 103 + } 104 + 105 + function WebhookDisplayBlock({ action }: { action: WebhookAction }) { 106 + return ( 107 + <> 108 + <dt>Callback URL</dt> 109 + <dd> 110 + <InlineCode>{action.callbackUrl}</InlineCode> 111 + </dd> 112 + <dt>HMAC Secret</dt> 113 + <dd> 114 + <InlineCode>{action.secret}</InlineCode> 115 + </dd> 116 + </> 117 + ); 118 + } 119 + 120 + /** Public-view counterpart to {@link WebhookDisplayBlock}. The webhook record 121 + * is the only action whose public projection has a different shape: the 122 + * secret and full callback URL are stripped, and only the host domain is 123 + * shown. */ 124 + function WebhookPublicDisplayBlock({ action }: { action: PublicWebhookAction }) { 125 + return ( 126 + <> 127 + <dt>Destination</dt> 128 + <dd> 129 + <InlineCode>{action.callbackDomain}</InlineCode> 130 + </dd> 131 + </> 132 + ); 133 + } 134 + 135 + function WebhookHeaderBadge({ action }: { action: WebhookAction | PublicWebhookAction }) { 136 + return ( 137 + <Badge variant={action.verified ? "success" : "neutral"}> 138 + {action.verified ? "Verified" : "Unverified"} 139 + </Badge> 140 + ); 141 + } 142 + 143 + export const webhookUiDefinition: ActionUIDefinition< 144 + WebhookDraft, 145 + WebhookAction, 146 + PublicWebhookAction 147 + > = { 148 + type: "webhook", 149 + recordProducing: false, 150 + catalogue: { 151 + label: "Send a webhook", 152 + description: "POST event data to an external URL", 153 + category: "webhook", 154 + icon: Webhook, 155 + available: true, 156 + }, 157 + newDraft: () => ({ type: "webhook", callbackUrl: "", headers: [], comment: "" }), 158 + fromAction: (a) => ({ 159 + type: "webhook", 160 + callbackUrl: a.callbackUrl, 161 + headers: a.headers ? Object.entries(a.headers).map(([key, value]) => ({ key, value })) : [], 162 + comment: a.comment ?? "", 163 + }), 164 + toInput: (d) => { 165 + const filtered = d.headers.filter((h) => h.key.trim() && h.value.trim()); 166 + const headers = 167 + filtered.length > 0 168 + ? Object.fromEntries(filtered.map((h) => [h.key.trim(), h.value.trim()])) 169 + : undefined; 170 + return { 171 + type: "webhook", 172 + callbackUrl: d.callbackUrl, 173 + ...(headers ? { headers } : {}), 174 + }; 175 + }, 176 + EditorBlock: WebhookActionEditor, 177 + DisplayBlock: WebhookDisplayBlock, 178 + PublicDisplayBlock: WebhookPublicDisplayBlock, 179 + HeaderBadge: WebhookHeaderBadge, 180 + };
+27 -296
app/routes/api/automations/[rkey].ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq, and, desc } from "drizzle-orm"; 3 - import { nanoid } from "nanoid"; 4 3 import { db } from "@/db/index.js"; 5 - import { 6 - automations, 7 - deliveryLogs, 8 - type Action, 9 - type WebhookAction, 10 - type RecordAction, 11 - type BskyPostAction, 12 - type PatchRecordAction, 13 - type MarginBookmarkAction, 14 - type FollowAction, 15 - type SembleSaveAction, 16 - } from "@/db/schema.js"; 4 + import { automations, deliveryLogs, type Action } from "@/db/schema.js"; 5 + import { ACTION_REGISTRY } from "@/actions/registry.js"; 17 6 import { config } from "@/config.js"; 18 - import { isValidNsid } from "@/lexicons/resolver.js"; 19 7 import { getRecord, putRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; 20 8 import { toPdsAction } from "@/automations/pds-serialize.js"; 21 9 import { verifyCallback } from "@/automations/verify.js"; 22 - import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 23 - import { 24 - validateTemplate, 25 - validateTextTemplate, 26 - validateBaseRecordUri, 27 - } from "@/actions/template.js"; 10 + import { assertPublicUrl } from "@/url-guard.js"; 28 11 import { 29 12 type ActionInput, 30 13 VALID_OPERATIONS, 31 - VALID_BSKY_LABELS, 32 - BCP47_RE, 33 - validateWebhookHeaders, 34 - validateMarginBookmarkInput, 35 - validateFollowInput, 36 - validateSembleSaveInput, 37 14 validateForEachInput, 38 15 resolveWantedDids, 39 16 } from "@/actions/validation.js"; ··· 101 78 description: auto.description, 102 79 lexicon: auto.lexicon, 103 80 operations: auto.operations, 104 - actions: auto.actions.map((a) => 105 - a.$type === "webhook" 106 - ? { 107 - $type: a.$type, 108 - callbackUrl: a.callbackUrl, 109 - ...(a.headers ? { headers: a.headers } : {}), 110 - verified: a.verified ?? false, 111 - comment: a.comment, 112 - ...(a.forEach ? { forEach: a.forEach } : {}), 113 - } 114 - : a, 115 - ), 81 + actions: auto.actions.map((a) => ACTION_REGISTRY[a.$type].serializeForApi?.(a) ?? a), 116 82 fetches: auto.fetches, 117 83 conditions: auto.conditions, 118 84 wantedDids: auto.wantedDids, ··· 246 212 const hasItem = forEach !== undefined; 247 213 const forEachField = forEach ? { forEach } : {}; 248 214 249 - if (input.type === "webhook") { 250 - if (!input.callbackUrl) { 251 - return c.json({ error: "callbackUrl is required for webhook actions" }, 400); 252 - } 253 - try { 254 - await assertPublicUrl(input.callbackUrl); 255 - } catch (err) { 256 - const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 257 - return c.json({ error: message }, 400); 258 - } 259 - 260 - // Validate custom headers if provided 261 - if (input.headers && Object.keys(input.headers).length > 0) { 262 - const headersValidation = validateWebhookHeaders(input.headers); 263 - if (!headersValidation.valid) { 264 - return c.json({ error: headersValidation.error }, 400); 265 - } 266 - } 267 - 268 - // Verify callback (non-blocking — stores verified status) 269 - const verification = await verifyCallback(input.callbackUrl, auto.lexicon); 270 - 271 - // Preserve existing secret if callbackUrl unchanged 272 - const existing = auto.actions.find( 273 - (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl, 274 - ); 275 - const secret = existing?.secret ?? nanoid(32); 276 - const headers = 277 - input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 278 - 279 - newLocalActions.push({ 280 - $type: "webhook", 281 - callbackUrl: input.callbackUrl, 282 - secret, 283 - ...(headers ? { headers } : {}), 284 - verified: verification.ok, 285 - ...forEachField, 286 - ...(input.comment ? { comment: input.comment } : {}), 287 - } satisfies WebhookAction); 288 - newPdsActions.push({ 289 - $type: "run.airglow.automation#webhookAction", 290 - callbackUrl: input.callbackUrl, 291 - ...forEachField, 292 - ...(input.comment ? { comment: input.comment } : {}), 293 - }); 294 - } else if (input.type === "record") { 295 - if (!input.targetCollection) { 296 - return c.json({ error: "targetCollection is required for record actions" }, 400); 297 - } 298 - if (!isValidNsid(input.targetCollection)) { 299 - return c.json({ error: "Invalid target collection NSID" }, 400); 300 - } 301 - if (!input.recordTemplate) { 302 - return c.json({ error: "recordTemplate is required for record actions" }, 400); 303 - } 304 - const templateValidation = validateTemplate( 305 - input.recordTemplate, 306 - fetchNames, 307 - actionResultNames, 308 - hasItem, 309 - ); 310 - if (!templateValidation.valid) { 311 - return c.json({ error: templateValidation.error }, 400); 312 - } 313 - 314 - newLocalActions.push({ 315 - $type: "record", 316 - targetCollection: input.targetCollection, 317 - recordTemplate: input.recordTemplate, 318 - ...forEachField, 319 - ...(input.comment ? { comment: input.comment } : {}), 320 - } satisfies RecordAction); 321 - newPdsActions.push({ 322 - $type: "run.airglow.automation#recordAction", 323 - targetCollection: input.targetCollection, 324 - recordTemplate: input.recordTemplate, 325 - ...forEachField, 326 - ...(input.comment ? { comment: input.comment } : {}), 327 - }); 328 - actionResultNames.push(`action${actionIndex + 1}`); 329 - } else if (input.type === "bsky-post") { 330 - if (!input.textTemplate || !input.textTemplate.trim()) { 331 - return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 332 - } 333 - const textValidation = validateTextTemplate( 334 - input.textTemplate, 335 - fetchNames, 336 - actionResultNames, 337 - hasItem, 338 - ); 339 - if (!textValidation.valid) { 340 - return c.json({ error: textValidation.error }, 400); 341 - } 342 - if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 343 - return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 344 - } 345 - if (input.langs?.some((l) => !BCP47_RE.test(l))) { 346 - return c.json( 347 - { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 348 - 400, 349 - ); 350 - } 351 - if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 352 - return c.json( 353 - { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 354 - 400, 355 - ); 356 - } 357 - 358 - const langs = input.langs?.filter(Boolean); 359 - const labels = input.labels?.filter(Boolean); 360 - 361 - newLocalActions.push({ 362 - $type: "bsky-post", 363 - textTemplate: input.textTemplate, 364 - ...(langs && langs.length > 0 ? { langs } : {}), 365 - ...(labels && labels.length > 0 ? { labels } : {}), 366 - ...forEachField, 367 - ...(input.comment ? { comment: input.comment } : {}), 368 - } satisfies BskyPostAction); 369 - newPdsActions.push({ 370 - $type: "run.airglow.automation#bskyPostAction", 371 - textTemplate: input.textTemplate, 372 - ...(langs && langs.length > 0 ? { langs } : {}), 373 - ...(labels && labels.length > 0 ? { labels } : {}), 374 - ...forEachField, 375 - ...(input.comment ? { comment: input.comment } : {}), 376 - }); 377 - actionResultNames.push(`action${actionIndex + 1}`); 378 - } else if (input.type === "patch-record") { 379 - if (!input.targetCollection) { 380 - return c.json({ error: "targetCollection is required for patch-record actions" }, 400); 381 - } 382 - if (!isValidNsid(input.targetCollection)) { 383 - return c.json({ error: "Invalid target collection NSID" }, 400); 384 - } 385 - if (!input.baseRecordUri) { 386 - return c.json({ error: "baseRecordUri is required for patch-record actions" }, 400); 387 - } 388 - const uriValidation = validateBaseRecordUri( 389 - input.baseRecordUri, 390 - fetchNames, 391 - actionResultNames, 392 - hasItem, 393 - ); 394 - if (!uriValidation.valid) { 395 - return c.json({ error: uriValidation.error }, 400); 396 - } 397 - if (!input.recordTemplate) { 398 - return c.json({ error: "recordTemplate is required for patch-record actions" }, 400); 399 - } 400 - const templateValidation = validateTemplate( 401 - input.recordTemplate, 402 - fetchNames, 403 - actionResultNames, 404 - hasItem, 405 - ); 406 - if (!templateValidation.valid) { 407 - return c.json({ error: templateValidation.error }, 400); 408 - } 409 - 410 - newLocalActions.push({ 411 - $type: "patch-record", 412 - targetCollection: input.targetCollection, 413 - baseRecordUri: input.baseRecordUri, 414 - recordTemplate: input.recordTemplate, 415 - ...forEachField, 416 - ...(input.comment ? { comment: input.comment } : {}), 417 - } satisfies PatchRecordAction); 418 - newPdsActions.push({ 419 - $type: "run.airglow.automation#patchRecordAction", 420 - targetCollection: input.targetCollection, 421 - baseRecordUri: input.baseRecordUri, 422 - recordTemplate: input.recordTemplate, 423 - ...forEachField, 424 - ...(input.comment ? { comment: input.comment } : {}), 425 - }); 426 - actionResultNames.push(`action${actionIndex + 1}`); 427 - } else if (input.type === "margin-bookmark") { 428 - const bookmarkValidation = validateMarginBookmarkInput( 429 - input, 430 - fetchNames, 431 - actionResultNames, 432 - hasItem, 433 - ); 434 - if (!bookmarkValidation.valid) { 435 - return c.json({ error: bookmarkValidation.error }, 400); 436 - } 437 - 438 - const bodyValue = input.bodyValue?.trim() || undefined; 439 - const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 440 - 441 - newLocalActions.push({ 442 - $type: "margin-bookmark", 443 - targetSource: input.targetSource, 444 - ...(bodyValue ? { bodyValue } : {}), 445 - ...(tags ? { tags } : {}), 446 - ...forEachField, 447 - ...(input.comment ? { comment: input.comment } : {}), 448 - } satisfies MarginBookmarkAction); 449 - newPdsActions.push({ 450 - $type: "run.airglow.automation#marginBookmarkAction", 451 - targetSource: input.targetSource, 452 - ...(bodyValue ? { bodyValue } : {}), 453 - ...(tags ? { tags } : {}), 454 - ...forEachField, 455 - ...(input.comment ? { comment: input.comment } : {}), 456 - }); 457 - actionResultNames.push(`action${actionIndex + 1}`); 458 - } else if (input.type === "follow") { 459 - const followValidation = validateFollowInput(input, fetchNames, actionResultNames, hasItem); 460 - if (!followValidation.valid) { 461 - return c.json({ error: followValidation.error }, 400); 462 - } 463 - 464 - newLocalActions.push({ 465 - $type: "follow", 466 - target: input.target, 467 - subject: input.subject, 468 - ...forEachField, 469 - ...(input.comment ? { comment: input.comment } : {}), 470 - } satisfies FollowAction); 471 - newPdsActions.push({ 472 - $type: "run.airglow.automation#followAction", 473 - target: input.target, 474 - subject: input.subject, 475 - ...forEachField, 476 - ...(input.comment ? { comment: input.comment } : {}), 477 - }); 478 - actionResultNames.push(`action${actionIndex + 1}`); 479 - } else if (input.type === "semble-save") { 480 - const sembleSaveValidation = validateSembleSaveInput( 481 - input, 482 - fetchNames, 483 - actionResultNames, 484 - hasItem, 485 - ); 486 - if (!sembleSaveValidation.valid) { 487 - return c.json({ error: sembleSaveValidation.error }, 400); 488 - } 489 - 490 - newLocalActions.push({ 491 - $type: "semble-save", 492 - url: input.url, 493 - ...forEachField, 494 - ...(input.comment ? { comment: input.comment } : {}), 495 - } satisfies SembleSaveAction); 496 - newPdsActions.push({ 497 - $type: "run.airglow.automation#sembleSaveAction", 498 - url: input.url, 499 - ...forEachField, 500 - ...(input.comment ? { comment: input.comment } : {}), 501 - }); 502 - actionResultNames.push(`action${actionIndex + 1}`); 503 - } else { 504 - return c.json({ error: "Invalid action type" }, 400); 505 - } 215 + const def = ACTION_REGISTRY[input.type]; 216 + if (!def) return c.json({ error: "Invalid action type" }, 400); 217 + const r = await def.validate(input, { 218 + fetchNames, 219 + actionResultNames, 220 + hasItem, 221 + lexicon: auto.lexicon, 222 + existingActions: auto.actions, 223 + }); 224 + if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 225 + const local = { 226 + ...r.local, 227 + ...forEachField, 228 + ...(input.comment ? { comment: input.comment } : {}), 229 + }; 230 + newLocalActions.push(local); 231 + newPdsActions.push(def.toPds(local)); 232 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 506 233 } 507 234 508 235 localActions = newLocalActions; 509 236 pdsActions = newPdsActions; 510 237 } 511 238 512 - // Re-verify webhook callbacks when reactivating (updates verified status) 239 + // Re-verify webhook callbacks when reactivating (updates verified status). 240 + // Webhook is the only action with a callback to re-check; the inline $type 241 + // narrowing is intentional rather than a missed dispatcher migration — 242 + // routing this through the registry would mean an `onReactivate?` method 243 + // with one consumer. 513 244 if (body.active === true && !auto.active && !body.actions) { 514 245 localActions = await Promise.all( 515 246 localActions.map(async (action) => {
+25 -290
app/routes/api/automations/index.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq } from "drizzle-orm"; 3 - import { nanoid } from "nanoid"; 4 3 import { db } from "@/db/index.js"; 5 - import { 6 - automations, 7 - type Action, 8 - type WebhookAction, 9 - type RecordAction, 10 - type BskyPostAction, 11 - type PatchRecordAction, 12 - type MarginBookmarkAction, 13 - type FollowAction, 14 - type SembleSaveAction, 15 - } from "@/db/schema.js"; 4 + import { automations, type Action } from "@/db/schema.js"; 5 + import { ACTION_REGISTRY } from "@/actions/registry.js"; 16 6 import { config } from "@/config.js"; 17 7 import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 18 - import { verifyCallback } from "@/automations/verify.js"; 19 - import { assertPublicUrl, UrlGuardError } from "@/url-guard.js"; 20 8 import { createRecord, deleteRecord, type PdsAction } from "@/automations/pds.js"; 21 9 import { 22 - validateTemplate, 23 - validateTextTemplate, 24 - validateBaseRecordUri, 25 - } from "@/actions/template.js"; 26 - import { 27 10 type ActionInput, 28 11 VALID_OPERATIONS, 29 - VALID_BSKY_LABELS, 30 - BCP47_RE, 31 - validateWebhookHeaders, 32 - validateMarginBookmarkInput, 33 - validateFollowInput, 34 - validateSembleSaveInput, 35 12 validateForEachInput, 36 13 resolveWantedDids, 37 14 } from "@/actions/validation.js"; ··· 59 36 description: r.description, 60 37 lexicon: r.lexicon, 61 38 operations: r.operations, 62 - actions: r.actions.map((a) => 63 - a.$type === "webhook" 64 - ? { 65 - $type: a.$type, 66 - callbackUrl: a.callbackUrl, 67 - ...(a.headers ? { headers: a.headers } : {}), 68 - verified: a.verified ?? false, 69 - comment: a.comment, 70 - ...(a.forEach ? { forEach: a.forEach } : {}), 71 - } 72 - : a, 73 - ), 39 + actions: r.actions.map((a) => ACTION_REGISTRY[a.$type].serializeForApi?.(a) ?? a), 74 40 fetches: r.fetches, 75 41 conditions: r.conditions, 76 42 wantedDids: r.wantedDids, ··· 169 135 const hasItem = forEach !== undefined; 170 136 const forEachField = forEach ? { forEach } : {}; 171 137 172 - if (input.type === "webhook") { 173 - if (!input.callbackUrl) { 174 - return c.json({ error: "callbackUrl is required for webhook actions" }, 400); 175 - } 176 - try { 177 - await assertPublicUrl(input.callbackUrl); 178 - } catch (err) { 179 - const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 180 - return c.json({ error: message }, 400); 181 - } 182 - 183 - // Validate custom headers if provided 184 - if (input.headers && Object.keys(input.headers).length > 0) { 185 - const headersValidation = validateWebhookHeaders(input.headers); 186 - if (!headersValidation.valid) { 187 - return c.json({ error: headersValidation.error }, 400); 188 - } 189 - } 190 - 191 - const verification = await verifyCallback(input.callbackUrl, body.lexicon); 192 - 193 - const secret = nanoid(32); 194 - const headers = 195 - input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 196 - localActions.push({ 197 - $type: "webhook", 198 - callbackUrl: input.callbackUrl, 199 - secret, 200 - ...(headers ? { headers } : {}), 201 - verified: verification.ok, 202 - ...forEachField, 203 - ...(input.comment ? { comment: input.comment } : {}), 204 - } satisfies WebhookAction); 205 - pdsActions.push({ 206 - $type: "run.airglow.automation#webhookAction", 207 - callbackUrl: input.callbackUrl, 208 - ...forEachField, 209 - ...(input.comment ? { comment: input.comment } : {}), 210 - }); 211 - } else if (input.type === "record") { 212 - if (!input.targetCollection) { 213 - return c.json({ error: "targetCollection is required for record actions" }, 400); 214 - } 215 - if (!isValidNsid(input.targetCollection)) { 216 - return c.json({ error: "Invalid target collection NSID" }, 400); 217 - } 218 - if (!input.recordTemplate) { 219 - return c.json({ error: "recordTemplate is required for record actions" }, 400); 220 - } 221 - const templateValidation = validateTemplate( 222 - input.recordTemplate, 223 - fetchNames, 224 - actionResultNames, 225 - hasItem, 226 - ); 227 - if (!templateValidation.valid) { 228 - return c.json({ error: templateValidation.error }, 400); 229 - } 230 - 231 - localActions.push({ 232 - $type: "record", 233 - targetCollection: input.targetCollection, 234 - recordTemplate: input.recordTemplate, 235 - ...forEachField, 236 - ...(input.comment ? { comment: input.comment } : {}), 237 - } satisfies RecordAction); 238 - pdsActions.push({ 239 - $type: "run.airglow.automation#recordAction", 240 - targetCollection: input.targetCollection, 241 - recordTemplate: input.recordTemplate, 242 - ...forEachField, 243 - ...(input.comment ? { comment: input.comment } : {}), 244 - }); 245 - actionResultNames.push(`action${actionIndex + 1}`); 246 - } else if (input.type === "bsky-post") { 247 - if (!input.textTemplate || !input.textTemplate.trim()) { 248 - return c.json({ error: "textTemplate is required for bsky-post actions" }, 400); 249 - } 250 - const textValidation = validateTextTemplate( 251 - input.textTemplate, 252 - fetchNames, 253 - actionResultNames, 254 - hasItem, 255 - ); 256 - if (!textValidation.valid) { 257 - return c.json({ error: textValidation.error }, 400); 258 - } 259 - if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 260 - return c.json({ error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }, 400); 261 - } 262 - if (input.langs?.some((l) => !BCP47_RE.test(l))) { 263 - return c.json( 264 - { error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }, 265 - 400, 266 - ); 267 - } 268 - if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 269 - return c.json( 270 - { error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media" }, 271 - 400, 272 - ); 273 - } 274 - 275 - const langs = input.langs?.filter(Boolean); 276 - const labels = input.labels?.filter(Boolean); 277 - 278 - localActions.push({ 279 - $type: "bsky-post", 280 - textTemplate: input.textTemplate, 281 - ...(langs && langs.length > 0 ? { langs } : {}), 282 - ...(labels && labels.length > 0 ? { labels } : {}), 283 - ...forEachField, 284 - ...(input.comment ? { comment: input.comment } : {}), 285 - } satisfies BskyPostAction); 286 - pdsActions.push({ 287 - $type: "run.airglow.automation#bskyPostAction", 288 - textTemplate: input.textTemplate, 289 - ...(langs && langs.length > 0 ? { langs } : {}), 290 - ...(labels && labels.length > 0 ? { labels } : {}), 291 - ...forEachField, 292 - ...(input.comment ? { comment: input.comment } : {}), 293 - }); 294 - actionResultNames.push(`action${actionIndex + 1}`); 295 - } else if (input.type === "patch-record") { 296 - if (!input.targetCollection) { 297 - return c.json({ error: "targetCollection is required for patch-record actions" }, 400); 298 - } 299 - if (!isValidNsid(input.targetCollection)) { 300 - return c.json({ error: "Invalid target collection NSID" }, 400); 301 - } 302 - if (!input.baseRecordUri) { 303 - return c.json({ error: "baseRecordUri is required for patch-record actions" }, 400); 304 - } 305 - const uriValidation = validateBaseRecordUri( 306 - input.baseRecordUri, 307 - fetchNames, 308 - actionResultNames, 309 - hasItem, 310 - ); 311 - if (!uriValidation.valid) { 312 - return c.json({ error: uriValidation.error }, 400); 313 - } 314 - if (!input.recordTemplate) { 315 - return c.json({ error: "recordTemplate is required for patch-record actions" }, 400); 316 - } 317 - const templateValidation = validateTemplate( 318 - input.recordTemplate, 319 - fetchNames, 320 - actionResultNames, 321 - hasItem, 322 - ); 323 - if (!templateValidation.valid) { 324 - return c.json({ error: templateValidation.error }, 400); 325 - } 326 - 327 - localActions.push({ 328 - $type: "patch-record", 329 - targetCollection: input.targetCollection, 330 - baseRecordUri: input.baseRecordUri, 331 - recordTemplate: input.recordTemplate, 332 - ...forEachField, 333 - ...(input.comment ? { comment: input.comment } : {}), 334 - } satisfies PatchRecordAction); 335 - pdsActions.push({ 336 - $type: "run.airglow.automation#patchRecordAction", 337 - targetCollection: input.targetCollection, 338 - baseRecordUri: input.baseRecordUri, 339 - recordTemplate: input.recordTemplate, 340 - ...forEachField, 341 - ...(input.comment ? { comment: input.comment } : {}), 342 - }); 343 - actionResultNames.push(`action${actionIndex + 1}`); 344 - } else if (input.type === "margin-bookmark") { 345 - const bookmarkValidation = validateMarginBookmarkInput( 346 - input, 347 - fetchNames, 348 - actionResultNames, 349 - hasItem, 350 - ); 351 - if (!bookmarkValidation.valid) { 352 - return c.json({ error: bookmarkValidation.error }, 400); 353 - } 354 - 355 - const bodyValue = input.bodyValue?.trim() || undefined; 356 - const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 357 - 358 - localActions.push({ 359 - $type: "margin-bookmark", 360 - targetSource: input.targetSource, 361 - ...(bodyValue ? { bodyValue } : {}), 362 - ...(tags ? { tags } : {}), 363 - ...forEachField, 364 - ...(input.comment ? { comment: input.comment } : {}), 365 - } satisfies MarginBookmarkAction); 366 - pdsActions.push({ 367 - $type: "run.airglow.automation#marginBookmarkAction", 368 - targetSource: input.targetSource, 369 - ...(bodyValue ? { bodyValue } : {}), 370 - ...(tags ? { tags } : {}), 371 - ...forEachField, 372 - ...(input.comment ? { comment: input.comment } : {}), 373 - }); 374 - actionResultNames.push(`action${actionIndex + 1}`); 375 - } else if (input.type === "follow") { 376 - const followValidation = validateFollowInput(input, fetchNames, actionResultNames, hasItem); 377 - if (!followValidation.valid) { 378 - return c.json({ error: followValidation.error }, 400); 379 - } 380 - 381 - localActions.push({ 382 - $type: "follow", 383 - target: input.target, 384 - subject: input.subject, 385 - ...forEachField, 386 - ...(input.comment ? { comment: input.comment } : {}), 387 - } satisfies FollowAction); 388 - pdsActions.push({ 389 - $type: "run.airglow.automation#followAction", 390 - target: input.target, 391 - subject: input.subject, 392 - ...forEachField, 393 - ...(input.comment ? { comment: input.comment } : {}), 394 - }); 395 - actionResultNames.push(`action${actionIndex + 1}`); 396 - } else if (input.type === "semble-save") { 397 - const sembleSaveValidation = validateSembleSaveInput( 398 - input, 399 - fetchNames, 400 - actionResultNames, 401 - hasItem, 402 - ); 403 - if (!sembleSaveValidation.valid) { 404 - return c.json({ error: sembleSaveValidation.error }, 400); 405 - } 406 - 407 - localActions.push({ 408 - $type: "semble-save", 409 - url: input.url, 410 - ...forEachField, 411 - ...(input.comment ? { comment: input.comment } : {}), 412 - } satisfies SembleSaveAction); 413 - pdsActions.push({ 414 - $type: "run.airglow.automation#sembleSaveAction", 415 - url: input.url, 416 - ...forEachField, 417 - ...(input.comment ? { comment: input.comment } : {}), 418 - }); 419 - actionResultNames.push(`action${actionIndex + 1}`); 420 - } else { 421 - return c.json({ error: "Invalid action type" }, 400); 422 - } 138 + const def = ACTION_REGISTRY[input.type]; 139 + if (!def) return c.json({ error: "Invalid action type" }, 400); 140 + const r = await def.validate(input, { 141 + fetchNames, 142 + actionResultNames, 143 + hasItem, 144 + lexicon: body.lexicon, 145 + existingActions: [], 146 + }); 147 + if (!r.ok) return c.json({ error: r.error }, (r.status ?? 400) as 400); 148 + const local = { 149 + ...r.local, 150 + ...forEachField, 151 + ...(input.comment ? { comment: input.comment } : {}), 152 + }; 153 + localActions.push(local); 154 + pdsActions.push(def.toPds(local)); 155 + if (def.recordProducing) actionResultNames.push(`action${actionIndex + 1}`); 423 156 } 424 157 425 158 // Write record to PDS ··· 492 225 493 226 notifyAutomationChange(); 494 227 495 - // Return secrets for webhook actions so the user can copy them 228 + // Return per-action secrets surfaced once at creation time. Today only 229 + // webhook returns its freshly-generated secret here; the registry's 230 + // `getCreatedSecrets` keeps the route uniform. 496 231 const actionSecrets = localActions.map((a, i) => ({ 497 232 index: i, 498 233 type: a.$type, 499 - ...(a.$type === "webhook" ? { secret: a.secret } : {}), 234 + ...ACTION_REGISTRY[a.$type].getCreatedSecrets?.(a), 500 235 })); 501 236 502 237 return c.json({ uri, rkey, actions: actionSecrets }, 201);
+10 -105
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"; ··· 233 233 totalOfType={totalOfType} 234 234 as="h4" 235 235 > 236 - {action.$type === "webhook" && ( 237 - <Badge variant={action.verified ? "success" : "neutral"}> 238 - {action.verified ? "Verified" : "Unverified"} 239 - </Badge> 240 - )} 236 + {(() => { 237 + const HeaderBadge = ACTION_UI_REGISTRY[action.$type].HeaderBadge; 238 + return HeaderBadge ? <HeaderBadge action={action} /> : null; 239 + })()} 241 240 {action.comment && ( 242 241 <span class={actionHeaderSubtitle}>- {action.comment}</span> 243 242 )} 244 243 </ActionHeader> 245 244 <DescriptionList> 246 245 <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 - )} 246 + {(() => { 247 + const Block = ACTION_UI_REGISTRY[action.$type].DisplayBlock; 248 + return <Block action={action} />; 249 + })()} 345 250 </DescriptionList> 346 251 </Stack> 347 252 </Card>
+11 -95
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 { ACTION_UI_REGISTRY } from "../../../islands/action-editors/registry.js"; 9 9 import { db } from "@/db/index.js"; 10 10 import { users, automations } from "@/db/schema.js"; 11 11 import { sanitizeActions } from "@/automations/sanitize.js"; ··· 17 17 import { Badge } from "../../../components/Badge/index.js"; 18 18 import { Button } from "../../../components/Button/index.js"; 19 19 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 20 - import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 20 + import { InlineCode } from "../../../components/CodeBlock/index.js"; 21 21 import { FetchCard } from "../../../components/FetchCard/index.js"; 22 22 import { ForEachSummary } from "../../../components/ForEachSummary/index.js"; 23 23 import { NsidCode } from "../../../components/NsidCode/index.js"; ··· 220 220 totalOfType={totalOfType} 221 221 as="h4" 222 222 > 223 - {action.$type === "webhook" && ( 224 - <Badge variant={action.verified ? "success" : "neutral"}> 225 - {action.verified ? "Verified" : "Unverified"} 226 - </Badge> 227 - )} 223 + {(() => { 224 + const HeaderBadge = ACTION_UI_REGISTRY[action.$type].HeaderBadge; 225 + return HeaderBadge ? <HeaderBadge action={action} /> : null; 226 + })()} 228 227 {action.comment && ( 229 228 <span class={actionHeaderSubtitle}>- {action.comment}</span> 230 229 )} 231 230 </ActionHeader> 232 231 <DescriptionList> 233 232 <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 - )} 233 + {(() => { 234 + const def = ACTION_UI_REGISTRY[action.$type]; 235 + const Block = def.PublicDisplayBlock ?? def.DisplayBlock; 236 + return <Block action={action} />; 237 + })()} 322 238 </DescriptionList> 323 239 </Stack> 324 240 </Card>
+119 -1
lib/actions/bsky-post.ts
··· 1 1 import { type BskyPostAction } from "../db/schema.js"; 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 - import { renderTextTemplate, type FetchContext } from "./template.js"; 3 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 4 4 import { detectFacets } from "./richtext.js"; 5 5 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 + import { AUTOMATION_LIMITS } from "../automations/limits.js"; 8 + import { BCP47_RE, VALID_BSKY_LABELS } from "./validation.js"; 9 + import type { ActionDefinition } from "./registry.js"; 10 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 11 8 12 const TARGET_COLLECTION = "app.bsky.feed.post"; 9 13 ··· 67 71 execute, 68 72 (action) => JSON.stringify({ textTemplate: action.textTemplate }), 69 73 ); 74 + 75 + type BskyPostInput = { 76 + type: "bsky-post"; 77 + textTemplate: string; 78 + langs?: string[]; 79 + labels?: string[]; 80 + }; 81 + 82 + type PdsBskyPostAction = { 83 + $type: "run.airglow.automation#bskyPostAction"; 84 + textTemplate: string; 85 + langs?: string[]; 86 + labels?: string[]; 87 + forEach?: BskyPostAction["forEach"]; 88 + comment?: string; 89 + }; 90 + 91 + async function validate( 92 + input: BskyPostInput, 93 + ctx: ValidationContext, 94 + ): Promise<{ ok: true; local: BskyPostAction } | { ok: false; error: string; status?: number }> { 95 + if (!input.textTemplate || !input.textTemplate.trim()) { 96 + return { ok: false, error: "textTemplate is required for bsky-post actions" }; 97 + } 98 + const textValidation = validateTextTemplate( 99 + input.textTemplate, 100 + ctx.fetchNames, 101 + ctx.actionResultNames, 102 + ctx.hasItem, 103 + ); 104 + if (!textValidation.valid) { 105 + return { ok: false, error: textValidation.error }; 106 + } 107 + if (input.langs && input.langs.length > AUTOMATION_LIMITS.bskyLangs) { 108 + return { ok: false, error: `Maximum ${AUTOMATION_LIMITS.bskyLangs} languages allowed` }; 109 + } 110 + if (input.langs?.some((l) => !BCP47_RE.test(l))) { 111 + return { ok: false, error: "Invalid language code. Use BCP-47 format (e.g. en, fr, pt-BR)" }; 112 + } 113 + if (input.labels?.some((l) => !VALID_BSKY_LABELS.has(l))) { 114 + return { 115 + ok: false, 116 + error: "Invalid label. Must be one of: sexual, nudity, porn, graphic-media", 117 + }; 118 + } 119 + 120 + const langs = input.langs?.filter(Boolean); 121 + const labels = input.labels?.filter(Boolean); 122 + const local: BskyPostAction = { 123 + $type: "bsky-post", 124 + textTemplate: input.textTemplate, 125 + ...(langs && langs.length > 0 ? { langs } : {}), 126 + ...(labels && labels.length > 0 ? { labels } : {}), 127 + }; 128 + return { ok: true, local }; 129 + } 130 + 131 + function toPds(action: BskyPostAction): PdsBskyPostAction { 132 + return { 133 + $type: "run.airglow.automation#bskyPostAction", 134 + textTemplate: action.textTemplate, 135 + ...(action.langs && action.langs.length > 0 ? { langs: action.langs } : {}), 136 + ...(action.labels && action.labels.length > 0 ? { labels: action.labels } : {}), 137 + ...(action.forEach ? { forEach: action.forEach } : {}), 138 + ...(action.comment ? { comment: action.comment } : {}), 139 + }; 140 + } 141 + 142 + async function dryRunDescribe( 143 + action: BskyPostAction, 144 + ctx: DryRunContext, 145 + ): Promise<DryRunDescription> { 146 + try { 147 + const text = await renderTextTemplate( 148 + action.textTemplate, 149 + ctx.match.event, 150 + ctx.fetchContext, 151 + ctx.match.automation, 152 + ctx.item, 153 + ); 154 + return { 155 + message: `Would post to Bluesky${ctx.itemSuffix}`, 156 + payload: JSON.stringify({ 157 + text, 158 + langs: action.langs, 159 + labels: action.labels, 160 + item: ctx.item, 161 + }), 162 + error: null, 163 + }; 164 + } catch (err) { 165 + return { 166 + message: null, 167 + payload: null, 168 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 169 + }; 170 + } 171 + } 172 + 173 + export const bskyPostDefinition: ActionDefinition< 174 + BskyPostAction, 175 + BskyPostInput, 176 + PdsBskyPostAction 177 + > = { 178 + type: "bsky-post", 179 + pdsType: "run.airglow.automation#bskyPostAction", 180 + displayLabel: "Bluesky Post", 181 + recordProducing: true, 182 + needsFullScope: true, 183 + validate, 184 + toPds, 185 + execute: executeBskyPost, 186 + dryRunDescribe, 187 + };
+1 -1
lib/actions/executor.test.ts lib/actions/record.test.ts
··· 9 9 createArbitraryRecord: vi.fn(), 10 10 })); 11 11 12 - import { executeAction } from "./executor.js"; 12 + import { executeAction } from "./record.js"; 13 13 import { createArbitraryRecord } from "../automations/pds.js"; 14 14 import { db } from "../db/index.js"; 15 15 import { automations, deliveryLogs } from "../db/schema.js";
-44
lib/actions/executor.ts
··· 1 - import { type RecordAction } from "../db/schema.js"; 2 - import { createArbitraryRecord } from "../automations/pds.js"; 3 - import { renderTemplate, type FetchContext } from "./template.js"; 4 - import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 - import type { MatchedEvent } from "../jetstream/consumer.js"; 6 - 7 - export type { ActionResult }; 8 - 9 - async function execute( 10 - match: MatchedEvent, 11 - action: RecordAction, 12 - fetchContext?: FetchContext, 13 - item?: unknown, 14 - ): Promise<ActionResult> { 15 - const { automation, event } = match; 16 - 17 - let record: Record<string, unknown>; 18 - try { 19 - record = await renderTemplate(action.recordTemplate, event, fetchContext, automation, item); 20 - } catch (err) { 21 - return { 22 - statusCode: 0, 23 - error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 24 - }; 25 - } 26 - 27 - try { 28 - const created = await createArbitraryRecord(automation.did, action.targetCollection, record); 29 - return { statusCode: 200, uri: created.uri, cid: created.cid }; 30 - } catch (err) { 31 - return parsePdsError(err); 32 - } 33 - } 34 - 35 - /** Execute a record action for a matched event. */ 36 - export const executeAction = wrapWithDelivery( 37 - (match, i) => match.automation.actions[i] as RecordAction, 38 - execute, 39 - (action) => 40 - JSON.stringify({ 41 - targetCollection: action.targetCollection, 42 - recordTemplate: action.recordTemplate, 43 - }), 44 - );
+55 -1
lib/actions/follow.test.ts
··· 21 21 resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 22 22 })); 23 23 24 - import { executeFollow } from "./follow.js"; 24 + import { executeFollow, followDefinition } from "./follow.js"; 25 25 import { createArbitraryRecord } from "../automations/pds.js"; 26 26 import { fetchRecord } from "../pds/resolver.js"; 27 27 import { executeSearch } from "./searcher.js"; ··· 294 294 }); 295 295 }); 296 296 }); 297 + 298 + describe("followDefinition.validate", () => { 299 + const ctx = { 300 + fetchNames: [], 301 + actionResultNames: [], 302 + hasItem: false, 303 + lexicon: "app.bsky.feed.post", 304 + existingActions: [], 305 + }; 306 + 307 + it("accepts a literal DID subject for each target", async () => { 308 + for (const target of ["bluesky", "tangled", "sifa"] as const) { 309 + const res = await followDefinition.validate( 310 + { type: "follow", target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 311 + ctx, 312 + ); 313 + expect(res.ok).toBe(true); 314 + } 315 + }); 316 + 317 + it("accepts placeholders in subject (validated at render time)", async () => { 318 + const res = await followDefinition.validate( 319 + { type: "follow", target: "bluesky", subject: "{{event.did}}" }, 320 + ctx, 321 + ); 322 + expect(res.ok).toBe(true); 323 + }); 324 + 325 + it("rejects an unknown target", async () => { 326 + const res = await followDefinition.validate( 327 + { type: "follow", target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 328 + ctx, 329 + ); 330 + expect(res.ok).toBe(false); 331 + if (!res.ok) expect(res.error).toMatch(/target/); 332 + }); 333 + 334 + it("rejects empty subject", async () => { 335 + const res = await followDefinition.validate( 336 + { type: "follow", target: "bluesky", subject: "" }, 337 + ctx, 338 + ); 339 + expect(res.ok).toBe(false); 340 + }); 341 + 342 + it("rejects unknown placeholders in subject", async () => { 343 + const res = await followDefinition.validate( 344 + { type: "follow", target: "bluesky", subject: "{{mystery.field}}" }, 345 + ctx, 346 + ); 347 + expect(res.ok).toBe(false); 348 + if (!res.ok) expect(res.error).toMatch(/subject/); 349 + }); 350 + });
+117 -2
lib/actions/follow.ts
··· 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 3 import { fetchRecord } from "../pds/resolver.js"; 4 4 import { executeSearch } from "./searcher.js"; 5 - import { renderTextTemplate, type FetchContext } from "./template.js"; 5 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 6 6 import { SKIP_STATUS, parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 7 7 import { DID_RE } from "./validation.js"; 8 - import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 8 + import { 9 + FOLLOW_TARGETS, 10 + VALID_FOLLOW_TARGETS, 11 + type FollowTarget, 12 + } from "../automations/follow-targets.js"; 9 13 import type { MatchedEvent } from "../jetstream/consumer.js"; 14 + import type { ActionDefinition } from "./registry.js"; 15 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 10 16 11 17 async function checkProfileExists( 12 18 action: FollowAction, ··· 147 153 subject: action.subject, 148 154 }), 149 155 ); 156 + 157 + type FollowInput = { 158 + type: "follow"; 159 + target: string; 160 + subject: string; 161 + }; 162 + 163 + type PdsFollowAction = { 164 + $type: "run.airglow.automation#followAction"; 165 + target: FollowTarget; 166 + subject: string; 167 + forEach?: FollowAction["forEach"]; 168 + comment?: string; 169 + }; 170 + 171 + const FOLLOW_SUBJECT_MAX = 512; 172 + 173 + async function validate( 174 + input: FollowInput, 175 + ctx: ValidationContext, 176 + ): Promise<{ ok: true; local: FollowAction } | { ok: false; error: string; status?: number }> { 177 + if (!input.target || typeof input.target !== "string") { 178 + return { ok: false, error: "target is required for follow actions" }; 179 + } 180 + if (!VALID_FOLLOW_TARGETS.has(input.target)) { 181 + return { 182 + ok: false, 183 + error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`, 184 + }; 185 + } 186 + if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) { 187 + return { ok: false, error: "subject is required for follow actions" }; 188 + } 189 + if (input.subject.length > FOLLOW_SUBJECT_MAX) { 190 + return { ok: false, error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less` }; 191 + } 192 + const templateCheck = validateTextTemplate( 193 + input.subject, 194 + ctx.fetchNames, 195 + ctx.actionResultNames, 196 + ctx.hasItem, 197 + ); 198 + if (!templateCheck.valid) { 199 + return { ok: false, error: `subject: ${templateCheck.error}` }; 200 + } 201 + 202 + const local: FollowAction = { 203 + $type: "follow", 204 + target: input.target as FollowTarget, 205 + subject: input.subject, 206 + }; 207 + return { ok: true, local }; 208 + } 209 + 210 + function toPds(action: FollowAction): PdsFollowAction { 211 + return { 212 + $type: "run.airglow.automation#followAction", 213 + target: action.target, 214 + subject: action.subject, 215 + ...(action.forEach ? { forEach: action.forEach } : {}), 216 + ...(action.comment ? { comment: action.comment } : {}), 217 + }; 218 + } 219 + 220 + async function dryRunDescribe( 221 + action: FollowAction, 222 + ctx: DryRunContext, 223 + ): Promise<DryRunDescription> { 224 + try { 225 + const subject = ( 226 + await renderTextTemplate( 227 + action.subject, 228 + ctx.match.event, 229 + ctx.fetchContext, 230 + ctx.match.automation, 231 + ctx.item, 232 + ) 233 + ).trim(); 234 + const target = FOLLOW_TARGETS[action.target]; 235 + // The built-in safety checks live inside executeFollow and aren't run in 236 + // dry-run (keeps the preview cheap). Advertise their presence in the 237 + // message so authors know the real run will skip cleanly on both edges. 238 + return { 239 + message: `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${target.appName} profile exists or already following)${ctx.itemSuffix}`, 240 + payload: JSON.stringify({ collection: target.collection, subject, item: ctx.item }), 241 + error: null, 242 + }; 243 + } catch (err) { 244 + return { 245 + message: null, 246 + payload: null, 247 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 248 + }; 249 + } 250 + } 251 + 252 + export const followDefinition: ActionDefinition<FollowAction, FollowInput, PdsFollowAction> = { 253 + type: "follow", 254 + pdsType: "run.airglow.automation#followAction", 255 + displayLabel: "Follow", 256 + recordProducing: true, 257 + needsFullScope: true, 258 + validate, 259 + toPds, 260 + execute: executeFollow, 261 + dryRunDescribe, 262 + // Catalogue tiles are derived per-target from FOLLOW_TARGETS in 263 + // action-catalogue.ts (one tile per Bluesky/Sifa/Tangled), not 1:1 with $type. 264 + };
+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 + });
+197 -1
lib/actions/margin-bookmark.ts
··· 1 1 import { createHash } from "node:crypto"; 2 2 import { type MarginBookmarkAction } from "../db/schema.js"; 3 3 import { createArbitraryRecord } from "../automations/pds.js"; 4 - import { renderTextTemplate, type FetchContext } from "./template.js"; 4 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 5 5 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 6 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 7 import { fetchURLMetadata } from "../url-metadata.js"; 8 8 import { config } from "../config.js"; 9 + import { MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; 10 + import type { ActionDefinition } from "./registry.js"; 11 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 9 12 10 13 const TARGET_COLLECTION = "at.margin.note"; 11 14 ··· 134 137 tags: action.tags, 135 138 }), 136 139 ); 140 + 141 + type MarginBookmarkInput = { 142 + type: "margin-bookmark"; 143 + targetSource: string; 144 + bodyValue?: string; 145 + tags?: string[]; 146 + }; 147 + 148 + type PdsMarginBookmarkAction = { 149 + $type: "run.airglow.automation#marginBookmarkAction"; 150 + targetSource: string; 151 + bodyValue?: string; 152 + tags?: string[]; 153 + forEach?: MarginBookmarkAction["forEach"]; 154 + comment?: string; 155 + }; 156 + 157 + // Allow either a literal http(s):// prefix or a leading {{...}} placeholder. 158 + // Mirrors the semble-save form check; the runtime guard inside the executor 159 + // remains the real boundary. 160 + const URL_OK_RE = /^(https?:\/\/|\{\{)/i; 161 + 162 + async function validate( 163 + input: MarginBookmarkInput, 164 + ctx: ValidationContext, 165 + ): Promise< 166 + { ok: true; local: MarginBookmarkAction } | { ok: false; error: string; status?: number } 167 + > { 168 + if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 169 + return { ok: false, error: "targetSource is required for margin-bookmark actions" }; 170 + } 171 + if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) { 172 + return { 173 + ok: false, 174 + error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`, 175 + }; 176 + } 177 + if (!URL_OK_RE.test(input.targetSource)) { 178 + return { 179 + ok: false, 180 + error: "targetSource must start with http://, https://, or a {{placeholder}}", 181 + }; 182 + } 183 + const sourceValidation = validateTextTemplate( 184 + input.targetSource, 185 + ctx.fetchNames, 186 + ctx.actionResultNames, 187 + ctx.hasItem, 188 + ); 189 + if (!sourceValidation.valid) { 190 + return { ok: false, error: `targetSource: ${sourceValidation.error}` }; 191 + } 192 + 193 + if (input.bodyValue !== undefined) { 194 + if (typeof input.bodyValue !== "string") { 195 + return { ok: false, error: "bodyValue must be a string" }; 196 + } 197 + if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) { 198 + return { 199 + ok: false, 200 + error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`, 201 + }; 202 + } 203 + if (input.bodyValue.trim()) { 204 + const bodyValidation = validateTextTemplate( 205 + input.bodyValue, 206 + ctx.fetchNames, 207 + ctx.actionResultNames, 208 + ctx.hasItem, 209 + ); 210 + if (!bodyValidation.valid) { 211 + return { ok: false, error: `bodyValue: ${bodyValidation.error}` }; 212 + } 213 + } 214 + } 215 + 216 + const tags: string[] = []; 217 + if (input.tags !== undefined) { 218 + if (!Array.isArray(input.tags)) { 219 + return { ok: false, error: "tags must be an array of strings" }; 220 + } 221 + if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) { 222 + return { ok: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` }; 223 + } 224 + for (const tag of input.tags) { 225 + if (typeof tag !== "string") { 226 + return { ok: false, error: "Each tag must be a string" }; 227 + } 228 + const trimmed = tag.trim(); 229 + if (!trimmed) continue; 230 + if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) { 231 + return { 232 + ok: false, 233 + error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`, 234 + }; 235 + } 236 + const tagValidation = validateTextTemplate( 237 + trimmed, 238 + ctx.fetchNames, 239 + ctx.actionResultNames, 240 + ctx.hasItem, 241 + ); 242 + if (!tagValidation.valid) { 243 + return { ok: false, error: `tag: ${tagValidation.error}` }; 244 + } 245 + tags.push(trimmed); 246 + } 247 + } 248 + 249 + const bodyValue = input.bodyValue?.trim() || undefined; 250 + const local: MarginBookmarkAction = { 251 + $type: "margin-bookmark", 252 + targetSource: input.targetSource, 253 + ...(bodyValue ? { bodyValue } : {}), 254 + ...(tags.length > 0 ? { tags } : {}), 255 + }; 256 + return { ok: true, local }; 257 + } 258 + 259 + function toPds(action: MarginBookmarkAction): PdsMarginBookmarkAction { 260 + return { 261 + $type: "run.airglow.automation#marginBookmarkAction", 262 + targetSource: action.targetSource, 263 + ...(action.bodyValue ? { bodyValue: action.bodyValue } : {}), 264 + ...(action.tags && action.tags.length > 0 ? { tags: action.tags } : {}), 265 + ...(action.forEach ? { forEach: action.forEach } : {}), 266 + ...(action.comment ? { comment: action.comment } : {}), 267 + }; 268 + } 269 + 270 + async function dryRunDescribe( 271 + action: MarginBookmarkAction, 272 + ctx: DryRunContext, 273 + ): Promise<DryRunDescription> { 274 + try { 275 + const source = await renderTextTemplate( 276 + action.targetSource, 277 + ctx.match.event, 278 + ctx.fetchContext, 279 + ctx.match.automation, 280 + ctx.item, 281 + ); 282 + const body = action.bodyValue 283 + ? await renderTextTemplate( 284 + action.bodyValue, 285 + ctx.match.event, 286 + ctx.fetchContext, 287 + ctx.match.automation, 288 + ctx.item, 289 + ) 290 + : undefined; 291 + const tags: string[] = []; 292 + if (action.tags) { 293 + for (const tag of action.tags) { 294 + const rendered = await renderTextTemplate( 295 + tag, 296 + ctx.match.event, 297 + ctx.fetchContext, 298 + ctx.match.automation, 299 + ctx.item, 300 + ); 301 + if (rendered.trim()) tags.push(rendered.trim()); 302 + } 303 + } 304 + return { 305 + message: `Would bookmark ${source}${ctx.itemSuffix}`, 306 + payload: JSON.stringify({ source, body, tags, item: ctx.item }), 307 + error: null, 308 + }; 309 + } catch (err) { 310 + return { 311 + message: null, 312 + payload: null, 313 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 314 + }; 315 + } 316 + } 317 + 318 + export const marginBookmarkDefinition: ActionDefinition< 319 + MarginBookmarkAction, 320 + MarginBookmarkInput, 321 + PdsMarginBookmarkAction 322 + > = { 323 + type: "margin-bookmark", 324 + pdsType: "run.airglow.automation#marginBookmarkAction", 325 + displayLabel: "Bookmark on Margin", 326 + recordProducing: true, 327 + needsFullScope: true, 328 + validate, 329 + toPds, 330 + execute: executeMarginBookmark, 331 + dryRunDescribe, 332 + };
+123 -1
lib/actions/patch-record.ts
··· 1 1 import { type PatchRecordAction } from "../db/schema.js"; 2 2 import { patchArbitraryRecord } from "../automations/pds.js"; 3 3 import { fetchRecord, parseAtUri } from "../pds/resolver.js"; 4 - import { renderTemplate, resolveUriTemplate, type FetchContext } from "./template.js"; 4 + import { 5 + renderTemplate, 6 + resolveUriTemplate, 7 + validateTemplate, 8 + validateBaseRecordUri, 9 + type FetchContext, 10 + } from "./template.js"; 5 11 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 6 12 import type { MatchedEvent } from "../jetstream/consumer.js"; 13 + import { isValidNsid } from "../lexicons/resolver.js"; 14 + import type { ActionDefinition } from "./registry.js"; 15 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 16 8 17 async function execute( 9 18 match: MatchedEvent, ··· 93 102 recordTemplate: action.recordTemplate, 94 103 }), 95 104 ); 105 + 106 + type PatchRecordInput = { 107 + type: "patch-record"; 108 + targetCollection: string; 109 + baseRecordUri: string; 110 + recordTemplate: string; 111 + }; 112 + 113 + type PdsPatchRecordAction = { 114 + $type: "run.airglow.automation#patchRecordAction"; 115 + targetCollection: string; 116 + baseRecordUri: string; 117 + recordTemplate: string; 118 + forEach?: PatchRecordAction["forEach"]; 119 + comment?: string; 120 + }; 121 + 122 + async function validate( 123 + input: PatchRecordInput, 124 + ctx: ValidationContext, 125 + ): Promise<{ ok: true; local: PatchRecordAction } | { ok: false; error: string; status?: number }> { 126 + if (!input.targetCollection) { 127 + return { ok: false, error: "targetCollection is required for patch-record actions" }; 128 + } 129 + if (!isValidNsid(input.targetCollection)) { 130 + return { ok: false, error: "Invalid target collection NSID" }; 131 + } 132 + if (!input.baseRecordUri) { 133 + return { ok: false, error: "baseRecordUri is required for patch-record actions" }; 134 + } 135 + const uriValidation = validateBaseRecordUri( 136 + input.baseRecordUri, 137 + ctx.fetchNames, 138 + ctx.actionResultNames, 139 + ctx.hasItem, 140 + ); 141 + if (!uriValidation.valid) { 142 + return { ok: false, error: uriValidation.error }; 143 + } 144 + if (!input.recordTemplate) { 145 + return { ok: false, error: "recordTemplate is required for patch-record actions" }; 146 + } 147 + const templateValidation = validateTemplate( 148 + input.recordTemplate, 149 + ctx.fetchNames, 150 + ctx.actionResultNames, 151 + ctx.hasItem, 152 + ); 153 + if (!templateValidation.valid) { 154 + return { ok: false, error: templateValidation.error }; 155 + } 156 + 157 + const local: PatchRecordAction = { 158 + $type: "patch-record", 159 + targetCollection: input.targetCollection, 160 + baseRecordUri: input.baseRecordUri, 161 + recordTemplate: input.recordTemplate, 162 + }; 163 + return { ok: true, local }; 164 + } 165 + 166 + function toPds(action: PatchRecordAction): PdsPatchRecordAction { 167 + return { 168 + $type: "run.airglow.automation#patchRecordAction", 169 + targetCollection: action.targetCollection, 170 + baseRecordUri: action.baseRecordUri, 171 + recordTemplate: action.recordTemplate, 172 + ...(action.forEach ? { forEach: action.forEach } : {}), 173 + ...(action.comment ? { comment: action.comment } : {}), 174 + }; 175 + } 176 + 177 + async function dryRunDescribe( 178 + action: PatchRecordAction, 179 + ctx: DryRunContext, 180 + ): Promise<DryRunDescription> { 181 + try { 182 + const rendered = await renderTemplate( 183 + action.recordTemplate, 184 + ctx.match.event, 185 + ctx.fetchContext, 186 + ctx.match.automation, 187 + ctx.item, 188 + ); 189 + return { 190 + message: `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${ctx.itemSuffix}`, 191 + payload: JSON.stringify(ctx.item !== undefined ? { rendered, item: ctx.item } : rendered), 192 + error: null, 193 + }; 194 + } catch (err) { 195 + return { 196 + message: null, 197 + payload: null, 198 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 199 + }; 200 + } 201 + } 202 + 203 + export const patchRecordDefinition: ActionDefinition< 204 + PatchRecordAction, 205 + PatchRecordInput, 206 + PdsPatchRecordAction 207 + > = { 208 + type: "patch-record", 209 + pdsType: "run.airglow.automation#patchRecordAction", 210 + displayLabel: "Update Record", 211 + recordProducing: true, 212 + needsFullScope: true, 213 + validate, 214 + toPds, 215 + execute: executePatchRecord, 216 + dryRunDescribe, 217 + };
+137
lib/actions/record.ts
··· 1 + import { type RecordAction } from "../db/schema.js"; 2 + import { createArbitraryRecord } from "../automations/pds.js"; 3 + import { renderTemplate, validateTemplate, type FetchContext } from "./template.js"; 4 + import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 + import type { MatchedEvent } from "../jetstream/consumer.js"; 6 + import { isValidNsid } from "../lexicons/resolver.js"; 7 + import type { ActionDefinition } from "./registry.js"; 8 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 9 + 10 + async function execute( 11 + match: MatchedEvent, 12 + action: RecordAction, 13 + fetchContext?: FetchContext, 14 + item?: unknown, 15 + ): Promise<ActionResult> { 16 + const { automation, event } = match; 17 + 18 + let record: Record<string, unknown>; 19 + try { 20 + record = await renderTemplate(action.recordTemplate, event, fetchContext, automation, item); 21 + } catch (err) { 22 + return { 23 + statusCode: 0, 24 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 25 + }; 26 + } 27 + 28 + try { 29 + const created = await createArbitraryRecord(automation.did, action.targetCollection, record); 30 + return { statusCode: 200, uri: created.uri, cid: created.cid }; 31 + } catch (err) { 32 + return parsePdsError(err); 33 + } 34 + } 35 + 36 + /** Execute a record action for a matched event. */ 37 + export const executeAction = wrapWithDelivery( 38 + (match, i) => match.automation.actions[i] as RecordAction, 39 + execute, 40 + (action) => 41 + JSON.stringify({ 42 + targetCollection: action.targetCollection, 43 + recordTemplate: action.recordTemplate, 44 + }), 45 + ); 46 + 47 + type RecordInput = { 48 + type: "record"; 49 + targetCollection: string; 50 + recordTemplate: string; 51 + }; 52 + 53 + type PdsRecordAction = { 54 + $type: "run.airglow.automation#recordAction"; 55 + targetCollection: string; 56 + recordTemplate: string; 57 + forEach?: RecordAction["forEach"]; 58 + comment?: string; 59 + }; 60 + 61 + async function validate( 62 + input: RecordInput, 63 + ctx: ValidationContext, 64 + ): Promise<{ ok: true; local: RecordAction } | { ok: false; error: string; status?: number }> { 65 + if (!input.targetCollection) { 66 + return { ok: false, error: "targetCollection is required for record actions" }; 67 + } 68 + if (!isValidNsid(input.targetCollection)) { 69 + return { ok: false, error: "Invalid target collection NSID" }; 70 + } 71 + if (!input.recordTemplate) { 72 + return { ok: false, error: "recordTemplate is required for record actions" }; 73 + } 74 + const templateValidation = validateTemplate( 75 + input.recordTemplate, 76 + ctx.fetchNames, 77 + ctx.actionResultNames, 78 + ctx.hasItem, 79 + ); 80 + if (!templateValidation.valid) { 81 + return { ok: false, error: templateValidation.error }; 82 + } 83 + const local: RecordAction = { 84 + $type: "record", 85 + targetCollection: input.targetCollection, 86 + recordTemplate: input.recordTemplate, 87 + }; 88 + return { ok: true, local }; 89 + } 90 + 91 + function toPds(action: RecordAction): PdsRecordAction { 92 + return { 93 + $type: "run.airglow.automation#recordAction", 94 + targetCollection: action.targetCollection, 95 + recordTemplate: action.recordTemplate, 96 + ...(action.forEach ? { forEach: action.forEach } : {}), 97 + ...(action.comment ? { comment: action.comment } : {}), 98 + }; 99 + } 100 + 101 + async function dryRunDescribe( 102 + action: RecordAction, 103 + ctx: DryRunContext, 104 + ): Promise<DryRunDescription> { 105 + try { 106 + const rendered = await renderTemplate( 107 + action.recordTemplate, 108 + ctx.match.event, 109 + ctx.fetchContext, 110 + ctx.match.automation, 111 + ctx.item, 112 + ); 113 + return { 114 + message: `Would create record in ${action.targetCollection}${ctx.itemSuffix}`, 115 + payload: JSON.stringify(ctx.item !== undefined ? { rendered, item: ctx.item } : rendered), 116 + error: null, 117 + }; 118 + } catch (err) { 119 + return { 120 + message: null, 121 + payload: null, 122 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 123 + }; 124 + } 125 + } 126 + 127 + export const recordDefinition: ActionDefinition<RecordAction, RecordInput, PdsRecordAction> = { 128 + type: "record", 129 + pdsType: "run.airglow.automation#recordAction", 130 + displayLabel: "Create Record", 131 + recordProducing: true, 132 + needsFullScope: true, 133 + validate, 134 + toPds, 135 + execute: executeAction, 136 + dryRunDescribe, 137 + };
+27
lib/actions/registry-contract.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { ACTION_REGISTRY, ACTION_TYPES } from "./registry.ts"; 3 + import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts"; 4 + 5 + // The form/island side keeps a `recordProducing` mirror on each 6 + // ActionUIDefinition because client code can't import the server registry 7 + // (it would drag drizzle/sqlite through transitive deps). This test pins the 8 + // two halves together so a future change to the server flag isn't silently 9 + // out of sync with the client form. 10 + describe("server vs UI registry contract", () => { 11 + it("recordProducing flags match across both registries", () => { 12 + for (const type of ACTION_TYPES) { 13 + expect(ACTION_UI_REGISTRY[type].recordProducing, type).toBe( 14 + ACTION_REGISTRY[type].recordProducing, 15 + ); 16 + } 17 + }); 18 + 19 + it("every action $type is present in both registries", () => { 20 + expect(Object.keys(ACTION_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort()); 21 + expect(Object.keys(ACTION_UI_REGISTRY).sort()).toEqual([...ACTION_TYPES].sort()); 22 + }); 23 + 24 + it("UI registry insertion order matches the server registry", () => { 25 + expect(Object.keys(ACTION_UI_REGISTRY)).toEqual([...ACTION_TYPES]); 26 + }); 27 + });
+123
lib/actions/registry.ts
··· 1 + import type { Action } from "../db/schema.js"; 2 + import type { PdsAction } from "../automations/pds.js"; 3 + import type { 4 + ActionHandler, 5 + DryRunContext, 6 + DryRunDescription, 7 + ValidationContext, 8 + } from "./types.js"; 9 + 10 + /** $type discriminator shared between the local Action union and the registry 11 + * key. Derived from `Action` so adding a new variant is a type error here. */ 12 + export type ActionType = Action["$type"]; 13 + 14 + /** Validation outcome for the API POST/PATCH routes. The single function call 15 + * collapses what's currently a per-action if-branch in both routes. PDS 16 + * serialization is a separate concern (see `toPds`) so PATCH can reproject a 17 + * stored action without re-running input validation. */ 18 + export type ValidateResult<TAction extends Action> = 19 + | { ok: true; local: TAction } 20 + | { ok: false; error: string; status?: number }; 21 + 22 + /** Server-side single source of truth per action type. The dispatchers in 23 + * handler.ts, the API routes, auth/client.ts, and pds-serialize.ts reduce 24 + * to `ACTION_REGISTRY[type].method(...)` lookups. 25 + * 26 + * Pure UI metadata (icons, picker tiles) lives on the client side in 27 + * `app/islands/action-editors`. Keeping it out of the server registry means 28 + * importing this file from a route or executor doesn't drag the icon 29 + * components or app-layer code into the server bundle, and conversely the 30 + * client form bundle doesn't transitively pull drizzle/sqlite. */ 31 + export type ActionDefinition< 32 + TAction extends Action = Action, 33 + TInput = unknown, 34 + TPdsAction extends PdsAction = PdsAction, 35 + > = { 36 + type: TAction["$type"]; 37 + pdsType: TPdsAction["$type"]; 38 + 39 + /** Short noun-phrase label used in the dashboard automation list and 40 + * delivery-log filters (e.g. "Bluesky Post", "Webhook"). */ 41 + displayLabel: string; 42 + 43 + /** Replaces `RECORD_PRODUCING_TYPES` and the `actionResultNames.push` calls 44 + * in API routes. Mirrored on the client side via `ActionUIDefinition` 45 + * to keep the form's `isRecordProducingAction` check off the server registry. */ 46 + recordProducing: boolean; 47 + 48 + /** Replaces the per-$type checks in `actionsNeedFullScope`. */ 49 + needsFullScope: boolean; 50 + 51 + /** Validate a raw API input and produce the local action shape. Async to 52 + * accommodate webhooks (callback verification). */ 53 + validate(input: TInput, ctx: ValidationContext): Promise<ValidateResult<TAction>>; 54 + 55 + /** Pure local→PDS projection. Called by the API write paths and by 56 + * `pds-serialize.toPdsAction` for stored-action re-serialization. */ 57 + toPds(action: TAction): TPdsAction; 58 + 59 + /** Runtime executor wired into `handlerFor()` today. */ 60 + execute: ActionHandler; 61 + 62 + /** Build the dry-run delivery_logs row content for this action type. */ 63 + dryRunDescribe(action: TAction, ctx: DryRunContext): Promise<DryRunDescription>; 64 + 65 + /** Optional override for how the GET /api/automations route serializes a 66 + * stored action — webhooks use this to strip the secret. */ 67 + serializeForApi?(action: TAction): unknown; 68 + 69 + /** Optional secrets to surface in the POST /api/automations response so the 70 + * user can copy them once at creation time. Webhook returns its 71 + * freshly-generated `secret`; everyone else omits this. */ 72 + getCreatedSecrets?(action: TAction): Record<string, string>; 73 + 74 + /** Optional public-profile projection. Returns a shape stripped of any 75 + * instance-local secrets so it can be safely rendered on `/u/<handle>` 76 + * pages. Webhook is the only action type that diverges from its owner 77 + * shape; everyone else's owner shape is already safe to expose, so this 78 + * stays undefined and `sanitizeActions` falls through to the action as 79 + * stored. */ 80 + toPublic?(action: TAction): unknown; 81 + }; 82 + 83 + import { sembleSaveDefinition } from "./semble-save.js"; 84 + import { marginBookmarkDefinition } from "./margin-bookmark.js"; 85 + import { followDefinition } from "./follow.js"; 86 + import { bskyPostDefinition } from "./bsky-post.js"; 87 + import { patchRecordDefinition } from "./patch-record.js"; 88 + import { recordDefinition } from "./record.js"; 89 + import { webhookDefinition } from "./webhook.js"; 90 + 91 + /** Map of $type → definition. Server-side only — importing this file from a 92 + * client island will pull drizzle/sqlite through transitive deps. The client 93 + * form uses `ACTION_UI_REGISTRY` instead (purely UI metadata + JSX). 94 + * 95 + * The `Record<ActionType, ActionDefinition<any, any, any>>` index type widens 96 + * the per-action type parameters because TypeScript can't represent a 97 + * variadic mapping where each entry has its own `TAction`/`TInput`/`TPdsAction` 98 + * triple. Each declaration site (e.g. `webhookDefinition`) keeps full 99 + * precision, so the unsafety is contained to the index lookup itself. */ 100 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above. 101 + export const ACTION_REGISTRY: Record<ActionType, ActionDefinition<any, any, any>> = { 102 + webhook: webhookDefinition, 103 + "bsky-post": bskyPostDefinition, 104 + follow: followDefinition, 105 + "margin-bookmark": marginBookmarkDefinition, 106 + "semble-save": sembleSaveDefinition, 107 + record: recordDefinition, 108 + "patch-record": patchRecordDefinition, 109 + }; 110 + 111 + /** Canonical action ordering used everywhere a list of action types matters 112 + * (catalogue tiles, label tables, lexicon refs, drift tests). Derived from 113 + * the registry's insertion order so the two can't drift. */ 114 + export const ACTION_TYPES: readonly ActionType[] = Object.keys( 115 + ACTION_REGISTRY, 116 + ) as readonly ActionType[]; 117 + 118 + /** Server-side `isRecordProducingAction`. The form/island side has its own 119 + * client-safe copy in `action-editors/registry.ts` driven by the UI 120 + * definitions; both stay in sync via a vitest contract test. */ 121 + export function isRecordProducingAction(type: string): boolean { 122 + return ACTION_REGISTRY[type as ActionType]?.recordProducing ?? false; 123 + }
+124 -1
lib/actions/semble-save.ts
··· 1 1 import { type SembleSaveAction } from "../db/schema.js"; 2 2 import { createArbitraryRecord } from "../automations/pds.js"; 3 - import { renderTextTemplate, type FetchContext } from "./template.js"; 3 + import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./template.js"; 4 4 import { parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; 5 5 import type { MatchedEvent } from "../jetstream/consumer.js"; 6 6 import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js"; 7 + import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js"; 8 + import type { ActionDefinition } from "./registry.js"; 9 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 7 10 8 11 const TARGET_COLLECTION = "network.cosmik.card"; 12 + 13 + // Allow either a literal http(s):// prefix or a leading {{...}} placeholder 14 + // (so the entire URL can come from event/action data). A literal non-http 15 + // scheme like `javascript:` is rejected here so it can't be persisted to the 16 + // user's PDS; the runtime SSRF guard remains the real security boundary. 17 + const URL_OK_RE = /^(https?:\/\/|\{\{)/i; 9 18 10 19 async function buildRecord( 11 20 match: MatchedEvent, ··· 85 94 execute, 86 95 (action) => JSON.stringify({ url: action.url }), 87 96 ); 97 + 98 + type SembleSaveInput = { 99 + type: "semble-save"; 100 + url: string; 101 + }; 102 + 103 + type PdsSembleSaveAction = { 104 + $type: "run.airglow.automation#sembleSaveAction"; 105 + url: string; 106 + forEach?: SembleSaveAction["forEach"]; 107 + comment?: string; 108 + }; 109 + 110 + async function validate( 111 + input: SembleSaveInput, 112 + ctx: ValidationContext, 113 + ): Promise<{ ok: true; local: SembleSaveAction } | { ok: false; error: string; status?: number }> { 114 + if (!input.url || typeof input.url !== "string" || !input.url.trim()) { 115 + return { ok: false, error: "url is required for semble-save actions" }; 116 + } 117 + if (input.url.length > SEMBLE_SAVE_LIMITS.url) { 118 + return { ok: false, error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less` }; 119 + } 120 + if (!URL_OK_RE.test(input.url)) { 121 + return { 122 + ok: false, 123 + error: "url must start with http://, https://, or a {{placeholder}}", 124 + }; 125 + } 126 + const urlValidation = validateTextTemplate( 127 + input.url, 128 + ctx.fetchNames, 129 + ctx.actionResultNames, 130 + ctx.hasItem, 131 + ); 132 + if (!urlValidation.valid) { 133 + return { ok: false, error: `url: ${urlValidation.error}` }; 134 + } 135 + 136 + // The route adds forEach/comment after this returns — they're orthogonal 137 + // to the action-specific fields and validated centrally. 138 + const local: SembleSaveAction = { $type: "semble-save", url: input.url }; 139 + return { ok: true, local }; 140 + } 141 + 142 + function toPds(action: SembleSaveAction): PdsSembleSaveAction { 143 + return { 144 + $type: "run.airglow.automation#sembleSaveAction", 145 + url: action.url, 146 + ...(action.forEach ? { forEach: action.forEach } : {}), 147 + ...(action.comment ? { comment: action.comment } : {}), 148 + }; 149 + } 150 + 151 + function redactUserinfo(url: string): string { 152 + try { 153 + const u = new URL(url); 154 + if (u.username || u.password) { 155 + u.username = ""; 156 + u.password = ""; 157 + return u.toString(); 158 + } 159 + return url; 160 + } catch { 161 + return url; 162 + } 163 + } 164 + 165 + async function dryRunDescribe( 166 + action: SembleSaveAction, 167 + ctx: DryRunContext, 168 + ): Promise<DryRunDescription> { 169 + try { 170 + const url = ( 171 + await renderTextTemplate( 172 + action.url, 173 + ctx.match.event, 174 + ctx.fetchContext, 175 + ctx.match.automation, 176 + ctx.item, 177 + ) 178 + ).trim(); 179 + // Redact credentials in userinfo before persisting to delivery_logs: 180 + // event-derived URLs may legitimately carry tokens we shouldn't store. 181 + const safeUrl = redactUserinfo(url); 182 + return { 183 + message: `Would save ${safeUrl || "(empty)"} to Semble${ctx.itemSuffix}`, 184 + payload: JSON.stringify({ url: safeUrl, item: ctx.item }), 185 + error: null, 186 + }; 187 + } catch (err) { 188 + return { 189 + message: null, 190 + payload: null, 191 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 192 + }; 193 + } 194 + } 195 + 196 + export const sembleSaveDefinition: ActionDefinition< 197 + SembleSaveAction, 198 + SembleSaveInput, 199 + PdsSembleSaveAction 200 + > = { 201 + type: "semble-save", 202 + pdsType: "run.airglow.automation#sembleSaveAction", 203 + displayLabel: "Save on Semble", 204 + recordProducing: true, 205 + needsFullScope: true, 206 + validate, 207 + toPds, 208 + execute: executeSembleSave, 209 + dryRunDescribe, 210 + };
+44
lib/actions/types.ts
··· 1 + import type { MatchedEvent } from "../jetstream/consumer.js"; 2 + import type { Action } from "../db/schema.js"; 3 + import type { ActionResult } from "./delivery.js"; 4 + import type { FetchContext } from "./template.js"; 5 + 6 + /** Runtime executor signature: handler.ts dispatches to one of these per action. */ 7 + export type ActionHandler = ( 8 + match: MatchedEvent, 9 + actionIndex: number, 10 + fetchContext?: FetchContext, 11 + item?: unknown, 12 + ) => Promise<ActionResult>; 13 + 14 + /** Context passed to per-action validators by the API POST/PATCH routes. 15 + * Names of declared fetches and prior record-producing actions, plus whether 16 + * this action runs inside a forEach (so validators can allow `{{item.*}}`), 17 + * the automation's lexicon NSID (used by webhook for callback verification), 18 + * and the actions previously stored on this automation (PATCH only — empty on 19 + * POST). The latter lets webhook preserve an existing secret when its 20 + * callbackUrl is unchanged across the update. */ 21 + export type ValidationContext = { 22 + fetchNames: string[]; 23 + actionResultNames: string[]; 24 + hasItem: boolean; 25 + lexicon: string; 26 + existingActions: Action[]; 27 + }; 28 + 29 + /** Outcome of `dryRunDescribe`: the three columns the handler writes into a 30 + * delivery_logs row for a dry-run fire. Any field may be null. */ 31 + export type DryRunDescription = { 32 + message: string | null; 33 + payload: string | null; 34 + error: string | null; 35 + }; 36 + 37 + /** Context passed to per-action `dryRunDescribe`: everything `logDryRun` 38 + * currently has in scope plus the per-iteration `item` for forEach. */ 39 + export type DryRunContext = { 40 + match: MatchedEvent; 41 + fetchContext: FetchContext; 42 + item: unknown; 43 + itemSuffix: string; 44 + };
-40
lib/actions/validation.test.ts
··· 3 3 validateWantedDids, 4 4 validateFetchConditionInputs, 5 5 validateFetchSearchStep, 6 - validateFollowInput, 7 6 type FetchConditionInput, 8 7 type FetchSearchInput, 9 8 } from "./validation.js"; ··· 270 269 expect(res.valid).toBe(false); 271 270 }); 272 271 }); 273 - 274 - describe("validateFollowInput", () => { 275 - it("accepts a literal DID subject for each target", () => { 276 - for (const target of ["bluesky", "tangled", "sifa"] as const) { 277 - const res = validateFollowInput( 278 - { target, subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 279 - [], 280 - [], 281 - ); 282 - expect(res.valid).toBe(true); 283 - } 284 - }); 285 - 286 - it("accepts placeholders in subject (validated at render time)", () => { 287 - const res = validateFollowInput({ target: "bluesky", subject: "{{event.did}}" }, [], []); 288 - expect(res.valid).toBe(true); 289 - }); 290 - 291 - it("rejects an unknown target", () => { 292 - const res = validateFollowInput( 293 - { target: "mastodon", subject: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, 294 - [], 295 - [], 296 - ); 297 - expect(res.valid).toBe(false); 298 - if (!res.valid) expect(res.error).toMatch(/target/); 299 - }); 300 - 301 - it("rejects empty subject", () => { 302 - const res = validateFollowInput({ target: "bluesky", subject: "" }, [], []); 303 - expect(res.valid).toBe(false); 304 - }); 305 - 306 - it("rejects unknown placeholders in subject", () => { 307 - const res = validateFollowInput({ target: "bluesky", subject: "{{mystery.field}}" }, [], []); 308 - expect(res.valid).toBe(false); 309 - if (!res.valid) expect(res.error).toMatch(/subject/); 310 - }); 311 - });
+3 -187
lib/actions/validation.ts
··· 1 1 import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js"; 2 - import { 3 - AUTOMATION_LIMITS, 4 - MARGIN_BOOKMARK_LIMITS, 5 - SEMBLE_SAVE_LIMITS, 6 - } from "../automations/limits.js"; 2 + import { AUTOMATION_LIMITS } from "../automations/limits.js"; 7 3 import { nsidRequiresWantedDids } from "../lexicons/match.js"; 8 4 import { isValidNsid } from "../lexicons/resolver.js"; 9 - import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js"; 5 + import { PLACEHOLDER_RE } from "./template.js"; 10 6 import type { Condition, FetchStepSearch, ForEachConfig } from "../db/schema.js"; 11 - import { 12 - FOLLOW_TARGETS, 13 - VALID_FOLLOW_TARGETS, 14 - type FollowTarget, 15 - } from "../automations/follow-targets.js"; 7 + import { type FollowTarget } from "../automations/follow-targets.js"; 16 8 17 9 export type ForEachInput = { 18 10 path: string; ··· 422 414 ...(step.comment ? { comment: step.comment } : {}), 423 415 }, 424 416 }; 425 - } 426 - 427 - type FollowInput = { 428 - target: string; 429 - subject: string; 430 - }; 431 - 432 - const FOLLOW_SUBJECT_MAX = 512; 433 - 434 - /** Validate a follow action input. The subject supports `{{placeholders}}` 435 - * which are resolved at execution time, so we validate it as a text template, 436 - * not as a literal DID. */ 437 - export function validateFollowInput( 438 - input: FollowInput, 439 - fetchNames: string[], 440 - actionNames: string[], 441 - hasItem?: boolean, 442 - ): { valid: true } | { valid: false; error: string } { 443 - if (!input.target || typeof input.target !== "string") { 444 - return { valid: false, error: "target is required for follow actions" }; 445 - } 446 - if (!VALID_FOLLOW_TARGETS.has(input.target)) { 447 - return { 448 - valid: false, 449 - error: `Invalid follow target "${input.target}". Must be one of: ${Object.keys(FOLLOW_TARGETS).join(", ")}`, 450 - }; 451 - } 452 - if (!input.subject || typeof input.subject !== "string" || !input.subject.trim()) { 453 - return { valid: false, error: "subject is required for follow actions" }; 454 - } 455 - if (input.subject.length > FOLLOW_SUBJECT_MAX) { 456 - return { 457 - valid: false, 458 - error: `subject must be ${FOLLOW_SUBJECT_MAX} characters or less`, 459 - }; 460 - } 461 - const templateCheck = validateTextTemplate(input.subject, fetchNames, actionNames, hasItem); 462 - if (!templateCheck.valid) { 463 - return { valid: false, error: `subject: ${templateCheck.error}` }; 464 - } 465 - return { valid: true }; 466 - } 467 - 468 - type MarginBookmarkInput = { 469 - targetSource: string; 470 - bodyValue?: string; 471 - tags?: string[]; 472 - }; 473 - 474 - // Allow either a literal http(s):// prefix or a leading {{...}} placeholder. 475 - // Mirrors the semble-save form check; the runtime guard inside the executor 476 - // remains the real boundary. 477 - const MARGIN_BOOKMARK_URL_OK_RE = /^(https?:\/\/|\{\{)/i; 478 - 479 - /** Validate a margin-bookmark action input. Returns the trimmed, filtered tags on success. */ 480 - export function validateMarginBookmarkInput( 481 - input: MarginBookmarkInput, 482 - fetchNames: string[], 483 - actionNames: string[], 484 - hasItem?: boolean, 485 - ): { valid: true; tags: string[] } | { valid: false; error: string } { 486 - if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 487 - return { valid: false, error: "targetSource is required for margin-bookmark actions" }; 488 - } 489 - if (input.targetSource.length > MARGIN_BOOKMARK_LIMITS.targetSource) { 490 - return { 491 - valid: false, 492 - error: `targetSource must be ${MARGIN_BOOKMARK_LIMITS.targetSource} characters or less`, 493 - }; 494 - } 495 - if (!MARGIN_BOOKMARK_URL_OK_RE.test(input.targetSource)) { 496 - return { 497 - valid: false, 498 - error: "targetSource must start with http://, https://, or a {{placeholder}}", 499 - }; 500 - } 501 - const sourceValidation = validateTextTemplate( 502 - input.targetSource, 503 - fetchNames, 504 - actionNames, 505 - hasItem, 506 - ); 507 - if (!sourceValidation.valid) { 508 - return { valid: false, error: `targetSource: ${sourceValidation.error}` }; 509 - } 510 - 511 - if (input.bodyValue !== undefined) { 512 - if (typeof input.bodyValue !== "string") { 513 - return { valid: false, error: "bodyValue must be a string" }; 514 - } 515 - if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) { 516 - return { 517 - valid: false, 518 - error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`, 519 - }; 520 - } 521 - if (input.bodyValue.trim()) { 522 - const bodyValidation = validateTextTemplate( 523 - input.bodyValue, 524 - fetchNames, 525 - actionNames, 526 - hasItem, 527 - ); 528 - if (!bodyValidation.valid) { 529 - return { valid: false, error: `bodyValue: ${bodyValidation.error}` }; 530 - } 531 - } 532 - } 533 - 534 - const tags: string[] = []; 535 - if (input.tags !== undefined) { 536 - if (!Array.isArray(input.tags)) { 537 - return { valid: false, error: "tags must be an array of strings" }; 538 - } 539 - if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) { 540 - return { valid: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` }; 541 - } 542 - for (const tag of input.tags) { 543 - if (typeof tag !== "string") { 544 - return { valid: false, error: "Each tag must be a string" }; 545 - } 546 - const trimmed = tag.trim(); 547 - if (!trimmed) continue; 548 - if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) { 549 - return { 550 - valid: false, 551 - error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`, 552 - }; 553 - } 554 - const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames, hasItem); 555 - if (!tagValidation.valid) { 556 - return { valid: false, error: `tag: ${tagValidation.error}` }; 557 - } 558 - tags.push(trimmed); 559 - } 560 - } 561 - 562 - return { valid: true, tags }; 563 - } 564 - 565 - type SembleSaveInput = { 566 - url: string; 567 - }; 568 - 569 - // Allow either a literal http(s):// prefix or a leading {{...}} placeholder 570 - // (so the entire URL can come from event/action data). A literal non-http 571 - // scheme like `javascript:` is rejected here so it can't be persisted to the 572 - // user's PDS; the runtime SSRF guard remains the real security boundary. 573 - const SEMBLE_SAVE_URL_OK_RE = /^(https?:\/\/|\{\{)/i; 574 - 575 - export function validateSembleSaveInput( 576 - input: SembleSaveInput, 577 - fetchNames: string[], 578 - actionNames: string[], 579 - hasItem?: boolean, 580 - ): { valid: true } | { valid: false; error: string } { 581 - if (!input.url || typeof input.url !== "string" || !input.url.trim()) { 582 - return { valid: false, error: "url is required for semble-save actions" }; 583 - } 584 - if (input.url.length > SEMBLE_SAVE_LIMITS.url) { 585 - return { 586 - valid: false, 587 - error: `url must be ${SEMBLE_SAVE_LIMITS.url} characters or less`, 588 - }; 589 - } 590 - if (!SEMBLE_SAVE_URL_OK_RE.test(input.url)) { 591 - return { 592 - valid: false, 593 - error: "url must start with http://, https://, or a {{placeholder}}", 594 - }; 595 - } 596 - const urlValidation = validateTextTemplate(input.url, fetchNames, actionNames, hasItem); 597 - if (!urlValidation.valid) { 598 - return { valid: false, error: `url: ${urlValidation.error}` }; 599 - } 600 - return { valid: true }; 601 417 } 602 418 603 419 const FOR_EACH_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\])+$/;
+161
lib/actions/webhook.ts
··· 1 + import { nanoid } from "nanoid"; 2 + import { type WebhookAction, type ForEachConfig } from "../db/schema.js"; 3 + import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 + import { assertPublicUrl, UrlGuardError } from "../url-guard.js"; 5 + import { verifyCallback } from "../automations/verify.js"; 6 + import { validateWebhookHeaders } from "./validation.js"; 7 + import type { ActionDefinition } from "./registry.js"; 8 + import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; 9 + 10 + type WebhookInput = { 11 + type: "webhook"; 12 + callbackUrl: string; 13 + headers?: Record<string, string>; 14 + }; 15 + 16 + type PdsWebhookAction = { 17 + $type: "run.airglow.automation#webhookAction"; 18 + callbackUrl: string; 19 + forEach?: WebhookAction["forEach"]; 20 + comment?: string; 21 + }; 22 + 23 + /** Public-profile projection of a webhook action: the secret and full 24 + * callback URL are stripped, only the host domain leaks out, and the header 25 + * values are replaced with just the names. Webhook is the only action type 26 + * with a non-identity public projection (every other action's owner shape 27 + * is already safe to expose), which is why the registry's `toPublic` is 28 + * optional. */ 29 + export type PublicWebhookAction = { 30 + $type: "webhook"; 31 + callbackDomain: string; 32 + headerNames?: string[]; 33 + verified?: boolean; 34 + comment?: string; 35 + forEach?: ForEachConfig; 36 + }; 37 + 38 + async function validate( 39 + input: WebhookInput, 40 + ctx: ValidationContext, 41 + ): Promise<{ ok: true; local: WebhookAction } | { ok: false; error: string; status?: number }> { 42 + if (!input.callbackUrl) { 43 + return { ok: false, error: "callbackUrl is required for webhook actions" }; 44 + } 45 + try { 46 + await assertPublicUrl(input.callbackUrl); 47 + } catch (err) { 48 + const message = err instanceof UrlGuardError ? err.message : "Invalid callback URL"; 49 + return { ok: false, error: message }; 50 + } 51 + 52 + if (input.headers && Object.keys(input.headers).length > 0) { 53 + const headersValidation = validateWebhookHeaders(input.headers); 54 + if (!headersValidation.valid) { 55 + return { ok: false, error: headersValidation.error }; 56 + } 57 + } 58 + 59 + const verification = await verifyCallback(input.callbackUrl, ctx.lexicon); 60 + 61 + // Preserve the existing secret when the same callbackUrl is already wired up 62 + // on this automation. Without this, a no-op PATCH (e.g. toggling unrelated 63 + // fields) would re-key the webhook and force the user to update the secret 64 + // on their receiver. Matching by URL — the same callback identity — keeps 65 + // editing safe regardless of action reordering. 66 + const existing = ctx.existingActions.find( 67 + (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl, 68 + ); 69 + const secret = existing?.secret ?? nanoid(32); 70 + const headers = 71 + input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 72 + 73 + const local: WebhookAction = { 74 + $type: "webhook", 75 + callbackUrl: input.callbackUrl, 76 + secret, 77 + ...(headers ? { headers } : {}), 78 + verified: verification.ok, 79 + }; 80 + return { ok: true, local }; 81 + } 82 + 83 + function toPds(action: WebhookAction): PdsWebhookAction { 84 + // PDS shape deliberately omits secret, headers, and verified — those are 85 + // server-side state, not part of the user-visible automation record. 86 + return { 87 + $type: "run.airglow.automation#webhookAction", 88 + callbackUrl: action.callbackUrl, 89 + ...(action.forEach ? { forEach: action.forEach } : {}), 90 + ...(action.comment ? { comment: action.comment } : {}), 91 + }; 92 + } 93 + 94 + async function dryRunDescribe( 95 + action: WebhookAction, 96 + ctx: DryRunContext, 97 + ): Promise<DryRunDescription> { 98 + const headerCount = action.headers ? Object.keys(action.headers).length : 0; 99 + const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : ""; 100 + return { 101 + message: `Would POST to ${action.callbackUrl}${headerNote}${ctx.itemSuffix}`, 102 + payload: JSON.stringify(buildPayload(ctx.match, ctx.fetchContext, ctx.item)), 103 + error: null, 104 + }; 105 + } 106 + 107 + /** Strip the server-side secret from the GET response. The verified flag and 108 + * headers stay; secret never leaves the server. */ 109 + function serializeForApi(action: WebhookAction): unknown { 110 + return { 111 + $type: action.$type, 112 + callbackUrl: action.callbackUrl, 113 + ...(action.headers ? { headers: action.headers } : {}), 114 + verified: action.verified ?? false, 115 + comment: action.comment, 116 + ...(action.forEach ? { forEach: action.forEach } : {}), 117 + }; 118 + } 119 + 120 + /** Surface the freshly-generated secret in the POST /api/automations response 121 + * so the user can copy it once. Subsequent GETs strip it (see 122 + * `serializeForApi`). */ 123 + function getCreatedSecrets(action: WebhookAction): Record<string, string> { 124 + return { secret: action.secret }; 125 + } 126 + 127 + /** Project a webhook action into its public-profile shape. Drops the secret 128 + * and full callback URL, keeps only the callback host. Used by 129 + * `sanitizeActions` on the public profile route. */ 130 + function toPublic(action: WebhookAction): PublicWebhookAction { 131 + let callbackDomain: string; 132 + try { 133 + callbackDomain = new URL(action.callbackUrl).hostname; 134 + } catch { 135 + callbackDomain = "unknown"; 136 + } 137 + const headerNames = action.headers ? Object.keys(action.headers) : undefined; 138 + return { 139 + $type: "webhook", 140 + callbackDomain, 141 + ...(headerNames && headerNames.length > 0 ? { headerNames } : {}), 142 + verified: action.verified, 143 + comment: action.comment, 144 + ...(action.forEach ? { forEach: action.forEach } : {}), 145 + }; 146 + } 147 + 148 + export const webhookDefinition: ActionDefinition<WebhookAction, WebhookInput, PdsWebhookAction> = { 149 + type: "webhook", 150 + pdsType: "run.airglow.automation#webhookAction", 151 + displayLabel: "Webhook", 152 + recordProducing: false, 153 + needsFullScope: false, 154 + validate, 155 + toPds, 156 + execute: dispatch, 157 + dryRunDescribe, 158 + serializeForApi, 159 + getCreatedSecrets, 160 + toPublic, 161 + };
+5 -9
lib/auth/client.ts
··· 12 12 import { config } from "../config.js"; 13 13 import { db } from "../db/index.js"; 14 14 import { automations } from "../db/schema.js"; 15 + import { ACTION_REGISTRY } from "../actions/registry.js"; 15 16 import { 16 17 resolveDidToHandle as pdsResolveDidToHandle, 17 18 resolveHandle as pdsResolveHandle, ··· 31 32 32 33 /** Returns true if any action writes to a collection beyond run.airglow.automation. */ 33 34 export function actionsNeedFullScope(actions: ActionLike[]): boolean { 34 - return actions.some( 35 - (a) => 36 - a.$type === "bsky-post" || 37 - a.$type === "record" || 38 - a.$type === "patch-record" || 39 - a.$type === "margin-bookmark" || 40 - a.$type === "follow" || 41 - a.$type === "semble-save", 42 - ); 35 + return actions.some((a) => { 36 + const def = ACTION_REGISTRY[a.$type as keyof typeof ACTION_REGISTRY]; 37 + return def?.needsFullScope ?? false; 38 + }); 43 39 } 44 40 45 41 /** Check if the granted scope covers all collections needed by actions. */
+66 -90
lib/automations/action-catalogue.ts
··· 1 - import { 2 - Bookmark, 3 - BookmarkPlus, 4 - FilePlus2, 5 - Heart, 6 - MessageSquare, 7 - Pencil, 8 - Trash2, 9 - UserPlus, 10 - Webhook, 11 - } from "../../app/icons.js"; 1 + import { Heart, Trash2, UserPlus } from "../../app/icons.js"; 2 + import { ACTION_UI_REGISTRY } from "../../app/islands/action-editors/registry.ts"; 3 + import type { ActionIcon } from "../../app/islands/action-editors/types.ts"; 12 4 import { FOLLOW_TARGETS, type ColorKey, type FollowTarget } from "./follow-targets.js"; 13 5 14 6 export type AddableActionId = ··· 20 12 | "semble-save" 21 13 | `follow-${FollowTarget}`; 22 14 15 + type Tile = { 16 + id: string; 17 + label: string; 18 + description: string; 19 + icon: ActionIcon; 20 + available: boolean; 21 + colorKey?: ColorKey; 22 + faviconDomain?: string; 23 + }; 24 + 23 25 type ActionInfo = { 24 26 label: string; 25 - icon: (typeof ACTION_CATALOGUE)[number]["actions"][number]["icon"]; 26 - catId: (typeof ACTION_CATALOGUE)[number]["id"]; 27 + icon: Tile["icon"]; 28 + catId: "webhook" | "bluesky" | "apps" | "pds"; 27 29 /** Optional override for the icon-tile color key (data-cat). Lets follow-sifa 28 30 * use a sifa-blue and follow-tangled a grey while still grouping under the 29 31 * Bluesky/Apps categories. */ 30 32 colorKey?: ColorKey; 31 - /** Domain used to render the per-app favicon next to the icon (margin-bookmark, follow). */ 33 + /** Domain used to render the per-app favicon next to the icon. */ 32 34 faviconDomain?: string; 33 35 }; 34 36 37 + /** Tile entry derived from a registered action's `catalogue` metadata. */ 38 + function tileFromRegistry(type: keyof typeof ACTION_UI_REGISTRY): Tile { 39 + const def = ACTION_UI_REGISTRY[type]!; 40 + const cat = def.catalogue!; 41 + return { 42 + id: type, 43 + label: cat.label, 44 + description: cat.description, 45 + icon: cat.icon, 46 + available: cat.available, 47 + ...(cat.colorKey ? { colorKey: cat.colorKey } : {}), 48 + ...(cat.faviconDomain ? { faviconDomain: cat.faviconDomain } : {}), 49 + }; 50 + } 51 + 35 52 /** Build the catalogue tile for a given follow target. Insertion order in 36 53 * `FOLLOW_TARGETS` controls the order tiles appear within their category. */ 37 - function followTileFor(target: FollowTarget) { 54 + function followTileFor(target: FollowTarget): Tile { 38 55 const t = FOLLOW_TARGETS[target]; 39 56 return { 40 - id: `follow-${target}` as const, 57 + id: `follow-${target}`, 41 58 label: t.label, 42 59 description: t.description, 43 60 icon: UserPlus, ··· 47 64 }; 48 65 } 49 66 50 - const followTilesByCat: Record<"bluesky" | "apps", ReturnType<typeof followTileFor>[]> = { 51 - bluesky: [], 52 - apps: [], 53 - }; 67 + const followTilesByCat: Record<"bluesky" | "apps", Tile[]> = { bluesky: [], apps: [] }; 54 68 for (const target of Object.keys(FOLLOW_TARGETS) as FollowTarget[]) { 55 69 followTilesByCat[FOLLOW_TARGETS[target].catId].push(followTileFor(target)); 56 70 } 57 71 72 + /** "Coming soon" placeholders that have no executor yet. Kept here so the 73 + * picker can preview the planned shape of each category. */ 74 + const COMING_SOON: Record<"bluesky" | "pds", Tile[]> = { 75 + bluesky: [ 76 + { 77 + id: "bsky-like", 78 + label: "Like a post", 79 + description: "Like a Bluesky post on your behalf", 80 + icon: Heart, 81 + available: false, 82 + }, 83 + ], 84 + pds: [ 85 + { 86 + id: "delete-record", 87 + label: "Delete a record", 88 + description: "Remove a record from a collection", 89 + icon: Trash2, 90 + available: false, 91 + }, 92 + ], 93 + }; 94 + 58 95 export const ACTION_CATALOGUE = [ 59 96 { 60 - id: "webhook", 97 + id: "webhook" as const, 61 98 label: "Webhooks", 62 99 description: "Send event data to your own server", 63 - actions: [ 64 - { 65 - id: "webhook", 66 - label: "Send a webhook", 67 - description: "POST event data to an external URL", 68 - icon: Webhook, 69 - available: true, 70 - }, 71 - ], 100 + actions: [tileFromRegistry("webhook")], 72 101 }, 73 102 { 74 - id: "bluesky", 103 + id: "bluesky" as const, 75 104 label: "Bluesky", 76 105 description: "High-level Bluesky interactions", 77 - actions: [ 78 - { 79 - id: "bsky-post", 80 - label: "Post to Bluesky", 81 - description: "Publish a post to your Bluesky account", 82 - icon: MessageSquare, 83 - available: true, 84 - }, 85 - ...followTilesByCat.bluesky, 86 - { 87 - id: "bsky-like", 88 - label: "Like a post", 89 - description: "Like a Bluesky post on your behalf", 90 - icon: Heart, 91 - available: false, 92 - }, 93 - ], 106 + actions: [tileFromRegistry("bsky-post"), ...followTilesByCat.bluesky, ...COMING_SOON.bluesky], 94 107 }, 95 108 { 96 - id: "apps", 109 + id: "apps" as const, 97 110 label: "Apps", 98 111 description: "Quick actions for specific AT Protocol apps", 99 112 actions: [ 100 - { 101 - id: "margin-bookmark", 102 - label: "Bookmark on Margin", 103 - description: "Create a bookmark note in Margin.at", 104 - icon: Bookmark, 105 - available: true, 106 - faviconDomain: "margin.at", 107 - }, 108 - { 109 - id: "semble-save", 110 - label: "Save on Semble", 111 - description: "Save a URL as a card on Semble", 112 - icon: BookmarkPlus, 113 - available: true, 114 - colorKey: "cosmik" as ColorKey, 115 - faviconDomain: "semble.so", 116 - }, 113 + tileFromRegistry("margin-bookmark"), 114 + tileFromRegistry("semble-save"), 117 115 ...followTilesByCat.apps, 118 116 ], 119 117 }, 120 118 { 121 - id: "pds", 119 + id: "pds" as const, 122 120 label: "PDS records", 123 121 description: "Low-level lexicon record operations", 124 - actions: [ 125 - { 126 - id: "record", 127 - label: "Create a record", 128 - description: "Create a new record in any collection", 129 - icon: FilePlus2, 130 - available: true, 131 - }, 132 - { 133 - id: "patch-record", 134 - label: "Update a record", 135 - description: "Modify fields of an existing record", 136 - icon: Pencil, 137 - available: true, 138 - }, 139 - { 140 - id: "delete-record", 141 - label: "Delete a record", 142 - description: "Remove a record from a collection", 143 - icon: Trash2, 144 - available: false, 145 - }, 146 - ], 122 + actions: [tileFromRegistry("record"), tileFromRegistry("patch-record"), ...COMING_SOON.pds], 147 123 }, 148 124 ]; 149 125
+7 -8
lib/automations/labels.ts
··· 16 16 "not-exists": "is missing", 17 17 }; 18 18 19 - export const actionTypeLabels: Record<string, string> = { 20 - webhook: "Webhook", 21 - record: "Create Record", 22 - "bsky-post": "Bluesky Post", 23 - "patch-record": "Update Record", 24 - "margin-bookmark": "Bookmark on Margin", 25 - follow: "Follow", 26 - }; 19 + import { ACTION_REGISTRY } from "../actions/registry.js"; 20 + 21 + /** Short noun-phrase label shown next to existing automations and in delivery 22 + * logs. Pulled from each action's registry definition. */ 23 + export const actionTypeLabels: Record<string, string> = Object.fromEntries( 24 + Object.entries(ACTION_REGISTRY).map(([t, def]) => [t, def.displayLabel]), 25 + ); 27 26 28 27 export const operationLabels: Record<string, string> = { 29 28 create: "Record created",
+4 -75
lib/automations/pds-serialize.ts
··· 1 - import type { Action, ForEachConfig } from "../db/schema.js"; 2 - import type { PdsAction, PdsForEachConfig } from "./pds.js"; 3 - 4 - function toPdsForEach(fe: ForEachConfig | undefined): PdsForEachConfig | undefined { 5 - if (!fe) return undefined; 6 - return { 7 - path: fe.path, 8 - ...(fe.conditions && fe.conditions.length > 0 ? { conditions: fe.conditions } : {}), 9 - }; 10 - } 1 + import type { Action } from "../db/schema.js"; 2 + import type { PdsAction } from "./pds.js"; 3 + import { ACTION_REGISTRY } from "../actions/registry.js"; 11 4 12 5 /** Serialize a stored Action into its PDS-record shape. Split from pds.ts so 13 6 * tests that mock the OAuth-backed PDS client don't need to re-stub this. */ 14 7 export function toPdsAction(a: Action): PdsAction { 15 - const forEach = toPdsForEach(a.forEach); 16 - const forEachField = forEach ? { forEach } : {}; 17 - 18 - if (a.$type === "webhook") { 19 - return { 20 - $type: "run.airglow.automation#webhookAction", 21 - callbackUrl: a.callbackUrl, 22 - ...forEachField, 23 - ...(a.comment ? { comment: a.comment } : {}), 24 - }; 25 - } 26 - if (a.$type === "bsky-post") { 27 - return { 28 - $type: "run.airglow.automation#bskyPostAction", 29 - textTemplate: a.textTemplate, 30 - ...(a.langs && a.langs.length > 0 ? { langs: a.langs } : {}), 31 - ...(a.labels && a.labels.length > 0 ? { labels: a.labels } : {}), 32 - ...forEachField, 33 - ...(a.comment ? { comment: a.comment } : {}), 34 - }; 35 - } 36 - if (a.$type === "patch-record") { 37 - return { 38 - $type: "run.airglow.automation#patchRecordAction", 39 - targetCollection: a.targetCollection, 40 - baseRecordUri: a.baseRecordUri, 41 - recordTemplate: a.recordTemplate, 42 - ...forEachField, 43 - ...(a.comment ? { comment: a.comment } : {}), 44 - }; 45 - } 46 - if (a.$type === "margin-bookmark") { 47 - return { 48 - $type: "run.airglow.automation#marginBookmarkAction", 49 - targetSource: a.targetSource, 50 - ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 51 - ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 52 - ...forEachField, 53 - ...(a.comment ? { comment: a.comment } : {}), 54 - }; 55 - } 56 - if (a.$type === "follow") { 57 - return { 58 - $type: "run.airglow.automation#followAction", 59 - target: a.target, 60 - subject: a.subject, 61 - ...forEachField, 62 - ...(a.comment ? { comment: a.comment } : {}), 63 - }; 64 - } 65 - if (a.$type === "semble-save") { 66 - return { 67 - $type: "run.airglow.automation#sembleSaveAction", 68 - url: a.url, 69 - ...forEachField, 70 - ...(a.comment ? { comment: a.comment } : {}), 71 - }; 72 - } 73 - return { 74 - $type: "run.airglow.automation#recordAction", 75 - targetCollection: a.targetCollection, 76 - recordTemplate: a.recordTemplate, 77 - ...forEachField, 78 - ...(a.comment ? { comment: a.comment } : {}), 79 - }; 8 + return ACTION_REGISTRY[a.$type].toPds(a) as PdsAction; 80 9 }
+8 -31
lib/automations/sanitize.ts
··· 1 - import type { Action, ForEachConfig, WebhookAction } from "../db/schema.ts"; 2 - 3 - type PublicWebhookAction = { 4 - $type: "webhook"; 5 - callbackDomain: string; 6 - headerNames?: string[]; 7 - verified?: boolean; 8 - comment?: string; 9 - forEach?: ForEachConfig; 10 - }; 1 + import type { Action, WebhookAction } from "../db/schema.ts"; 2 + import { ACTION_REGISTRY } from "../actions/registry.js"; 3 + import type { PublicWebhookAction } from "../actions/webhook.js"; 11 4 12 5 export type PublicAction = PublicWebhookAction | Exclude<Action, WebhookAction>; 13 6 14 - /** Strip instance-local secrets and truncate webhook URLs to domain-only. */ 7 + /** Strip instance-local secrets from each action via the registry's 8 + * `toPublic`. Today only webhook supplies one (it diverges to a domain-only 9 + * shape); every other action's owner shape is already safe to expose, so it 10 + * passes through unchanged. */ 15 11 export function sanitizeActions(actions: Action[]): PublicAction[] { 16 - return actions.map((a) => { 17 - if (a.$type === "webhook") { 18 - let callbackDomain: string; 19 - try { 20 - callbackDomain = new URL(a.callbackUrl).hostname; 21 - } catch { 22 - callbackDomain = "unknown"; 23 - } 24 - const headerNames = a.headers ? Object.keys(a.headers) : undefined; 25 - return { 26 - $type: "webhook" as const, 27 - callbackDomain, 28 - ...(headerNames && headerNames.length > 0 ? { headerNames } : {}), 29 - verified: a.verified, 30 - comment: a.comment, 31 - ...(a.forEach ? { forEach: a.forEach } : {}), 32 - }; 33 - } 34 - return a; 35 - }); 12 + return actions.map((a) => (ACTION_REGISTRY[a.$type].toPublic?.(a) ?? a) as PublicAction); 36 13 }
-13
lib/db/schema.ts
··· 89 89 | FollowAction 90 90 | SembleSaveAction; 91 91 92 - /** Action types that produce a record result (uri, cid, rkey) for chaining. */ 93 - const RECORD_PRODUCING_TYPES = new Set([ 94 - "record", 95 - "bsky-post", 96 - "patch-record", 97 - "margin-bookmark", 98 - "follow", 99 - "semble-save", 100 - ]); 101 - export function isRecordProducingAction(type: string): boolean { 102 - return RECORD_PRODUCING_TYPES.has(type); 103 - } 104 - 105 92 export type Condition = { 106 93 field: string; 107 94 operator: string;
+50 -11
lib/jetstream/handler.test.ts
··· 15 15 buildPayload: vi.fn(() => ({ event: "dry-run" })), 16 16 })); 17 17 18 - vi.mock("@/actions/executor.js", () => ({ 19 - executeAction: vi.fn(), 20 - })); 18 + vi.mock("@/actions/record.js", () => { 19 + const executeAction = vi.fn(); 20 + const recordDefinition = { 21 + type: "record", 22 + pdsType: "run.airglow.automation#recordAction", 23 + recordProducing: true, 24 + needsFullScope: true, 25 + execute: executeAction, 26 + dryRunDescribe: vi.fn(), 27 + validate: vi.fn(), 28 + toPds: vi.fn(), 29 + catalogue: { label: "", description: "", category: "pds", icon: null, available: true }, 30 + }; 31 + return { executeAction, recordDefinition }; 32 + }); 21 33 22 - vi.mock("@/actions/bsky-post.js", () => ({ 23 - executeBskyPost: vi.fn(), 24 - })); 34 + vi.mock("@/actions/bsky-post.js", () => { 35 + const executeBskyPost = vi.fn(); 36 + // Stub definition so the action registry can resolve `bsky-post` to this 37 + // mock without dragging the real executor and its dependencies into the 38 + // test. Only fields the handler reads are populated. 39 + const bskyPostDefinition = { 40 + type: "bsky-post", 41 + pdsType: "run.airglow.automation#bskyPostAction", 42 + recordProducing: true, 43 + needsFullScope: true, 44 + execute: executeBskyPost, 45 + dryRunDescribe: vi.fn(), 46 + validate: vi.fn(), 47 + toPds: vi.fn(), 48 + catalogue: { label: "", description: "", category: "bluesky", icon: null, available: true }, 49 + }; 50 + return { executeBskyPost, bskyPostDefinition }; 51 + }); 25 52 26 - vi.mock("@/actions/patch-record.js", () => ({ 27 - executePatchRecord: vi.fn(), 28 - })); 53 + vi.mock("@/actions/patch-record.js", () => { 54 + const executePatchRecord = vi.fn(); 55 + const patchRecordDefinition = { 56 + type: "patch-record", 57 + pdsType: "run.airglow.automation#patchRecordAction", 58 + recordProducing: true, 59 + needsFullScope: true, 60 + execute: executePatchRecord, 61 + dryRunDescribe: vi.fn(), 62 + validate: vi.fn(), 63 + toPds: vi.fn(), 64 + catalogue: { label: "", description: "", category: "pds", icon: null, available: true }, 65 + }; 66 + return { executePatchRecord, patchRecordDefinition }; 67 + }); 29 68 30 69 vi.mock("@/actions/fetcher.js", () => ({ 31 70 resolveFetches: vi.fn(), ··· 42 81 43 82 import { handleMatchedEvent } from "./handler.js"; 44 83 import { dispatch } from "../webhooks/dispatcher.js"; 45 - import { executeAction } from "../actions/executor.js"; 84 + import { executeAction } from "../actions/record.js"; 46 85 import { executeBskyPost } from "../actions/bsky-post.js"; 47 86 import { executePatchRecord } from "../actions/patch-record.js"; 48 87 import { resolveFetches } from "../actions/fetcher.js"; ··· 56 95 makeFetchStep, 57 96 makeFollowAction, 58 97 } from "../test/fixtures.js"; 59 - import type { ActionResult } from "../actions/executor.js"; 98 + import type { ActionResult } from "../actions/delivery.js"; 60 99 61 100 const ok: ActionResult = { statusCode: 200 }; 62 101 const okWithUri: ActionResult = {
+19 -145
lib/jetstream/handler.ts
··· 1 1 import { db } from "../db/index.js"; 2 - import { deliveryLogs, type Action, isRecordProducingAction } from "../db/schema.js"; 3 - import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 4 - import { executeAction, type ActionResult } from "../actions/executor.js"; 5 - import { executeBskyPost } from "../actions/bsky-post.js"; 6 - import { executePatchRecord } from "../actions/patch-record.js"; 7 - import { executeMarginBookmark } from "../actions/margin-bookmark.js"; 8 - import { executeFollow } from "../actions/follow.js"; 9 - import { executeSembleSave } from "../actions/semble-save.js"; 10 - import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; 2 + import { deliveryLogs, type Action } from "../db/schema.js"; 3 + import { type ActionResult } from "../actions/delivery.js"; 4 + import { ACTION_REGISTRY, isRecordProducingAction } from "../actions/registry.js"; 11 5 import { resolveFetches } from "../actions/fetcher.js"; 12 6 import { isSuccess } from "../actions/delivery.js"; 13 - import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 7 + import { type FetchContext } from "../actions/template.js"; 14 8 import { parseAtUri } from "../pds/resolver.js"; 15 9 import { collectItems, matchItemConditions } from "./matcher.js"; 16 10 import { notifyAutomationChange, type MatchedEvent } from "./consumer.js"; ··· 24 18 ) => Promise<ActionResult>; 25 19 26 20 function handlerFor(action: Action): ActionHandler { 27 - switch (action.$type) { 28 - case "bsky-post": 29 - return executeBskyPost; 30 - case "record": 31 - return executeAction; 32 - case "patch-record": 33 - return executePatchRecord; 34 - case "margin-bookmark": 35 - return executeMarginBookmark; 36 - case "follow": 37 - return executeFollow; 38 - case "semble-save": 39 - return executeSembleSave; 40 - default: 41 - return dispatch; 42 - } 21 + return ACTION_REGISTRY[action.$type].execute; 43 22 } 44 23 45 24 /** ··· 258 237 failedFetches: string[], 259 238 options?: { item?: unknown; forEachEmpty?: boolean; totalItems?: number }, 260 239 ) { 261 - let message: string | null = null; 262 - let error: string | null = null; 263 - let payload: string | null = null; 264 - 265 240 // forEach with no matching item: emit a single explanatory row instead of 266 241 // staying silent — otherwise the user sees nothing and can't tell whether 267 242 // the path or the conditions filtered everything out. 268 243 if (options?.forEachEmpty) { 269 244 const total = options.totalItems ?? 0; 270 - message = 245 + const message = 271 246 total === 0 272 247 ? `Would skip: forEach path "${action.forEach?.path}" resolved to no items` 273 248 : `Would skip: ${total} item(s) found at "${action.forEach?.path}" but none matched the per-item conditions`; ··· 289 264 const item = options?.item; 290 265 const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : ""; 291 266 267 + let message: string | null = null; 268 + let payload: string | null = null; 269 + let error: string | null = null; 270 + 292 271 if (failedFetches.length > 0) { 293 272 error = `Fetch failed: ${failedFetches.join(", ")}`; 294 - } else if (action.$type === "webhook") { 295 - const headerCount = action.headers ? Object.keys(action.headers).length : 0; 296 - const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : ""; 297 - message = `Would POST to ${action.callbackUrl}${headerNote}${itemSuffix}`; 298 - payload = JSON.stringify(buildPayload(match, fetchContext, item)); 299 - } else if (action.$type === "bsky-post") { 300 - try { 301 - const text = await renderTextTemplate( 302 - action.textTemplate, 303 - match.event, 304 - fetchContext, 305 - match.automation, 306 - item, 307 - ); 308 - message = `Would post to Bluesky${itemSuffix}`; 309 - payload = JSON.stringify({ text, langs: action.langs, labels: action.labels, item }); 310 - } catch (err) { 311 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 312 - } 313 - } else if (action.$type === "follow") { 314 - try { 315 - const subject = ( 316 - await renderTextTemplate(action.subject, match.event, fetchContext, match.automation, item) 317 - ).trim(); 318 - const target = FOLLOW_TARGETS[action.target]; 319 - const collection = target.collection; 320 - const appName = target.appName; 321 - // The built-in safety checks live inside executeFollow and aren't run in 322 - // dry-run (keeps the preview cheap). Advertise their presence in the 323 - // message so authors know the real run will skip cleanly on both edges. 324 - message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)${itemSuffix}`; 325 - payload = JSON.stringify({ collection, subject, item }); 326 - } catch (err) { 327 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 328 - } 329 - } else if (action.$type === "margin-bookmark") { 330 - try { 331 - const source = await renderTextTemplate( 332 - action.targetSource, 333 - match.event, 334 - fetchContext, 335 - match.automation, 336 - item, 337 - ); 338 - const body = action.bodyValue 339 - ? await renderTextTemplate( 340 - action.bodyValue, 341 - match.event, 342 - fetchContext, 343 - match.automation, 344 - item, 345 - ) 346 - : undefined; 347 - const tags: string[] = []; 348 - if (action.tags) { 349 - for (const tag of action.tags) { 350 - const rendered = await renderTextTemplate( 351 - tag, 352 - match.event, 353 - fetchContext, 354 - match.automation, 355 - item, 356 - ); 357 - if (rendered.trim()) tags.push(rendered.trim()); 358 - } 359 - } 360 - message = `Would bookmark ${source}${itemSuffix}`; 361 - payload = JSON.stringify({ source, body, tags, item }); 362 - } catch (err) { 363 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 364 - } 365 - } else if (action.$type === "semble-save") { 366 - try { 367 - const url = ( 368 - await renderTextTemplate(action.url, match.event, fetchContext, match.automation, item) 369 - ).trim(); 370 - // Redact credentials in userinfo before persisting to delivery_logs: 371 - // event-derived URLs may legitimately carry tokens we shouldn't store. 372 - const safeUrl = redactUserinfo(url); 373 - message = `Would save ${safeUrl || "(empty)"} to Semble${itemSuffix}`; 374 - payload = JSON.stringify({ url: safeUrl, item }); 375 - } catch (err) { 376 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 377 - } 378 273 } else { 379 - try { 380 - const rendered = await renderTemplate( 381 - action.recordTemplate, 382 - match.event, 383 - fetchContext, 384 - match.automation, 385 - item, 386 - ); 387 - message = 388 - action.$type === "patch-record" 389 - ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${itemSuffix}` 390 - : `Would create record in ${action.targetCollection}${itemSuffix}`; 391 - payload = JSON.stringify(item !== undefined ? { rendered, item } : rendered); 392 - } catch (err) { 393 - error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 394 - } 274 + const desc = await ACTION_REGISTRY[action.$type].dryRunDescribe(action, { 275 + match, 276 + fetchContext, 277 + item, 278 + itemSuffix, 279 + }); 280 + message = desc.message; 281 + payload = desc.payload; 282 + error = desc.error; 395 283 } 396 284 397 285 await db.insert(deliveryLogs).values({ ··· 410 298 411 299 function truncateForLog(s: string, max = 120): string { 412 300 return s.length <= max ? s : s.slice(0, max) + "..."; 413 - } 414 - 415 - function redactUserinfo(url: string): string { 416 - try { 417 - const u = new URL(url); 418 - if (u.username || u.password) { 419 - u.username = ""; 420 - u.password = ""; 421 - return u.toString(); 422 - } 423 - return url; 424 - } catch { 425 - return url; 426 - } 427 301 } 428 302 429 303 /**
+1 -1
lib/webhooks/dispatcher.ts
··· 4 4 import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "../actions/delivery.js"; 5 5 import { resolve as resolveSecrets, SECRET_REF_RE } from "../secrets/store.js"; 6 6 import { config } from "../config.js"; 7 - import type { ActionResult } from "../actions/executor.js"; 7 + import type { ActionResult } from "../actions/delivery.js"; 8 8 import type { MatchedEvent } from "../jetstream/consumer.js"; 9 9 import type { FetchContext } from "../actions/template.js"; 10 10