loopengine 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/agent-config.d.ts +2 -0
- package/dist/core/http-notifier.js +14 -3
- package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
- package/dist/core/http-notify-triggers/telegram.js +63 -17
- package/dist/core/messaging/connector.d.ts +7 -0
- package/dist/core/messaging/connector.js +1 -1
- package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/events.js +6 -2
- package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/setup.js +10 -5
- package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
- package/dist/core/messaging/connectors/telegram/format.js +57 -0
- package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
- package/dist/core/messaging/connectors/telegram/index.js +136 -0
- package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
- package/dist/core/messaging/connectors/telegram/store.js +69 -0
- package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
- package/dist/core/messaging/connectors/telegram/updates.js +112 -0
- package/dist/core/messaging/jobs.d.ts +44 -0
- package/dist/core/messaging/jobs.js +100 -0
- package/dist/core/messaging/threads.d.ts +12 -0
- package/dist/core/messaging/threads.js +36 -0
- package/dist/core/notifier-file.d.ts +13 -5
- package/dist/core/notifier-file.js +41 -6
- package/dist/core/run-callbacks.d.ts +39 -0
- package/dist/core/run-callbacks.js +125 -0
- package/dist/core/system-agents/workflow-planner.d.ts +37 -4
- package/dist/core/system-agents/workflow-planner.js +79 -5
- package/dist/core/workflow-runs.d.ts +131 -0
- package/dist/core/workflow-runs.js +229 -0
- package/dist/core/workflow-triggers.d.ts +140 -0
- package/dist/core/workflow-triggers.js +378 -0
- package/dist/web/agents-config-page.js +168 -50
- package/dist/web/notifier-admin.d.ts +69 -13
- package/dist/web/notifier-admin.js +127 -31
- package/dist/web/workflow-admin.d.ts +10 -0
- package/dist/web/workflow-admin.js +42 -3
- package/dist/web/workflow-page.js +93 -2
- package/package.json +7 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Webhook triggers: a secret URL per workflow that an outside service
|
|
2
|
+
// (Stripe, GitHub, Shopify, a form or monitoring tool) can post its own
|
|
3
|
+
// events to, to start a run, with no API key and no body format of ours:
|
|
4
|
+
//
|
|
5
|
+
// POST /hooks/workflows/<token>
|
|
6
|
+
//
|
|
7
|
+
// The token in the URL is the credential (it's kept here only as a
|
|
8
|
+
// SHA-256 hash, so the full URL is shown once, when it's made or
|
|
9
|
+
// rotated). A trigger can also check the sender's own signature, keep
|
|
10
|
+
// only some events (filter), and fill the workflow's parameters from the
|
|
11
|
+
// payload (mapping, or the whole payload as one parameter). A delivery a
|
|
12
|
+
// sender retries is recognised by its id and starts no second run.
|
|
13
|
+
//
|
|
14
|
+
// Stored like runs and schedules: one JSON file per trigger under
|
|
15
|
+
// `<LOOPENGINE_SCHEDULER_DIR or .scheduler>/triggers/`.
|
|
16
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { KeyedMutex } from './session-store.js';
|
|
20
|
+
import { checkCallbackUrl } from './run-callbacks.js';
|
|
21
|
+
export class TriggerError extends Error {
|
|
22
|
+
}
|
|
23
|
+
const MAX_DELIVERIES = 20;
|
|
24
|
+
const MAX_SEEN = 200;
|
|
25
|
+
const MAX_PAYLOAD_PARAM = 20_000;
|
|
26
|
+
const SENDERS = ['stripe', 'github', 'shopify', 'standard', 'custom'];
|
|
27
|
+
const ALGORITHMS = ['sha256', 'sha1', 'sha512'];
|
|
28
|
+
/** How old a signed timestamp (Stripe, Standard Webhooks) may be. */
|
|
29
|
+
const SIGNATURE_TOLERANCE_S = 300;
|
|
30
|
+
const fileMutex = new KeyedMutex();
|
|
31
|
+
/** One JSON file per trigger under `<dir>/triggers/` — one server process. */
|
|
32
|
+
export class FileTriggerStore {
|
|
33
|
+
dir;
|
|
34
|
+
constructor(dir) {
|
|
35
|
+
this.dir = path.join(dir, 'triggers');
|
|
36
|
+
}
|
|
37
|
+
file(id) {
|
|
38
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
39
|
+
throw new TriggerError(`invalid trigger id "${id}"`);
|
|
40
|
+
return path.join(this.dir, `${id}.json`);
|
|
41
|
+
}
|
|
42
|
+
async list() {
|
|
43
|
+
if (!existsSync(this.dir))
|
|
44
|
+
return [];
|
|
45
|
+
const triggers = [];
|
|
46
|
+
for (const name of readdirSync(this.dir)) {
|
|
47
|
+
if (!name.endsWith('.json'))
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
triggers.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A file being written, or not a trigger: skip it.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return triggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
57
|
+
}
|
|
58
|
+
async get(id) {
|
|
59
|
+
const file = this.file(id);
|
|
60
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
|
|
61
|
+
}
|
|
62
|
+
async put(trigger) {
|
|
63
|
+
mkdirSync(this.dir, { recursive: true });
|
|
64
|
+
const file = this.file(trigger.trigger_id);
|
|
65
|
+
writeFileSync(file + '.tmp', JSON.stringify(trigger, null, 2), { mode: 0o600 });
|
|
66
|
+
renameSync(file + '.tmp', file);
|
|
67
|
+
}
|
|
68
|
+
async remove(id) {
|
|
69
|
+
rmSync(this.file(id), { force: true });
|
|
70
|
+
}
|
|
71
|
+
async update(id, fn) {
|
|
72
|
+
return fileMutex.run(id, async () => {
|
|
73
|
+
const next = fn(await this.get(id));
|
|
74
|
+
if (next)
|
|
75
|
+
await this.put(next);
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let store;
|
|
81
|
+
export function getTriggerStore() {
|
|
82
|
+
store ??= new FileTriggerStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
|
|
83
|
+
return store;
|
|
84
|
+
}
|
|
85
|
+
export function setTriggerStore(next) {
|
|
86
|
+
store = next;
|
|
87
|
+
}
|
|
88
|
+
// ---------- making and finding ----------
|
|
89
|
+
function hashToken(token) {
|
|
90
|
+
return createHash('sha256').update(token).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
function newToken() {
|
|
93
|
+
return `whk_${randomBytes(24).toString('base64url')}`;
|
|
94
|
+
}
|
|
95
|
+
export function publicTrigger(t) {
|
|
96
|
+
const { token_hash: _hash, verify, seen_ids: _seen, ...rest } = t;
|
|
97
|
+
if (!verify)
|
|
98
|
+
return rest;
|
|
99
|
+
const { secret, ...shown } = verify;
|
|
100
|
+
return { ...rest, verify: { ...shown, secret_set: Boolean(secret) } };
|
|
101
|
+
}
|
|
102
|
+
/** Checks settings for a trigger of `workflow`; throws TriggerError saying
|
|
103
|
+
* what's wrong. With `existing` (a change), a setting left out keeps its
|
|
104
|
+
* value and null removes it, and a sender's secret is kept when only the
|
|
105
|
+
* sender is given again (the console never sees the secret).
|
|
106
|
+
*
|
|
107
|
+
* A trigger that checks a signature can be made turned off without its
|
|
108
|
+
* secret yet: the workflow planner makes one that way, for the person to
|
|
109
|
+
* add the secret the sender shows them. It can't be turned on until the
|
|
110
|
+
* secret is there. */
|
|
111
|
+
export function checkTriggerSettings(input, workflow, existing) {
|
|
112
|
+
if (existing) {
|
|
113
|
+
const keys = ['verify', 'filter', 'mapping', 'payload_param', 'notify', 'callback_url'];
|
|
114
|
+
input = { ...input };
|
|
115
|
+
for (const key of keys)
|
|
116
|
+
if (input[key] === undefined && existing[key] !== undefined)
|
|
117
|
+
input[key] = existing[key];
|
|
118
|
+
}
|
|
119
|
+
const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim().slice(0, 80) : existing?.name ?? 'Webhook';
|
|
120
|
+
if (input.enabled !== undefined && typeof input.enabled !== 'boolean')
|
|
121
|
+
throw new TriggerError('enabled must be true or false');
|
|
122
|
+
const enabled = input.enabled === undefined ? existing?.enabled ?? true : input.enabled;
|
|
123
|
+
let verify;
|
|
124
|
+
if (input.verify !== undefined && input.verify !== null) {
|
|
125
|
+
const v = input.verify;
|
|
126
|
+
if (typeof v !== 'object' || !SENDERS.includes(v.sender))
|
|
127
|
+
throw new TriggerError(`verify.sender must be one of ${SENDERS.join(', ')}`);
|
|
128
|
+
const kept = existing?.verify && existing.verify.sender === v.sender ? existing.verify.secret : '';
|
|
129
|
+
const secret = typeof v.secret === 'string' && v.secret.trim() ? v.secret.trim() : kept;
|
|
130
|
+
if (!secret && enabled)
|
|
131
|
+
throw new TriggerError(`Give the signing secret ${v.sender === 'custom' ? 'the sender' : v.sender} shows for this webhook, so its signature can be checked.`);
|
|
132
|
+
verify = { sender: v.sender, secret };
|
|
133
|
+
if (v.sender === 'standard' && secret && !/^(whsec_)?[A-Za-z0-9+/]+=*$/.test(secret))
|
|
134
|
+
throw new TriggerError('A Standard Webhooks secret looks like "whsec_" followed by base64 text.');
|
|
135
|
+
if (v.sender === 'custom') {
|
|
136
|
+
const headerName = typeof v.header === 'string' ? v.header.trim() : '';
|
|
137
|
+
if (!/^[A-Za-z0-9-]{1,80}$/.test(headerName))
|
|
138
|
+
throw new TriggerError('Give the header the signature arrives in, e.g. "Linear-Signature" (letters, digits and dashes).');
|
|
139
|
+
const algorithm = (v.algorithm ?? 'sha256');
|
|
140
|
+
if (!ALGORITHMS.includes(algorithm))
|
|
141
|
+
throw new TriggerError(`algorithm must be one of ${ALGORITHMS.join(', ')}`);
|
|
142
|
+
const encoding = v.encoding ?? 'hex';
|
|
143
|
+
if (encoding !== 'hex' && encoding !== 'base64')
|
|
144
|
+
throw new TriggerError('encoding must be hex or base64');
|
|
145
|
+
const prefix = typeof v.prefix === 'string' ? v.prefix.trim() : '';
|
|
146
|
+
if (prefix.length > 20)
|
|
147
|
+
throw new TriggerError('prefix is too long: keep it under 20 characters, e.g. "sha256="');
|
|
148
|
+
verify = { ...verify, header: headerName.toLowerCase(), algorithm, encoding, ...(prefix ? { prefix } : {}) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
let filter;
|
|
152
|
+
if (input.filter !== undefined && input.filter !== null) {
|
|
153
|
+
const f = input.filter;
|
|
154
|
+
const values = Array.isArray(f.values) ? f.values.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : [];
|
|
155
|
+
if (typeof f.path !== 'string' || !f.path.trim() || !values.length)
|
|
156
|
+
throw new TriggerError('filter needs a path, e.g. "type", and at least one value, e.g. ["charge.dispute.created"]');
|
|
157
|
+
filter = { path: f.path.trim(), values };
|
|
158
|
+
}
|
|
159
|
+
const declared = Object.keys(workflow.parameters);
|
|
160
|
+
let mapping;
|
|
161
|
+
if (input.mapping !== undefined && input.mapping !== null) {
|
|
162
|
+
if (typeof input.mapping !== 'object' || Array.isArray(input.mapping))
|
|
163
|
+
throw new TriggerError('mapping must be an object of parameter names to payload paths');
|
|
164
|
+
mapping = {};
|
|
165
|
+
for (const [param, p] of Object.entries(input.mapping)) {
|
|
166
|
+
if (typeof p !== 'string' || !p.trim())
|
|
167
|
+
continue;
|
|
168
|
+
if (!declared.includes(param))
|
|
169
|
+
throw new TriggerError(`mapping: ${workflow.title} has no parameter "${param}"${declared.length ? ` (its parameters: ${declared.join(', ')})` : ''}`);
|
|
170
|
+
mapping[param] = p.trim();
|
|
171
|
+
}
|
|
172
|
+
if (!Object.keys(mapping).length)
|
|
173
|
+
mapping = undefined;
|
|
174
|
+
}
|
|
175
|
+
let payloadParam;
|
|
176
|
+
if (typeof input.payload_param === 'string' && input.payload_param) {
|
|
177
|
+
if (!declared.includes(input.payload_param))
|
|
178
|
+
throw new TriggerError(`payload_param: ${workflow.title} has no parameter "${input.payload_param}"`);
|
|
179
|
+
payloadParam = input.payload_param;
|
|
180
|
+
}
|
|
181
|
+
if (input.notify !== undefined && typeof input.notify !== 'boolean')
|
|
182
|
+
throw new TriggerError('notify must be true or false');
|
|
183
|
+
const callbackUrl = input.callback_url ? checkCallbackUrl(input.callback_url) : undefined;
|
|
184
|
+
return {
|
|
185
|
+
name,
|
|
186
|
+
enabled,
|
|
187
|
+
...(verify ? { verify } : {}),
|
|
188
|
+
...(filter ? { filter } : {}),
|
|
189
|
+
...(mapping ? { mapping } : {}),
|
|
190
|
+
...(payloadParam ? { payload_param: payloadParam } : {}),
|
|
191
|
+
...(input.notify === true ? { notify: true } : {}),
|
|
192
|
+
...(callbackUrl ? { callback_url: callbackUrl } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Makes a trigger for a workflow; returns it with its token (shown once). */
|
|
196
|
+
export async function createTrigger(agent, workflow, input) {
|
|
197
|
+
const settings = checkTriggerSettings(input, workflow);
|
|
198
|
+
const token = newToken();
|
|
199
|
+
const trigger = {
|
|
200
|
+
trigger_id: randomUUID(),
|
|
201
|
+
agent,
|
|
202
|
+
workflow_id: workflow.id,
|
|
203
|
+
...settings,
|
|
204
|
+
token_hash: hashToken(token),
|
|
205
|
+
token_hint: `${token.slice(0, 8)}…`,
|
|
206
|
+
created_at: new Date().toISOString(),
|
|
207
|
+
deliveries: [],
|
|
208
|
+
seen_ids: [],
|
|
209
|
+
};
|
|
210
|
+
await getTriggerStore().put(trigger);
|
|
211
|
+
return { trigger, token };
|
|
212
|
+
}
|
|
213
|
+
export async function updateTrigger(id, workflow, input) {
|
|
214
|
+
const existing = await getTriggerStore().get(id);
|
|
215
|
+
if (!existing)
|
|
216
|
+
throw new TriggerError(`no trigger ${id}`);
|
|
217
|
+
const settings = checkTriggerSettings(input, workflow, existing);
|
|
218
|
+
const { verify: _v, filter: _f, mapping: _m, payload_param: _p, notify: _n, callback_url: _c, ...kept } = existing;
|
|
219
|
+
const next = { ...kept, ...settings };
|
|
220
|
+
await getTriggerStore().put(next);
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
/** A new URL for a trigger; the old one stops working. */
|
|
224
|
+
export async function rotateTrigger(id) {
|
|
225
|
+
const token = newToken();
|
|
226
|
+
const trigger = await getTriggerStore().update(id, (t) => t && { ...t, token_hash: hashToken(token), token_hint: `${token.slice(0, 8)}…` });
|
|
227
|
+
if (!trigger)
|
|
228
|
+
throw new TriggerError(`no trigger ${id}`);
|
|
229
|
+
return { trigger, token };
|
|
230
|
+
}
|
|
231
|
+
export async function listTriggers(agent, workflowId) {
|
|
232
|
+
return (await getTriggerStore().list()).filter((t) => t.agent === agent && (!workflowId || t.workflow_id === workflowId));
|
|
233
|
+
}
|
|
234
|
+
/** The trigger a URL's token belongs to. */
|
|
235
|
+
export async function findTriggerByToken(token) {
|
|
236
|
+
if (!/^whk_[A-Za-z0-9_-]{16,}$/.test(token))
|
|
237
|
+
return undefined;
|
|
238
|
+
const hash = Buffer.from(hashToken(token), 'hex');
|
|
239
|
+
return (await getTriggerStore().list()).find((t) => {
|
|
240
|
+
const other = Buffer.from(t.token_hash, 'hex');
|
|
241
|
+
return other.length === hash.length && timingSafeEqual(other, hash);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// ---------- a delivery ----------
|
|
245
|
+
const hmacHex = (secret, data) => createHmac('sha256', secret).update(data).digest('hex');
|
|
246
|
+
const sameText = (a, b) => {
|
|
247
|
+
const x = Buffer.from(a);
|
|
248
|
+
const y = Buffer.from(b);
|
|
249
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
250
|
+
};
|
|
251
|
+
const header = (headers, name) => {
|
|
252
|
+
const v = headers[name];
|
|
253
|
+
return Array.isArray(v) ? v[0] : v;
|
|
254
|
+
};
|
|
255
|
+
/** Whether the sender's own signature on the raw body is right. Never
|
|
256
|
+
* with no secret: anyone can sign with an empty key. */
|
|
257
|
+
export function verifySenderSignature(verify, headers, raw, nowS = Math.floor(Date.now() / 1000)) {
|
|
258
|
+
if (!verify.secret)
|
|
259
|
+
return false;
|
|
260
|
+
if (verify.sender === 'stripe') {
|
|
261
|
+
const parts = (header(headers, 'stripe-signature') ?? '').split(',').map((p) => p.split('='));
|
|
262
|
+
const t = Number(parts.find(([k]) => k === 't')?.[1]);
|
|
263
|
+
if (!Number.isFinite(t) || Math.abs(nowS - t) > SIGNATURE_TOLERANCE_S)
|
|
264
|
+
return false;
|
|
265
|
+
const expected = hmacHex(verify.secret, Buffer.concat([Buffer.from(`${t}.`), raw]));
|
|
266
|
+
return parts.some(([k, v]) => k === 'v1' && typeof v === 'string' && sameText(v, expected));
|
|
267
|
+
}
|
|
268
|
+
if (verify.sender === 'github') {
|
|
269
|
+
const sig = header(headers, 'x-hub-signature-256') ?? '';
|
|
270
|
+
return sig.startsWith('sha256=') && sameText(sig.slice(7), hmacHex(verify.secret, raw));
|
|
271
|
+
}
|
|
272
|
+
if (verify.sender === 'shopify') {
|
|
273
|
+
const sig = header(headers, 'x-shopify-hmac-sha256') ?? '';
|
|
274
|
+
return Boolean(sig) && sameText(sig, createHmac('sha256', verify.secret).update(raw).digest('base64'));
|
|
275
|
+
}
|
|
276
|
+
if (verify.sender === 'standard') {
|
|
277
|
+
// webhook-* headers, or Svix's own svix-* names for the same thing.
|
|
278
|
+
const id = header(headers, 'webhook-id') ?? header(headers, 'svix-id');
|
|
279
|
+
const t = Number(header(headers, 'webhook-timestamp') ?? header(headers, 'svix-timestamp'));
|
|
280
|
+
const sigs = header(headers, 'webhook-signature') ?? header(headers, 'svix-signature') ?? '';
|
|
281
|
+
if (!id || !Number.isFinite(t) || Math.abs(nowS - t) > SIGNATURE_TOLERANCE_S)
|
|
282
|
+
return false;
|
|
283
|
+
const key = Buffer.from(verify.secret.replace(/^whsec_/, ''), 'base64');
|
|
284
|
+
const expected = createHmac('sha256', key).update(Buffer.concat([Buffer.from(`${id}.${t}.`), raw])).digest('base64');
|
|
285
|
+
return sigs.split(' ').some((part) => {
|
|
286
|
+
const [version, sig] = part.split(',');
|
|
287
|
+
return version === 'v1' && typeof sig === 'string' && sameText(sig, expected);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// custom: an HMAC of the raw body, in the header the sender names.
|
|
291
|
+
const got = (header(headers, verify.header ?? '') ?? '').trim();
|
|
292
|
+
const prefix = verify.prefix ?? '';
|
|
293
|
+
if (!got || (prefix && !got.startsWith(prefix)))
|
|
294
|
+
return false;
|
|
295
|
+
const expected = createHmac(verify.algorithm ?? 'sha256', verify.secret).update(raw).digest(verify.encoding ?? 'hex');
|
|
296
|
+
const value = got.slice(prefix.length);
|
|
297
|
+
return verify.encoding === 'base64' ? sameText(value, expected) : sameText(value.toLowerCase(), expected);
|
|
298
|
+
}
|
|
299
|
+
/** The payload: JSON, a form, or plain text (as { body }). */
|
|
300
|
+
export function parsePayload(raw, contentType) {
|
|
301
|
+
const text = raw.toString('utf8');
|
|
302
|
+
if (/application\/x-www-form-urlencoded/i.test(contentType ?? '')) {
|
|
303
|
+
const form = Object.fromEntries(new URLSearchParams(text));
|
|
304
|
+
// Slack-style and some tools put JSON in a "payload" field.
|
|
305
|
+
if (typeof form.payload === 'string') {
|
|
306
|
+
try {
|
|
307
|
+
return JSON.parse(form.payload);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Leave it as a form.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return form;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return { body: text };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** The value at a dot path: "data.object.id", "items.0.sku". */
|
|
323
|
+
export function valueAt(payload, dotPath) {
|
|
324
|
+
let value = payload;
|
|
325
|
+
for (const key of dotPath.split('.')) {
|
|
326
|
+
if (value === null || value === undefined || typeof value !== 'object')
|
|
327
|
+
return undefined;
|
|
328
|
+
value = value[key];
|
|
329
|
+
}
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
function asText(value) {
|
|
333
|
+
if (value === undefined || value === null)
|
|
334
|
+
return undefined;
|
|
335
|
+
if (typeof value === 'string')
|
|
336
|
+
return value;
|
|
337
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
338
|
+
return String(value);
|
|
339
|
+
return JSON.stringify(value);
|
|
340
|
+
}
|
|
341
|
+
/** The sender's id for this delivery, if it gives one: what a retry
|
|
342
|
+
* repeats. */
|
|
343
|
+
export function deliveryId(headers, payload) {
|
|
344
|
+
for (const name of ['x-github-delivery', 'x-shopify-webhook-id', 'idempotency-key', 'x-delivery-id', 'x-request-id']) {
|
|
345
|
+
const v = header(headers, name);
|
|
346
|
+
if (v)
|
|
347
|
+
return v.slice(0, 200);
|
|
348
|
+
}
|
|
349
|
+
const id = valueAt(payload, 'id');
|
|
350
|
+
return typeof id === 'string' && id ? id.slice(0, 200) : undefined;
|
|
351
|
+
}
|
|
352
|
+
/** Whether the filter keeps this payload. */
|
|
353
|
+
export function passesFilter(filter, payload) {
|
|
354
|
+
if (!filter)
|
|
355
|
+
return true;
|
|
356
|
+
const value = asText(valueAt(payload, filter.path));
|
|
357
|
+
return value !== undefined && filter.values.includes(value);
|
|
358
|
+
}
|
|
359
|
+
/** The workflow's parameters from the payload. */
|
|
360
|
+
export function paramsFromPayload(trigger, payload) {
|
|
361
|
+
const params = {};
|
|
362
|
+
for (const [param, dotPath] of Object.entries(trigger.mapping ?? {})) {
|
|
363
|
+
const value = asText(valueAt(payload, dotPath));
|
|
364
|
+
if (value !== undefined && value !== '')
|
|
365
|
+
params[param] = value.slice(0, MAX_PAYLOAD_PARAM);
|
|
366
|
+
}
|
|
367
|
+
if (trigger.payload_param)
|
|
368
|
+
params[trigger.payload_param] = JSON.stringify(payload).slice(0, MAX_PAYLOAD_PARAM);
|
|
369
|
+
return params;
|
|
370
|
+
}
|
|
371
|
+
/** Notes a delivery on its trigger, and its id as handled. */
|
|
372
|
+
export async function recordDelivery(id, delivery, seenId) {
|
|
373
|
+
await getTriggerStore().update(id, (t) => t && {
|
|
374
|
+
...t,
|
|
375
|
+
deliveries: [delivery, ...t.deliveries].slice(0, MAX_DELIVERIES),
|
|
376
|
+
seen_ids: seenId ? [...t.seen_ids.filter((s) => s !== seenId), seenId].slice(-MAX_SEEN) : t.seen_ids,
|
|
377
|
+
});
|
|
378
|
+
}
|