···1212});
13131414// Action types for automation actions (stored as JSON)
1515+/** Per-action fan-out: when set, the action runs once per matching item
1616+ * resolved from `path` (a dotted path with `[]` segments meaning "flat-map this
1717+ * array level"). Conditions are evaluated against each item, rooted at `item.*`. */
1818+export type ForEachConfig = {
1919+ path: string;
2020+ conditions?: Condition[];
2121+};
2222+1523export type WebhookAction = {
1624 $type: "webhook";
1725 callbackUrl: string;
···1927 headers?: Record<string, string>; // custom HTTP headers, values may reference {{secret:name}}
2028 verified?: boolean; // true if /.well-known/airglow manifest matched
2129 comment?: string;
3030+ forEach?: ForEachConfig;
2231};
23322433export type RecordAction = {
···2635 targetCollection: string;
2736 recordTemplate: string;
2837 comment?: string;
3838+ forEach?: ForEachConfig;
2939};
30403141export type BskyPostAction = {
···3444 langs?: string[];
3545 labels?: string[];
3646 comment?: string;
4747+ forEach?: ForEachConfig;
3748};
38493950export type PatchRecordAction = {
···4253 baseRecordUri: string; // AT URI template for the record to update
4354 recordTemplate: string; // JSON template — fields merged on top of base
4455 comment?: string;
5656+ forEach?: ForEachConfig;
4557};
46584759export type BookmarkAction = {
···5163 bodyValue?: string;
5264 tags?: string[];
5365 comment?: string;
6666+ forEach?: ForEachConfig;
5467};
55685669export type FollowAction = {
···5871 target: FollowTarget;
5972 subject: string;
6073 comment?: string;
7474+ forEach?: ForEachConfig;
6175};
62766377export type Action =
+260
lib/jetstream/handler.test.ts
···483483 expect(mockDispatch).toHaveBeenCalledOnce();
484484 });
485485 });
486486+487487+ describe("forEach", () => {
488488+ // A bsky-post event with two link facets and one mention facet — the
489489+ // canonical "iterate over facets" use case that drove this feature.
490490+ function postWithFacets() {
491491+ return makeMatch({
492492+ event: {
493493+ did: "did:plc:author",
494494+ time_us: 1700000000000000,
495495+ kind: "commit",
496496+ commit: {
497497+ rev: "r",
498498+ operation: "create",
499499+ collection: "app.bsky.feed.post",
500500+ rkey: "rk1",
501501+ record: {
502502+ text: "x",
503503+ facets: [
504504+ {
505505+ index: { byteStart: 0, byteEnd: 5 },
506506+ features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://a.test" }],
507507+ },
508508+ {
509509+ index: { byteStart: 6, byteEnd: 12 },
510510+ features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:bob" }],
511511+ },
512512+ {
513513+ index: { byteStart: 14, byteEnd: 20 },
514514+ features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://b.test" }],
515515+ },
516516+ ],
517517+ },
518518+ },
519519+ },
520520+ });
521521+ }
522522+523523+ it("invokes the action once per matching item", async () => {
524524+ const match = postWithFacets();
525525+ match.automation.actions = [
526526+ makeWebhookAction({
527527+ forEach: {
528528+ path: "event.commit.record.facets[].features[]",
529529+ conditions: [{ field: "$type", operator: "eq", value: "app.bsky.richtext.facet#link" }],
530530+ },
531531+ }),
532532+ ];
533533+534534+ await handleMatchedEvent(match);
535535+536536+ // Two link facets → two dispatch calls. Mention facet filtered out.
537537+ expect(mockDispatch).toHaveBeenCalledTimes(2);
538538+ const itemArgs = mockDispatch.mock.calls.map((c) => c[3]);
539539+ expect((itemArgs[0] as { uri: string }).uri).toBe("https://a.test");
540540+ expect((itemArgs[1] as { uri: string }).uri).toBe("https://b.test");
541541+ });
542542+543543+ it("skips the action entirely when no item matches", async () => {
544544+ const match = postWithFacets();
545545+ match.automation.actions = [
546546+ makeWebhookAction({
547547+ forEach: {
548548+ path: "event.commit.record.facets[].features[]",
549549+ conditions: [{ field: "$type", operator: "eq", value: "nonexistent" }],
550550+ },
551551+ }),
552552+ ];
553553+554554+ await handleMatchedEvent(match);
555555+ expect(mockDispatch).not.toHaveBeenCalled();
556556+ });
557557+558558+ it("returns an empty list (no calls) when the path resolves to nothing", async () => {
559559+ const match = postWithFacets();
560560+ match.automation.actions = [
561561+ makeWebhookAction({
562562+ forEach: { path: "event.commit.record.tags[]" },
563563+ }),
564564+ ];
565565+566566+ await handleMatchedEvent(match);
567567+ expect(mockDispatch).not.toHaveBeenCalled();
568568+ });
569569+570570+ it("chains the last successful iteration's result into actionN", async () => {
571571+ mockExecuteAction
572572+ .mockReset()
573573+ .mockResolvedValueOnce({
574574+ statusCode: 200,
575575+ uri: "at://did:plc:test/col/first",
576576+ cid: "cidA",
577577+ })
578578+ .mockResolvedValueOnce({
579579+ statusCode: 200,
580580+ uri: "at://did:plc:test/col/second",
581581+ cid: "cidB",
582582+ })
583583+ .mockResolvedValue(okWithUri);
584584+585585+ const match = postWithFacets();
586586+ match.automation.actions = [
587587+ makeRecordAction({
588588+ forEach: { path: "event.commit.record.facets[].features[]" },
589589+ }),
590590+ makeRecordAction({ recordTemplate: '{"x":"{{action1.uri}}"}' }),
591591+ ];
592592+593593+ await handleMatchedEvent(match);
594594+595595+ // 3 forEach iterations + 1 trailing action = 4 calls (only 2 mock results
596596+ // were queued for the iterations; the rest fall through to the default
597597+ // `okWithUri`). The 4th call is the trailing action — its fetchContext
598598+ // should expose actionN populated from the *last successful* iteration.
599599+ expect(mockExecuteAction).toHaveBeenCalledTimes(4);
600600+ const trailingCall = mockExecuteAction.mock.calls[3]!;
601601+ expect(trailingCall[1]).toBe(1); // action index
602602+ const trailingFetchContext = trailingCall[2] as Record<string, { uri: string }>;
603603+ expect(trailingFetchContext.action1!.uri).toBe("at://did:plc:test/app.bsky.feed.post/abc123");
604604+ });
605605+606606+ it("dry-run logs one row per matching item, plus actionN is synthesized", async () => {
607607+ const match = postWithFacets();
608608+ match.automation.dryRun = true;
609609+ match.automation.actions = [
610610+ makeWebhookAction({
611611+ forEach: {
612612+ path: "event.commit.record.facets[].features[]",
613613+ conditions: [{ field: "$type", operator: "eq", value: "app.bsky.richtext.facet#link" }],
614614+ },
615615+ }),
616616+ ];
617617+618618+ await handleMatchedEvent(match);
619619+ expect(mockDispatch).not.toHaveBeenCalled();
620620+ // 2 dry-run rows (one per matching link)
621621+ expect(mockInsertValues).toHaveBeenCalledTimes(2);
622622+ });
623623+624624+ it("dry-run with empty forEach result writes one explanatory row", async () => {
625625+ const match = postWithFacets();
626626+ match.automation.dryRun = true;
627627+ match.automation.actions = [
628628+ makeWebhookAction({
629629+ forEach: {
630630+ path: "event.commit.record.facets[].features[]",
631631+ conditions: [{ field: "$type", operator: "eq", value: "nonexistent" }],
632632+ },
633633+ }),
634634+ ];
635635+636636+ await handleMatchedEvent(match);
637637+ expect(mockInsertValues).toHaveBeenCalledTimes(1);
638638+ const row = mockInsertValues.mock.calls[0]![0] as { message: string | null };
639639+ expect(row.message).toMatch(/none matched the per-item conditions/);
640640+ });
641641+642642+ it("caps iterations at 64 per trigger and writes a truncation log row", async () => {
643643+ // 80-element array — past the cap of 64. Each item is a link facet that
644644+ // matches the condition.
645645+ const features = Array.from({ length: 80 }, (_, i) => ({
646646+ $type: "app.bsky.richtext.facet#link",
647647+ uri: `https://link${i}.test`,
648648+ }));
649649+ const match = makeMatch({
650650+ event: {
651651+ did: "did:plc:author",
652652+ time_us: 1700000000000000,
653653+ kind: "commit",
654654+ commit: {
655655+ rev: "r",
656656+ operation: "create",
657657+ collection: "app.bsky.feed.post",
658658+ rkey: "rk",
659659+ record: {
660660+ text: "x",
661661+ facets: features.map((f) => ({
662662+ index: { byteStart: 0, byteEnd: 1 },
663663+ features: [f],
664664+ })),
665665+ },
666666+ },
667667+ },
668668+ });
669669+ match.automation.actions = [
670670+ makeWebhookAction({
671671+ forEach: { path: "event.commit.record.facets[].features[]" },
672672+ }),
673673+ ];
674674+675675+ mockInsertValues.mockClear();
676676+ await handleMatchedEvent(match);
677677+ // Action ran exactly 64 times, not 80.
678678+ expect(mockDispatch).toHaveBeenCalledTimes(64);
679679+ // Truncation row was written. The handler doesn't write per-call
680680+ // delivery logs (executors do that themselves and they're mocked here),
681681+ // so this is the only insert we expect.
682682+ expect(mockInsertValues).toHaveBeenCalledTimes(1);
683683+ const row = mockInsertValues.mock.calls[0]![0] as { error: string | null };
684684+ expect(row.error).toMatch(/forEach matched 80 items, capped at 64/);
685685+ });
686686+687687+ it("dry-run also caps at 64 and emits one truncation row alongside per-item rows", async () => {
688688+ const features = Array.from({ length: 100 }, (_, i) => ({
689689+ $type: "app.bsky.richtext.facet#link",
690690+ uri: `https://l${i}.test`,
691691+ }));
692692+ const match = makeMatch({
693693+ event: {
694694+ did: "did:plc:author",
695695+ time_us: 1700000000000000,
696696+ kind: "commit",
697697+ commit: {
698698+ rev: "r",
699699+ operation: "create",
700700+ collection: "app.bsky.feed.post",
701701+ rkey: "rk",
702702+ record: {
703703+ text: "x",
704704+ facets: features.map((f) => ({
705705+ index: { byteStart: 0, byteEnd: 1 },
706706+ features: [f],
707707+ })),
708708+ },
709709+ },
710710+ },
711711+ });
712712+ match.automation.dryRun = true;
713713+ match.automation.actions = [
714714+ makeWebhookAction({
715715+ forEach: { path: "event.commit.record.facets[].features[]" },
716716+ }),
717717+ ];
718718+719719+ mockInsertValues.mockClear();
720720+ await handleMatchedEvent(match);
721721+ // 64 per-item dry-run rows + 1 truncation row.
722722+ expect(mockInsertValues).toHaveBeenCalledTimes(65);
723723+ });
724724+725725+ it("stops the chain (fail-fast) on the first failed iteration", async () => {
726726+ mockDispatch
727727+ .mockReset()
728728+ .mockResolvedValueOnce({ statusCode: 200 })
729729+ .mockResolvedValueOnce({ statusCode: 500, error: "boom" });
730730+731731+ const match = postWithFacets();
732732+ match.automation.actions = [
733733+ makeWebhookAction({
734734+ forEach: { path: "event.commit.record.facets[].features[]" },
735735+ }),
736736+ makeWebhookAction({ callbackUrl: "https://other.test/hook" }),
737737+ ];
738738+739739+ await handleMatchedEvent(match);
740740+741741+ // 2 iterations into action 0 (first ok, second 500), then action 1 must
742742+ // not run because the chain broke.
743743+ expect(mockDispatch).toHaveBeenCalledTimes(2);
744744+ });
745745+ });
486746});
+230-41
lib/jetstream/handler.ts
···1010import { resolveFetches } from "../actions/fetcher.js";
1111import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js";
1212import { parseAtUri } from "../pds/resolver.js";
1313+import { collectItems, matchItemConditions } from "./matcher.js";
1314import { notifyAutomationChange, type MatchedEvent } from "./consumer.js";
1415import { checkRateLimit, disableForRateLimit, type RateLimitBreach } from "./rate-limit.js";
15161717+type ActionHandler = (
1818+ match: MatchedEvent,
1919+ actionIndex: number,
2020+ fetchContext?: FetchContext,
2121+ item?: unknown,
2222+) => Promise<ActionResult>;
2323+2424+function handlerFor(action: Action): ActionHandler {
2525+ switch (action.$type) {
2626+ case "bsky-post":
2727+ return executeBskyPost;
2828+ case "record":
2929+ return executeAction;
3030+ case "patch-record":
3131+ return executePatchRecord;
3232+ case "bookmark":
3333+ return executeBookmark;
3434+ case "follow":
3535+ return executeFollow;
3636+ default:
3737+ return dispatch;
3838+ }
3939+}
4040+4141+/**
4242+ * Hard cap on items processed per forEach action. Bounds blast radius so a
4343+ * single adversarial event (e.g. a Bluesky post crafted with thousands of
4444+ * facets) can't cause a runaway fan-out of webhook deliveries / PDS writes /
4545+ * delivery_log rows for one action. Items beyond the cap are silently dropped
4646+ * and a single delivery_log entry records the truncation.
4747+ *
4848+ * Note: this cap is per-action, not per-trigger. With the per-automation
4949+ * action limit (`AUTOMATION_LIMITS.actions = 10`), the absolute upper bound
5050+ * on deliveries from one trigger is 10 × cap. The work cap inside
5151+ * `collectItems` (matcher.ts) bounds the *processing* fan-out independently.
5252+ */
5353+const MAX_FOR_EACH_ITEMS_PER_ACTION = 64;
5454+5555+async function logForEachTruncation(
5656+ automationUri: string,
5757+ actionIndex: number,
5858+ eventTimeUs: number,
5959+ matchedCount: number,
6060+ dryRun: boolean,
6161+) {
6262+ await db.insert(deliveryLogs).values({
6363+ automationUri,
6464+ actionIndex,
6565+ eventTimeUs,
6666+ payload: null,
6767+ statusCode: null,
6868+ message: null,
6969+ error: `forEach matched ${matchedCount} items, capped at ${MAX_FOR_EACH_ITEMS_PER_ACTION}; remaining items skipped for this trigger.`,
7070+ dryRun,
7171+ attempt: 1,
7272+ createdAt: new Date(),
7373+ });
7474+}
7575+1676/** Handle a matched Jetstream event: resolve fetches, then dispatch all actions. */
1777export async function handleMatchedEvent(match: MatchedEvent) {
1878 // Rate-limit gate. Runs before fetches so a breached automation stops
···58118 : [];
59119 for (let i = 0; i < match.automation.actions.length; i++) {
60120 const action = match.automation.actions[i]!;
6161- await logDryRun(match, i, action, fetchContext, fetchErrors);
121121+122122+ if (action.forEach) {
123123+ const items = collectItems(
124124+ action.forEach.path,
125125+ match.event,
126126+ fetchContext,
127127+ match.automation.uri,
128128+ );
129129+ const matched = items.filter((item) =>
130130+ matchItemConditions(item, action.forEach!.conditions, match.automation.did),
131131+ );
132132+ if (matched.length === 0) {
133133+ await logDryRun(match, i, action, fetchContext, fetchErrors, {
134134+ forEachEmpty: true,
135135+ totalItems: items.length,
136136+ });
137137+ } else {
138138+ const truncated = matched.length > MAX_FOR_EACH_ITEMS_PER_ACTION;
139139+ const toRun = truncated ? matched.slice(0, MAX_FOR_EACH_ITEMS_PER_ACTION) : matched;
140140+ for (const item of toRun) {
141141+ await logDryRun(match, i, action, fetchContext, fetchErrors, { item });
142142+ }
143143+ if (truncated) {
144144+ await logForEachTruncation(
145145+ match.automation.uri,
146146+ i,
147147+ match.event.time_us,
148148+ matched.length,
149149+ true,
150150+ );
151151+ }
152152+ }
153153+ } else {
154154+ await logDryRun(match, i, action, fetchContext, fetchErrors);
155155+ }
156156+62157 // Inject synthetic action result so subsequent dry-run actions can reference {{actionN.*}}
63158 if (isRecordProducingAction(action.$type)) {
64159 fetchContext[`action${i + 1}`] = {
···7717278173 for (let i = 0; i < match.automation.actions.length; i++) {
79174 const action = match.automation.actions[i]!;
8080- const handler =
8181- action.$type === "bsky-post"
8282- ? executeBskyPost
8383- : action.$type === "record"
8484- ? executeAction
8585- : action.$type === "patch-record"
8686- ? executePatchRecord
8787- : action.$type === "bookmark"
8888- ? executeBookmark
8989- : action.$type === "follow"
9090- ? executeFollow
9191- : dispatch;
175175+ const handler = handlerFor(action);
9217693177 try {
9494- const result: ActionResult = await handler(match, i, fetchContext);
178178+ let lastSuccess: ActionResult | null = null;
179179+ let chainBreak = false;
180180+181181+ if (action.forEach) {
182182+ const items = collectItems(
183183+ action.forEach.path,
184184+ match.event,
185185+ fetchContext,
186186+ match.automation.uri,
187187+ );
188188+ const matched = items.filter((item) =>
189189+ matchItemConditions(item, action.forEach!.conditions, match.automation.did),
190190+ );
191191+ const truncated = matched.length > MAX_FOR_EACH_ITEMS_PER_ACTION;
192192+ const toRun = truncated ? matched.slice(0, MAX_FOR_EACH_ITEMS_PER_ACTION) : matched;
193193+ for (const item of toRun) {
194194+ const result: ActionResult = await handler(match, i, fetchContext, item);
195195+ if (isActionSuccess(result.statusCode)) {
196196+ lastSuccess = result;
197197+ } else {
198198+ console.error(
199199+ `Action ${i} (${action.$type}) failed (${result.statusCode}) on forEach item, stopping chain: ${result.error ?? ""}`,
200200+ );
201201+ chainBreak = true;
202202+ break;
203203+ }
204204+ }
205205+ if (truncated && !chainBreak) {
206206+ await logForEachTruncation(
207207+ match.automation.uri,
208208+ i,
209209+ match.event.time_us,
210210+ matched.length,
211211+ false,
212212+ );
213213+ }
214214+ } else {
215215+ const result: ActionResult = await handler(match, i, fetchContext);
216216+ if (isActionSuccess(result.statusCode)) {
217217+ lastSuccess = result;
218218+ } else {
219219+ console.error(
220220+ `Action ${i} (${action.$type}) failed (${result.statusCode}), stopping chain: ${result.error ?? ""}`,
221221+ );
222222+ chainBreak = true;
223223+ }
224224+ }
952259696- // Accumulate result into fetchContext for downstream actions
9797- if (result.uri && result.cid) {
9898- const { did, collection, rkey } = parseAtUri(result.uri);
226226+ // Accumulate the most recent successful result into fetchContext for
227227+ // downstream actions. For forEach, this is the last successful iteration.
228228+ if (lastSuccess?.uri && lastSuccess.cid) {
229229+ const { did, collection, rkey } = parseAtUri(lastSuccess.uri);
99230 fetchContext[`action${i + 1}`] = {
100231 found: true,
101101- uri: result.uri,
102102- cid: result.cid,
232232+ uri: lastSuccess.uri,
233233+ cid: lastSuccess.cid,
103234 did,
104235 collection,
105236 rkey,
···107238 };
108239 }
109240110110- // Fail-fast: stop chain on error
111111- if (!isActionSuccess(result.statusCode)) {
112112- console.error(
113113- `Action ${i} (${action.$type}) failed (${result.statusCode}), stopping chain: ${result.error ?? ""}`,
114114- );
115115- break;
116116- }
241241+ if (chainBreak) break;
117242 } catch (err) {
118243 console.error(`Action ${i} (${action.$type}) threw, stopping chain:`, err);
119244 break;
···131256 action: Action,
132257 fetchContext: FetchContext,
133258 failedFetches: string[],
259259+ options?: { item?: unknown; forEachEmpty?: boolean; totalItems?: number },
134260) {
135261 let message: string | null = null;
136262 let error: string | null = null;
137263 let payload: string | null = null;
138264265265+ // forEach with no matching item: emit a single explanatory row instead of
266266+ // staying silent — otherwise the user sees nothing and can't tell whether
267267+ // the path or the conditions filtered everything out.
268268+ if (options?.forEachEmpty) {
269269+ const total = options.totalItems ?? 0;
270270+ message =
271271+ total === 0
272272+ ? `Would skip: forEach path "${action.forEach?.path}" resolved to no items`
273273+ : `Would skip: ${total} item(s) found at "${action.forEach?.path}" but none matched the per-item conditions`;
274274+ await db.insert(deliveryLogs).values({
275275+ automationUri: match.automation.uri,
276276+ actionIndex,
277277+ eventTimeUs: match.event.time_us,
278278+ payload: null,
279279+ statusCode: null,
280280+ message,
281281+ error: null,
282282+ dryRun: true,
283283+ attempt: 1,
284284+ createdAt: new Date(),
285285+ });
286286+ return;
287287+ }
288288+289289+ const item = options?.item;
290290+ const itemSuffix = item !== undefined ? ` (item: ${truncateForLog(JSON.stringify(item))})` : "";
291291+139292 if (failedFetches.length > 0) {
140293 error = `Fetch failed: ${failedFetches.join(", ")}`;
141294 } else if (action.$type === "webhook") {
142295 const headerCount = action.headers ? Object.keys(action.headers).length : 0;
143296 const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : "";
144144- message = `Would POST to ${action.callbackUrl}${headerNote}`;
145145- payload = JSON.stringify(buildPayload(match, fetchContext));
297297+ message = `Would POST to ${action.callbackUrl}${headerNote}${itemSuffix}`;
298298+ payload = JSON.stringify(buildPayload(match, fetchContext, item));
146299 } else if (action.$type === "bsky-post") {
147300 try {
148301 const text = await renderTextTemplate(
···150303 match.event,
151304 fetchContext,
152305 match.automation,
306306+ item,
153307 );
154154- message = `Would post to Bluesky`;
155155- payload = JSON.stringify({ text, langs: action.langs, labels: action.labels });
308308+ message = `Would post to Bluesky${itemSuffix}`;
309309+ payload = JSON.stringify({ text, langs: action.langs, labels: action.labels, item });
156310 } catch (err) {
157311 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
158312 }
159313 } else if (action.$type === "follow") {
160314 try {
161315 const subject = (
162162- await renderTextTemplate(action.subject, match.event, fetchContext, match.automation)
316316+ await renderTextTemplate(action.subject, match.event, fetchContext, match.automation, item)
163317 ).trim();
164318 const target = FOLLOW_TARGETS[action.target];
165319 const collection = target.collection;
···167321 // The built-in safety checks live inside executeFollow and aren't run in
168322 // dry-run (keeps the preview cheap). Advertise their presence in the
169323 // message so authors know the real run will skip cleanly on both edges.
170170- message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)`;
171171- payload = JSON.stringify({ collection, subject });
324324+ message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)${itemSuffix}`;
325325+ payload = JSON.stringify({ collection, subject, item });
172326 } catch (err) {
173327 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
174328 }
···179333 match.event,
180334 fetchContext,
181335 match.automation,
336336+ item,
182337 );
183338 const title = action.targetTitle
184184- ? await renderTextTemplate(action.targetTitle, match.event, fetchContext, match.automation)
339339+ ? await renderTextTemplate(
340340+ action.targetTitle,
341341+ match.event,
342342+ fetchContext,
343343+ match.automation,
344344+ item,
345345+ )
185346 : undefined;
186347 const body = action.bodyValue
187187- ? await renderTextTemplate(action.bodyValue, match.event, fetchContext, match.automation)
348348+ ? await renderTextTemplate(
349349+ action.bodyValue,
350350+ match.event,
351351+ fetchContext,
352352+ match.automation,
353353+ item,
354354+ )
188355 : undefined;
189356 const tags: string[] = [];
190357 if (action.tags) {
···194361 match.event,
195362 fetchContext,
196363 match.automation,
364364+ item,
197365 );
198366 if (rendered.trim()) tags.push(rendered.trim());
199367 }
200368 }
201201- message = `Would bookmark ${source}`;
202202- payload = JSON.stringify({ source, title, body, tags });
369369+ message = `Would bookmark ${source}${itemSuffix}`;
370370+ payload = JSON.stringify({ source, title, body, tags, item });
203371 } catch (err) {
204372 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
205373 }
···210378 match.event,
211379 fetchContext,
212380 match.automation,
381381+ item,
213382 );
214383 message =
215384 action.$type === "patch-record"
216216- ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}`
217217- : `Would create record in ${action.targetCollection}`;
218218- payload = JSON.stringify(rendered);
385385+ ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}${itemSuffix}`
386386+ : `Would create record in ${action.targetCollection}${itemSuffix}`;
387387+ payload = JSON.stringify(item !== undefined ? { rendered, item } : rendered);
219388 } catch (err) {
220389 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
221390 }
···225394 automationUri: match.automation.uri,
226395 actionIndex,
227396 eventTimeUs: match.event.time_us,
228228- payload,
397397+ payload: capPayload(payload),
229398 statusCode: null,
230399 message,
231400 error,
···233402 attempt: 1,
234403 createdAt: new Date(),
235404 });
405405+}
406406+407407+function truncateForLog(s: string, max = 120): string {
408408+ return s.length <= max ? s : s.slice(0, max) + "...";
409409+}
410410+411411+/**
412412+ * Cap the size of a payload string before persisting it to delivery_logs.
413413+ * Item content (and other PDS-derived data) flows into dry-run payloads, and
414414+ * a single record can be tens of KB; multiplied by 64 forEach iterations this
415415+ * could swell the local SQLite quickly. The cap keeps any individual log row
416416+ * bounded while still leaving enough room to debug a template. The marker
417417+ * suffix breaks JSON-validity intentionally so consumers don't try to parse a
418418+ * truncated blob as a complete object.
419419+ */
420420+const MAX_LOG_PAYLOAD_BYTES = 8 * 1024;
421421+function capPayload(payload: string | null): string | null {
422422+ if (payload == null) return null;
423423+ if (payload.length <= MAX_LOG_PAYLOAD_BYTES) return payload;
424424+ return payload.slice(0, MAX_LOG_PAYLOAD_BYTES) + `…(truncated, ${payload.length} bytes total)`;
236425}
237426238427/**
+238-2
lib/jetstream/matcher.test.ts
···11-import { describe, it, expect } from "vitest";
22-import { matchConditions, evaluateFetchConditions } from "./matcher.js";
11+import { describe, it, expect, vi } from "vitest";
22+import {
33+ matchConditions,
44+ evaluateFetchConditions,
55+ collectItems,
66+ matchItemConditions,
77+ MAX_FOR_EACH_COLLECT_ITEMS,
88+} from "./matcher.js";
39import { makeEvent } from "../test/fixtures.js";
410import type { FetchContextEntry } from "../actions/template.js";
511···478484 });
479485 });
480486});
487487+488488+describe("collectItems", () => {
489489+ // Bluesky post with a mention facet and a link facet — mirrors the example
490490+ // in the user's request so a regression here means the headline use case
491491+ // would break.
492492+ function postEvent() {
493493+ return makeEvent({
494494+ commit: {
495495+ rev: "r",
496496+ operation: "create",
497497+ collection: "app.bsky.feed.post",
498498+ rkey: "rkey1",
499499+ record: {
500500+ text: "hi",
501501+ facets: [
502502+ {
503503+ index: { byteStart: 0, byteEnd: 5 },
504504+ features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:alice" }],
505505+ },
506506+ {
507507+ index: { byteStart: 6, byteEnd: 12 },
508508+ features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }],
509509+ },
510510+ ],
511511+ },
512512+ },
513513+ });
514514+ }
515515+516516+ it("flattens a single-level array path (facets[])", () => {
517517+ const items = collectItems("event.commit.record.facets[]", postEvent(), undefined);
518518+ expect(items).toHaveLength(2);
519519+ expect((items[0] as { index: { byteStart: number } }).index.byteStart).toBe(0);
520520+ });
521521+522522+ it("flat-maps a nested array path (facets[].features[])", () => {
523523+ const items = collectItems("event.commit.record.facets[].features[]", postEvent(), undefined);
524524+ expect(items).toHaveLength(2);
525525+ expect((items[0] as { $type: string }).$type).toBe("app.bsky.richtext.facet#mention");
526526+ expect((items[1] as { uri: string }).uri).toBe("https://example.com");
527527+ });
528528+529529+ it("returns an empty list when the path resolves to nothing", () => {
530530+ const items = collectItems("event.commit.record.missing[]", postEvent(), undefined);
531531+ expect(items).toEqual([]);
532532+ });
533533+534534+ it("returns an empty list when the leaf isn't an array", () => {
535535+ // text is a string, not an array — `text[]` should yield nothing
536536+ const items = collectItems("event.commit.record.text[]", postEvent(), undefined);
537537+ expect(items).toEqual([]);
538538+ });
539539+540540+ it("walks a path rooted at a fetch context entry", () => {
541541+ const fetchContext = {
542542+ likedPost: {
543543+ found: true,
544544+ uri: "at://did:plc:author/app.bsky.feed.post/x",
545545+ cid: "cid",
546546+ record: {
547547+ facets: [
548548+ {
549549+ features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://b.test" }],
550550+ },
551551+ ],
552552+ },
553553+ },
554554+ };
555555+ const items = collectItems("likedPost.record.facets[].features[]", makeEvent(), fetchContext);
556556+ expect(items).toHaveLength(1);
557557+ expect((items[0] as { uri: string }).uri).toBe("https://b.test");
558558+ });
559559+560560+ it("returns an empty list when the root is unknown", () => {
561561+ expect(collectItems("nope.x[]", makeEvent(), undefined)).toEqual([]);
562562+ });
563563+564564+ it("caps the number of leaves materialized regardless of array size", () => {
565565+ // Build a record with a single array of MAX + 100 items. The cap should
566566+ // bound the result length even when no conditions are filtering.
567567+ const huge = Array.from({ length: MAX_FOR_EACH_COLLECT_ITEMS + 100 }, (_, i) => ({
568568+ idx: i,
569569+ }));
570570+ const event = makeEvent({
571571+ commit: {
572572+ rev: "r",
573573+ operation: "create",
574574+ collection: "x",
575575+ rkey: "k",
576576+ record: { items: huge },
577577+ },
578578+ });
579579+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
580580+ try {
581581+ const result = collectItems(
582582+ "event.commit.record.items[]",
583583+ event,
584584+ undefined,
585585+ "at://owner/auto/x",
586586+ );
587587+ expect(result.length).toBe(MAX_FOR_EACH_COLLECT_ITEMS);
588588+ expect((result[0] as { idx: number }).idx).toBe(0);
589589+ expect((result.at(-1) as { idx: number }).idx).toBe(MAX_FOR_EACH_COLLECT_ITEMS - 1);
590590+ // Operator-visible warning fired with the path and automation URI for
591591+ // diagnostics. This is the only signal of the work cap; it doesn't
592592+ // surface in delivery_logs.
593593+ expect(warn).toHaveBeenCalledOnce();
594594+ expect(warn.mock.calls[0]![0]).toMatch(/work cap/);
595595+ expect(warn.mock.calls[0]![0]).toMatch(/at:\/\/owner\/auto\/x/);
596596+ } finally {
597597+ warn.mockRestore();
598598+ }
599599+ });
600600+601601+ it("does not log a work-cap warning under the cap", () => {
602602+ const small = Array.from({ length: 8 }, (_, i) => ({ idx: i }));
603603+ const event = makeEvent({
604604+ commit: {
605605+ rev: "r",
606606+ operation: "create",
607607+ collection: "x",
608608+ rkey: "k",
609609+ record: { items: small },
610610+ },
611611+ });
612612+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
613613+ try {
614614+ const result = collectItems("event.commit.record.items[]", event, undefined);
615615+ expect(result).toHaveLength(8);
616616+ expect(warn).not.toHaveBeenCalled();
617617+ } finally {
618618+ warn.mockRestore();
619619+ }
620620+ });
621621+622622+ it("caps across nested array levels", () => {
623623+ // A 2-deep nested array whose total leaf count exceeds the cap. Each outer
624624+ // entry contributes 100 leaves; we expect the cap to short-circuit
625625+ // mid-iteration.
626626+ const outer = Array.from({ length: 50 }, () => ({
627627+ inner: Array.from({ length: 100 }, (_, j) => ({ j })),
628628+ }));
629629+ const event = makeEvent({
630630+ commit: {
631631+ rev: "r",
632632+ operation: "create",
633633+ collection: "x",
634634+ rkey: "k",
635635+ record: { outer },
636636+ },
637637+ });
638638+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
639639+ try {
640640+ const result = collectItems("event.commit.record.outer[].inner[]", event, undefined);
641641+ expect(result.length).toBe(MAX_FOR_EACH_COLLECT_ITEMS);
642642+ expect(warn).toHaveBeenCalledOnce();
643643+ } finally {
644644+ warn.mockRestore();
645645+ }
646646+ });
647647+648648+ it("refuses to walk into __proto__ / constructor / prototype", () => {
649649+ // Even though the validator should reject these segments at save time,
650650+ // the runtime guard prevents leaking JS engine internals into payloads
651651+ // if a path with these segments somehow reaches collectItems.
652652+ const event = makeEvent();
653653+ expect(collectItems("event.__proto__[]", event, undefined)).toEqual([]);
654654+ expect(collectItems("event.commit.record.constructor[]", event, undefined)).toEqual([]);
655655+ expect(collectItems("event.commit.record.prototype[]", event, undefined)).toEqual([]);
656656+ });
657657+});
658658+659659+describe("matchItemConditions", () => {
660660+ const ownerDid = "did:plc:owner";
661661+662662+ it("passes when an empty/undefined condition list is given", () => {
663663+ expect(matchItemConditions({ a: 1 }, undefined, ownerDid)).toBe(true);
664664+ expect(matchItemConditions({ a: 1 }, [], ownerDid)).toBe(true);
665665+ });
666666+667667+ it("filters facet features by $type", () => {
668668+ const link = { $type: "app.bsky.richtext.facet#link", uri: "https://x" };
669669+ const mention = { $type: "app.bsky.richtext.facet#mention", did: "did:plc:bob" };
670670+ const conditions = [{ field: "$type", operator: "eq", value: "app.bsky.richtext.facet#link" }];
671671+ expect(matchItemConditions(link, conditions, ownerDid)).toBe(true);
672672+ expect(matchItemConditions(mention, conditions, ownerDid)).toBe(false);
673673+ });
674674+675675+ it("accepts the optional `item.` prefix on field paths", () => {
676676+ const item = { uri: "https://x" };
677677+ expect(
678678+ matchItemConditions(
679679+ item,
680680+ [{ field: "item.uri", operator: "startsWith", value: "https://" }],
681681+ ownerDid,
682682+ ),
683683+ ).toBe(true);
684684+ });
685685+686686+ it("supports exists / not-exists on item fields", () => {
687687+ const item = { uri: "https://x" };
688688+ expect(
689689+ matchItemConditions(item, [{ field: "uri", operator: "exists", value: "" }], ownerDid),
690690+ ).toBe(true);
691691+ expect(
692692+ matchItemConditions(item, [{ field: "did", operator: "not-exists", value: "" }], ownerDid),
693693+ ).toBe(true);
694694+ });
695695+696696+ it("treats __proto__ / constructor / prototype as missing", () => {
697697+ const item = { uri: "https://x" };
698698+ expect(
699699+ matchItemConditions(item, [{ field: "__proto__", operator: "exists", value: "" }], ownerDid),
700700+ ).toBe(false);
701701+ expect(
702702+ matchItemConditions(
703703+ item,
704704+ [{ field: "constructor", operator: "exists", value: "" }],
705705+ ownerDid,
706706+ ),
707707+ ).toBe(false);
708708+ expect(
709709+ matchItemConditions(
710710+ item,
711711+ [{ field: "constructor.name", operator: "eq", value: "Object" }],
712712+ ownerDid,
713713+ ),
714714+ ).toBe(false);
715715+ });
716716+});
+200-1
lib/jetstream/matcher.ts
···11import type { Condition } from "../db/schema.js";
22-import type { FetchContextEntry } from "../actions/template.js";
22+import type { FetchContext, FetchContextEntry } from "../actions/template.js";
3344export type JetstreamCommit = {
55 rev: string;
···147147 if (!conditions || conditions.length === 0) return true;
148148 return conditions.every((cond) => evaluateFetchCondition(entry, cond, ownerDid));
149149}
150150+151151+// ---------------------------------------------------------------------------
152152+// forEach: array iteration over event/fetch data
153153+// ---------------------------------------------------------------------------
154154+155155+/**
156156+ * Path segments that walk into JS engine internals are rejected runtime-side.
157157+ * Templates / conditions can never reach `Object.prototype`, the constructor
158158+ * function, etc., even if validators upstream let one slip through. This is
159159+ * defense-in-depth — it only matters if a future regex change opens a hole.
160160+ */
161161+const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
162162+function isUnsafeKey(k: string): boolean {
163163+ return UNSAFE_KEYS.has(k);
164164+}
165165+export { isUnsafeKey };
166166+167167+/**
168168+ * Resolve the root object that a forEach path is rooted at.
169169+ * - `event.*` → the event itself
170170+ * - `<fetchName>.*` → that fetch's context entry
171171+ *
172172+ * Returns `undefined` when the root cannot be resolved (e.g. unknown fetch
173173+ * name, or the path doesn't start with a known root).
174174+ */
175175+function rootForPath(
176176+ path: string,
177177+ event: JetstreamEvent,
178178+ fetchContext: FetchContext | undefined,
179179+): { root: unknown; rest: string } | undefined {
180180+ if (path.startsWith("event.")) {
181181+ return { root: event, rest: path.slice("event.".length) };
182182+ }
183183+ if (fetchContext) {
184184+ const dotIndex = path.indexOf(".");
185185+ const head = dotIndex > 0 ? path.slice(0, dotIndex) : path;
186186+ if (head in fetchContext) {
187187+ return {
188188+ root: fetchContext[head],
189189+ rest: dotIndex > 0 ? path.slice(dotIndex + 1) : "",
190190+ };
191191+ }
192192+ }
193193+ return undefined;
194194+}
195195+196196+/**
197197+ * Hard cap on the number of leaves `collectItems` will materialize from a
198198+ * single path. Bounds the *work* done by `walk` (memory + CPU) regardless of
199199+ * how the user filters with conditions. Without this, a single adversarial
200200+ * record with a million-element array would force a million-iteration filter
201201+ * pass and held references until the handler returned, even if the downstream
202202+ * delivery cap only fires 64 actions.
203203+ *
204204+ * Set to 4× the delivery cap (`MAX_FOR_EACH_ITEMS_PER_ACTION`). This is
205205+ * generous-enough headroom for legitimate filtering scenarios (e.g. 200
206206+ * candidate facets filtered down to 50 deliveries) while keeping the
207207+ * worst-case memory footprint trivial — at 256 references, the array spine
208208+ * is ~2KB regardless of what the user puts in their record. Real-world
209209+ * usage is well under this: typical Bluesky posts have <50 facets total.
210210+ */
211211+const MAX_FOR_EACH_COLLECT_ITEMS = 256;
212212+export { MAX_FOR_EACH_COLLECT_ITEMS };
213213+214214+type WalkBudget = { remaining: number; truncated: boolean };
215215+216216+/**
217217+ * Walk `value` along `segments`. Each segment is either a key or `[]`. A `[]`
218218+ * segment flat-maps the current array level — every entry is walked through
219219+ * the remaining segments and its leaves are appended. The result is always a
220220+ * flat array of leaves (objects, primitives, whatever the path resolves to).
221221+ *
222222+ * `budget` carries a shared remaining-leaves counter across the recursion;
223223+ * when it hits zero, further iteration short-circuits and the truncated flag
224224+ * is set so the caller can decide how to surface the cap.
225225+ */
226226+function walk(value: unknown, segments: string[], budget: WalkBudget): unknown[] {
227227+ if (budget.remaining <= 0) return [];
228228+229229+ if (segments.length === 0) {
230230+ if (value === undefined) return [];
231231+ budget.remaining -= 1;
232232+ return [value];
233233+ }
234234+235235+ const [head, ...rest] = segments;
236236+237237+ if (head === "[]") {
238238+ if (!Array.isArray(value)) return [];
239239+ const out: unknown[] = [];
240240+ for (const item of value) {
241241+ if (budget.remaining <= 0) {
242242+ budget.truncated = true;
243243+ break;
244244+ }
245245+ out.push(...walk(item, rest, budget));
246246+ }
247247+ return out;
248248+ }
249249+250250+ if (value == null || typeof value !== "object") return [];
251251+ if (isUnsafeKey(head!)) return [];
252252+ return walk((value as Record<string, unknown>)[head!], rest, budget);
253253+}
254254+255255+/**
256256+ * Resolve a forEach `path` against the event + fetch context and return a flat
257257+ * list of items. Paths must use `[]` segments to mark array levels (e.g.
258258+ * `event.commit.record.facets[].features[]`). Anything that doesn't ultimately
259259+ * resolve to an array of leaves returns an empty list.
260260+ *
261261+ * Capped at `MAX_FOR_EACH_COLLECT_ITEMS` leaves regardless of the underlying
262262+ * array size. When the cap is hit, the caller's `automationUri` (used only
263263+ * for the warning) is logged so operators can spot a runaway path. The user
264264+ * downstream will still see the regular per-action delivery cap kick in if
265265+ * matches exceed it; the operator-visible warning is purely diagnostic.
266266+ */
267267+export function collectItems(
268268+ path: string,
269269+ event: JetstreamEvent,
270270+ fetchContext: FetchContext | undefined,
271271+ automationUri?: string,
272272+): unknown[] {
273273+ const rooted = rootForPath(path, event, fetchContext);
274274+ if (!rooted) return [];
275275+ // Trailing `[]` is required, so the rest path must contain at least one `[]`
276276+ // segment for items to be returned.
277277+ const segments = rooted.rest === "" ? [] : rooted.rest.split(".");
278278+ // Split each segment that ends in `[]` into a key + `[]` pair. e.g. "facets[]"
279279+ // becomes ["facets", "[]"] so the walker can iterate properly.
280280+ const expanded: string[] = [];
281281+ for (const seg of segments) {
282282+ if (seg.endsWith("[]")) {
283283+ const head = seg.slice(0, -2);
284284+ if (head) expanded.push(head);
285285+ expanded.push("[]");
286286+ } else {
287287+ expanded.push(seg);
288288+ }
289289+ }
290290+ const budget: WalkBudget = { remaining: MAX_FOR_EACH_COLLECT_ITEMS, truncated: false };
291291+ const out = walk(rooted.root, expanded, budget);
292292+ if (budget.truncated) {
293293+ console.warn(
294294+ `[forEach] collectItems hit the ${MAX_FOR_EACH_COLLECT_ITEMS}-item work cap on path "${path}"` +
295295+ (automationUri ? ` for ${automationUri}` : "") +
296296+ "; remaining leaves were not materialized.",
297297+ );
298298+ }
299299+ return out;
300300+}
301301+302302+/**
303303+ * Resolve a dotted path against a single item. Used by item conditions and by
304304+ * `{{item.*}}` placeholder resolution. Returns the resolved string form of the
305305+ * leaf value, or `undefined` if missing.
306306+ */
307307+function resolveItemField(item: unknown, field: string): string | undefined {
308308+ if (item == null || typeof item !== "object") return undefined;
309309+ let value: unknown = item;
310310+ for (const key of field.split(".")) {
311311+ if (value == null || typeof value !== "object") return undefined;
312312+ if (isUnsafeKey(key)) return undefined;
313313+ value = (value as Record<string, unknown>)[key];
314314+ }
315315+ if (value == null) return undefined;
316316+ return typeof value === "string" ? value : JSON.stringify(value);
317317+}
318318+319319+function evaluateItemCondition(item: unknown, condition: Condition, ownerDid: string): boolean {
320320+ // Item conditions reference the item directly; the optional `item.` prefix
321321+ // is accepted for consistency with template placeholders.
322322+ const field = condition.field.startsWith("item.")
323323+ ? condition.field.slice("item.".length)
324324+ : condition.field;
325325+326326+ if (condition.operator === "exists" || condition.operator === "not-exists") {
327327+ const actual = resolveItemField(item, field);
328328+ const exists = actual !== undefined && actual !== "";
329329+ return condition.operator === "exists" ? exists : !exists;
330330+ }
331331+332332+ const actual = resolveItemField(item, field);
333333+ if (actual === undefined) return false;
334334+ const expected = resolveConditionValue(condition.value, ownerDid);
335335+ return applyStringOperator(condition.operator, actual, expected);
336336+}
337337+338338+/**
339339+ * AND-evaluate a list of conditions against a single item. Empty list passes.
340340+ */
341341+export function matchItemConditions(
342342+ item: unknown,
343343+ conditions: Condition[] | undefined,
344344+ ownerDid: string,
345345+): boolean {
346346+ if (!conditions || conditions.length === 0) return true;
347347+ return conditions.every((cond) => evaluateItemCondition(item, cond, ownerDid));
348348+}
+17
lib/lexicons/schema-tree.ts
···77 BooleanNode,
88 ObjectNode,
99 ArrayNode,
1010+ UnionNode,
1011 UnknownNode,
1112 SchemaNode,
1213 RecordSchema,
···1819 BooleanNode,
1920 ObjectNode,
2021 ArrayNode,
2222+ UnionNode,
2123 SchemaNode,
2224 RecordSchema,
2325} from "./schema-types.js";
···135137136138 // Use the external lexicon's defs for local ref resolution within it
137139 return buildNode(def, externalDefs, visited, depth + 1);
140140+ }
141141+142142+ case "union": {
143143+ const refs = (prop.refs as string[] | undefined) ?? [];
144144+ const variants: UnionNode["variants"] = [];
145145+ for (const ref of refs) {
146146+ // Resolve each variant as if it were a `ref`. Use a per-variant visited
147147+ // set so siblings can share refs without short-circuiting each other.
148148+ const variantVisited = new Set(visited);
149149+ const node = await buildNode({ type: "ref", ref }, localDefs, variantVisited, depth + 1);
150150+ variants.push({ ref, node });
151151+ }
152152+ const node: UnionNode = { type: "union", variants };
153153+ if (prop.description) node.description = prop.description;
154154+ return node;
138155 }
139156140157 default:
+9
lib/lexicons/schema-types.ts
···3636 maxLength?: number;
3737};
38383939+/** Union of refs (lexicon `union` type). Each variant carries its source ref
4040+ * so callers can label fields by `$type` without re-resolving the schema. */
4141+export type UnionNode = {
4242+ type: "union";
4343+ description?: string;
4444+ variants: Array<{ ref: string; node: SchemaNode }>;
4545+};
4646+3947export type UnknownNode = {
4048 type: "unknown";
4149 description?: string;
···4755 | BooleanNode
4856 | ObjectNode
4957 | ArrayNode
5858+ | UnionNode
5059 | UnknownNode;
51605261export type RecordSchema = {
+11-2
lib/webhooks/dispatcher.ts
···2525 };
2626 };
2727 fetches?: Record<string, { uri: string; cid: string; record: Record<string, unknown> }>;
2828+ /** Present only when the action is configured with a forEach modifier — this
2929+ * is the array element that triggered the current delivery. */
3030+ item?: unknown;
2831};
29323030-export function buildPayload(match: MatchedEvent, fetchContext?: FetchContext): WebhookPayload {
3333+export function buildPayload(
3434+ match: MatchedEvent,
3535+ fetchContext?: FetchContext,
3636+ item?: unknown,
3737+): WebhookPayload {
3138 const { automation, event } = match;
3239 return {
3340 automation: automation.uri,
···4855 : undefined,
4956 },
5057 fetches: fetchContext && Object.keys(fetchContext).length > 0 ? fetchContext : undefined,
5858+ ...(item !== undefined ? { item } : {}),
5159 };
5260}
5361···184192 match: MatchedEvent,
185193 actionIndex: number,
186194 fetchContext?: FetchContext,
195195+ item?: unknown,
187196): Promise<ActionResult> {
188197 const { automation, event } = match;
189198 const action = automation.actions[actionIndex] as WebhookAction;
190190- const payload = buildPayload(match, fetchContext);
199199+ const payload = buildPayload(match, fetchContext, item);
191200 const body = JSON.stringify(payload);
192201193202 // Resolve {{secret:name}} references in custom headers (once, reused for retries)