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: better margin bookmarks

Hugo ded626fa fcfd9447

+800 -230
+1 -1
app/components/LexiconFlow/index.tsx
··· 12 12 if (!isRecordProducingAction(a.$type)) continue; 13 13 let domain: string | null = null; 14 14 if (a.$type === "bsky-post") domain = "bsky.app"; 15 - else if (a.$type === "bookmark") domain = "margin.at"; 15 + else if (a.$type === "margin-bookmark") domain = "margin.at"; 16 16 else if (a.$type === "follow") domain = FOLLOW_TARGETS[a.target].faviconDomain; 17 17 else if (a.$type === "record" || a.$type === "patch-record") 18 18 domain = nsidToDomain(a.targetCollection);
+22 -43
app/islands/AutomationForm.tsx
··· 90 90 comment: string; 91 91 forEach?: ForEachDraft; 92 92 }; 93 - type BookmarkDraft = { 94 - type: "bookmark"; 93 + type MarginBookmarkDraft = { 94 + type: "margin-bookmark"; 95 95 targetSource: string; 96 - targetTitle: string; 97 96 bodyValue: string; 98 97 tagsText: string; 99 98 comment: string; ··· 117 116 | RecordDraft 118 117 | BskyPostDraft 119 118 | PatchRecordDraft 120 - | BookmarkDraft 119 + | MarginBookmarkDraft 121 120 | FollowDraft 122 121 | SembleSaveDraft; 123 122 ··· 803 802 } 804 803 805 804 // --------------------------------------------------------------------------- 806 - // Bookmark (margin.at) action editor 805 + // Margin bookmark action editor 807 806 // --------------------------------------------------------------------------- 808 807 809 - function BookmarkActionEditor({ 808 + function MarginBookmarkActionEditor({ 810 809 action, 811 810 index, 812 811 onChange, 813 812 }: { 814 - action: BookmarkDraft; 813 + action: MarginBookmarkDraft; 815 814 index: number; 816 - onChange: (a: BookmarkDraft) => void; 815 + onChange: (a: MarginBookmarkDraft) => void; 817 816 }) { 818 - const urlId = `action-${index}-bookmark-url`; 819 - const titleId = `action-${index}-bookmark-title`; 820 - const bodyId = `action-${index}-bookmark-body`; 821 - const tagsId = `action-${index}-bookmark-tags`; 817 + const urlId = `action-${index}-margin-bookmark-url`; 818 + const bodyId = `action-${index}-margin-bookmark-body`; 819 + const tagsId = `action-${index}-margin-bookmark-tags`; 822 820 823 821 return ( 824 822 <> ··· 838 836 required 839 837 autocomplete="off" 840 838 /> 841 - <span class={s.hint}>URL of the page to bookmark. Supports {"{{placeholders}}"}.</span> 842 - </div> 843 - 844 - <div class={s.fieldGroup}> 845 - <label class={s.label} for={titleId}> 846 - Title <span class={s.hint}>(optional)</span> 847 - </label> 848 - <input 849 - id={titleId} 850 - class={s.input} 851 - type="text" 852 - placeholder="e.g. Post by {{event.did}}" 853 - value={action.targetTitle} 854 - onInput={(e: Event) => 855 - onChange({ ...action, targetTitle: (e.target as HTMLInputElement).value }) 856 - } 857 - autocomplete="off" 858 - /> 859 - <span class={s.hint}>Page title. Supports {"{{placeholders}}"}.</span> 839 + <span class={s.hint}> 840 + URL of the page to bookmark. Supports {"{{placeholders}}"}. The page title is fetched 841 + automatically. 842 + </span> 860 843 </div> 861 844 862 845 <div class={s.fieldGroup}> ··· 1287 1270 ...forEachField, 1288 1271 }; 1289 1272 } 1290 - if (a.$type === "bookmark") { 1273 + if (a.$type === "margin-bookmark") { 1291 1274 return { 1292 - type: "bookmark", 1275 + type: "margin-bookmark", 1293 1276 targetSource: a.targetSource, 1294 - targetTitle: a.targetTitle ?? "", 1295 1277 bodyValue: a.bodyValue ?? "", 1296 1278 tagsText: (a.tags ?? []).join(", "), 1297 1279 comment: a.comment ?? "", ··· 1853 1835 comment: "", 1854 1836 }, 1855 1837 ]); 1856 - } else if (type === "bookmark") { 1838 + } else if (type === "margin-bookmark") { 1857 1839 setActions((prev) => [ 1858 1840 ...prev, 1859 1841 { 1860 - type: "bookmark", 1842 + type: "margin-bookmark", 1861 1843 targetSource: "", 1862 - targetTitle: "", 1863 1844 bodyValue: "", 1864 1845 tagsText: "", 1865 1846 comment: "", ··· 1946 1927 ...comment, 1947 1928 }; 1948 1929 } 1949 - if (a.type === "bookmark") { 1950 - const targetTitle = a.targetTitle.trim(); 1930 + if (a.type === "margin-bookmark") { 1951 1931 const bodyValue = a.bodyValue.trim(); 1952 1932 const tags = a.tagsText 1953 1933 .split(",") ··· 1955 1935 .filter(Boolean) 1956 1936 .slice(0, 10); 1957 1937 return { 1958 - type: "bookmark", 1938 + type: "margin-bookmark", 1959 1939 targetSource: a.targetSource, 1960 - ...(targetTitle ? { targetTitle } : {}), 1961 1940 ...(bodyValue ? { bodyValue } : {}), 1962 1941 ...(tags.length > 0 ? { tags } : {}), 1963 1942 ...forEachField, ··· 3046 3025 onChange={(a) => updateAction(i, a)} 3047 3026 placeholders={allPlaceholders} 3048 3027 /> 3049 - ) : action.type === "bookmark" ? ( 3050 - <BookmarkActionEditor 3028 + ) : action.type === "margin-bookmark" ? ( 3029 + <MarginBookmarkActionEditor 3051 3030 action={action} 3052 3031 index={i} 3053 3032 onChange={(a) => updateAction(i, a)}
+7 -10
app/routes/api/automations/[rkey].ts
··· 10 10 type RecordAction, 11 11 type BskyPostAction, 12 12 type PatchRecordAction, 13 - type BookmarkAction, 13 + type MarginBookmarkAction, 14 14 type FollowAction, 15 15 type SembleSaveAction, 16 16 } from "@/db/schema.js"; ··· 31 31 VALID_BSKY_LABELS, 32 32 BCP47_RE, 33 33 validateWebhookHeaders, 34 - validateBookmarkInput, 34 + validateMarginBookmarkInput, 35 35 validateFollowInput, 36 36 validateSembleSaveInput, 37 37 validateForEachInput, ··· 424 424 ...(input.comment ? { comment: input.comment } : {}), 425 425 }); 426 426 actionResultNames.push(`action${actionIndex + 1}`); 427 - } else if (input.type === "bookmark") { 428 - const bookmarkValidation = validateBookmarkInput( 427 + } else if (input.type === "margin-bookmark") { 428 + const bookmarkValidation = validateMarginBookmarkInput( 429 429 input, 430 430 fetchNames, 431 431 actionResultNames, ··· 435 435 return c.json({ error: bookmarkValidation.error }, 400); 436 436 } 437 437 438 - const targetTitle = input.targetTitle?.trim() || undefined; 439 438 const bodyValue = input.bodyValue?.trim() || undefined; 440 439 const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 441 440 442 441 newLocalActions.push({ 443 - $type: "bookmark", 442 + $type: "margin-bookmark", 444 443 targetSource: input.targetSource, 445 - ...(targetTitle ? { targetTitle } : {}), 446 444 ...(bodyValue ? { bodyValue } : {}), 447 445 ...(tags ? { tags } : {}), 448 446 ...forEachField, 449 447 ...(input.comment ? { comment: input.comment } : {}), 450 - } satisfies BookmarkAction); 448 + } satisfies MarginBookmarkAction); 451 449 newPdsActions.push({ 452 - $type: "run.airglow.automation#bookmarkAction", 450 + $type: "run.airglow.automation#marginBookmarkAction", 453 451 targetSource: input.targetSource, 454 - ...(targetTitle ? { targetTitle } : {}), 455 452 ...(bodyValue ? { bodyValue } : {}), 456 453 ...(tags ? { tags } : {}), 457 454 ...forEachField,
+7 -10
app/routes/api/automations/index.ts
··· 9 9 type RecordAction, 10 10 type BskyPostAction, 11 11 type PatchRecordAction, 12 - type BookmarkAction, 12 + type MarginBookmarkAction, 13 13 type FollowAction, 14 14 type SembleSaveAction, 15 15 } from "@/db/schema.js"; ··· 29 29 VALID_BSKY_LABELS, 30 30 BCP47_RE, 31 31 validateWebhookHeaders, 32 - validateBookmarkInput, 32 + validateMarginBookmarkInput, 33 33 validateFollowInput, 34 34 validateSembleSaveInput, 35 35 validateForEachInput, ··· 341 341 ...(input.comment ? { comment: input.comment } : {}), 342 342 }); 343 343 actionResultNames.push(`action${actionIndex + 1}`); 344 - } else if (input.type === "bookmark") { 345 - const bookmarkValidation = validateBookmarkInput( 344 + } else if (input.type === "margin-bookmark") { 345 + const bookmarkValidation = validateMarginBookmarkInput( 346 346 input, 347 347 fetchNames, 348 348 actionResultNames, ··· 352 352 return c.json({ error: bookmarkValidation.error }, 400); 353 353 } 354 354 355 - const targetTitle = input.targetTitle?.trim() || undefined; 356 355 const bodyValue = input.bodyValue?.trim() || undefined; 357 356 const tags = bookmarkValidation.tags.length > 0 ? bookmarkValidation.tags : undefined; 358 357 359 358 localActions.push({ 360 - $type: "bookmark", 359 + $type: "margin-bookmark", 361 360 targetSource: input.targetSource, 362 - ...(targetTitle ? { targetTitle } : {}), 363 361 ...(bodyValue ? { bodyValue } : {}), 364 362 ...(tags ? { tags } : {}), 365 363 ...forEachField, 366 364 ...(input.comment ? { comment: input.comment } : {}), 367 - } satisfies BookmarkAction); 365 + } satisfies MarginBookmarkAction); 368 366 pdsActions.push({ 369 - $type: "run.airglow.automation#bookmarkAction", 367 + $type: "run.airglow.automation#marginBookmarkAction", 370 368 targetSource: input.targetSource, 371 - ...(targetTitle ? { targetTitle } : {}), 372 369 ...(bodyValue ? { bodyValue } : {}), 373 370 ...(tags ? { tags } : {}), 374 371 ...forEachField,
+1 -9
app/routes/dashboard/automations/[rkey].tsx
··· 302 302 <InlineCode>{action.subject}</InlineCode> 303 303 </dd> 304 304 </> 305 - ) : action.$type === "bookmark" ? ( 305 + ) : action.$type === "margin-bookmark" ? ( 306 306 <> 307 307 <dt>Page URL</dt> 308 308 <dd> 309 309 <InlineCode>{action.targetSource}</InlineCode> 310 310 </dd> 311 - {action.targetTitle && ( 312 - <> 313 - <dt>Title</dt> 314 - <dd> 315 - <InlineCode>{action.targetTitle}</InlineCode> 316 - </dd> 317 - </> 318 - )} 319 311 {action.bodyValue && ( 320 312 <> 321 313 <dt>Description</dt>
+1 -9
app/routes/u/[handle]/[rkey].tsx
··· 279 279 <InlineCode>{action.subject}</InlineCode> 280 280 </dd> 281 281 </> 282 - ) : action.$type === "bookmark" ? ( 282 + ) : action.$type === "margin-bookmark" ? ( 283 283 <> 284 284 <dt>Page URL</dt> 285 285 <dd> 286 286 <InlineCode>{action.targetSource}</InlineCode> 287 287 </dd> 288 - {action.targetTitle && ( 289 - <> 290 - <dt>Title</dt> 291 - <dd> 292 - <InlineCode>{action.targetTitle}</InlineCode> 293 - </dd> 294 - </> 295 - )} 296 288 {action.bodyValue && ( 297 289 <> 298 290 <dt>Description</dt>
+3 -8
lexicons/run/airglow/automation.json
··· 48 48 "#recordAction", 49 49 "#bskyPostAction", 50 50 "#patchRecordAction", 51 - "#bookmarkAction", 51 + "#marginBookmarkAction", 52 52 "#followAction", 53 53 "#sembleSaveAction" 54 54 ] ··· 330 330 } 331 331 } 332 332 }, 333 - "bookmarkAction": { 333 + "marginBookmarkAction": { 334 334 "type": "object", 335 - "description": "Create a bookmark (at.margin.note record with motivation 'bookmarking') on the user's PDS when a matching event occurs.", 335 + "description": "Create a bookmark on Margin (at.margin.note record with motivation 'bookmarking'). The page title is fetched automatically from the URL at execution time.", 336 336 "required": ["targetSource"], 337 337 "properties": { 338 338 "targetSource": { 339 339 "type": "string", 340 340 "description": "URL of the page being bookmarked. Supports {{placeholders}}.", 341 341 "maxLength": 2048 342 - }, 343 - "targetTitle": { 344 - "type": "string", 345 - "description": "Optional page title at time of bookmarking. Supports {{placeholders}}.", 346 - "maxLength": 500 347 342 }, 348 343 "bodyValue": { 349 344 "type": "string",
+64 -27
lib/actions/bookmark.test.ts lib/actions/margin-bookmark.test.ts
··· 9 9 createArbitraryRecord: vi.fn(), 10 10 })); 11 11 12 + vi.mock("../url-metadata.js", () => ({ 13 + fetchURLMetadata: vi.fn(), 14 + })); 15 + 12 16 vi.mock("../auth/client.js", () => ({ 13 17 resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 14 18 })); 15 19 16 - import { executeBookmark, computeSourceHash, normalizeUrlForHash } from "./bookmark.js"; 20 + import { 21 + executeMarginBookmark, 22 + computeSourceHash, 23 + normalizeUrlForHash, 24 + } from "./margin-bookmark.js"; 17 25 import { createArbitraryRecord } from "../automations/pds.js"; 26 + import { fetchURLMetadata } from "../url-metadata.js"; 18 27 import { db } from "../db/index.js"; 19 28 import { automations, deliveryLogs } from "../db/schema.js"; 20 - import { makeMatch, makeBookmarkAction, makeAutomation } from "../test/fixtures.js"; 29 + import { makeMatch, makeMarginBookmarkAction, makeAutomation } from "../test/fixtures.js"; 21 30 22 31 const mockCreateRecord = vi.mocked(createArbitraryRecord); 32 + const mockFetchMeta = vi.mocked(fetchURLMetadata); 23 33 24 34 describe("normalizeUrlForHash", () => { 25 35 it("lowercases protocol and host, preserves path/query/hash", () => { ··· 49 59 }); 50 60 }); 51 61 52 - describe("executeBookmark", () => { 62 + describe("executeMarginBookmark", () => { 53 63 beforeEach(async () => { 54 64 vi.useFakeTimers(); 55 65 vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 56 66 mockCreateRecord.mockReset(); 67 + mockFetchMeta.mockReset(); 57 68 58 69 await db.delete(deliveryLogs); 59 70 await db.delete(automations); ··· 64 75 vi.useRealTimers(); 65 76 }); 66 77 67 - it("renders fields, computes sourceHash, and creates record on PDS", async () => { 78 + it("renders fields, fetches title, computes sourceHash, and creates record on PDS", async () => { 79 + mockFetchMeta.mockResolvedValueOnce({ 80 + url: "https://example.com/3k2la7bx", 81 + title: "Fetched Page Title", 82 + type: "article", 83 + }); 68 84 mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 69 85 70 - const action = makeBookmarkAction({ 86 + const action = makeMarginBookmarkAction({ 71 87 targetSource: "https://example.com/{{event.commit.rkey}}", 72 - targetTitle: "Post by {{event.did}}", 73 88 bodyValue: "Saved from Airglow", 74 89 tags: ["bluesky", "{{event.commit.collection}}"], 75 90 }); 76 91 const match = makeMatch({ automation: { actions: [action] } }); 77 - await executeBookmark(match, 0); 92 + await executeMarginBookmark(match, 0); 78 93 94 + expect(mockFetchMeta).toHaveBeenCalledWith("https://example.com/3k2la7bx"); 79 95 expect(mockCreateRecord).toHaveBeenCalledTimes(1); 80 96 const [did, collection, record] = mockCreateRecord.mock.calls[0]!; 81 97 expect(did).toBe(match.automation.did); ··· 85 101 createdAt: "2024-06-15T12:00:00.000Z", 86 102 target: { 87 103 source: "https://example.com/3k2la7bx", 88 - title: "Post by did:plc:testuser123", 104 + title: "Fetched Page Title", 89 105 }, 90 106 body: { value: "Saved from Airglow", format: "text/plain" }, 91 107 tags: ["bluesky", "app.bsky.feed.like"], ··· 103 119 expect(logs[0]!.statusCode).toBe(200); 104 120 }); 105 121 106 - it("omits optional fields when empty", async () => { 122 + it("omits title when fetchURLMetadata returns no title", async () => { 123 + mockFetchMeta.mockResolvedValueOnce({ url: "https://example.com/x", type: "link" }); 107 124 mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 108 125 109 - const action = makeBookmarkAction({ targetSource: "https://example.com/x" }); 126 + const action = makeMarginBookmarkAction({ targetSource: "https://example.com/x" }); 110 127 const match = makeMatch({ automation: { actions: [action] } }); 111 - await executeBookmark(match, 0); 128 + await executeMarginBookmark(match, 0); 112 129 113 130 const record = mockCreateRecord.mock.calls[0]![2]!; 114 131 expect(record).not.toHaveProperty("body"); ··· 116 133 expect((record.target as Record<string, unknown>).title).toBeUndefined(); 117 134 }); 118 135 136 + it("creates the record without title when metadata fetch fails", async () => { 137 + mockFetchMeta.mockRejectedValueOnce(new Error("network error")); 138 + mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 139 + 140 + const action = makeMarginBookmarkAction({ targetSource: "https://example.com/x" }); 141 + const match = makeMatch({ automation: { actions: [action] } }); 142 + await executeMarginBookmark(match, 0); 143 + 144 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 145 + const record = mockCreateRecord.mock.calls[0]![2]!; 146 + expect((record.target as Record<string, unknown>).title).toBeUndefined(); 147 + }); 148 + 119 149 it("drops tags that render to empty strings", async () => { 150 + mockFetchMeta.mockResolvedValueOnce({ url: "https://example.com/x", type: "link" }); 120 151 mockCreateRecord.mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 121 152 122 - const action = makeBookmarkAction({ 153 + const action = makeMarginBookmarkAction({ 123 154 targetSource: "https://example.com/x", 124 155 tags: ["{{event.missing.field}}", "real-tag"], 125 156 }); 126 157 const match = makeMatch({ automation: { actions: [action] } }); 127 - await executeBookmark(match, 0); 158 + await executeMarginBookmark(match, 0); 128 159 129 160 const record = mockCreateRecord.mock.calls[0]![2]!; 130 161 expect(record.tags).toEqual(["real-tag"]); 131 162 }); 132 163 133 164 it("fails with template error when targetSource is empty after render", async () => { 134 - const action = makeBookmarkAction({ targetSource: "{{event.missing}}" }); 165 + const action = makeMarginBookmarkAction({ targetSource: "{{event.missing}}" }); 135 166 const match = makeMatch({ automation: { actions: [action] } }); 136 - await executeBookmark(match, 0); 167 + await executeMarginBookmark(match, 0); 137 168 138 169 expect(mockCreateRecord).not.toHaveBeenCalled(); 139 170 const logs = await db.query.deliveryLogs.findMany(); ··· 141 172 expect(logs[0]!.error).toContain("Template error"); 142 173 }); 143 174 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); 175 + it("rejects rendered URL with non-http(s) scheme", async () => { 176 + const action = makeMarginBookmarkAction({ targetSource: "{{event.url}}" }); 177 + const match = makeMatch({ 178 + automation: { actions: [action] }, 179 + event: { url: "javascript:alert(1)" } as unknown as Record<string, unknown>, 180 + }); 181 + await executeMarginBookmark(match, 0); 148 182 183 + expect(mockFetchMeta).not.toHaveBeenCalled(); 149 184 expect(mockCreateRecord).not.toHaveBeenCalled(); 150 185 const logs = await db.query.deliveryLogs.findMany(); 151 - expect(logs[0]!.statusCode).toBe(0); 152 - expect(logs[0]!.error).toContain("Template error"); 186 + expect(logs[0]!.error).toContain("http://"); 153 187 }); 154 188 155 189 it("extracts status code from PDS error message", async () => { 190 + mockFetchMeta.mockResolvedValueOnce({ url: "https://example.com/x", type: "link" }); 156 191 mockCreateRecord.mockRejectedValueOnce( 157 192 new Error("PDS com.atproto.repo.createRecord failed (400): bad request"), 158 193 ); 159 194 160 - const action = makeBookmarkAction(); 195 + const action = makeMarginBookmarkAction(); 161 196 const match = makeMatch({ automation: { actions: [action] } }); 162 - await executeBookmark(match, 0); 197 + await executeMarginBookmark(match, 0); 163 198 164 199 const logs = await db.query.deliveryLogs.findMany(); 165 200 expect(logs).toHaveLength(1); ··· 167 202 }); 168 203 169 204 it("retries on 5xx PDS errors", async () => { 205 + mockFetchMeta.mockResolvedValue({ url: "https://example.com/x", type: "link" }); 170 206 mockCreateRecord 171 207 .mockRejectedValueOnce(new Error("PDS failed (500): internal")) 172 208 .mockResolvedValueOnce({ uri: "at://x/at.margin.note/rk", cid: "c" }); 173 209 174 - const action = makeBookmarkAction(); 210 + const action = makeMarginBookmarkAction(); 175 211 const match = makeMatch({ automation: { actions: [action] } }); 176 - await executeBookmark(match, 0); 212 + await executeMarginBookmark(match, 0); 177 213 178 214 expect(mockCreateRecord).toHaveBeenCalledTimes(1); 179 215 ··· 185 221 }); 186 222 187 223 it("does not retry on 4xx PDS errors", async () => { 224 + mockFetchMeta.mockResolvedValue({ url: "https://example.com/x", type: "link" }); 188 225 mockCreateRecord.mockRejectedValueOnce(new Error("PDS failed (400): bad request")); 189 226 190 - const action = makeBookmarkAction(); 227 + const action = makeMarginBookmarkAction(); 191 228 const match = makeMatch({ automation: { actions: [action] } }); 192 - await executeBookmark(match, 0); 229 + await executeMarginBookmark(match, 0); 193 230 194 231 await vi.advanceTimersByTimeAsync(60_000); 195 232 expect(mockCreateRecord).toHaveBeenCalledTimes(1);
+34 -32
lib/actions/bookmark.ts lib/actions/margin-bookmark.ts
··· 1 1 import { createHash } from "node:crypto"; 2 - import { type BookmarkAction } from "../db/schema.js"; 2 + import { type MarginBookmarkAction } from "../db/schema.js"; 3 3 import { createArbitraryRecord } from "../automations/pds.js"; 4 4 import { renderTextTemplate, 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 + import { fetchURLMetadata } from "../url-metadata.js"; 7 8 import { config } from "../config.js"; 8 9 9 10 const TARGET_COLLECTION = "at.margin.note"; ··· 23 24 24 25 async function buildRecord( 25 26 match: MatchedEvent, 26 - action: BookmarkAction, 27 + action: MarginBookmarkAction, 27 28 fetchContext?: FetchContext, 28 29 item?: unknown, 29 30 ): Promise<Record<string, unknown>> { ··· 39 40 item, 40 41 ); 41 42 42 - const targetSource = await renderTextTemplate( 43 - action.targetSource, 44 - event, 45 - fetchContext, 46 - automation, 47 - item, 48 - ); 49 - if (!targetSource.trim()) { 43 + const targetSource = ( 44 + await renderTextTemplate(action.targetSource, event, fetchContext, automation, item) 45 + ).trim(); 46 + if (!targetSource) { 50 47 throw new Error("targetSource rendered to an empty string"); 51 48 } 49 + // Reject non-http(s) before fetching metadata; we don't want to write a 50 + // bookmark to the user's PDS pointing at a javascript:/file: URL. 51 + if (!/^https?:\/\//i.test(targetSource)) { 52 + throw new Error("targetSource must start with http:// or https://"); 53 + } 52 54 53 55 const sourceHash = computeSourceHash(targetSource); 54 56 55 - const title = action.targetTitle 56 - ? (await renderTextTemplate(action.targetTitle, event, fetchContext, automation, item)).trim() 57 - : ""; 58 - const body = action.bodyValue 59 - ? (await renderTextTemplate(action.bodyValue, event, fetchContext, automation, item)).trim() 60 - : ""; 61 - 62 - const tags: string[] = []; 63 - if (action.tags) { 64 - for (const tag of action.tags) { 65 - const rendered = ( 66 - await renderTextTemplate(tag, event, fetchContext, automation, item) 67 - ).trim(); 68 - if (rendered) tags.push(rendered); 69 - } 70 - } 57 + // Fetch metadata concurrently with body/tag template rendering — the network 58 + // call dominates and doesn't depend on the local renders. 59 + const [title, body, tags] = await Promise.all([ 60 + fetchURLMetadata(targetSource) 61 + .then((m) => m.title) 62 + .catch(() => undefined), 63 + action.bodyValue 64 + ? renderTextTemplate(action.bodyValue, event, fetchContext, automation, item).then((s) => 65 + s.trim(), 66 + ) 67 + : Promise.resolve(""), 68 + Promise.all( 69 + (action.tags ?? []).map((tag) => 70 + renderTextTemplate(tag, event, fetchContext, automation, item).then((s) => s.trim()), 71 + ), 72 + ).then((rendered) => rendered.filter(Boolean)), 73 + ]); 71 74 72 75 const record: Record<string, unknown> = { 73 76 motivation: "bookmarking", ··· 79 82 }, 80 83 generator: { 81 84 id: automationUrl, 82 - name: `Airglow`, 85 + name: "Airglow", 83 86 homepage: config.publicUrl, 84 87 }, 85 88 }; ··· 96 99 97 100 async function execute( 98 101 match: MatchedEvent, 99 - action: BookmarkAction, 102 + action: MarginBookmarkAction, 100 103 fetchContext?: FetchContext, 101 104 item?: unknown, 102 105 ): Promise<ActionResult> { ··· 120 123 } 121 124 } 122 125 123 - /** Execute a bookmark action for a matched event. */ 124 - export const executeBookmark = wrapWithDelivery( 125 - (match, i) => match.automation.actions[i] as BookmarkAction, 126 + /** Execute a margin-bookmark action for a matched event. */ 127 + export const executeMarginBookmark = wrapWithDelivery( 128 + (match, i) => match.automation.actions[i] as MarginBookmarkAction, 126 129 execute, 127 130 (action) => 128 131 JSON.stringify({ 129 132 targetSource: action.targetSource, 130 - targetTitle: action.targetTitle, 131 133 bodyValue: action.bodyValue, 132 134 tags: action.tags, 133 135 }),
+30 -40
lib/actions/validation.ts
··· 1 1 import { SECRET_NAME_RE, SECRET_REF_RE } from "../secrets/store.js"; 2 - import { AUTOMATION_LIMITS, BOOKMARK_LIMITS, SEMBLE_SAVE_LIMITS } from "../automations/limits.js"; 2 + import { 3 + AUTOMATION_LIMITS, 4 + MARGIN_BOOKMARK_LIMITS, 5 + SEMBLE_SAVE_LIMITS, 6 + } from "../automations/limits.js"; 3 7 import { nsidRequiresWantedDids } from "../lexicons/match.js"; 4 8 import { isValidNsid } from "../lexicons/resolver.js"; 5 9 import { PLACEHOLDER_RE, validateTextTemplate } from "./template.js"; ··· 47 51 comment?: string; 48 52 }) 49 53 | (ActionBase & { 50 - type: "bookmark"; 54 + type: "margin-bookmark"; 51 55 targetSource: string; 52 - targetTitle?: string; 53 56 bodyValue?: string; 54 57 tags?: string[]; 55 58 comment?: string; ··· 462 465 return { valid: true }; 463 466 } 464 467 465 - type BookmarkInput = { 468 + type MarginBookmarkInput = { 466 469 targetSource: string; 467 - targetTitle?: string; 468 470 bodyValue?: string; 469 471 tags?: string[]; 470 472 }; 471 473 472 - /** Validate a bookmark action input. Returns the trimmed, filtered tags on success. */ 473 - export function validateBookmarkInput( 474 - input: BookmarkInput, 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, 475 482 fetchNames: string[], 476 483 actionNames: string[], 477 484 hasItem?: boolean, 478 485 ): { valid: true; tags: string[] } | { valid: false; error: string } { 479 486 if (!input.targetSource || typeof input.targetSource !== "string" || !input.targetSource.trim()) { 480 - return { valid: false, error: "targetSource is required for bookmark actions" }; 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 + }; 481 494 } 482 - if (input.targetSource.length > BOOKMARK_LIMITS.targetSource) { 495 + if (!MARGIN_BOOKMARK_URL_OK_RE.test(input.targetSource)) { 483 496 return { 484 497 valid: false, 485 - error: `targetSource must be ${BOOKMARK_LIMITS.targetSource} characters or less`, 498 + error: "targetSource must start with http://, https://, or a {{placeholder}}", 486 499 }; 487 500 } 488 501 const sourceValidation = validateTextTemplate( ··· 495 508 return { valid: false, error: `targetSource: ${sourceValidation.error}` }; 496 509 } 497 510 498 - if (input.targetTitle !== undefined) { 499 - if (typeof input.targetTitle !== "string") { 500 - return { valid: false, error: "targetTitle must be a string" }; 501 - } 502 - if (input.targetTitle.length > BOOKMARK_LIMITS.targetTitle) { 503 - return { 504 - valid: false, 505 - error: `targetTitle must be ${BOOKMARK_LIMITS.targetTitle} characters or less`, 506 - }; 507 - } 508 - if (input.targetTitle.trim()) { 509 - const titleValidation = validateTextTemplate( 510 - input.targetTitle, 511 - fetchNames, 512 - actionNames, 513 - hasItem, 514 - ); 515 - if (!titleValidation.valid) { 516 - return { valid: false, error: `targetTitle: ${titleValidation.error}` }; 517 - } 518 - } 519 - } 520 - 521 511 if (input.bodyValue !== undefined) { 522 512 if (typeof input.bodyValue !== "string") { 523 513 return { valid: false, error: "bodyValue must be a string" }; 524 514 } 525 - if (input.bodyValue.length > BOOKMARK_LIMITS.bodyValue) { 515 + if (input.bodyValue.length > MARGIN_BOOKMARK_LIMITS.bodyValue) { 526 516 return { 527 517 valid: false, 528 - error: `bodyValue must be ${BOOKMARK_LIMITS.bodyValue} characters or less`, 518 + error: `bodyValue must be ${MARGIN_BOOKMARK_LIMITS.bodyValue} characters or less`, 529 519 }; 530 520 } 531 521 if (input.bodyValue.trim()) { ··· 546 536 if (!Array.isArray(input.tags)) { 547 537 return { valid: false, error: "tags must be an array of strings" }; 548 538 } 549 - if (input.tags.length > BOOKMARK_LIMITS.maxTags) { 550 - return { valid: false, error: `Maximum ${BOOKMARK_LIMITS.maxTags} tags allowed` }; 539 + if (input.tags.length > MARGIN_BOOKMARK_LIMITS.maxTags) { 540 + return { valid: false, error: `Maximum ${MARGIN_BOOKMARK_LIMITS.maxTags} tags allowed` }; 551 541 } 552 542 for (const tag of input.tags) { 553 543 if (typeof tag !== "string") { ··· 555 545 } 556 546 const trimmed = tag.trim(); 557 547 if (!trimmed) continue; 558 - if (trimmed.length > BOOKMARK_LIMITS.tag) { 548 + if (trimmed.length > MARGIN_BOOKMARK_LIMITS.tag) { 559 549 return { 560 550 valid: false, 561 - error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${BOOKMARK_LIMITS.tag} characters`, 551 + error: `Tag "${trimmed.slice(0, 32)}..." exceeds ${MARGIN_BOOKMARK_LIMITS.tag} characters`, 562 552 }; 563 553 } 564 554 const tagValidation = validateTextTemplate(trimmed, fetchNames, actionNames, hasItem);
+1 -1
lib/auth/client.ts
··· 36 36 a.$type === "bsky-post" || 37 37 a.$type === "record" || 38 38 a.$type === "patch-record" || 39 - a.$type === "bookmark" || 39 + a.$type === "margin-bookmark" || 40 40 a.$type === "follow" || 41 41 a.$type === "semble-save", 42 42 );
+4 -4
lib/automations/action-catalogue.test.ts
··· 15 15 it("orders Apps tiles: Bookmark → Save Semble → Follow Semble → Follow Sifa → Follow Tangled", () => { 16 16 const apps = ACTION_CATALOGUE.find((c) => c.id === "apps")!; 17 17 expect(apps.actions.map((a) => a.id)).toEqual([ 18 - "bookmark", 18 + "margin-bookmark", 19 19 "semble-save", 20 20 "follow-cosmik", 21 21 "follow-sifa", ··· 46 46 }); 47 47 48 48 it("leaves colorKey undefined for tiles that use their category's color", () => { 49 - expect(ACTION_INFO_BY_TYPE["bookmark"]!.colorKey).toBeUndefined(); 49 + expect(ACTION_INFO_BY_TYPE["margin-bookmark"]!.colorKey).toBeUndefined(); 50 50 expect(ACTION_INFO_BY_TYPE["bsky-post"]!.colorKey).toBeUndefined(); 51 51 expect(ACTION_INFO_BY_TYPE["webhook"]!.colorKey).toBeUndefined(); 52 52 }); ··· 56 56 expect(ACTION_INFO_BY_TYPE["follow-sifa"]!.faviconDomain).toBe("sifa.id"); 57 57 expect(ACTION_INFO_BY_TYPE["follow-tangled"]!.faviconDomain).toBe("tangled.sh"); 58 58 expect(ACTION_INFO_BY_TYPE["follow-cosmik"]!.faviconDomain).toBe("semble.so"); 59 - expect(ACTION_INFO_BY_TYPE["bookmark"]!.faviconDomain).toBe("margin.at"); 59 + expect(ACTION_INFO_BY_TYPE["margin-bookmark"]!.faviconDomain).toBe("margin.at"); 60 60 expect(ACTION_INFO_BY_TYPE["semble-save"]!.faviconDomain).toBe("semble.so"); 61 61 }); 62 62 }); ··· 70 70 }); 71 71 72 72 it("passes through non-follow types unchanged", () => { 73 - expect(actionTypeKey({ $type: "bookmark" })).toBe("bookmark"); 73 + expect(actionTypeKey({ $type: "margin-bookmark" })).toBe("margin-bookmark"); 74 74 expect(actionTypeKey({ $type: "webhook" })).toBe("webhook"); 75 75 }); 76 76
+3 -3
lib/automations/action-catalogue.ts
··· 16 16 | "bsky-post" 17 17 | "record" 18 18 | "patch-record" 19 - | "bookmark" 19 + | "margin-bookmark" 20 20 | "semble-save" 21 21 | `follow-${FollowTarget}`; 22 22 ··· 28 28 * use a sifa-blue and follow-tangled a grey while still grouping under the 29 29 * Bluesky/Apps categories. */ 30 30 colorKey?: ColorKey; 31 - /** Domain used to render the per-app favicon next to the icon (bookmark, follow). */ 31 + /** Domain used to render the per-app favicon next to the icon (margin-bookmark, follow). */ 32 32 faviconDomain?: string; 33 33 }; 34 34 ··· 98 98 description: "Quick actions for specific AT Protocol apps", 99 99 actions: [ 100 100 { 101 - id: "bookmark", 101 + id: "margin-bookmark", 102 102 label: "Bookmark on Margin", 103 103 description: "Create a bookmark note in Margin.at", 104 104 icon: Bookmark,
+1 -1
lib/automations/follow-targets.ts
··· 32 32 * 33 33 * Insertion order doubles as the catalogue tile order within each category 34 34 * (Bluesky tile group: bsky-post → follow-bluesky → ...; Apps tile group: 35 - * bookmark → follow-sifa → follow-tangled). Reorder here to reorder the UI. 35 + * margin-bookmark → follow-sifa → follow-tangled). Reorder here to reorder the UI. 36 36 * 37 37 * Pure-data module: no JSX / icon imports, so backend code paths can read 38 38 * `appName` etc. without pulling in UI components. */
+1 -1
lib/automations/labels.ts
··· 21 21 record: "Create Record", 22 22 "bsky-post": "Bluesky Post", 23 23 "patch-record": "Update Record", 24 - bookmark: "Bookmark", 24 + "margin-bookmark": "Bookmark on Margin", 25 25 follow: "Follow", 26 26 }; 27 27
+1 -2
lib/automations/limits.ts
··· 11 11 webhookHeaderValue: 2048, 12 12 } as const; 13 13 14 - export const BOOKMARK_LIMITS = { 14 + export const MARGIN_BOOKMARK_LIMITS = { 15 15 targetSource: 2048, 16 - targetTitle: 500, 17 16 bodyValue: 10000, 18 17 tag: 64, 19 18 maxTags: 10,
+2 -3
lib/automations/pds-serialize.ts
··· 43 43 ...(a.comment ? { comment: a.comment } : {}), 44 44 }; 45 45 } 46 - if (a.$type === "bookmark") { 46 + if (a.$type === "margin-bookmark") { 47 47 return { 48 - $type: "run.airglow.automation#bookmarkAction", 48 + $type: "run.airglow.automation#marginBookmarkAction", 49 49 targetSource: a.targetSource, 50 - ...(a.targetTitle ? { targetTitle: a.targetTitle } : {}), 51 50 ...(a.bodyValue ? { bodyValue: a.bodyValue } : {}), 52 51 ...(a.tags && a.tags.length > 0 ? { tags: a.tags } : {}), 53 52 ...forEachField,
+3 -4
lib/automations/pds.ts
··· 77 77 comment?: string; 78 78 }; 79 79 80 - type PdsBookmarkAction = { 81 - $type: "run.airglow.automation#bookmarkAction"; 80 + type PdsMarginBookmarkAction = { 81 + $type: "run.airglow.automation#marginBookmarkAction"; 82 82 targetSource: string; 83 - targetTitle?: string; 84 83 bodyValue?: string; 85 84 tags?: string[]; 86 85 forEach?: PdsForEachConfig; ··· 107 106 | PdsRecordAction 108 107 | PdsBskyPostAction 109 108 | PdsPatchRecordAction 110 - | PdsBookmarkAction 109 + | PdsMarginBookmarkAction 111 110 | PdsFollowAction 112 111 | PdsSembleSaveAction; 113 112
+6
lib/db/migrations/0009_rename_bookmark_action.sql
··· 1 + -- Rename "bookmark" action $type to "margin-bookmark" in stored automations. 2 + -- Drizzle serializes the `actions` column with JSON.stringify (no spaces), so a 3 + -- literal text replace on the canonical `"$type":"bookmark"` substring is safe. 4 + UPDATE automations 5 + SET actions = REPLACE(actions, '"$type":"bookmark"', '"$type":"margin-bookmark"') 6 + WHERE actions LIKE '%"$type":"bookmark"%';
+587
lib/db/migrations/meta/0009_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "5ec22ab6-f35d-5577-9eef-9b565ca0a229", 5 + "prevId": "4db119a5-e24c-4466-8dda-8a454ba9918b", 6 + "tables": { 7 + "automations": { 8 + "name": "automations", 9 + "columns": { 10 + "uri": { 11 + "name": "uri", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "rkey": { 25 + "name": "rkey", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "name": { 32 + "name": "name", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "description": { 39 + "name": "description", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "lexicon": { 46 + "name": "lexicon", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'[\"create\"]'" 59 + }, 60 + "actions": { 61 + "name": "actions", 62 + "type": "text", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false, 66 + "default": "'[]'" 67 + }, 68 + "fetches": { 69 + "name": "fetches", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "autoincrement": false, 74 + "default": "'[]'" 75 + }, 76 + "conditions": { 77 + "name": "conditions", 78 + "type": "text", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false, 82 + "default": "'[]'" 83 + }, 84 + "wanted_dids": { 85 + "name": "wanted_dids", 86 + "type": "text", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false, 90 + "default": "'[]'" 91 + }, 92 + "active": { 93 + "name": "active", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 100 + "dry_run": { 101 + "name": "dry_run", 102 + "type": "integer", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false, 106 + "default": false 107 + }, 108 + "disabled_reason": { 109 + "name": "disabled_reason", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false, 113 + "autoincrement": false 114 + }, 115 + "disabled_at": { 116 + "name": "disabled_at", 117 + "type": "integer", 118 + "primaryKey": false, 119 + "notNull": false, 120 + "autoincrement": false 121 + }, 122 + "rate_limit_reset_at": { 123 + "name": "rate_limit_reset_at", 124 + "type": "integer", 125 + "primaryKey": false, 126 + "notNull": false, 127 + "autoincrement": false 128 + }, 129 + "indexed_at": { 130 + "name": "indexed_at", 131 + "type": "integer", 132 + "primaryKey": false, 133 + "notNull": true, 134 + "autoincrement": false 135 + } 136 + }, 137 + "indexes": { 138 + "automations_did_idx": { 139 + "name": "automations_did_idx", 140 + "columns": [ 141 + "did" 142 + ], 143 + "isUnique": false 144 + }, 145 + "automations_active_indexed_at_idx": { 146 + "name": "automations_active_indexed_at_idx", 147 + "columns": [ 148 + "active", 149 + "indexed_at" 150 + ], 151 + "isUnique": false 152 + } 153 + }, 154 + "foreignKeys": {}, 155 + "compositePrimaryKeys": {}, 156 + "uniqueConstraints": {}, 157 + "checkConstraints": {} 158 + }, 159 + "delivery_logs": { 160 + "name": "delivery_logs", 161 + "columns": { 162 + "id": { 163 + "name": "id", 164 + "type": "integer", 165 + "primaryKey": true, 166 + "notNull": true, 167 + "autoincrement": true 168 + }, 169 + "automation_uri": { 170 + "name": "automation_uri", 171 + "type": "text", 172 + "primaryKey": false, 173 + "notNull": true, 174 + "autoincrement": false 175 + }, 176 + "action_index": { 177 + "name": "action_index", 178 + "type": "integer", 179 + "primaryKey": false, 180 + "notNull": true, 181 + "autoincrement": false, 182 + "default": 0 183 + }, 184 + "event_time_us": { 185 + "name": "event_time_us", 186 + "type": "integer", 187 + "primaryKey": false, 188 + "notNull": true, 189 + "autoincrement": false 190 + }, 191 + "payload": { 192 + "name": "payload", 193 + "type": "text", 194 + "primaryKey": false, 195 + "notNull": false, 196 + "autoincrement": false 197 + }, 198 + "status_code": { 199 + "name": "status_code", 200 + "type": "integer", 201 + "primaryKey": false, 202 + "notNull": false, 203 + "autoincrement": false 204 + }, 205 + "message": { 206 + "name": "message", 207 + "type": "text", 208 + "primaryKey": false, 209 + "notNull": false, 210 + "autoincrement": false 211 + }, 212 + "error": { 213 + "name": "error", 214 + "type": "text", 215 + "primaryKey": false, 216 + "notNull": false, 217 + "autoincrement": false 218 + }, 219 + "dry_run": { 220 + "name": "dry_run", 221 + "type": "integer", 222 + "primaryKey": false, 223 + "notNull": true, 224 + "autoincrement": false, 225 + "default": false 226 + }, 227 + "attempt": { 228 + "name": "attempt", 229 + "type": "integer", 230 + "primaryKey": false, 231 + "notNull": true, 232 + "autoincrement": false, 233 + "default": 1 234 + }, 235 + "created_at": { 236 + "name": "created_at", 237 + "type": "integer", 238 + "primaryKey": false, 239 + "notNull": true, 240 + "autoincrement": false 241 + } 242 + }, 243 + "indexes": { 244 + "delivery_logs_automation_uri_id_idx": { 245 + "name": "delivery_logs_automation_uri_id_idx", 246 + "columns": [ 247 + "automation_uri", 248 + "id" 249 + ], 250 + "isUnique": false 251 + } 252 + }, 253 + "foreignKeys": { 254 + "delivery_logs_automation_uri_automations_uri_fk": { 255 + "name": "delivery_logs_automation_uri_automations_uri_fk", 256 + "tableFrom": "delivery_logs", 257 + "tableTo": "automations", 258 + "columnsFrom": [ 259 + "automation_uri" 260 + ], 261 + "columnsTo": [ 262 + "uri" 263 + ], 264 + "onDelete": "cascade", 265 + "onUpdate": "no action" 266 + } 267 + }, 268 + "compositePrimaryKeys": {}, 269 + "uniqueConstraints": {}, 270 + "checkConstraints": {} 271 + }, 272 + "favicon_cache": { 273 + "name": "favicon_cache", 274 + "columns": { 275 + "domain": { 276 + "name": "domain", 277 + "type": "text", 278 + "primaryKey": true, 279 + "notNull": true, 280 + "autoincrement": false 281 + }, 282 + "data": { 283 + "name": "data", 284 + "type": "text", 285 + "primaryKey": false, 286 + "notNull": true, 287 + "autoincrement": false 288 + }, 289 + "content_type": { 290 + "name": "content_type", 291 + "type": "text", 292 + "primaryKey": false, 293 + "notNull": true, 294 + "autoincrement": false 295 + }, 296 + "fetched_at": { 297 + "name": "fetched_at", 298 + "type": "integer", 299 + "primaryKey": false, 300 + "notNull": true, 301 + "autoincrement": false 302 + } 303 + }, 304 + "indexes": {}, 305 + "foreignKeys": {}, 306 + "compositePrimaryKeys": {}, 307 + "uniqueConstraints": {}, 308 + "checkConstraints": {} 309 + }, 310 + "lexicon_cache": { 311 + "name": "lexicon_cache", 312 + "columns": { 313 + "nsid": { 314 + "name": "nsid", 315 + "type": "text", 316 + "primaryKey": true, 317 + "notNull": true, 318 + "autoincrement": false 319 + }, 320 + "schema": { 321 + "name": "schema", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true, 325 + "autoincrement": false 326 + }, 327 + "fetched_at": { 328 + "name": "fetched_at", 329 + "type": "integer", 330 + "primaryKey": false, 331 + "notNull": true, 332 + "autoincrement": false 333 + } 334 + }, 335 + "indexes": {}, 336 + "foreignKeys": {}, 337 + "compositePrimaryKeys": {}, 338 + "uniqueConstraints": {}, 339 + "checkConstraints": {} 340 + }, 341 + "oauth_sessions": { 342 + "name": "oauth_sessions", 343 + "columns": { 344 + "key": { 345 + "name": "key", 346 + "type": "text", 347 + "primaryKey": true, 348 + "notNull": true, 349 + "autoincrement": false 350 + }, 351 + "value": { 352 + "name": "value", 353 + "type": "text", 354 + "primaryKey": false, 355 + "notNull": true, 356 + "autoincrement": false 357 + }, 358 + "expires_at": { 359 + "name": "expires_at", 360 + "type": "integer", 361 + "primaryKey": false, 362 + "notNull": false, 363 + "autoincrement": false 364 + } 365 + }, 366 + "indexes": {}, 367 + "foreignKeys": {}, 368 + "compositePrimaryKeys": {}, 369 + "uniqueConstraints": {}, 370 + "checkConstraints": {} 371 + }, 372 + "oauth_states": { 373 + "name": "oauth_states", 374 + "columns": { 375 + "key": { 376 + "name": "key", 377 + "type": "text", 378 + "primaryKey": true, 379 + "notNull": true, 380 + "autoincrement": false 381 + }, 382 + "value": { 383 + "name": "value", 384 + "type": "text", 385 + "primaryKey": false, 386 + "notNull": true, 387 + "autoincrement": false 388 + }, 389 + "expires_at": { 390 + "name": "expires_at", 391 + "type": "integer", 392 + "primaryKey": false, 393 + "notNull": false, 394 + "autoincrement": false 395 + } 396 + }, 397 + "indexes": {}, 398 + "foreignKeys": {}, 399 + "compositePrimaryKeys": {}, 400 + "uniqueConstraints": {}, 401 + "checkConstraints": {} 402 + }, 403 + "secret_events": { 404 + "name": "secret_events", 405 + "columns": { 406 + "id": { 407 + "name": "id", 408 + "type": "integer", 409 + "primaryKey": true, 410 + "notNull": true, 411 + "autoincrement": true 412 + }, 413 + "did": { 414 + "name": "did", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": true, 418 + "autoincrement": false 419 + }, 420 + "name": { 421 + "name": "name", 422 + "type": "text", 423 + "primaryKey": false, 424 + "notNull": true, 425 + "autoincrement": false 426 + }, 427 + "action": { 428 + "name": "action", 429 + "type": "text", 430 + "primaryKey": false, 431 + "notNull": true, 432 + "autoincrement": false 433 + }, 434 + "created_at": { 435 + "name": "created_at", 436 + "type": "integer", 437 + "primaryKey": false, 438 + "notNull": true, 439 + "autoincrement": false 440 + } 441 + }, 442 + "indexes": {}, 443 + "foreignKeys": {}, 444 + "compositePrimaryKeys": {}, 445 + "uniqueConstraints": {}, 446 + "checkConstraints": {} 447 + }, 448 + "user_secrets": { 449 + "name": "user_secrets", 450 + "columns": { 451 + "id": { 452 + "name": "id", 453 + "type": "integer", 454 + "primaryKey": true, 455 + "notNull": true, 456 + "autoincrement": true 457 + }, 458 + "did": { 459 + "name": "did", 460 + "type": "text", 461 + "primaryKey": false, 462 + "notNull": true, 463 + "autoincrement": false 464 + }, 465 + "name": { 466 + "name": "name", 467 + "type": "text", 468 + "primaryKey": false, 469 + "notNull": true, 470 + "autoincrement": false 471 + }, 472 + "encrypted_value": { 473 + "name": "encrypted_value", 474 + "type": "blob", 475 + "primaryKey": false, 476 + "notNull": true, 477 + "autoincrement": false 478 + }, 479 + "created_at": { 480 + "name": "created_at", 481 + "type": "integer", 482 + "primaryKey": false, 483 + "notNull": true, 484 + "autoincrement": false 485 + }, 486 + "updated_at": { 487 + "name": "updated_at", 488 + "type": "integer", 489 + "primaryKey": false, 490 + "notNull": true, 491 + "autoincrement": false 492 + } 493 + }, 494 + "indexes": { 495 + "user_secrets_did_name_unique": { 496 + "name": "user_secrets_did_name_unique", 497 + "columns": [ 498 + "did", 499 + "name" 500 + ], 501 + "isUnique": true 502 + } 503 + }, 504 + "foreignKeys": { 505 + "user_secrets_did_users_did_fk": { 506 + "name": "user_secrets_did_users_did_fk", 507 + "tableFrom": "user_secrets", 508 + "tableTo": "users", 509 + "columnsFrom": [ 510 + "did" 511 + ], 512 + "columnsTo": [ 513 + "did" 514 + ], 515 + "onDelete": "cascade", 516 + "onUpdate": "no action" 517 + } 518 + }, 519 + "compositePrimaryKeys": {}, 520 + "uniqueConstraints": {}, 521 + "checkConstraints": {} 522 + }, 523 + "users": { 524 + "name": "users", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "integer", 529 + "primaryKey": true, 530 + "notNull": true, 531 + "autoincrement": true 532 + }, 533 + "did": { 534 + "name": "did", 535 + "type": "text", 536 + "primaryKey": false, 537 + "notNull": true, 538 + "autoincrement": false 539 + }, 540 + "handle": { 541 + "name": "handle", 542 + "type": "text", 543 + "primaryKey": false, 544 + "notNull": true, 545 + "autoincrement": false 546 + }, 547 + "scope": { 548 + "name": "scope", 549 + "type": "text", 550 + "primaryKey": false, 551 + "notNull": false, 552 + "autoincrement": false 553 + }, 554 + "created_at": { 555 + "name": "created_at", 556 + "type": "integer", 557 + "primaryKey": false, 558 + "notNull": true, 559 + "autoincrement": false 560 + } 561 + }, 562 + "indexes": { 563 + "users_did_unique": { 564 + "name": "users_did_unique", 565 + "columns": [ 566 + "did" 567 + ], 568 + "isUnique": true 569 + } 570 + }, 571 + "foreignKeys": {}, 572 + "compositePrimaryKeys": {}, 573 + "uniqueConstraints": {}, 574 + "checkConstraints": {} 575 + } 576 + }, 577 + "views": {}, 578 + "enums": {}, 579 + "_meta": { 580 + "schemas": {}, 581 + "tables": {}, 582 + "columns": {} 583 + }, 584 + "internal": { 585 + "indexes": {} 586 + } 587 + }
+7
lib/db/migrations/meta/_journal.json
··· 64 64 "when": 1777022971580, 65 65 "tag": "0008_volatile_thundra", 66 66 "breakpoints": true 67 + }, 68 + { 69 + "idx": 9, 70 + "version": "6", 71 + "when": 1777800000000, 72 + "tag": "0009_rename_bookmark_action", 73 + "breakpoints": true 67 74 } 68 75 ] 69 76 }
+4 -5
lib/db/schema.ts
··· 56 56 forEach?: ForEachConfig; 57 57 }; 58 58 59 - export type BookmarkAction = { 60 - $type: "bookmark"; 59 + export type MarginBookmarkAction = { 60 + $type: "margin-bookmark"; 61 61 targetSource: string; 62 - targetTitle?: string; 63 62 bodyValue?: string; 64 63 tags?: string[]; 65 64 comment?: string; ··· 86 85 | RecordAction 87 86 | BskyPostAction 88 87 | PatchRecordAction 89 - | BookmarkAction 88 + | MarginBookmarkAction 90 89 | FollowAction 91 90 | SembleSaveAction; 92 91 ··· 95 94 "record", 96 95 "bsky-post", 97 96 "patch-record", 98 - "bookmark", 97 + "margin-bookmark", 99 98 "follow", 100 99 "semble-save", 101 100 ]);
+5 -14
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 + import { executeMarginBookmark } from "../actions/margin-bookmark.js"; 8 8 import { executeFollow } from "../actions/follow.js"; 9 9 import { executeSembleSave } from "../actions/semble-save.js"; 10 10 import { FOLLOW_TARGETS } from "../automations/follow-targets.js"; ··· 31 31 return executeAction; 32 32 case "patch-record": 33 33 return executePatchRecord; 34 - case "bookmark": 35 - return executeBookmark; 34 + case "margin-bookmark": 35 + return executeMarginBookmark; 36 36 case "follow": 37 37 return executeFollow; 38 38 case "semble-save": ··· 326 326 } catch (err) { 327 327 error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 328 328 } 329 - } else if (action.$type === "bookmark") { 329 + } else if (action.$type === "margin-bookmark") { 330 330 try { 331 331 const source = await renderTextTemplate( 332 332 action.targetSource, ··· 335 335 match.automation, 336 336 item, 337 337 ); 338 - const title = action.targetTitle 339 - ? await renderTextTemplate( 340 - action.targetTitle, 341 - match.event, 342 - fetchContext, 343 - match.automation, 344 - item, 345 - ) 346 - : undefined; 347 338 const body = action.bodyValue 348 339 ? await renderTextTemplate( 349 340 action.bodyValue, ··· 367 358 } 368 359 } 369 360 message = `Would bookmark ${source}${itemSuffix}`; 370 - payload = JSON.stringify({ source, title, body, tags, item }); 361 + payload = JSON.stringify({ source, body, tags, item }); 371 362 } catch (err) { 372 363 error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 373 364 }
+5 -3
lib/test/fixtures.ts
··· 5 5 RecordAction, 6 6 BskyPostAction, 7 7 PatchRecordAction, 8 - BookmarkAction, 8 + MarginBookmarkAction, 9 9 FollowAction, 10 10 SembleSaveAction, 11 11 FetchStep, ··· 78 78 }; 79 79 } 80 80 81 - export function makeBookmarkAction(overrides?: Partial<BookmarkAction>): BookmarkAction { 81 + export function makeMarginBookmarkAction( 82 + overrides?: Partial<MarginBookmarkAction>, 83 + ): MarginBookmarkAction { 82 84 return { 83 - $type: "bookmark", 85 + $type: "margin-bookmark", 84 86 targetSource: "https://example.com/{{event.commit.rkey}}", 85 87 ...overrides, 86 88 };