···9292 if (rest) {
9393 for (const key of rest.split(".")) {
9494 if (value == null || typeof value !== "object") return undefined;
9595+ if (isUnsafeKey(key)) return undefined;
9596 value = (value as Record<string, unknown>)[key];
9697 }
9798 }
···106107 let value: unknown = event;
107108 for (const key of rest.split(".")) {
108109 if (value == null || typeof value !== "object") return undefined;
110110+ if (isUnsafeKey(key)) return undefined;
109111 value = (value as Record<string, unknown>)[key];
110112 }
111113 return value;
···194196 });
195197}
196198199199+/**
200200+ * Validate a single placeholder path against the available context. Returns an
201201+ * error string when invalid, or `null` when the placeholder is valid.
202202+ */
203203+function validatePlaceholderPath(
204204+ rawPlaceholder: string,
205205+ opts: {
206206+ fetchSet: Set<string>;
207207+ actionSet: Set<string>;
208208+ hasItem?: boolean;
209209+ allowFunctions?: boolean;
210210+ allowAutomation?: boolean;
211211+ errorPrefix?: string;
212212+ },
213213+): string | null {
214214+ const call = opts.allowFunctions ? parseFunctionCall(rawPlaceholder) : null;
215215+216216+ if (!opts.allowFunctions && parseFunctionCall(rawPlaceholder)) {
217217+ return `Function calls not allowed in ${opts.errorPrefix ?? "placeholder"}: {{${rawPlaceholder}}}`;
218218+ }
219219+220220+ const toValidate = call ? call.arg : rawPlaceholder;
221221+222222+ if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) return null;
223223+224224+ if (toValidate === "item" || toValidate.startsWith("item.")) {
225225+ if (opts.hasItem) return null;
226226+ const prefix = opts.errorPrefix
227227+ ? `Invalid placeholder in ${opts.errorPrefix}`
228228+ : "Invalid placeholder";
229229+ return `${prefix}: {{${rawPlaceholder}}}. The "item.*" placeholder is only available on actions with a forEach config.`;
230230+ }
231231+232232+ if (opts.allowAutomation && toValidate.startsWith("automation.")) {
233233+ const field = toValidate.slice("automation.".length);
234234+ if (AUTOMATION_FIELDS.has(field)) return null;
235235+ return `${opts.errorPrefix ? `Invalid placeholder in ${opts.errorPrefix}` : "Invalid placeholder"}: {{${rawPlaceholder}}}`;
236236+ }
237237+238238+ const root = toValidate.split(".")[0]!;
239239+ if (opts.fetchSet.has(root)) return null;
240240+ if (opts.actionSet.has(root)) return null;
241241+242242+ return `${opts.errorPrefix ? `Invalid placeholder in ${opts.errorPrefix}` : "Invalid placeholder"}: {{${rawPlaceholder}}}`;
243243+}
244244+197245/** Validate template syntax at creation time. */
198246export function validateTemplate(
199247 template: string,
···228276 return { valid: false, error: "Template must contain at least one {{placeholder}}" };
229277 }
230278231231- const fetchSet = new Set(fetchNames ?? []);
232232- const actionSet = new Set(actionNames ?? []);
279279+ const opts = {
280280+ fetchSet: new Set(fetchNames ?? []),
281281+ actionSet: new Set(actionNames ?? []),
282282+ hasItem,
283283+ allowFunctions: true,
284284+ allowAutomation: true,
285285+ };
233286 for (const p of placeholders) {
234234- const call = parseFunctionCall(p);
235235- const toValidate = call ? call.arg : p;
236236- if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue;
237237- if (toValidate === "item" || toValidate.startsWith("item.")) {
238238- if (hasItem) continue;
239239- return {
240240- valid: false,
241241- error: `Invalid placeholder: {{${p}}}. The "item.*" placeholder is only available on actions with a forEach config.`,
242242- };
243243- }
244244- if (toValidate.startsWith("automation.")) {
245245- const field = toValidate.slice("automation.".length);
246246- if (AUTOMATION_FIELDS.has(field)) continue;
247247- return { valid: false, error: `Invalid placeholder: {{${p}}}` };
248248- }
249249- const root = toValidate.split(".")[0]!;
250250- if (fetchSet.has(root)) continue;
251251- if (actionSet.has(root)) continue;
252252- return { valid: false, error: `Invalid placeholder: {{${p}}}` };
287287+ const error = validatePlaceholderPath(p, opts);
288288+ if (error) return { valid: false, error };
253289 }
254290255291 return { valid: true, placeholders };
···338374 };
339375 }
340376341341- const fetchSet = new Set(fetchNames ?? []);
342342- const actionSet = new Set(actionNames ?? []);
377377+ const opts = {
378378+ fetchSet: new Set(fetchNames ?? []),
379379+ actionSet: new Set(actionNames ?? []),
380380+ hasItem,
381381+ allowFunctions: false,
382382+ allowAutomation: false,
383383+ errorPrefix: "base record URI",
384384+ };
343385 for (const p of placeholders) {
344344- if (parseFunctionCall(p)) {
345345- return { valid: false, error: `Function calls not allowed in base record URI: {{${p}}}` };
346346- }
347347- if (p === "now" || p === "self" || p.startsWith("event.")) continue;
348348- if (p === "item" || p.startsWith("item.")) {
349349- if (hasItem) continue;
350350- return {
351351- valid: false,
352352- error: `Invalid placeholder in base record URI: {{${p}}}. The "item.*" placeholder is only available on actions with a forEach config.`,
353353- };
354354- }
355355- const root = p.split(".")[0]!;
356356- if (fetchSet.has(root)) continue;
357357- if (actionSet.has(root)) continue;
358358- return { valid: false, error: `Invalid placeholder in base record URI: {{${p}}}` };
386386+ const error = validatePlaceholderPath(p, opts);
387387+ if (error) return { valid: false, error };
359388 }
360389361390 return { valid: true, placeholders };
···378407 return "";
379408 });
380409381381- const fetchSet = new Set(fetchNames ?? []);
382382- const actionSet = new Set(actionNames ?? []);
410410+ const opts = {
411411+ fetchSet: new Set(fetchNames ?? []),
412412+ actionSet: new Set(actionNames ?? []),
413413+ hasItem,
414414+ allowFunctions: true,
415415+ allowAutomation: true,
416416+ };
383417 for (const p of placeholders) {
384384- const call = parseFunctionCall(p);
385385- const toValidate = call ? call.arg : p;
386386- if (toValidate === "now" || toValidate === "self" || toValidate.startsWith("event.")) continue;
387387- if (toValidate === "item" || toValidate.startsWith("item.")) {
388388- if (hasItem) continue;
389389- return {
390390- valid: false,
391391- error: `Invalid placeholder: {{${p}}}. The "item.*" placeholder is only available on actions with a forEach config.`,
392392- };
393393- }
394394- if (toValidate.startsWith("automation.")) {
395395- const field = toValidate.slice("automation.".length);
396396- if (AUTOMATION_FIELDS.has(field)) continue;
397397- return { valid: false, error: `Invalid placeholder: {{${p}}}` };
398398- }
399399- const root = toValidate.split(".")[0]!;
400400- if (fetchSet.has(root)) continue;
401401- if (actionSet.has(root)) continue;
402402- return { valid: false, error: `Invalid placeholder: {{${p}}}` };
418418+ const error = validatePlaceholderPath(p, opts);
419419+ if (error) return { valid: false, error };
403420 }
404421405422 return { valid: true, placeholders };
+1-1
lib/jetstream/handler.test.ts
···274274 it("continues the action chain after an action skips with 204", async () => {
275275 // A 204 skip (e.g. a follow action's "already following" pre-flight)
276276 // must not stop the chain: it's success, not failure. Lock it in so
277277- // future refactors of `isActionSuccess` don't accidentally drop 204.
277277+ // future refactors of `isSuccess` don't accidentally drop 204.
278278 mockDispatch.mockResolvedValueOnce({ statusCode: 204, message: "Skipped: something" });
279279280280 const match = makeMatch({
+3-6
lib/jetstream/handler.ts
···88import { executeFollow } from "../actions/follow.js";
99import { FOLLOW_TARGETS } from "../automations/follow-targets.js";
1010import { resolveFetches } from "../actions/fetcher.js";
1111+import { isSuccess } from "../actions/delivery.js";
1112import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js";
1213import { parseAtUri } from "../pds/resolver.js";
1314import { collectItems, matchItemConditions } from "./matcher.js";
···192193 const toRun = truncated ? matched.slice(0, MAX_FOR_EACH_ITEMS_PER_ACTION) : matched;
193194 for (const item of toRun) {
194195 const result: ActionResult = await handler(match, i, fetchContext, item);
195195- if (isActionSuccess(result.statusCode)) {
196196+ if (isSuccess(result.statusCode)) {
196197 lastSuccess = result;
197198 } else {
198199 console.error(
···213214 }
214215 } else {
215216 const result: ActionResult = await handler(match, i, fetchContext);
216216- if (isActionSuccess(result.statusCode)) {
217217+ if (isSuccess(result.statusCode)) {
217218 lastSuccess = result;
218219 } else {
219220 console.error(
···244245 break;
245246 }
246247 }
247247-}
248248-249249-function isActionSuccess(code: number): boolean {
250250- return code >= 200 && code < 400;
251248}
252249253250async function logDryRun(