1
25
26import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
27import { z } from "zod";
28
29
30
31
32
33
34import cronParser from "cron-parser";
35
36
37
38
39
40export const AUTOMATION_TRIGGER_TYPES = [
41 "RECORD_CREATED",
42 "RECORD_UPDATED",
43 "PROPERTY_CHANGED",
44 "PAGE_CREATED",
45 "FORM_SUBMITTED",
46 "SCHEDULE",
47 "WEBHOOK",
48 "MANUAL"
49] as const;
50export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number];
51
52export const AUTOMATION_ACTION_TYPES = [
53 "UPDATE_RECORD",
54 "CREATE_PAGE",
55 "SEND_EMAIL",
56 "SEND_WEBHOOK",
57 "CREATE_NOTIFICATION",
58 "ASSIGN_USER",
59 "CHANGE_STATUS",
60 "CALL_INTEGRATION",
61 "RUN_AI_ACTION"
62] as const;
63export type AutomationActionType = (typeof AUTOMATION_ACTION_TYPES)[number];
64
65export const AUTOMATION_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"] as const;
66export type AutomationStatus = (typeof AUTOMATION_STATUSES)[number];
67
68export const AUTOMATION_RUN_STATUSES = [
69 "PENDING",
70 "RUNNING",
71 "SUCCEEDED",
72 "FAILED",
73 "DEAD_LETTER"
74] as const;
75export type AutomationRunStatus = (typeof AUTOMATION_RUN_STATUSES)[number];
76
77
78export const TERMINAL_RUN_STATUSES: readonly AutomationRunStatus[] = [
79 "SUCCEEDED",
80 "FAILED",
81 "DEAD_LETTER"
82];
83
84
85
86
87
88
89export const AUTOMATION_MAX_ACTIONS = 50;
90
91export const AUTOMATION_MAX_TRIGGERS = 3;
92
93export const AUTOMATION_MAX_RETRIES = 8;
94
95export const AUTOMATION_MAX_DEPTH = 5;
96
97export const CONDITION_MAX_DEPTH = 5;
98
99export const CONDITION_MAX_NODES = 100;
100
101export const AUTOMATION_MAX_CONDITIONS = 1;
102
103export const AUTOMATION_NAME_MAX = 100;
104
105export const AUTOMATION_DESCRIPTION_MAX = 500;
106
107export const AUTOMATION_ACTION_RETRIES = { min: 1, max: 8, default: 3 } as const;
108
109export const AUTOMATION_ACTION_TIMEOUT_MS = { min: 1_000, max: 300_000, default: 30_000 } as const;
110
111export const RUN_INPUT_MAX_BYTES = 64 * 1024;
112export const RUN_OUTPUT_MAX_BYTES = 128 * 1024;
113
114export const WEBHOOK_BODY_MAX_BYTES = 256 * 1024;
115export const WEBHOOK_PAYLOAD_MAX_BYTES = 64 * 1024;
116
117export const WEBHOOK_TS_TOLERANCE_MS = 5 * 60 * 1000;
118
119export const WEBHOOK_RATE_PER_MINUTE = 60;
120
121export const MANUAL_RUN_INPUT_MAX_BYTES = 64 * 1024;
122
123export const CRON_MIN_INTERVAL_MS = 60_000;
124
125export const AUTOMATION_EMAIL_DAILY_CAP = 500;
126
127
132export function runBackoffSeconds(attempt: number): number {
133 if (!Number.isInteger(attempt) || attempt < 1) return 0;
134 return Math.min(2 ** (attempt - 1), 600);
135}
136
137
138
139
140
141
152
158export const AUTOMATION_STATUS_TRANSITIONS: Record<AutomationStatus, AutomationStatus[]> = {
159 DRAFT: ["ACTIVE"],
160 ACTIVE: ["PAUSED", "ARCHIVED"],
161 PAUSED: ["ACTIVE", "ARCHIVED"],
162 ARCHIVED: ["ACTIVE"]
163};
164
165export function isValidAutomationTransition(from: AutomationStatus, to: AutomationStatus): boolean {
166 if (from === to) return false;
167 return (AUTOMATION_STATUS_TRANSITIONS[from] ?? []).includes(to);
168}
169
170
171
172
173
174const cuid = z
175 .string()
176 .min(1)
177 .max(64)
178 .refine((s) => !s.includes(" ") && s.length >= 8, "expected an id");
179
180export const TriggerConfigSchemas = {
181 RECORD_CREATED: z.object({
182 databaseId: cuid.optional()
183 }).strict(),
184 RECORD_UPDATED: z
185 .object({
186 databaseId: cuid.optional(),
187
188 properties: z.array(z.string().min(1).max(100)).max(20).optional()
189 })
190 .strict(),
191 PROPERTY_CHANGED: z
192 .object({
193 databaseId: cuid.optional(),
194
195 properties: z.array(z.string().min(1).max(100)).min(1).max(20)
196 })
197 .strict(),
198 PAGE_CREATED: z.object({}).strict(),
199 FORM_SUBMITTED: z.object({ databaseId: cuid.optional() }).strict(),
200 SCHEDULE: z
201 .object({
202 cron: z.string().min(2).max(120),
203 timeZone: z.string().min(1).max(64).optional()
204 })
205 .strict()
206 .refine((c) => cronCheck(c.cron).ok, { message: "invalid cron" }),
207 WEBHOOK: z
208 .object({
209
210 webhookId: cuid.optional(),
211
212 secretHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
213
214 secretEncrypted: z.string().min(8).optional()
215 })
216 .strict(),
217 MANUAL: z.object({}).strict()
218} as const;
219
220export const AUTOMATION_TRIGGER_TYPES_Z = z.enum(AUTOMATION_TRIGGER_TYPES);
221
222export const AutomationTriggerDto = z.object({
223 type: AUTOMATION_TRIGGER_TYPES_Z,
224 config: z
225 .object({})
226 .passthrough()
227 .default({})
228});
229
230
235export function validateTriggerConfig(
236 type: AutomationTriggerType,
237 config: unknown
238): { ok: true } | { ok: false; errors: { path: string; message: string }[] } {
239 const schema = TriggerConfigSchemas[type];
240 const parsed = schema.safeParse(config ?? {});
241 if (parsed.success) return { ok: true };
242 return {
243 ok: false,
244 errors: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
245 };
246}
247
248
249
250
251
252export interface CronCheckResult {
253 ok: boolean;
254
255 code: "CRON_INVALID" | "CRON_TOO_FREQUENT" | null;
256 message: string | null;
257
258 nextFire?: Date;
259}
260
261
268export function cronCheck(expr: string): CronCheckResult {
269 const fields = expr.trim().split(/\s+/).filter(Boolean);
270 if (fields.length !== 5 && fields.length !== 6) {
271 return { ok: false, code: "CRON_INVALID", message: `cron must have 5 or 6 fields (got ${fields.length})` };
272 }
273 let intervalMs = Infinity;
274 try {
275 const it = cronParser.parseExpression(expr);
276 const next1 = it.next().toDate();
277 const next2 = it.next().toDate();
278 intervalMs = next2.getTime() - next1.getTime();
279 return {
280 ok: intervalMs >= CRON_MIN_INTERVAL_MS,
281 code: intervalMs >= CRON_MIN_INTERVAL_MS ? null : "CRON_TOO_FREQUENT",
282 message:
283 intervalMs >= CRON_MIN_INTERVAL_MS
284 ? null
285 : `cron fires every ${Math.round(intervalMs / 1000)} s (minimum 60 s)`,
286 nextFire: next1
287 };
288 } catch (err) {
289 return {
290 ok: false,
291 code: "CRON_INVALID",
292 message: `invalid cron expression: ${(err as Error).message}`
293 };
294 }
295}
296
297
298
299
300
301export const CONDITION_OPERATORS = [
302 "eq",
303 "neq",
304 "contains",
305 "not_contains",
306 "starts_with",
307 "ends_with",
308 "gt",
309 "gte",
310 "lt",
311 "lte",
312 "in",
313 "not_in",
314 "is_empty",
315 "is_not_empty"
316] as const;
317export type ConditionOperator = (typeof CONDITION_OPERATORS)[number];
318
319const predicateSchema = z
320 .object({
321 op: z.never().optional(),
322 field: z
323 .object({
324 ref: z.enum(["event", "property"]),
325 name: z.string().min(1).max(200)
326 })
327 .strict(),
328 operator: z.enum(CONDITION_OPERATORS),
329 value: z
330 .union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
331 .optional()
332 })
333 .strict();
334
335
348export const CONDITION_EXPRESSION_SCHEMA: z.ZodType<unknown> = z.lazy(() =>
349 z.union([
350 z
351 .object({
352 op: z.enum(["AND", "OR"]),
353 children: z.array(CONDITION_EXPRESSION_SCHEMA).min(1).max(CONDITION_MAX_NODES)
354 })
355 .strict(),
356 z.object({ op: z.literal("NOT"), child: CONDITION_EXPRESSION_SCHEMA }).strict(),
357 predicateSchema
358 ])
359);
360
361export interface ConditionTreeCheck {
362 ok: boolean;
363 code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null;
364 message: string | null;
365 depth: number;
366 nodes: number;
367}
368
369
377export function validateConditionTree(tree: unknown): ConditionTreeCheck {
378 if (tree === null || tree === undefined) return { ok: true, code: null, message: null, depth: 0, nodes: 0 };
379 let depth = 0;
380 let nodes = 0;
381 let valid = true;
382 let message: string | null = null;
383 let code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null = null;
384
385
386 const walk = (node: unknown, enclosing: number): void => {
387 if (!valid) return;
388 nodes += 1;
389 if (nodes > CONDITION_MAX_NODES) {
390 valid = false;
391 code = "CONDITION_TOO_COMPLEX";
392 message = `condition tree exceeds ${CONDITION_MAX_NODES} nodes`;
393 return;
394 }
395 if (typeof node !== "object" || node === null) {
396 valid = false;
397 code = "CONDITION_INVALID";
398 message = "condition node must be an object";
399 return;
400 }
401 const n = node as Record<string, unknown>;
402 if (n.op === "AND" || n.op === "OR" || n.op === "NOT") {
403 const level = enclosing + 1;
404 if (level > CONDITION_MAX_DEPTH) {
405 valid = false;
406 code = "CONDITION_TOO_COMPLEX";
407 message = `condition tree exceeds depth ${CONDITION_MAX_DEPTH}`;
408 return;
409 }
410 let children: unknown[];
411 if (n.op === "NOT") {
412
413 if (n.child === undefined) {
414 valid = false;
415 code = "CONDITION_INVALID";
416 message = "NOT group needs a `child`";
417 return;
418 }
419 children = [n.child];
420 } else {
421 if (!Array.isArray(n.children) || n.children.length === 0) {
422 valid = false;
423 code = "CONDITION_INVALID";
424 message = `${n.op} group needs children`;
425 return;
426 }
427 if (n.children.length < 2) {
428 valid = false;
429 code = "CONDITION_INVALID";
430 message = `${n.op} group needs ≥ 2 children`;
431 return;
432 }
433 children = n.children;
434 }
435 depth = Math.max(depth, level);
436 for (const c of children) walk(c, level);
437 return;
438 }
439
440 const f = n.field;
441 if (
442 typeof f !== "object" ||
443 f === null ||
444 !["event", "property"].includes((f as { ref?: unknown }).ref as string) ||
445 typeof (f as { name?: unknown }).name !== "string"
446 ) {
447 valid = false;
448 code = "CONDITION_INVALID";
449 message = "predicate needs field { ref: event|property, name }";
450 return;
451 }
452 const op = n.operator;
453 if (typeof op !== "string" || !(CONDITION_OPERATORS as readonly string[]).includes(op)) {
454 valid = false;
455 code = "CONDITION_INVALID";
456 message = `unknown operator: ${String(op)}`;
457 return;
458 }
459 if (op !== "is_empty" && op !== "is_not_empty" && n.value === undefined) {
460 valid = false;
461 code = "CONDITION_INVALID";
462 message = `operator ${op} requires a value`;
463 return;
464 }
465 };
466
467 walk(tree, 0);
468 if (!valid) return { ok: false, code, message, depth, nodes };
469 return { ok: true, code: null, message: null, depth, nodes };
470}
471
472
479export function normalizeConditionValue(
480 type: string,
481 config: Record<string, unknown> | null | undefined,
482 value: unknown
483): unknown {
484 const options: { id: string; name: string }[] = Array.isArray(config?.options)
485 ? (config!.options as { id: string; name: string }[])
486 : [];
487 const nameOf = (id: unknown): string =>
488 typeof id === "string" ? (options.find((o) => o.id === id)?.name ?? id) : String(id);
489
490 switch (type) {
491 case "SELECT":
492 case "STATUS":
493 return typeof value === "string" ? nameOf(value) : value ?? null;
494 case "MULTI_SELECT":
495 return Array.isArray(value) ? [...value].map(nameOf).sort() : value ?? null;
496 case "PERSON":
497 return Array.isArray(value) ? value.map(String) : value ?? null;
498 case "DATE":
499 if (value && typeof value === "object" && "start" in value && "end" in value) {
500 return `${(value as { start: unknown }).start}..${(value as { end: unknown }).end}`;
501 }
502 return typeof value === "string" || typeof value === "number" ? String(value) : value ?? null;
503 case "NUMBER":
504 return typeof value === "number" && Number.isFinite(value) ? value : value ?? null;
505 case "CHECKBOX":
506 return typeof value === "boolean" ? value : value ?? null;
507 case "TITLE":
508 case "TEXT":
509 return typeof value === "string" ? value : value ?? null;
510 default:
511 return value ?? null;
512 }
513}
514
515
516
517
518
519
535export class PropertyCoercionError extends Error {
536 constructor(
537 public readonly code: "PROPERTY_NOT_WRITABLE" | "PROPERTY_VALUE_INVALID",
538 message: string
539 ) {
540 super(message);
541 this.name = "PropertyCoercionError";
542 }
543}
544
545const READ_ONLY_PROPERTY_TYPES = new Set([
546 "FORMULA",
547 "ROLLUP",
548 "CREATED_TIME",
549 "CREATED_BY",
550 "LAST_EDITED_TIME",
551 "LAST_EDITED_BY",
552 "RELATION",
553 "BUTTON",
554 "UNIQUE_ID"
555]);
556
557export function coercePropertyWriteValue(
558 type: string,
559 raw: unknown
560): Record<string, unknown> | null {
561 if (raw === null || raw === undefined) return null;
562 if (READ_ONLY_PROPERTY_TYPES.has(type)) {
563 throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `${type} is read-only`);
564 }
565 const asStringArray = (v: unknown): string[] => {
566 const arr = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
567 if (!arr.every((x) => typeof x === "string")) {
568 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "string array required");
569 }
570 return arr;
571 };
572 switch (type) {
573 case "TEXT":
574 case "RICH_TEXT":
575 case "TITLE": {
576 if (typeof raw === "string") return { text: raw, marks: [] };
577 if (typeof raw === "object" && raw !== null && "text" in (raw as object)) return raw as Record<string, unknown>;
578 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "text value required");
579 }
580 case "NUMBER":
581 case "EMAIL":
582 case "PHONE":
583 case "CHECKBOX": {
584 if (typeof raw === "number" || typeof raw === "string" || typeof raw === "boolean") return { value: raw };
585 if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record<string, unknown>;
586 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", `${type} value required`);
587 }
588 case "SELECT":
589 case "STATUS": {
590 if (typeof raw === "string") return { optionId: raw };
591 if (typeof raw === "object" && raw !== null && "optionId" in (raw as object)) return raw as Record<string, unknown>;
592 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "optionId value required");
593 }
594 case "MULTI_SELECT":
595 return { optionIds: asStringArray(raw) };
596 case "PERSON":
597 return { userIds: asStringArray(raw) };
598 case "FILES":
599 return { fileIds: asStringArray(raw) };
600 case "DATE":
601 case "DATE_RANGE": {
602 if (typeof raw === "string") return { start: raw, end: null };
603 if (typeof raw === "object" && raw !== null && "start" in (raw as object)) return raw as Record<string, unknown>;
604 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "date value required");
605 }
606 case "URL": {
607 if (typeof raw === "string") return { value: raw, title: null };
608 if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record<string, unknown>;
609 throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "url value required");
610 }
611 default:
612 throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `type ${type} does not accept direct writes`);
613 }
614}
615
616
617
618
619
620
621const interpString = (max: number) =>
622 z
623 .string()
624 .min(1)
625 .max(max)
626 .refine((s) => {
627
628 const paths = [...s.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => (m[1] as string).split("."));
629 return paths.every((p) => p.length <= 3);
630 }, "interpolation path too deep (max 3 segments)");
631
632const fromTrigger = z
633 .literal("from_trigger")
634 .or(cuid)
635 .or(z.literal("trigger_user"))
636 .or(z.literal("automation_creator"));
637
638export const ActionConfigSchemas: Record<AutomationActionType, z.ZodTypeAny> = {
639 UPDATE_RECORD: z
640 .object({
641 recordId: z
642 .union([z.literal("from_trigger"), cuid])
643 .default("from_trigger"),
644
645 propertyValues: z
646 .record(z.string().min(1).max(100), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]))
647 .refine((o) => Object.keys(o).length >= 1, "propertyValues must have ≥ 1 entry")
648 .refine((o) => Object.keys(o).length <= 20, "propertyValues allows ≤ 20 entries")
649 })
650 .strict(),
651 CREATE_PAGE: z
652 .object({
653 title: interpString(200),
654 parentId: z.union([z.literal("from_trigger_page"), cuid]).optional(),
655 databaseId: cuid.optional(),
656
657 templateId: cuid.optional(),
658 icon: z
659 .string()
660 .min(1)
661 .max(8)
662 .optional()
663 .refine((s) => [...(s ?? "")].length <= 2, "icon must be an emoji (≤ 2 codepoints)"),
664
665 propertyValues: z
666 .record(
667 z.string().min(1).max(100),
668 z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
669 )
670 .optional()
671 })
672 .strict(),
673 SEND_EMAIL: z
674 .object({
675 to: z
676 .union([
677 z.string().email(),
678 z.literal("trigger_user"),
679 z.literal("automation_creator")
680 ])
681 .default("automation_creator"),
682 subject: interpString(200),
683 body: z.string().min(1).max(20_000),
684 cc: z
685 .array(z.string().email())
686 .max(5)
687 .optional()
688 })
689 .strict(),
690 SEND_WEBHOOK: z
691 .object({
692 url: z
693 .string()
694 .url()
695 .max(2048)
696 .refine((u) => u.startsWith("https://"), "webhook URL must be https (SSRF guard)"),
697 headers: z
698 .record(z.string().min(1).max(128), z.string().max(512))
699 .refine((h) => Object.keys(h).length <= 20, "≤ 20 headers")
700 .optional(),
701 payload: z
702 .record(z.string(), z.unknown())
703 .optional()
704 .refine((p) => JSON.stringify(p ?? {}).length <= WEBHOOK_PAYLOAD_MAX_BYTES, "payload ≤ 64 KB")
705 })
706 .strict(),
707 CREATE_NOTIFICATION: z
708 .object({
709 to: z
710 .union([cuid, z.literal("trigger_user"), z.literal("automation_creator")])
711 .default("automation_creator"),
712 type: z.enum([
713 "MENTION",
714 "FORM_SUBMITTED",
715 "COMMENT",
716 "ASSIGNMENT",
717 "PAGE_SHARED",
718 "TASK_ASSIGNED",
719 "DATABASE_CHANGE",
720 "AUTOMATION",
721 "INTEGRATION"
722 ]),
723 body: interpString(500),
724
725 link: z
726 .string()
727 .max(500)
728 .refine((l) => l.startsWith("/") && !l.startsWith("//"), "link must be an internal path (starts with /)")
729 .optional()
730 })
731 .strict(),
732 ASSIGN_USER: z
733 .object({
734 recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"),
735
740 propertyId: cuid,
741 userId: fromTrigger
742 })
743 .strict(),
744 CHANGE_STATUS: z
745 .object({
746 recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"),
747
748 propertyId: cuid,
749
750 value: z.string().min(1).max(100)
751 })
752 .strict(),
753 CALL_INTEGRATION: z
754 .object({
755 integrationId: cuid,
756 operation: z.string().min(1).max(100),
757 params: z.record(z.string(), z.unknown()).optional()
758 })
759 .strict(),
760 RUN_AI_ACTION: z
761 .object({
762 prompt: interpString(4_000),
763 task: z.enum(["summarize", "classify", "extract"])
764 })
765 .strict()
766};
767
768export interface AutomationActionDtoInput {
769 type: AutomationActionType;
770 config: Record<string, unknown>;
771 retries?: number;
772 timeoutMs?: number;
773}
774
775
780export function validateActionDto(
781 action: AutomationActionDtoInput
782): { ok: true; normalized: { type: AutomationActionType; config: Record<string, unknown>; retries: number; timeoutMs: number } } | {
783 ok: false;
784 errors: { path: string; message: string }[];
785} {
786 if (!(AUTOMATION_ACTION_TYPES as readonly string[]).includes(action.type)) {
787 return { ok: false, errors: [{ path: "type", message: `unknown action type: ${action.type}` }] };
788 }
789 const schema = ActionConfigSchemas[action.type];
790 const parsed = schema.safeParse(action.config ?? {});
791 if (!parsed.success) {
792 return {
793 ok: false,
794 errors: parsed.error.issues.map((i) => ({ path: `config.${i.path.join(".")}`, message: i.message }))
795 };
796 }
797 const retries = action.retries ?? AUTOMATION_ACTION_RETRIES.default;
798 const timeoutMs = action.timeoutMs ?? AUTOMATION_ACTION_TIMEOUT_MS.default;
799 if (!Number.isInteger(retries) || retries < AUTOMATION_ACTION_RETRIES.min || retries > AUTOMATION_ACTION_RETRIES.max) {
800 return { ok: false, errors: [{ path: "retries", message: `retries must be ${AUTOMATION_ACTION_RETRIES.min}..${AUTOMATION_ACTION_RETRIES.max}` }] };
801 }
802 if (!Number.isInteger(timeoutMs) || timeoutMs < AUTOMATION_ACTION_TIMEOUT_MS.min || timeoutMs > AUTOMATION_ACTION_TIMEOUT_MS.max) {
803 return {
804 ok: false,
805 errors: [{ path: "timeoutMs", message: `timeoutMs must be ${AUTOMATION_ACTION_TIMEOUT_MS.min}..${AUTOMATION_ACTION_TIMEOUT_MS.max}` }]
806 };
807 }
808 return { ok: true, normalized: { type: action.type, config: parsed.data as Record<string, unknown>, retries, timeoutMs } };
809}
810
811
812
813
814
815export interface AutomationTriggerMeta {
816 type: AutomationTriggerType;
817 label: string;
818 description: string;
819
820 events: string[];
821
822 needsDatabase: boolean;
823}
824
825export const AUTOMATION_TRIGGERS: readonly AutomationTriggerMeta[] = [
826 { type: "RECORD_CREATED", label: "When a record is created", description: "Fires when a new record appears in a database.", events: ["record.created"], needsDatabase: true },
827 { type: "RECORD_UPDATED", label: "When a record is updated", description: "Fires on any record update (optionally narrowed to properties).", events: ["record.updated"], needsDatabase: true },
828 { type: "PROPERTY_CHANGED", label: "When a property changes", description: "Fires when one of the listed properties changes.", events: ["record.updated"], needsDatabase: true },
829 { type: "PAGE_CREATED", label: "When a page is created", description: "Fires when a page is created in the workspace.", events: ["page.created"], needsDatabase: false },
830 { type: "FORM_SUBMITTED", label: "When a form is submitted", description: "Fires on a public form submission (creates a record).", events: ["form.submitted"], needsDatabase: true },
831 { type: "SCHEDULE", label: "On a schedule", description: "Runs on a cron schedule (minimum 60 s).", events: ["schedule"], needsDatabase: false },
832 { type: "WEBHOOK", label: "When a webhook is received", description: "Fires on a signed POST to the automation's public URL.", events: ["webhook"], needsDatabase: false },
833 { type: "MANUAL", label: "Manually", description: "Can be run from the Run now button or the API.", events: ["manual"], needsDatabase: false }
834];
835
836export interface AutomationActionMeta {
837 type: AutomationActionType;
838 label: string;
839 description: string;
840 icon: string;
841
842 stub: "phase-12" | "phase-13" | null;
843}
844
845export const AUTOMATION_ACTIONS: readonly AutomationActionMeta[] = [
846 { type: "UPDATE_RECORD", label: "Update record", description: "Set property values on a record.", icon: "pencil", stub: null },
847 { type: "CREATE_PAGE", label: "Create page", description: "Create a page (or a record page).", icon: "file-plus", stub: null },
848 { type: "SEND_EMAIL", label: "Send email", description: "Email a user or role.", icon: "mail", stub: null },
849 { type: "SEND_WEBHOOK", label: "Send webhook", description: "POST a JSON payload to a public URL.", icon: "webhook", stub: null },
850 { type: "CREATE_NOTIFICATION", label: "Notify user", description: "Create an in-app notification.", icon: "bell", stub: null },
851 { type: "ASSIGN_USER", label: "Assign user", description: "Set a person property to a user.", icon: "user-plus", stub: null },
852 { type: "CHANGE_STATUS", label: "Change status", description: "Set a status/select property to an option.", icon: "refresh-cw", stub: null },
853 { type: "CALL_INTEGRATION", label: "Call integration", description: "Call a connected integration (Phase 13).", icon: "plug", stub: "phase-13" },
854 { type: "RUN_AI_ACTION", label: "Run AI action", description: "Summarize / classify / extract with AI (Phase 12).", icon: "sparkles", stub: "phase-12" }
855];
856
857
858export const STUB_ACTION_TYPES: readonly AutomationActionType[] = [
859 "CALL_INTEGRATION",
860 "RUN_AI_ACTION"
861];
862
863
864
865
866
867
868export interface AutomationContext {
869 runId: string;
870 automationId: string;
871 depth: number;
872}
873
874export type LoopGuardDecision =
875 | { blocked: false }
876 | { blocked: true; reason: "self_trigger" | "depth_limit"; depth: number };
877
878
886export function loopGuard(
887 incoming: { automationId?: string; depth?: number } | null | undefined,
888 automationId: string
889): LoopGuardDecision {
890 const depth = typeof incoming?.depth === "number" ? incoming.depth : 0;
891 if (incoming && typeof incoming.automationId === "string" && incoming.automationId === automationId) {
892 return { blocked: true, reason: "self_trigger", depth };
893 }
894 if (depth >= AUTOMATION_MAX_DEPTH) {
895 return { blocked: true, reason: "depth_limit", depth };
896 }
897 return { blocked: false };
898}
899
900
901export function nextAutomationContext(
902 runId: string,
903 automationId: string,
904 depth: number
905): AutomationContext {
906 return { runId, automationId, depth: depth + 1 };
907}
908
909
910
911
912
913export interface InterpolationVars {
914 record?: Record<string, unknown> | null;
915 event?: Record<string, unknown> | null;
916 input?: Record<string, unknown> | null;
917 automation?: { id?: string; name?: string } | null;
918 run?: { id?: string } | null;
919 [key: string]: unknown;
920}
921
922
930export function interpolate(
931 template: string,
932 vars: InterpolationVars
933): { value: string; warnings: string[] } {
934 const warnings: string[] = [];
935 const value = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => {
936 const parts = path.split(".").slice(0, 3);
937 let cur: unknown = vars;
938 for (const p of parts) {
939 if (cur === null || cur === undefined || typeof cur !== "object") {
940 cur = undefined;
941 break;
942 }
943 cur = (cur as Record<string, unknown>)[p];
944 }
945 if (cur === undefined || cur === null) {
946 if (!/^(record|event|input|automation|run)\./.test(path)) {
947 warnings.push(`unknown interpolation key: {{${path}}}`);
948 }
949 return "";
950 }
951 if (typeof cur === "object") return JSON.stringify(cur);
952 return String(cur);
953 });
954 return { value, warnings };
955}
956
957
958
959
960
961
966export function truncateJson(value: unknown, maxBytes: number): { value: unknown; truncated: boolean } {
967 const enc = new TextEncoder();
968 const raw = JSON.stringify(value ?? null);
969 if (raw === undefined) return { value: null, truncated: false };
970 if (enc.encode(raw).length <= maxBytes) return { value, truncated: false };
971
972 if (value !== null && typeof value === "object" && !Array.isArray(value)) {
973
974 const obj = { ...(value as Record<string, unknown>) };
975 const entries = Object.entries(obj).sort((a, b) => JSON.stringify(b[1]).length - JSON.stringify(a[1]).length);
976 for (const [k, v] of entries) {
977 if (enc.encode(JSON.stringify(obj)).length <= maxBytes) break;
978 obj[k] = `[truncated]`;
979 }
980 obj.truncated = true;
981 return { value: obj, truncated: true };
982 }
983
984 let s = raw;
985 while (enc.encode(s).length > maxBytes - 24) {
986 s = s.slice(0, Math.max(0, s.length - 1024));
987 }
988 return { value: s + ` …[truncated]`, truncated: true };
989}
990
991
992
993
994
995export interface WebhookSignatureParts {
996 t: number;
997 v1: string;
998}
999
1000
1001export function buildWebhookSignature(secret: string, unixTsSeconds: number, rawBody: string): string {
1002 const mac = createHmac("sha256", secret).update(`${unixTsSeconds}.${rawBody}`).digest("hex");
1003 return `t=${unixTsSeconds},v1=${mac}`;
1004}
1005
1006export function parseWebhookSignature(header: string): WebhookSignatureParts | null {
1007 const m = header.match(/t=(\d+),v1=([0-9a-f]{64})/);
1008 if (!m || m[1] === undefined || m[2] === undefined) return null;
1009 return { t: Number(m[1]), v1: m[2] };
1010}
1011
1012
1017export function verifyWebhookSignature(
1018 secret: string,
1019 header: string | undefined,
1020 rawBody: string,
1021 nowMs: number = Date.now()
1022): null | "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE" {
1023 const parts = header ? parseWebhookSignature(header) : null;
1024 if (!parts) return "WEBHOOK_SIGNATURE_INVALID";
1025 const tsMs = parts.t * 1000;
1026 if (Math.abs(nowMs - tsMs) > WEBHOOK_TS_TOLERANCE_MS) return "WEBHOOK_TIMESTAMP_STALE";
1027 const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
1028 const a = Buffer.from(expected, "hex");
1029 const b = Buffer.from(parts.v1, "hex");
1030 if (a.length !== b.length || !timingSafeEqual(a, b)) return "WEBHOOK_SIGNATURE_INVALID";
1031 return null;
1032}
1033
1034
1035export function generateWebhookSecret(): string {
1036 return "whsec_" + randomBytes(32).toString("hex");
1037}
1038
1039
1040export function webhookSecretHash(secret: string): string {
1041 return createHmac("sha256", "hash").update(secret).digest("hex");
1042}
1043
1044
1045
1046
1047
1048
1049export const DEDUPE_TTL_SECONDS = 600;
1050export function dedupeKey(automationId: string, eventId: string): string {
1051 return `auto:dedupe:${automationId}:${eventId}`;
1052}
1053
1054
1055export function webhookRateKey(webhookId: string): string {
1056 return `auto:webhook-rate:${webhookId}`;
1057}
1058
1059
1060export function emailCapKey(workspaceId: string, date: Date = new Date()): string {
1061 return `auto:email:${workspaceId}:${date.toISOString().slice(0, 10)}`;
1062}
1063