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: better and self contained follow quick actions

Hugo 1fc1ab23 b81fb655

+490 -78
+3
app/islands/AutomationForm.tsx
··· 670 670 <span class={s.hint}> 671 671 DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} 672 672 {"{{event.did}}"} or {"{{event.commit.record.subject}}"}. 673 + <br /> 674 + Automatically checks that the subject has a {meta.appName} profile and that you don't 675 + already follow them. No extra conditions needed. 673 676 </span> 674 677 </div> 675 678 </>
+10 -13
docs/data-source-fetches.md
··· 51 51 (`at://did/collection/rkey`). Non-AT-URI → the fetch errors with a log entry. 52 52 3. The URI is fetched via `fetchRecord` ([lib/pds/resolver.ts](../lib/pds/resolver.ts)), 53 53 which resolves the DID to its PDS and calls `com.atproto.repo.getRecord`. 54 - 4. A 404 writes `found: false`; any other failure is treated as an error. 54 + 4. A 404, _or_ a 400 with XRPC error body `{"error":"RecordNotFound"}` 55 + (which is what the `com.atproto.repo.getRecord` lexicon actually returns 56 + for missing records on most PDSes), writes `found: false`. Any other 57 + failure is treated as an error. 55 58 56 59 Record fetches are **independent of each other** and run in parallel via 57 60 `Promise.all`. Their `conditions` are evaluated once all record fetches have ··· 76 79 "name": "existingMirror", 77 80 "repo": "{{self}}", 78 81 "collection": "id.sifa.graph.follow", 79 - "where": [ 80 - { "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" } 81 - ], 82 + "where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }], 82 83 "limit": 1, 83 - "conditions": [ 84 - { "field": "found", "operator": "not-exists", "value": "" } 85 - ] 84 + "conditions": [{ "field": "found", "operator": "not-exists", "value": "" }] 86 85 } 87 86 ``` 88 87 ··· 94 93 - `where` — list of equality clauses. Currently only `operator: "eq"` is 95 94 supported. Multiple clauses are ANDed. 96 95 - `limit` — max number of matches to accept. Defaults to 1. The current 97 - executor always returns the *first* match as the context entry; `limit` just 96 + executor always returns the _first_ match as the context entry; `limit` just 98 97 controls how many matches the executor is willing to find before stopping. 99 98 - `conditions` — per-fetch conditions evaluated after the search resolves. 100 99 Typically `found` + `exists` / `not-exists` to gate on presence. ··· 161 160 4. If the PDS returns a `cursor`, continue; otherwise stop. 162 161 163 162 If the scan completes without finding a match, the entry is `notFoundEntry()`. 164 - If the 100-page cap is hit *and* the cursor would continue, a warning is 163 + If the 100-page cap is hit _and_ the cursor would continue, a warning is 165 164 logged and the entry is still `notFoundEntry()` — we intentionally treat an 166 165 exhausted scan as "not found" rather than erroring, because the usual caller 167 166 is a `not-exists` gate and false negatives are preferable to hard failures. ··· 198 197 199 198 ## Per-fetch conditions 200 199 201 - Both fetch kinds support a `conditions` array. These run *after* the fetch 200 + Both fetch kinds support a `conditions` array. These run _after_ the fetch 202 201 resolves and are evaluated against the fetch's own entry — paths are 203 202 entry-scoped, not event-scoped: 204 203 ··· 232 231 "collection": "id.sifa.graph.follow", 233 232 "where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }], 234 233 "limit": 1, 235 - "conditions": [ 236 - { "field": "found", "operator": "not-exists", "value": "" } 237 - ] 234 + "conditions": [{ "field": "found", "operator": "not-exists", "value": "" }] 238 235 } 239 236 ``` 240 237
+10 -13
docs/wanted-dids.md
··· 91 91 92 92 ### When you'd use both 93 93 94 - Nothing stops you from setting `wantedDids` *and* adding `event.did` conditions 94 + Nothing stops you from setting `wantedDids` _and_ adding `event.did` conditions 95 95 on top, and there's a real use case for it: `wantedDids` narrows the firehose 96 96 to a set of accounts cheaply, and the condition layer then applies additional 97 97 constraints (record fields, subject DIDs, etc.) that Jetstream can't express. ··· 118 118 ([lib/jetstream/consumer.ts](../lib/jetstream/consumer.ts)): 119 119 120 120 ```ts 121 - if ( 122 - nsidRequiresWantedDids(row.lexicon, config.nsidRequireDids) && 123 - resolvedDids.length === 0 124 - ) { 121 + if (nsidRequiresWantedDids(row.lexicon, config.nsidRequireDids) && resolvedDids.length === 0) { 125 122 console.warn( 126 123 `Jetstream: skipping ${row.uri} — ${row.lexicon} requires wantedDids but none are set`, 127 124 ); ··· 165 162 166 163 ## Rules of thumb 167 164 168 - | Situation | Use | 169 - | ---------------------------------------------------------- | ----------------- | 170 - | Automation listens to an `app.bsky.*` collection | `wantedDids` | 171 - | Owner-only automation on a custom lexicon | Either; `wantedDids` preferred | 172 - | "Anyone posting about topic X on `run.airglow.*`" | Condition on record fields, no DID filter | 173 - | Stable list of ≤ a few hundred DIDs | `wantedDids` | 174 - | Rapidly changing DID allowlist | `event.did` condition | 175 - | Filter by DID **and** by record-shape in the same rule | `wantedDids` + conditions | 165 + | Situation | Use | 166 + | ------------------------------------------------------ | ----------------------------------------- | 167 + | Automation listens to an `app.bsky.*` collection | `wantedDids` | 168 + | Owner-only automation on a custom lexicon | Either; `wantedDids` preferred | 169 + | "Anyone posting about topic X on `run.airglow.*`" | Condition on record fields, no DID filter | 170 + | Stable list of ≤ a few hundred DIDs | `wantedDids` | 171 + | Rapidly changing DID allowlist | `event.did` condition | 172 + | Filter by DID **and** by record-shape in the same rule | `wantedDids` + conditions | 176 173 177 174 When in doubt: if the NSID is in `NSID_REQUIRES_DIDS`, you don't get a choice 178 175 — the manager will skip the automation until `wantedDids` is populated.
+2
lib/actions/delivery.ts
··· 19 19 statusCode: number, 20 20 error: string | null, 21 21 attempt: number, 22 + message: string | null = null, 22 23 ) { 23 24 await db.insert(deliveryLogs).values({ 24 25 automationUri, ··· 27 28 payload, 28 29 statusCode, 29 30 error, 31 + message, 30 32 attempt, 31 33 createdAt: new Date(), 32 34 });
+4
lib/actions/executor.ts
··· 8 8 export type ActionResult = { 9 9 statusCode: number; 10 10 error?: string; 11 + /** Human-readable, non-error summary. Used by skip-style results (e.g. the 12 + * follow action's built-in safety checks) that want a readable trace in the 13 + * delivery log without marking the fire as a failure. */ 14 + message?: string; 11 15 /** AT URI of the created/updated record (record-producing actions only). */ 12 16 uri?: string; 13 17 /** CID of the created/updated record (record-producing actions only). */
+137
lib/actions/follow.test.ts
··· 9 9 createArbitraryRecord: vi.fn(), 10 10 })); 11 11 12 + vi.mock("../pds/resolver.js", () => ({ 13 + fetchRecord: vi.fn(), 14 + })); 15 + 16 + vi.mock("./searcher.js", () => ({ 17 + executeSearch: vi.fn(), 18 + })); 19 + 12 20 vi.mock("../auth/client.js", () => ({ 13 21 resolveDidToHandle: vi.fn(async (did: string) => `handle-for-${did.slice(-4)}`), 14 22 })); 15 23 16 24 import { executeFollow } from "./follow.js"; 17 25 import { createArbitraryRecord } from "../automations/pds.js"; 26 + import { fetchRecord } from "../pds/resolver.js"; 27 + import { executeSearch } from "./searcher.js"; 18 28 import { db } from "../db/index.js"; 19 29 import { automations, deliveryLogs } from "../db/schema.js"; 20 30 import { makeMatch, makeFollowAction, makeAutomation } from "../test/fixtures.js"; 21 31 22 32 const mockCreateRecord = vi.mocked(createArbitraryRecord); 33 + const mockFetchRecord = vi.mocked(fetchRecord); 34 + const mockExecuteSearch = vi.mocked(executeSearch); 23 35 24 36 const EVENT_DID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; // 24-char valid did:plc 25 37 38 + /** Sensible defaults so pre-flight checks pass and follows go through unless 39 + * a test overrides them: profile exists, not already following. */ 40 + function setPreflightPassing() { 41 + mockFetchRecord.mockResolvedValue({ 42 + found: true, 43 + uri: "at://x/app.bsky.actor.profile/self", 44 + cid: "c", 45 + did: "did:plc:x", 46 + collection: "app.bsky.actor.profile", 47 + rkey: "self", 48 + record: {}, 49 + }); 50 + mockExecuteSearch.mockResolvedValue({ 51 + found: false, 52 + uri: "", 53 + cid: "", 54 + record: {}, 55 + }); 56 + } 57 + 26 58 describe("executeFollow", () => { 27 59 beforeEach(async () => { 28 60 vi.useFakeTimers(); 29 61 vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); 30 62 mockCreateRecord.mockReset(); 63 + mockFetchRecord.mockReset(); 64 + mockExecuteSearch.mockReset(); 65 + setPreflightPassing(); 31 66 32 67 await db.delete(deliveryLogs); 33 68 await db.delete(automations); ··· 140 175 expect(vi.getTimerCount()).toBe(0); 141 176 await vi.advanceTimersByTimeAsync(60_000); 142 177 expect(mockCreateRecord).toHaveBeenCalledTimes(1); 178 + }); 179 + 180 + // --------------------------------------------------------------------------- 181 + // Built-in pre-flight safety checks 182 + // --------------------------------------------------------------------------- 183 + 184 + describe("pre-flight safety checks", () => { 185 + it("skips with 204 when the subject has no profile on the target", async () => { 186 + mockFetchRecord.mockResolvedValue({ 187 + found: false, 188 + uri: "", 189 + cid: "", 190 + did: "", 191 + collection: "", 192 + rkey: "", 193 + record: {}, 194 + }); 195 + 196 + const action = makeFollowAction({ target: "bluesky", subject: EVENT_DID }); 197 + const match = makeMatch({ automation: { actions: [action] }, event: { did: EVENT_DID } }); 198 + const result = await executeFollow(match, 0); 199 + 200 + expect(result.statusCode).toBe(204); 201 + expect(result.message).toMatch(/no Bluesky profile/); 202 + expect(mockCreateRecord).not.toHaveBeenCalled(); 203 + expect(mockExecuteSearch).not.toHaveBeenCalled(); 204 + 205 + const logs = await db.query.deliveryLogs.findMany(); 206 + expect(logs).toHaveLength(1); 207 + expect(logs[0]!.statusCode).toBe(204); 208 + expect(logs[0]!.message).toMatch(/no Bluesky profile/); 209 + expect(logs[0]!.error).toBeNull(); 210 + // Not retryable (204 is a success code, no setTimeout scheduled). 211 + expect(vi.getTimerCount()).toBe(0); 212 + }); 213 + 214 + it("skips with 204 when the subject is already followed on the target", async () => { 215 + mockExecuteSearch.mockResolvedValue({ 216 + found: true, 217 + uri: "at://did:plc:me/app.bsky.graph.follow/existing", 218 + cid: "c", 219 + record: { subject: EVENT_DID }, 220 + }); 221 + 222 + const action = makeFollowAction({ target: "bluesky", subject: EVENT_DID }); 223 + const match = makeMatch({ automation: { actions: [action] }, event: { did: EVENT_DID } }); 224 + const result = await executeFollow(match, 0); 225 + 226 + expect(result.statusCode).toBe(204); 227 + expect(result.message).toMatch(/already following/); 228 + expect(mockCreateRecord).not.toHaveBeenCalled(); 229 + 230 + const logs = await db.query.deliveryLogs.findMany(); 231 + expect(logs).toHaveLength(1); 232 + expect(logs[0]!.statusCode).toBe(204); 233 + expect(logs[0]!.message).toMatch(/already following/); 234 + expect(vi.getTimerCount()).toBe(0); 235 + }); 236 + 237 + it("uses the target-specific profile NSID for the profile pre-check", async () => { 238 + mockCreateRecord.mockResolvedValue({ uri: "at://x/_/rk", cid: "c" }); 239 + 240 + const tangled = makeFollowAction({ target: "tangled", subject: EVENT_DID }); 241 + await executeFollow(makeMatch({ automation: { actions: [tangled] } }), 0); 242 + expect(mockFetchRecord.mock.calls[0]![0]).toBe( 243 + `at://${EVENT_DID}/sh.tangled.actor.profile/self`, 244 + ); 245 + 246 + const sifa = makeFollowAction({ target: "sifa", subject: EVENT_DID }); 247 + await executeFollow(makeMatch({ automation: { actions: [sifa] } }), 0); 248 + expect(mockFetchRecord.mock.calls[1]![0]).toBe(`at://${EVENT_DID}/id.sifa.profile.self/self`); 249 + }); 250 + 251 + it("proceeds with the follow when a profile lookup throws (fail-open)", async () => { 252 + mockFetchRecord.mockRejectedValueOnce(new Error("PDS getRecord failed (502)")); 253 + mockCreateRecord.mockResolvedValueOnce({ 254 + uri: "at://x/app.bsky.graph.follow/rk", 255 + cid: "c", 256 + }); 257 + 258 + const action = makeFollowAction({ subject: EVENT_DID }); 259 + const match = makeMatch({ automation: { actions: [action] } }); 260 + const result = await executeFollow(match, 0); 261 + 262 + expect(result.statusCode).toBe(200); 263 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 264 + }); 265 + 266 + it("proceeds with the follow when the already-follows search throws (fail-open)", async () => { 267 + mockExecuteSearch.mockRejectedValueOnce(new Error("listRecords failed (503)")); 268 + mockCreateRecord.mockResolvedValueOnce({ 269 + uri: "at://x/app.bsky.graph.follow/rk", 270 + cid: "c", 271 + }); 272 + 273 + const action = makeFollowAction({ subject: EVENT_DID }); 274 + const match = makeMatch({ automation: { actions: [action] } }); 275 + const result = await executeFollow(match, 0); 276 + 277 + expect(result.statusCode).toBe(200); 278 + expect(mockCreateRecord).toHaveBeenCalledTimes(1); 279 + }); 143 280 }); 144 281 });
+105 -1
lib/actions/follow.ts
··· 1 - import { type FollowAction, FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 1 + import { 2 + type FollowAction, 3 + type FetchStepSearch, 4 + FOLLOW_TARGET_COLLECTION, 5 + FOLLOW_TARGET_PROFILE, 6 + } from "../db/schema.js"; 2 7 import { createArbitraryRecord } from "../automations/pds.js"; 8 + import { fetchRecord } from "../pds/resolver.js"; 9 + import { executeSearch } from "./searcher.js"; 3 10 import { renderTextTemplate, type FetchContext } from "./template.js"; 4 11 import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 5 12 import { DID_RE } from "./validation.js"; 13 + import { FOLLOW_TARGET_META } from "../automations/follow-targets.js"; 6 14 import type { ActionResult } from "./executor.js"; 7 15 import type { MatchedEvent } from "../jetstream/consumer.js"; 8 16 17 + /** HTTP status used to signal a skipped fire (no profile, already following). 18 + * Counts as success for chain-continuation (no fail-fast) and retry (no 5xx), 19 + * but is filtered out of rate-limit counts — a skipped follow did zero PDS 20 + * work and shouldn't burn the user's budget. */ 21 + const SKIP_STATUS = 204; 22 + 23 + async function checkProfileExists( 24 + action: FollowAction, 25 + subject: string, 26 + ): Promise<ActionResult | null> { 27 + const profile = FOLLOW_TARGET_PROFILE[action.target]; 28 + const profileUri = `at://${subject}/${profile.collection}/${profile.rkey}`; 29 + try { 30 + const result = await fetchRecord(profileUri); 31 + if (!result.found) { 32 + const appName = FOLLOW_TARGET_META[action.target].appName; 33 + return { 34 + statusCode: SKIP_STATUS, 35 + message: `Skipped: no ${appName} profile for ${subject}`, 36 + }; 37 + } 38 + } catch (err) { 39 + // Transient lookup failure (DID not resolvable, PDS unreachable) is not 40 + // the same signal as 404. Fall through and let the follow attempt proceed; 41 + // if it genuinely can't succeed, the PDS write will surface the failure 42 + // the normal way. 43 + console.warn( 44 + `follow: profile pre-check failed for ${subject} on ${action.target}: ${err instanceof Error ? err.message : String(err)}`, 45 + ); 46 + } 47 + return null; 48 + } 49 + 50 + /** 51 + * "Does the automation owner already follow the subject on the target graph?" 52 + * 53 + * Reuses `executeSearch`, which transparently picks the Bluesky appview 54 + * fast-path for `target: "bluesky"` and falls back to a paginated 55 + * `listRecords` scan on the owner's repo for Tangled/Sifa. The scan cost 56 + * matters there: for an owner with ~10k existing follows on those targets the 57 + * worst-case is tens of HTTP round-trips per fire, because AT Proto has no 58 + * server-side field index. Good enough for the MVP; revisit with a local 59 + * mirror-links table if it becomes a bottleneck. 60 + * 61 + * Not atomic with the subsequent write: two events racing for the same 62 + * subject can both observe `found: false` before either's follow record 63 + * lands, so a fast double-trigger can still produce duplicate rows. 64 + * Acceptable because Bluesky's appview collapses duplicate follows by 65 + * subject in the public graph; the extra row is cosmetic until cleaned up. 66 + */ 67 + async function checkNotAlreadyFollowing( 68 + match: MatchedEvent, 69 + action: FollowAction, 70 + subject: string, 71 + ): Promise<ActionResult | null> { 72 + const collection = FOLLOW_TARGET_COLLECTION[action.target]; 73 + const synthetic: FetchStepSearch = { 74 + kind: "search", 75 + name: "__follow_preflight_already_follows", 76 + repo: match.automation.did, 77 + collection, 78 + where: [{ field: "subject", operator: "eq", value: subject }], 79 + limit: 1, 80 + }; 81 + try { 82 + const entry = await executeSearch(synthetic, match.event, match.automation.did, {}); 83 + if (entry.found) { 84 + const appName = FOLLOW_TARGET_META[action.target].appName; 85 + return { 86 + statusCode: SKIP_STATUS, 87 + message: `Skipped: already following ${subject} on ${appName}`, 88 + }; 89 + } 90 + } catch (err) { 91 + // Same rationale as the profile pre-check: a transient search failure 92 + // shouldn't block the follow. Worst case is a duplicate record, which 93 + // the Bluesky appview collapses anyway — storage carries two rows until 94 + // one is cleaned up, but the public graph is still correct. 95 + console.warn( 96 + `follow: already-follows pre-check failed for ${subject} on ${action.target}: ${err instanceof Error ? err.message : String(err)}`, 97 + ); 98 + } 99 + return null; 100 + } 101 + 9 102 async function execute( 10 103 match: MatchedEvent, 11 104 action: FollowAction, ··· 31 124 return { statusCode: 400, error: `subject is not a valid DID: "${subject}"` }; 32 125 } 33 126 127 + // Built-in safety checks — run sequentially so users don't have to add them 128 + // manually as fetches + conditions. Either check resolving to "skip" writes 129 + // a 204 log entry and short-circuits before the PDS write. 130 + const profileSkip = await checkProfileExists(action, subject); 131 + if (profileSkip) return profileSkip; 132 + 133 + const dupSkip = await checkNotAlreadyFollowing(match, action, subject); 134 + if (dupSkip) return dupSkip; 135 + 34 136 const collection = FOLLOW_TARGET_COLLECTION[action.target]; 35 137 const record: Record<string, unknown> = { 36 138 subject, ··· 78 180 result.statusCode, 79 181 result.error ?? null, 80 182 retryIndex + 2, 183 + result.message ?? null, 81 184 ); 82 185 83 186 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { ··· 107 210 result.statusCode, 108 211 result.error ?? null, 109 212 1, 213 + result.message ?? null, 110 214 ); 111 215 112 216 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) {
+2 -2
lib/actions/searcher.test.ts
··· 122 122 123 123 it("stops at a safety cap and returns found:false on an exhausted scan", async () => { 124 124 mockResolveDid.mockResolvedValueOnce("https://pds.example.com"); 125 - // Always return a non-matching page with a cursor → trigger the 20-page cap. 125 + // Always return a non-matching page with a cursor → trigger the 100-page cap. 126 126 mockFetch.mockResolvedValue({ 127 127 ok: true, 128 128 json: async () => ({ ··· 140 140 const result = await executeSearch(makeSearchStep(), event, ownerDid, {}); 141 141 142 142 expect(result.found).toBe(false); 143 - expect(mockFetch).toHaveBeenCalledTimes(20); 143 + expect(mockFetch).toHaveBeenCalledTimes(100); 144 144 }); 145 145 146 146 it("renders templates in repo and where values", async () => {
+6 -45
lib/automations/action-catalogue.ts
··· 8 8 UserPlus, 9 9 Webhook, 10 10 } from "../../app/icons.js"; 11 - import type { FollowTarget } from "../db/schema.js"; 11 + import { FOLLOW_TARGET_META, type ColorKey } from "./follow-targets.js"; 12 + 13 + // Re-exported so existing consumers can keep importing from action-catalogue. 14 + // The data itself lives in follow-targets.ts (no icon / JSX dependencies) so 15 + // the backend action pipeline can read `appName` without pulling in UI code. 16 + export { FOLLOW_TARGET_META, type ColorKey }; 12 17 13 18 export type AddableActionId = 14 19 | "webhook" ··· 20 25 | "follow-tangled" 21 26 | "follow-sifa"; 22 27 23 - /** Keys that map to a CSS selector in action-header.css.ts. Keep in sync 24 - * with the `&[data-cat="..."]` selectors there; a typo silently loses the 25 - * accent otherwise. */ 26 - export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled"; 27 - 28 28 type ActionInfo = { 29 29 label: string; 30 30 icon: (typeof ACTION_CATALOGUE)[number]["actions"][number]["icon"]; ··· 35 35 colorKey?: ColorKey; 36 36 /** Domain used to render the per-app favicon next to the icon (bookmark, follow). */ 37 37 faviconDomain?: string; 38 - }; 39 - 40 - /** Per-target metadata for follow actions. Shared between catalogue, editor, 41 - * card target-icon, and ActionHeader. */ 42 - export const FOLLOW_TARGET_META: Record< 43 - FollowTarget, 44 - { 45 - catId: "bluesky" | "apps"; 46 - colorKey: ColorKey; 47 - appName: string; 48 - label: string; 49 - faviconDomain: string; 50 - description: string; 51 - } 52 - > = { 53 - bluesky: { 54 - catId: "bluesky", 55 - colorKey: "bluesky", 56 - appName: "Bluesky", 57 - label: "Follow on Bluesky", 58 - faviconDomain: "bsky.app", 59 - description: "Follow someone on Bluesky", 60 - }, 61 - tangled: { 62 - catId: "apps", 63 - colorKey: "tangled", 64 - appName: "Tangled", 65 - label: "Follow on Tangled", 66 - faviconDomain: "tangled.sh", 67 - description: "Follow someone on Tangled", 68 - }, 69 - sifa: { 70 - catId: "apps", 71 - colorKey: "sifa", 72 - appName: "Sifa", 73 - label: "Follow on Sifa", 74 - faviconDomain: "sifa.id", 75 - description: "Follow someone on Sifa", 76 - }, 77 38 }; 78 39 79 40 export const ACTION_CATALOGUE = [
+47
lib/automations/follow-targets.ts
··· 1 + import type { FollowTarget } from "../db/schema.js"; 2 + 3 + /** Keys that map to a CSS selector in action-header.css.ts. Keep in sync 4 + * with the `&[data-cat="..."]` selectors there; a typo silently loses the 5 + * accent otherwise. */ 6 + export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled"; 7 + 8 + /** Per-target metadata for follow actions. Lives in a pure-data module (no JSX 9 + * / icon imports) so backend code paths like `executeFollow` and the dry-run 10 + * logger can read `appName` without pulling in UI components. The action 11 + * catalogue in `action-catalogue.ts` re-exports this for UI consumers. */ 12 + export const FOLLOW_TARGET_META: Record< 13 + FollowTarget, 14 + { 15 + catId: "bluesky" | "apps"; 16 + colorKey: ColorKey; 17 + appName: string; 18 + label: string; 19 + faviconDomain: string; 20 + description: string; 21 + } 22 + > = { 23 + bluesky: { 24 + catId: "bluesky", 25 + colorKey: "bluesky", 26 + appName: "Bluesky", 27 + label: "Follow on Bluesky", 28 + faviconDomain: "bsky.app", 29 + description: "Follow someone on Bluesky", 30 + }, 31 + tangled: { 32 + catId: "apps", 33 + colorKey: "tangled", 34 + appName: "Tangled", 35 + label: "Follow on Tangled", 36 + faviconDomain: "tangled.sh", 37 + description: "Follow someone on Tangled", 38 + }, 39 + sifa: { 40 + catId: "apps", 41 + colorKey: "sifa", 42 + appName: "Sifa", 43 + label: "Follow on Sifa", 44 + faviconDomain: "sifa.id", 45 + description: "Follow someone on Sifa", 46 + }, 47 + };
+9
lib/db/schema.ts
··· 74 74 sifa: "id.sifa.graph.follow", 75 75 }; 76 76 77 + /** Profile record each follow target expects to exist before a follow is 78 + * meaningful. Used by the follow action's built-in "profile exists" pre-flight 79 + * check so users don't have to hand-write a record fetch + condition for it. */ 80 + export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = { 81 + bluesky: { collection: "app.bsky.actor.profile", rkey: "self" }, 82 + tangled: { collection: "sh.tangled.actor.profile", rkey: "self" }, 83 + sifa: { collection: "id.sifa.profile.self", rkey: "self" }, 84 + }; 85 + 77 86 /** Action types that produce a record result (uri, cid, rkey) for chaining. */ 78 87 const RECORD_PRODUCING_TYPES = new Set([ 79 88 "record",
+26
lib/jetstream/handler.test.ts
··· 54 54 makeRecordAction, 55 55 makeBskyPostAction, 56 56 makeFetchStep, 57 + makeFollowAction, 57 58 } from "../test/fixtures.js"; 58 59 import type { ActionResult } from "../actions/executor.js"; 59 60 ··· 212 213 message: expect.stringContaining("existingMirror"), 213 214 }), 214 215 ); 216 + }); 217 + 218 + it("dry-run follow log advertises the built-in safety checks", async () => { 219 + // The real `executeFollow` runs profile + already-follows pre-flight checks 220 + // inside the action handler. Dry-run skips those (would burn PDS calls for 221 + // a preview) but the dry-run log message should tell the author the real 222 + // run will skip cleanly on both edges. 223 + mockInsert.mockClear(); 224 + mockInsertValues.mockClear(); 225 + 226 + const match = makeMatch({ 227 + automation: { 228 + actions: [makeFollowAction({ target: "bluesky", subject: "did:plc:someone" })], 229 + fetches: [], 230 + dryRun: true, 231 + }, 232 + }); 233 + 234 + await handleMatchedEvent(match); 235 + 236 + expect(mockInsertValues).toHaveBeenCalledTimes(1); 237 + const logged = mockInsertValues.mock.calls[0]![0] as { message: string; dryRun: boolean }; 238 + expect(logged.dryRun).toBe(true); 239 + expect(logged.message).toContain("Would follow did:plc:someone on bluesky"); 240 + expect(logged.message).toContain("will skip if no Bluesky profile exists or already following"); 215 241 }); 216 242 217 243 it("skips fetch resolution when no fetch steps", async () => {
+6 -1
lib/jetstream/handler.ts
··· 7 7 import { executeBookmark } from "../actions/bookmark.js"; 8 8 import { executeFollow } from "../actions/follow.js"; 9 9 import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js"; 10 + import { FOLLOW_TARGET_META } from "../automations/follow-targets.js"; 10 11 import { resolveFetches } from "../actions/fetcher.js"; 11 12 import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js"; 12 13 import { parseAtUri } from "../pds/resolver.js"; ··· 162 163 await renderTextTemplate(action.subject, match.event, fetchContext, match.automation) 163 164 ).trim(); 164 165 const collection = FOLLOW_TARGET_COLLECTION[action.target]; 165 - message = `Would follow ${subject || "(empty)"} on ${action.target}`; 166 + const appName = FOLLOW_TARGET_META[action.target].appName; 167 + // The built-in safety checks live inside executeFollow and aren't run in 168 + // dry-run (keeps the preview cheap). Advertise their presence in the 169 + // message so authors know the real run will skip cleanly on both edges. 170 + message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)`; 166 171 payload = JSON.stringify({ collection, subject }); 167 172 } catch (err) { 168 173 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
+24 -1
lib/jetstream/rate-limit.test.ts
··· 45 45 return row; 46 46 } 47 47 48 - async function insertLogs(count: number, offsetsMs: number[], dryRun = false) { 48 + async function insertLogs( 49 + count: number, 50 + offsetsMs: number[], 51 + dryRun = false, 52 + statusCode: number | null = 200, 53 + ) { 49 54 const values = offsetsMs.slice(0, count).map((offset) => ({ 50 55 automationUri: URI, 51 56 actionIndex: 0, 52 57 eventTimeUs: NOW * 1000, 53 58 dryRun, 59 + statusCode, 54 60 attempt: 1, 55 61 createdAt: new Date(NOW - offset), 56 62 })); ··· 105 111 ); 106 112 const breach = await checkRateLimit(await loadAuto(), NOW); 107 113 expect(breach).toBeNull(); 114 + }); 115 + 116 + it("does not count 204-skipped fires toward the limit", async () => { 117 + // Follow-action safety-skip entries (profile missing, already following) 118 + // write 204 rows to deliveryLogs so users see why nothing happened, but 119 + // they did zero PDS work — excluding them keeps the rate budget fair. 120 + await insertLogs( 121 + 20, 122 + Array.from({ length: 20 }, (_, i) => 100 + i * 40), 123 + false, 124 + 204, 125 + ); 126 + const breach = await checkRateLimit(await loadAuto(), NOW); 127 + expect(breach).toBeNull(); 128 + 129 + const counts = await getRateLimitCounts(await loadAuto(), NOW); 130 + expect(counts.every((c) => c.count === 0)).toBe(true); 108 131 }); 109 132 110 133 it("ignores log rows older than rateLimitResetAt", async () => {
+9 -2
lib/jetstream/rate-limit.ts
··· 1 - import { and, eq, gte } from "drizzle-orm"; 1 + import { and, eq, gte, ne, or, isNull } from "drizzle-orm"; 2 2 import { db } from "../db/index.js"; 3 3 import { automations, deliveryLogs } from "../db/schema.js"; 4 4 ··· 48 48 49 49 /** Timestamps of recent (non dry-run) log rows. Honours `rateLimitResetAt` so 50 50 * a manual re-enable after an auto-disable starts the counters from zero 51 - * instead of immediately re-tripping on the same logs. */ 51 + * instead of immediately re-tripping on the same logs. 52 + * 53 + * `statusCode = 204` is excluded: those rows are emitted by action-level 54 + * safety checks (e.g. the follow action's "already following" / "no profile" 55 + * skips) that did zero PDS work and shouldn't burn the user's budget. `IS 56 + * NULL` is kept to be forward-compatible with any future non-dry-run log 57 + * that omits the status. */ 52 58 async function loadRecentTimestamps( 53 59 automation: RateLimitAutomation, 54 60 now: number, ··· 64 70 eq(deliveryLogs.automationUri, automation.uri), 65 71 eq(deliveryLogs.dryRun, false), 66 72 gte(deliveryLogs.createdAt, cutoff), 73 + or(ne(deliveryLogs.statusCode, 204), isNull(deliveryLogs.statusCode)), 67 74 ), 68 75 ); 69 76 return rows.map((r) => r.createdAt.getTime());
+74
lib/pds/resolver.test.ts
··· 208 208 await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (500)"); 209 209 }); 210 210 211 + it("returns found:false when PDS returns 400 with error=RecordNotFound", async () => { 212 + // AT Proto's getRecord lexicon returns missing records as HTTP 400 + 213 + // `{"error": "RecordNotFound"}`. Most PDSes (Bluesky, Tangled, Sifa) follow 214 + // this. Normalize to the same not-found entry as 404 so per-fetch 215 + // conditions can observe it. 216 + mockResolve.mockResolvedValueOnce({ 217 + id: "did:plc:abc", 218 + service: [ 219 + { 220 + id: "#atproto_pds", 221 + type: "AtprotoPersonalDataServer", 222 + serviceEndpoint: "https://pds.example.com", 223 + }, 224 + ], 225 + }); 226 + mockFetch.mockResolvedValueOnce({ 227 + ok: false, 228 + status: 400, 229 + json: async () => ({ 230 + error: "RecordNotFound", 231 + message: "Could not locate record: at://did:plc:abc/col/rk", 232 + }), 233 + }); 234 + 235 + const result = await fetchRecord("at://did:plc:abc/col/rk"); 236 + expect(result.found).toBe(false); 237 + expect(result.uri).toBe("at://did:plc:abc/col/rk"); 238 + expect(result.did).toBe("did:plc:abc"); 239 + expect(result.collection).toBe("col"); 240 + expect(result.rkey).toBe("rk"); 241 + }); 242 + 243 + it("still throws on 400 with a different XRPC error code", async () => { 244 + mockResolve.mockResolvedValueOnce({ 245 + id: "did:plc:abc", 246 + service: [ 247 + { 248 + id: "#atproto_pds", 249 + type: "AtprotoPersonalDataServer", 250 + serviceEndpoint: "https://pds.example.com", 251 + }, 252 + ], 253 + }); 254 + mockFetch.mockResolvedValueOnce({ 255 + ok: false, 256 + status: 400, 257 + json: async () => ({ error: "InvalidRequest", message: "bad rkey format" }), 258 + }); 259 + 260 + await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (400)"); 261 + }); 262 + 263 + it("still throws on 400 when the body isn't parseable JSON", async () => { 264 + mockResolve.mockResolvedValueOnce({ 265 + id: "did:plc:abc", 266 + service: [ 267 + { 268 + id: "#atproto_pds", 269 + type: "AtprotoPersonalDataServer", 270 + serviceEndpoint: "https://pds.example.com", 271 + }, 272 + ], 273 + }); 274 + mockFetch.mockResolvedValueOnce({ 275 + ok: false, 276 + status: 400, 277 + json: async () => { 278 + throw new Error("not JSON"); 279 + }, 280 + }); 281 + 282 + await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (400)"); 283 + }); 284 + 211 285 it("returns defaults for missing cid/value in response", async () => { 212 286 mockResolve.mockResolvedValueOnce({ 213 287 id: "did:plc:abc",
+16
lib/pds/resolver.ts
··· 100 100 } 101 101 102 102 if (!res.ok) { 103 + // AT Proto's `com.atproto.repo.getRecord` lexicon returns HTTP 400 with an 104 + // XRPC error body `{ "error": "RecordNotFound" }` when the record is 105 + // missing (rather than 404, which some PDSes also emit and we handle 106 + // above). Normalize the 400 variant to the same not-found entry so 107 + // per-fetch `found exists`/`not-exists` conditions can gate on it. Other 108 + // 4xx/5xx still throw — those are genuine errors the caller should see. 109 + if (res.status === 400) { 110 + try { 111 + const body = (await res.json()) as { error?: string }; 112 + if (body.error === "RecordNotFound") { 113 + return { found: false, uri: atUri, cid: "", did, collection, rkey, record: {} }; 114 + } 115 + } catch { 116 + // Body wasn't JSON, or didn't include `error` — fall through to throw. 117 + } 118 + } 103 119 throw new Error(`PDS getRecord failed (${res.status}) for ${atUri}`); 104 120 } 105 121