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.

feat: new margin.at bookmark quick action

Hugo 2057a6b0 192ebf93

+919 -13
+2
app/icons.ts
··· 28 28 } 29 29 30 30 import ActivityData from "lucide/icons/activity"; 31 + import BookmarkData from "lucide/icons/bookmark"; 31 32 import CopyData from "lucide/icons/copy"; 32 33 import ChevronDownData from "lucide/icons/chevron-down"; 33 34 import ChevronRightData from "lucide/icons/chevron-right"; ··· 57 58 58 59 export const Activity = icon(ActivityData); 59 60 export const ArrowLeft = icon(ArrowLeftData); 61 + export const Bookmark = icon(BookmarkData); 60 62 export const Copy = icon(CopyData); 61 63 export const ChevronDown = icon(ChevronDownData); 62 64 export const ChevronRight = icon(ChevronRightData);
+1
app/islands/AutomationForm.css.ts
··· 357 357 '&[data-cat="webhook"]': { backgroundColor: vars.color.accent }, 358 358 '&[data-cat="bluesky"]': { backgroundColor: vars.color.bsky }, 359 359 '&[data-cat="pds"]': { backgroundColor: vars.color.pds }, 360 + '&[data-cat="apps"]': { backgroundColor: vars.color.apps }, 360 361 }, 361 362 }); 362 363
+135 -2
app/islands/AutomationForm.tsx
··· 49 49 recordTemplate: string; 50 50 comment: string; 51 51 }; 52 - type ActionDraft = WebhookDraft | RecordDraft | BskyPostDraft | PatchRecordDraft; 52 + type BookmarkDraft = { 53 + type: "bookmark"; 54 + targetSource: string; 55 + targetTitle: string; 56 + bodyValue: string; 57 + tags: string[]; 58 + comment: string; 59 + }; 60 + type ActionDraft = WebhookDraft | RecordDraft | BskyPostDraft | PatchRecordDraft | BookmarkDraft; 53 61 54 62 export type AutomationInitial = { 55 63 rkey?: string; ··· 518 526 } 519 527 520 528 // --------------------------------------------------------------------------- 529 + // Bookmark (margin.at) action editor 530 + // --------------------------------------------------------------------------- 531 + 532 + function BookmarkActionEditor({ 533 + action, 534 + onChange, 535 + }: { 536 + action: BookmarkDraft; 537 + onChange: (a: BookmarkDraft) => void; 538 + }) { 539 + const [tagsText, setTagsText] = useState(action.tags.join(", ")); 540 + 541 + return ( 542 + <> 543 + <div class={s.fieldGroup}> 544 + <label class={s.label}>Page URL</label> 545 + <input 546 + class={s.input} 547 + type="text" 548 + placeholder="https://example.com or {{event.commit.record.subject.uri}}" 549 + value={action.targetSource} 550 + onInput={(e: Event) => 551 + onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 552 + } 553 + required 554 + /> 555 + <span class={s.hint}>URL of the page to bookmark. Supports {"{{placeholders}}"}.</span> 556 + </div> 557 + 558 + <div class={s.fieldGroup}> 559 + <label class={s.label}> 560 + Title <span class={s.hint}>(optional)</span> 561 + </label> 562 + <input 563 + class={s.input} 564 + type="text" 565 + placeholder="e.g. Post by {{event.did}}" 566 + value={action.targetTitle} 567 + onInput={(e: Event) => 568 + onChange({ ...action, targetTitle: (e.target as HTMLInputElement).value }) 569 + } 570 + /> 571 + <span class={s.hint}>Page title. Supports {"{{placeholders}}"}.</span> 572 + </div> 573 + 574 + <div class={s.fieldGroup}> 575 + <label class={s.label}> 576 + Description <span class={s.hint}>(optional)</span> 577 + </label> 578 + <textarea 579 + class={s.textarea} 580 + placeholder="A short note about this bookmark" 581 + value={action.bodyValue} 582 + onInput={(e: Event) => 583 + onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 584 + } 585 + rows={3} 586 + /> 587 + <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 588 + </div> 589 + 590 + <div class={s.fieldGroup}> 591 + <label class={s.label}> 592 + Tags <span class={s.hint}>(optional, max 10)</span> 593 + </label> 594 + <input 595 + class={s.input} 596 + type="text" 597 + placeholder="e.g. reading, research, bluesky" 598 + value={tagsText} 599 + onInput={(e: Event) => setTagsText((e.target as HTMLInputElement).value)} 600 + onBlur={() => { 601 + const tags = tagsText 602 + .split(",") 603 + .map((t) => t.trim()) 604 + .filter(Boolean) 605 + .slice(0, 10); 606 + setTagsText(tags.join(", ")); 607 + onChange({ ...action, tags }); 608 + }} 609 + /> 610 + <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 611 + </div> 612 + </> 613 + ); 614 + } 615 + 616 + // --------------------------------------------------------------------------- 521 617 // Copy-to-clipboard placeholder 522 618 // --------------------------------------------------------------------------- 523 619 ··· 578 674 comment: a.comment ?? "", 579 675 }; 580 676 } 677 + if (a.$type === "bookmark") { 678 + return { 679 + type: "bookmark", 680 + targetSource: a.targetSource, 681 + targetTitle: a.targetTitle ?? "", 682 + bodyValue: a.bodyValue ?? "", 683 + tags: a.tags ?? [], 684 + comment: a.comment ?? "", 685 + }; 686 + } 581 687 return { 582 688 type: "record", 583 689 targetCollection: a.targetCollection, ··· 762 868 setFetches((prev) => prev.map((f, i) => (i === index ? { ...f, [key]: val } : f))); 763 869 }, []); 764 870 765 - const addAction = useCallback((type: "webhook" | "record" | "bsky-post" | "patch-record") => { 871 + const addAction = useCallback((type: AddableActionId) => { 766 872 if (type === "webhook") { 767 873 setActions((prev) => [ 768 874 ...prev, ··· 781 887 targetCollection: "", 782 888 baseRecordUri: "", 783 889 recordTemplate: "", 890 + comment: "", 891 + }, 892 + ]); 893 + } else if (type === "bookmark") { 894 + setActions((prev) => [ 895 + ...prev, 896 + { 897 + type: "bookmark", 898 + targetSource: "", 899 + targetTitle: "", 900 + bodyValue: "", 901 + tags: [], 784 902 comment: "", 785 903 }, 786 904 ]); ··· 850 968 targetCollection: a.targetCollection, 851 969 baseRecordUri: a.baseRecordUri, 852 970 recordTemplate: a.recordTemplate, 971 + ...comment, 972 + }; 973 + } 974 + if (a.type === "bookmark") { 975 + const targetTitle = a.targetTitle.trim(); 976 + const bodyValue = a.bodyValue.trim(); 977 + const tags = a.tags.filter(Boolean); 978 + return { 979 + type: "bookmark", 980 + targetSource: a.targetSource, 981 + ...(targetTitle ? { targetTitle } : {}), 982 + ...(bodyValue ? { bodyValue } : {}), 983 + ...(tags.length > 0 ? { tags } : {}), 853 984 ...comment, 854 985 }; 855 986 } ··· 1356 1487 onChange={(a) => updateAction(i, a)} 1357 1488 placeholders={allPlaceholders} 1358 1489 /> 1490 + ) : action.type === "bookmark" ? ( 1491 + <BookmarkActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 1359 1492 ) : ( 1360 1493 <RecordActionEditor 1361 1494 action={action}
+39
app/routes/api/automations/[rkey].ts
··· 10 10 type RecordAction, 11 11 type BskyPostAction, 12 12 type PatchRecordAction, 13 + type BookmarkAction, 13 14 type FetchStep, 14 15 } from "@/db/schema.js"; 15 16 import { isValidNsid } from "@/lexicons/resolver.js"; ··· 35 36 VALID_BSKY_LABELS, 36 37 BCP47_RE, 37 38 validateWebhookHeaders, 39 + validateBookmarkInput, 38 40 } from "@/actions/validation.js"; 39 41 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 40 42 import { scopeCoversActions, computeRequiredScope } from "@/auth/client.js"; ··· 384 386 ...(input.comment ? { comment: input.comment } : {}), 385 387 }); 386 388 actionResultNames.push(`action${actionIndex + 1}`); 389 + } else if (input.type === "bookmark") { 390 + const bookmarkValidation = validateBookmarkInput(input, fetchNames, actionResultNames); 391 + if (!bookmarkValidation.valid) { 392 + return c.json({ error: bookmarkValidation.error }, 400); 393 + } 394 + 395 + const targetTitle = input.targetTitle?.trim() || undefined; 396 + const bodyValue = input.bodyValue?.trim() || undefined; 397 + const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 398 + 399 + newLocalActions.push({ 400 + $type: "bookmark", 401 + targetSource: input.targetSource, 402 + ...(targetTitle ? { targetTitle } : {}), 403 + ...(bodyValue ? { bodyValue } : {}), 404 + ...(tags ? { tags } : {}), 405 + ...(input.comment ? { comment: input.comment } : {}), 406 + } satisfies BookmarkAction); 407 + newPdsActions.push({ 408 + $type: "run.airglow.automation#bookmarkAction", 409 + targetSource: input.targetSource, 410 + ...(targetTitle ? { targetTitle } : {}), 411 + ...(bodyValue ? { bodyValue } : {}), 412 + ...(tags ? { tags } : {}), 413 + ...(input.comment ? { comment: input.comment } : {}), 414 + }); 415 + actionResultNames.push(`action${actionIndex + 1}`); 387 416 } else { 388 417 return c.json({ error: "Invalid action type" }, 400); 389 418 } ··· 438 467 targetCollection: a.targetCollection, 439 468 baseRecordUri: a.baseRecordUri, 440 469 recordTemplate: a.recordTemplate, 470 + ...(a.comment ? { comment: a.comment } : {}), 471 + }; 472 + } 473 + if (a.$type === "bookmark") { 474 + return { 475 + $type: "run.airglow.automation#bookmarkAction", 476 + targetSource: a.targetSource, 477 + ...(a.targetTitle ? { targetTitle: a.targetTitle } : {}), 478 + ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 479 + ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 441 480 ...(a.comment ? { comment: a.comment } : {}), 442 481 }; 443 482 }
+29
app/routes/api/automations/index.ts
··· 9 9 type RecordAction, 10 10 type BskyPostAction, 11 11 type PatchRecordAction, 12 + type BookmarkAction, 12 13 type FetchStep, 13 14 } from "@/db/schema.js"; 14 15 import { config } from "@/config.js"; ··· 34 35 VALID_BSKY_LABELS, 35 36 BCP47_RE, 36 37 validateWebhookHeaders, 38 + validateBookmarkInput, 37 39 } from "@/actions/validation.js"; 38 40 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 39 41 ··· 335 337 targetCollection: input.targetCollection, 336 338 baseRecordUri: input.baseRecordUri, 337 339 recordTemplate: input.recordTemplate, 340 + ...(input.comment ? { comment: input.comment } : {}), 341 + }); 342 + actionResultNames.push(`action${actionIndex + 1}`); 343 + } else if (input.type === "bookmark") { 344 + const bookmarkValidation = validateBookmarkInput(input, fetchNames, actionResultNames); 345 + if (!bookmarkValidation.valid) { 346 + return c.json({ error: bookmarkValidation.error }, 400); 347 + } 348 + 349 + const targetTitle = input.targetTitle?.trim() || undefined; 350 + const bodyValue = input.bodyValue?.trim() || undefined; 351 + const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 352 + 353 + localActions.push({ 354 + $type: "bookmark", 355 + targetSource: input.targetSource, 356 + ...(targetTitle ? { targetTitle } : {}), 357 + ...(bodyValue ? { bodyValue } : {}), 358 + ...(tags ? { tags } : {}), 359 + ...(input.comment ? { comment: input.comment } : {}), 360 + } satisfies BookmarkAction); 361 + pdsActions.push({ 362 + $type: "run.airglow.automation#bookmarkAction", 363 + targetSource: input.targetSource, 364 + ...(targetTitle ? { targetTitle } : {}), 365 + ...(bodyValue ? { bodyValue } : {}), 366 + ...(tags ? { tags } : {}), 338 367 ...(input.comment ? { comment: input.comment } : {}), 339 368 }); 340 369 actionResultNames.push(`action${actionIndex + 1}`);
+29
app/routes/dashboard/automations/[rkey].tsx
··· 251 251 <CodeBlock>{action.recordTemplate}</CodeBlock> 252 252 </dd> 253 253 </> 254 + ) : action.$type === "bookmark" ? ( 255 + <> 256 + <dt>Page URL</dt> 257 + <dd> 258 + <InlineCode>{action.targetSource}</InlineCode> 259 + </dd> 260 + {action.targetTitle && ( 261 + <> 262 + <dt>Title</dt> 263 + <dd> 264 + <InlineCode>{action.targetTitle}</InlineCode> 265 + </dd> 266 + </> 267 + )} 268 + {action.bodyValue && ( 269 + <> 270 + <dt>Description</dt> 271 + <dd> 272 + <CodeBlock>{action.bodyValue}</CodeBlock> 273 + </dd> 274 + </> 275 + )} 276 + {action.tags && action.tags.length > 0 && ( 277 + <> 278 + <dt>Tags</dt> 279 + <dd>{action.tags.join(", ")}</dd> 280 + </> 281 + )} 282 + </> 254 283 ) : ( 255 284 <> 256 285 <dt>Target Collection</dt>
+29
app/routes/u/[handle]/[rkey].tsx
··· 255 255 <CodeBlock>{action.recordTemplate}</CodeBlock> 256 256 </dd> 257 257 </> 258 + ) : action.$type === "bookmark" ? ( 259 + <> 260 + <dt>Page URL</dt> 261 + <dd> 262 + <InlineCode>{action.targetSource}</InlineCode> 263 + </dd> 264 + {action.targetTitle && ( 265 + <> 266 + <dt>Title</dt> 267 + <dd> 268 + <InlineCode>{action.targetTitle}</InlineCode> 269 + </dd> 270 + </> 271 + )} 272 + {action.bodyValue && ( 273 + <> 274 + <dt>Description</dt> 275 + <dd> 276 + <CodeBlock>{action.bodyValue}</CodeBlock> 277 + </dd> 278 + </> 279 + )} 280 + {action.tags && action.tags.length > 0 && ( 281 + <> 282 + <dt>Tags</dt> 283 + <dd>{action.tags.join(", ")}</dd> 284 + </> 285 + )} 286 + </> 258 287 ) : ( 259 288 <> 260 289 <dt>Target Collection</dt>
+4
app/styles/action-header.css.ts
··· 33 33 backgroundColor: vars.color.pdsSubtle, 34 34 color: vars.color.pds, 35 35 }, 36 + '&[data-cat="apps"]': { 37 + backgroundColor: vars.color.appsSubtle, 38 + color: vars.color.apps, 39 + }, 36 40 }, 37 41 }); 38 42
+4
app/styles/theme.css.ts
··· 32 32 bskySubtle: "color-bsky-subtle", 33 33 pds: "color-pds", 34 34 pdsSubtle: "color-pds-subtle", 35 + apps: "color-apps", 36 + appsSubtle: "color-apps-subtle", 35 37 code: "color-code", 36 38 }, 37 39 shadow: { ··· 96 98 [vars.color.bskySubtle]: darkColors.bskySubtle, 97 99 [vars.color.pds]: darkColors.pds, 98 100 [vars.color.pdsSubtle]: darkColors.pdsSubtle, 101 + [vars.color.apps]: darkColors.apps, 102 + [vars.color.appsSubtle]: darkColors.appsSubtle, 99 103 [vars.color.code]: darkColors.code, 100 104 [vars.shadow.highlight]: darkShadows.highlight, 101 105 [vars.shadow.sm]: darkShadows.sm,
+8 -4
app/styles/tokens/colors.ts
··· 28 28 error: "oklch(0.70 0.19 25)", 29 29 errorSubtle: "oklch(0.22 0.04 25)", 30 30 31 - bsky: "oklch(0.72 0.17 240)", 32 - bskySubtle: "oklch(0.22 0.05 240)", 31 + bsky: "oklch(0.72 0.17 245)", 32 + bskySubtle: "oklch(0.22 0.05 245)", 33 33 pds: "oklch(0.72 0.15 300)", 34 34 pdsSubtle: "oklch(0.22 0.04 300)", 35 + apps: "oklch(0.65 0.22 262)", 36 + appsSubtle: "oklch(0.22 0.05 262)", 35 37 36 38 code: "oklch(0.20 0 0)", 37 39 } as const; ··· 64 66 error: "oklch(0.55 0.22 25)", 65 67 errorSubtle: "oklch(0.96 0.04 25)", 66 68 67 - bsky: "oklch(0.58 0.18 240)", 68 - bskySubtle: "oklch(0.95 0.04 240)", 69 + bsky: "#1185FE", 70 + bskySubtle: "oklch(0.95 0.04 245)", 69 71 pds: "oklch(0.55 0.16 300)", 70 72 pdsSubtle: "oklch(0.96 0.03 300)", 73 + apps: "#2563EB", 74 + appsSubtle: "oklch(0.95 0.04 262)", 71 75 72 76 code: "oklch(0.95 0.005 90)", 73 77 } as const;
+43 -1
lexicons/run/airglow/automation.json
··· 42 42 "maxLength": 10, 43 43 "items": { 44 44 "type": "union", 45 - "refs": ["#webhookAction", "#recordAction", "#bskyPostAction", "#patchRecordAction"] 45 + "refs": [ 46 + "#webhookAction", 47 + "#recordAction", 48 + "#bskyPostAction", 49 + "#patchRecordAction", 50 + "#bookmarkAction" 51 + ] 46 52 } 47 53 }, 48 54 "conditions": { ··· 196 202 "type": "string", 197 203 "description": "JSON template with {{placeholder}} expressions. Fields are shallow-merged on top of the fetched base record.", 198 204 "maxLength": 10240 205 + }, 206 + "comment": { 207 + "type": "string", 208 + "description": "Optional user note about this action.", 209 + "maxLength": 512 210 + } 211 + } 212 + }, 213 + "bookmarkAction": { 214 + "type": "object", 215 + "description": "Create a bookmark (at.margin.note record with motivation 'bookmarking') on the user's PDS when a matching event occurs.", 216 + "required": ["targetSource"], 217 + "properties": { 218 + "targetSource": { 219 + "type": "string", 220 + "description": "URL of the page being bookmarked. Supports {{placeholders}}.", 221 + "maxLength": 2048 222 + }, 223 + "targetTitle": { 224 + "type": "string", 225 + "description": "Optional page title at time of bookmarking. Supports {{placeholders}}.", 226 + "maxLength": 500 227 + }, 228 + "bodyValue": { 229 + "type": "string", 230 + "description": "Optional description/note for the bookmark. Supports {{placeholders}}.", 231 + "maxLength": 10000 232 + }, 233 + "tags": { 234 + "type": "array", 235 + "description": "Optional tags for categorization. Each item supports {{placeholders}}.", 236 + "maxLength": 10, 237 + "items": { 238 + "type": "string", 239 + "maxLength": 64 240 + } 199 241 }, 200 242 "comment": { 201 243 "type": "string",
+197
lib/actions/bookmark.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + vi.mock("@/db/index.js", async () => { 4 + const { createTestDb } = await import("../test/db.js"); 5 + return { db: createTestDb() }; 6 + }); 7 + 8 + vi.mock("@/automations/pds.js", () => ({ 9 + createArbitraryRecord: vi.fn(), 10 + })); 11 + 12 + vi.mock("../auth/client.js", () => ({ 13 + resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 14 + })); 15 + 16 + import { executeBookmark, computeSourceHash, normalizeUrlForHash } from "./bookmark.js"; 17 + import { createArbitraryRecord } from "../automations/pds.js"; 18 + import { db } from "../db/index.js"; 19 + import { automations, deliveryLogs } from "../db/schema.js"; 20 + import { makeMatch, makeBookmarkAction, makeAutomation } from "../test/fixtures.js"; 21 + 22 + const mockCreateRecord = vi.mocked(createArbitraryRecord); 23 + 24 + describe("normalizeUrlForHash", () => { 25 + it("lowercases protocol and host, preserves path/query/hash", () => { 26 + const out = normalizeUrlForHash("HTTPS://Example.COM/Path?q=1#X"); 27 + expect(out).toBe("https://example.com/Path?q=1#X"); 28 + }); 29 + 30 + it("trims whitespace", () => { 31 + expect(normalizeUrlForHash(" https://example.com/ ")).toBe("https://example.com/"); 32 + }); 33 + 34 + it("throws on invalid URL", () => { 35 + expect(() => normalizeUrlForHash("not a url")).toThrow(); 36 + }); 37 + }); 38 + 39 + describe("computeSourceHash", () => { 40 + it("returns 64 hex chars", () => { 41 + const hash = computeSourceHash("https://example.com/abc"); 42 + expect(hash).toMatch(/^[a-f0-9]{64}$/); 43 + }); 44 + 45 + it("returns the same hash for normalized-equivalent URLs", () => { 46 + expect(computeSourceHash("HTTPS://Example.com/a")).toBe( 47 + computeSourceHash("https://example.com/a"), 48 + ); 49 + }); 50 + }); 51 + 52 + describe("executeBookmark", () => { 53 + beforeEach(async () => { 54 + vi.useFakeTimers(); 55 + vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 56 + mockCreateRecord.mockReset(); 57 + 58 + await db.delete(deliveryLogs); 59 + await db.delete(automations); 60 + await db.insert(automations).values(makeAutomation()); 61 + }); 62 + 63 + afterEach(() => { 64 + vi.useRealTimers(); 65 + }); 66 + 67 + it("renders fields, computes sourceHash, and creates record on PDS", async () => { 68 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 69 + 70 + const action = makeBookmarkAction({ 71 + targetSource: "https://example.com/{{event.commit.rkey}}", 72 + targetTitle: "Post by {{event.did}}", 73 + bodyValue: "Saved from Airglow", 74 + tags: ["bluesky", "{{event.commit.collection}}"], 75 + }); 76 + const match = makeMatch({ automation: { actions: [action] } }); 77 + await executeBookmark(match, 0); 78 + 79 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 80 + const [did, collection, record] = mockCreateRecord.mock.calls[0]!; 81 + expect(did).toBe(match.automation.did); 82 + expect(collection).toBe("at.margin.note"); 83 + expect(record).toMatchObject({ 84 + motivation: "bookmarking", 85 + createdAt: "2024-06-15T12:00:00.000Z", 86 + target: { 87 + source: "https://example.com/3k2la7bx", 88 + title: "Post by did:plc:testuser123", 89 + }, 90 + body: { value: "Saved from Airglow", format: "text/plain" }, 91 + tags: ["bluesky", "app.bsky.feed.like"], 92 + }); 93 + const target = record.target as { sourceHash: string }; 94 + expect(target.sourceHash).toMatch(/^[a-f0-9]{64}$/); 95 + const generator = record.generator as { name: string; homepage: string; id: string }; 96 + expect(generator.name).toBe("Airglow | Test Automation"); 97 + expect(generator.homepage).toMatch(/^https?:\/\//); 98 + expect(generator.id).toContain("/u/"); 99 + expect(generator.id).toContain("/abc123"); 100 + 101 + const logs = await db.query.deliveryLogs.findMany(); 102 + expect(logs).toHaveLength(1); 103 + expect(logs[0]!.statusCode).toBe(200); 104 + }); 105 + 106 + it("omits optional fields when empty", async () => { 107 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 108 + 109 + const action = makeBookmarkAction({ targetSource: "https://example.com/x" }); 110 + const match = makeMatch({ automation: { actions: [action] } }); 111 + await executeBookmark(match, 0); 112 + 113 + const record = mockCreateRecord.mock.calls[0]![2]!; 114 + expect(record).not.toHaveProperty("body"); 115 + expect(record).not.toHaveProperty("tags"); 116 + expect((record.target as Record<string, unknown>).title).toBeUndefined(); 117 + }); 118 + 119 + it("drops tags that render to empty strings", async () => { 120 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 121 + 122 + const action = makeBookmarkAction({ 123 + targetSource: "https://example.com/x", 124 + tags: ["{{event.missing.field}}", "real-tag"], 125 + }); 126 + const match = makeMatch({ automation: { actions: [action] } }); 127 + await executeBookmark(match, 0); 128 + 129 + const record = mockCreateRecord.mock.calls[0]![2]!; 130 + expect(record.tags).toEqual(["real-tag"]); 131 + }); 132 + 133 + it("fails with template error when targetSource is empty after render", async () => { 134 + const action = makeBookmarkAction({ targetSource: "{{event.missing}}" }); 135 + const match = makeMatch({ automation: { actions: [action] } }); 136 + await executeBookmark(match, 0); 137 + 138 + expect(mockCreateRecord).not.toHaveBeenCalled(); 139 + const logs = await db.query.deliveryLogs.findMany(); 140 + expect(logs[0]!.statusCode).toBe(0); 141 + expect(logs[0]!.error).toContain("Template error"); 142 + }); 143 + 144 + it("fails with template error when URL cannot be parsed", async () => { 145 + const action = makeBookmarkAction({ targetSource: "not a valid url" }); 146 + const match = makeMatch({ automation: { actions: [action] } }); 147 + await executeBookmark(match, 0); 148 + 149 + expect(mockCreateRecord).not.toHaveBeenCalled(); 150 + const logs = await db.query.deliveryLogs.findMany(); 151 + expect(logs[0]!.statusCode).toBe(0); 152 + expect(logs[0]!.error).toContain("Template error"); 153 + }); 154 + 155 + it("extracts status code from PDS error message", async () => { 156 + mockCreateRecord.mockRejectedValueOnce( 157 + new Error("PDS com.atproto.repo.createRecord failed (400): bad request"), 158 + ); 159 + 160 + const action = makeBookmarkAction(); 161 + const match = makeMatch({ automation: { actions: [action] } }); 162 + await executeBookmark(match, 0); 163 + 164 + const logs = await db.query.deliveryLogs.findMany(); 165 + expect(logs).toHaveLength(1); 166 + expect(logs[0]!.statusCode).toBe(400); 167 + }); 168 + 169 + it("retries on 5xx PDS errors", async () => { 170 + mockCreateRecord 171 + .mockRejectedValueOnce(new Error("PDS failed (500): internal")) 172 + .mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 173 + 174 + const action = makeBookmarkAction(); 175 + const match = makeMatch({ automation: { actions: [action] } }); 176 + await executeBookmark(match, 0); 177 + 178 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 179 + 180 + await vi.advanceTimersByTimeAsync(5_000); 181 + expect(mockCreateRecord).toHaveBeenCalledTimes(2); 182 + 183 + const logs = await db.query.deliveryLogs.findMany(); 184 + expect(logs).toHaveLength(2); 185 + }); 186 + 187 + it("does not retry on 4xx PDS errors", async () => { 188 + mockCreateRecord.mockRejectedValueOnce(new Error("PDS failed (400): bad request")); 189 + 190 + const action = makeBookmarkAction(); 191 + const match = makeMatch({ automation: { actions: [action] } }); 192 + await executeBookmark(match, 0); 193 + 194 + await vi.advanceTimersByTimeAsync(60_000); 195 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 196 + }); 197 + });
+188
lib/actions/bookmark.ts
··· 1 + import { createHash } from "node:crypto"; 2 + import { type BookmarkAction } from "../db/schema.js"; 3 + import { createArbitraryRecord } from "../automations/pds.js"; 4 + import { renderTextTemplate, type FetchContext } from "./template.js"; 5 + import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 6 + import type { ActionResult } from "./executor.js"; 7 + import type { MatchedEvent } from "../jetstream/consumer.js"; 8 + import { config } from "../config.js"; 9 + 10 + const TARGET_COLLECTION = "at.margin.note"; 11 + 12 + /** Normalize a URL for hashing: lowercase protocol+host, preserve path/query/hash. */ 13 + export function normalizeUrlForHash(raw: string): string { 14 + const url = new URL(raw.trim()); 15 + url.protocol = url.protocol.toLowerCase(); 16 + url.hostname = url.hostname.toLowerCase(); 17 + return url.toString(); 18 + } 19 + 20 + export function computeSourceHash(rawUrl: string): string { 21 + const normalized = normalizeUrlForHash(rawUrl); 22 + return createHash("sha256").update(normalized).digest("hex"); 23 + } 24 + 25 + async function buildRecord( 26 + match: MatchedEvent, 27 + action: BookmarkAction, 28 + fetchContext?: FetchContext, 29 + ): Promise<Record<string, unknown>> { 30 + const { automation, event } = match; 31 + 32 + // Always-resolve template to populate automation.url; the rendered value is 33 + // needed for generator.id. 34 + const automationUrl = await renderTextTemplate( 35 + "{{automation.url}}", 36 + event, 37 + fetchContext, 38 + automation, 39 + ); 40 + 41 + const targetSource = await renderTextTemplate( 42 + action.targetSource, 43 + event, 44 + fetchContext, 45 + automation, 46 + ); 47 + if (!targetSource.trim()) { 48 + throw new Error("targetSource rendered to an empty string"); 49 + } 50 + 51 + const sourceHash = computeSourceHash(targetSource); 52 + 53 + const title = action.targetTitle 54 + ? (await renderTextTemplate(action.targetTitle, event, fetchContext, automation)).trim() 55 + : ""; 56 + const body = action.bodyValue 57 + ? (await renderTextTemplate(action.bodyValue, event, fetchContext, automation)).trim() 58 + : ""; 59 + 60 + const tags: string[] = []; 61 + if (action.tags) { 62 + for (const tag of action.tags) { 63 + const rendered = (await renderTextTemplate(tag, event, fetchContext, automation)).trim(); 64 + if (rendered) tags.push(rendered); 65 + } 66 + } 67 + 68 + const record: Record<string, unknown> = { 69 + motivation: "bookmarking", 70 + createdAt: new Date().toISOString(), 71 + target: { 72 + source: targetSource, 73 + sourceHash, 74 + ...(title ? { title } : {}), 75 + }, 76 + generator: { 77 + id: automationUrl, 78 + name: `Airglow | ${automation.name}`, 79 + homepage: config.publicUrl, 80 + }, 81 + }; 82 + 83 + if (body) { 84 + record.body = { value: body, format: "text/plain" }; 85 + } 86 + if (tags.length > 0) { 87 + record.tags = tags; 88 + } 89 + 90 + return record; 91 + } 92 + 93 + async function execute( 94 + match: MatchedEvent, 95 + action: BookmarkAction, 96 + fetchContext?: FetchContext, 97 + ): Promise<ActionResult> { 98 + const { automation } = match; 99 + 100 + let record: Record<string, unknown>; 101 + try { 102 + record = await buildRecord(match, action, fetchContext); 103 + } catch (err) { 104 + return { 105 + statusCode: 0, 106 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 107 + }; 108 + } 109 + 110 + try { 111 + const created = await createArbitraryRecord(automation.did, TARGET_COLLECTION, record); 112 + return { statusCode: 200, uri: created.uri, cid: created.cid }; 113 + } catch (err) { 114 + const message = err instanceof Error ? err.message : String(err); 115 + const statusMatch = message.match(/\((\d{3})\)/); 116 + const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 117 + return { statusCode, error: message }; 118 + } 119 + } 120 + 121 + function actionPayload(action: BookmarkAction): string { 122 + return JSON.stringify({ 123 + targetSource: action.targetSource, 124 + targetTitle: action.targetTitle, 125 + bodyValue: action.bodyValue, 126 + tags: action.tags, 127 + }); 128 + } 129 + 130 + function scheduleRetry( 131 + match: MatchedEvent, 132 + actionIndex: number, 133 + retryIndex: number, 134 + fetchContext?: FetchContext, 135 + ) { 136 + if (retryIndex >= RETRY_DELAYS.length) return; 137 + 138 + setTimeout(async () => { 139 + try { 140 + const action = match.automation.actions[actionIndex] as BookmarkAction; 141 + const result = await execute(match, action, fetchContext); 142 + const body = actionPayload(action); 143 + 144 + await logDelivery( 145 + match.automation.uri, 146 + actionIndex, 147 + match.event.time_us, 148 + isSuccess(result.statusCode) ? null : body, 149 + result.statusCode, 150 + result.error ?? null, 151 + retryIndex + 2, 152 + ); 153 + 154 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 155 + scheduleRetry(match, actionIndex, retryIndex + 1, fetchContext); 156 + } 157 + } catch (err) { 158 + console.error("Bookmark retry error:", err); 159 + } 160 + }, RETRY_DELAYS[retryIndex]); 161 + } 162 + 163 + /** Execute a bookmark action for a matched event. */ 164 + export async function executeBookmark( 165 + match: MatchedEvent, 166 + actionIndex: number, 167 + fetchContext?: FetchContext, 168 + ): Promise<ActionResult> { 169 + const action = match.automation.actions[actionIndex] as BookmarkAction; 170 + const result = await execute(match, action, fetchContext); 171 + const body = actionPayload(action); 172 + 173 + await logDelivery( 174 + match.automation.uri, 175 + actionIndex, 176 + match.event.time_us, 177 + isSuccess(result.statusCode) ? null : body, 178 + result.statusCode, 179 + result.error ?? null, 180 + 1, 181 + ); 182 + 183 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 184 + scheduleRetry(match, actionIndex, 0, fetchContext); 185 + } 186 + 187 + return result; 188 + }
+111
lib/actions/validation.ts
··· 1 1 import { SECRET_NAME_RE } from "../secrets/store.js"; 2 + import { validateTextTemplate } from "./template.js"; 2 3 3 4 export type ActionInput = 4 5 | { ··· 20 21 targetCollection: string; 21 22 baseRecordUri: string; 22 23 recordTemplate: string; 24 + comment?: string; 25 + } 26 + | { 27 + type: "bookmark"; 28 + targetSource: string; 29 + targetTitle?: string; 30 + bodyValue?: string; 31 + tags?: string[]; 23 32 comment?: string; 24 33 }; 25 34 ··· 94 103 95 104 return { valid: true }; 96 105 } 106 + 107 + export const BOOKMARK_LIMITS = { 108 + targetSource: 2048, 109 + targetTitle: 500, 110 + bodyValue: 10000, 111 + tag: 64, 112 + maxTags: 10, 113 + } as const; 114 + 115 + type BookmarkInput = { 116 + targetSource: string; 117 + targetTitle?: string; 118 + bodyValue?: string; 119 + tags?: string[]; 120 + }; 121 + 122 + /** Validate a bookmark action input. Returns the trimmed, filtered tags on success. */ 123 + export function validateBookmarkInput( 124 + input: BookmarkInput, 125 + fetchNames: string[], 126 + actionNames: string[], 127 + ): { valid: true; tags: string[] } | { valid: false; error: string } { 128 + if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 129 + return { valid: false, error: "targetSource is required for bookmark actions" }; 130 + } 131 + if (input.targetSource.length > BOOKMARK_LIMITS.targetSource) { 132 + return { 133 + valid: false, 134 + error: `targetSource must be ${BOOKMARK_LIMITS.targetSource} characters or less`, 135 + }; 136 + } 137 + const sourceValidation = validateTextTemplate(input.targetSource, fetchNames, actionNames); 138 + if (!sourceValidation.valid) { 139 + return { valid: false, error: `targetSource: ${sourceValidation.error}` }; 140 + } 141 + 142 + if (input.targetTitle !== undefined) { 143 + if (typeof input.targetTitle !== "string") { 144 + return { valid: false, error: "targetTitle must be a string" }; 145 + } 146 + if (input.targetTitle.length > BOOKMARK_LIMITS.targetTitle) { 147 + return { 148 + valid: false, 149 + error: `targetTitle must be ${BOOKMARK_LIMITS.targetTitle} characters or less`, 150 + }; 151 + } 152 + if (input.targetTitle.trim()) { 153 + const titleValidation = validateTextTemplate(input.targetTitle, fetchNames, actionNames); 154 + if (!titleValidation.valid) { 155 + return { valid: false, error: `targetTitle: ${titleValidation.error}` }; 156 + } 157 + } 158 + } 159 + 160 + if (input.bodyValue !== undefined) { 161 + if (typeof input.bodyValue !== "string") { 162 + return { valid: false, error: "bodyValue must be a string" }; 163 + } 164 + if (input.bodyValue.length > BOOKMARK_LIMITS.bodyValue) { 165 + return { 166 + valid: false, 167 + error: `bodyValue must be ${BOOKMARK_LIMITS.bodyValue} characters or less`, 168 + }; 169 + } 170 + if (input.bodyValue.trim()) { 171 + const bodyValidation = validateTextTemplate(input.bodyValue, fetchNames, actionNames); 172 + if (!bodyValidation.valid) { 173 + return { valid: false, error: `bodyValue: ${bodyValidation.error}` }; 174 + } 175 + } 176 + } 177 + 178 + const tags: string[] = []; 179 + if (input.tags !== undefined) { 180 + if (!Array.isArray(input.tags)) { 181 + return { valid: false, error: "tags must be an array of strings" }; 182 + } 183 + if (input.tags.length > BOOKMARK_LIMITS.maxTags) { 184 + return { valid: false, error: `Maximum ${BOOKMARK_LIMITS.maxTags} tags allowed` }; 185 + } 186 + for (const tag of input.tags) { 187 + if (typeof tag !== "string") { 188 + return { valid: false, error: "Each tag must be a string" }; 189 + } 190 + const trimmed = tag.trim(); 191 + if (!trimmed) continue; 192 + if (trimmed.length > BOOKMARK_LIMITS.tag) { 193 + return { 194 + valid: false, 195 + error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${BOOKMARK_LIMITS.tag} characters`, 196 + }; 197 + } 198 + const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames); 199 + if (!tagValidation.valid) { 200 + return { valid: false, error: `tag: ${tagValidation.error}` }; 201 + } 202 + tags.push(trimmed); 203 + } 204 + } 205 + 206 + return { valid: true, tags }; 207 + }
+5 -1
lib/auth/client.ts
··· 29 29 /** Returns true if any action writes to a collection beyond run.airglow.automation. */ 30 30 export function actionsNeedFullScope(actions: ActionLike[]): boolean { 31 31 return actions.some( 32 - (a) => a.$type === "bsky-post" || a.$type === "record" || a.$type === "patch-record", 32 + (a) => 33 + a.$type === "bsky-post" || 34 + a.$type === "record" || 35 + a.$type === "patch-record" || 36 + a.$type === "bookmark", 33 37 ); 34 38 } 35 39
+16 -1
lib/automations/action-catalogue.ts
··· 1 1 import { 2 + Bookmark, 2 3 FilePlus2, 3 4 Heart, 4 5 MessageSquare, ··· 8 9 Webhook, 9 10 } from "../../app/icons.js"; 10 11 11 - export type AddableActionId = "webhook" | "bsky-post" | "record" | "patch-record"; 12 + export type AddableActionId = "webhook" | "bsky-post" | "record" | "patch-record" | "bookmark"; 12 13 13 14 type ActionInfo = { 14 15 label: string; ··· 56 57 description: "Follow another Bluesky user", 57 58 icon: UserPlus, 58 59 available: false, 60 + }, 61 + ], 62 + }, 63 + { 64 + id: "apps", 65 + label: "Apps", 66 + description: "Quick actions for specific AT Protocol apps", 67 + actions: [ 68 + { 69 + id: "bookmark", 70 + label: "Bookmark on Margin", 71 + description: "Create a bookmark note in Margin.at", 72 + icon: Bookmark, 73 + available: true, 59 74 }, 60 75 ], 61 76 },
+11 -1
lib/automations/pds.ts
··· 60 60 comment?: string; 61 61 }; 62 62 63 + type PdsBookmarkAction = { 64 + $type: "run.airglow.automation#bookmarkAction"; 65 + targetSource: string; 66 + targetTitle?: string; 67 + bodyValue?: string; 68 + tags?: string[]; 69 + comment?: string; 70 + }; 71 + 63 72 export type PdsAction = 64 73 | PdsWebhookAction 65 74 | PdsRecordAction 66 75 | PdsBskyPostAction 67 - | PdsPatchRecordAction; 76 + | PdsPatchRecordAction 77 + | PdsBookmarkAction; 68 78 69 79 export type PdsFetchStep = { 70 80 $type: "run.airglow.automation#fetchStep";
+8
lib/automations/sanitize.ts
··· 22 22 baseRecordUri: string; 23 23 recordTemplate: string; 24 24 comment?: string; 25 + } 26 + | { 27 + $type: "bookmark"; 28 + targetSource: string; 29 + targetTitle?: string; 30 + bodyValue?: string; 31 + tags?: string[]; 32 + comment?: string; 25 33 }; 26 34 27 35 /** Strip instance-local secrets and truncate webhook URLs to domain-only. */
+16 -2
lib/db/schema.ts
··· 41 41 comment?: string; 42 42 }; 43 43 44 - export type Action = WebhookAction | RecordAction | BskyPostAction | PatchRecordAction; 44 + export type BookmarkAction = { 45 + $type: "bookmark"; 46 + targetSource: string; 47 + targetTitle?: string; 48 + bodyValue?: string; 49 + tags?: string[]; 50 + comment?: string; 51 + }; 52 + 53 + export type Action = 54 + | WebhookAction 55 + | RecordAction 56 + | BskyPostAction 57 + | PatchRecordAction 58 + | BookmarkAction; 45 59 46 60 /** Action types that produce a record result (uri, cid, rkey) for chaining. */ 47 - const RECORD_PRODUCING_TYPES = new Set(["record", "bsky-post", "patch-record"]); 61 + const RECORD_PRODUCING_TYPES = new Set(["record", "bsky-post", "patch-record", "bookmark"]); 48 62 export function isRecordProducingAction(type: string): boolean { 49 63 return RECORD_PRODUCING_TYPES.has(type); 50 64 }
+35 -1
lib/jetstream/handler.ts
··· 4 4 import { executeAction, type ActionResult } from "../actions/executor.js"; 5 5 import { executeBskyPost } from "../actions/bsky-post.js"; 6 6 import { executePatchRecord } from "../actions/patch-record.js"; 7 + import { executeBookmark } from "../actions/bookmark.js"; 7 8 import { resolveFetches } from "../actions/fetcher.js"; 8 9 import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 9 10 import { parseAtUri } from "../pds/resolver.js"; ··· 56 57 ? executeAction 57 58 : action.$type === "patch-record" 58 59 ? executePatchRecord 59 - : dispatch; 60 + : action.$type === "bookmark" 61 + ? executeBookmark 62 + : dispatch; 60 63 61 64 try { 62 65 const result: ActionResult = await handler(match, i, fetchContext); ··· 120 123 ); 121 124 message = `Would post to Bluesky`; 122 125 payload = JSON.stringify({ text, langs: action.langs, labels: action.labels }); 126 + } catch (err) { 127 + error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 128 + } 129 + } else if (action.$type === "bookmark") { 130 + try { 131 + const source = await renderTextTemplate( 132 + action.targetSource, 133 + match.event, 134 + fetchContext, 135 + match.automation, 136 + ); 137 + const title = action.targetTitle 138 + ? await renderTextTemplate(action.targetTitle, match.event, fetchContext, match.automation) 139 + : undefined; 140 + const body = action.bodyValue 141 + ? await renderTextTemplate(action.bodyValue, match.event, fetchContext, match.automation) 142 + : undefined; 143 + const tags: string[] = []; 144 + if (action.tags) { 145 + for (const tag of action.tags) { 146 + const rendered = await renderTextTemplate( 147 + tag, 148 + match.event, 149 + fetchContext, 150 + match.automation, 151 + ); 152 + if (rendered.trim()) tags.push(rendered.trim()); 153 + } 154 + } 155 + message = `Would bookmark ${source}`; 156 + payload = JSON.stringify({ source, title, body, tags }); 123 157 } catch (err) { 124 158 error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 125 159 }
+9
lib/test/fixtures.ts
··· 5 5 RecordAction, 6 6 BskyPostAction, 7 7 PatchRecordAction, 8 + BookmarkAction, 8 9 FetchStep, 9 10 } from "../db/schema.js"; 10 11 import type { MatchedEvent } from "../jetstream/consumer.js"; ··· 69 70 return { 70 71 $type: "bsky-post", 71 72 textTemplate: "Post by {{event.did}}", 73 + ...overrides, 74 + }; 75 + } 76 + 77 + export function makeBookmarkAction(overrides?: Partial<BookmarkAction>): BookmarkAction { 78 + return { 79 + $type: "bookmark", 80 + targetSource: "https://example.com/{{event.commit.rkey}}", 72 81 ...overrides, 73 82 }; 74 83 }