@protobase/server 0.68.5 → 0.69.0
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/better-auth/auth-settings.d.ts +155 -28
- package/dist/better-auth/auth-settings.js +3 -2
- package/dist/better-auth/connections/connection-account-hooks.js +62 -0
- package/dist/better-auth/connections/connection-events.d.ts +11 -0
- package/dist/better-auth/connections/connection-events.js +16 -0
- package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
- package/dist/better-auth/connections/connections-plugin.js +98 -0
- package/dist/better-auth/connections/declared-connections.d.ts +21 -0
- package/dist/better-auth/connections/declared-connections.js +54 -0
- package/dist/better-auth/connections/revoke-token.js +34 -0
- package/dist/better-auth/create-auth.d.ts +162 -17
- package/dist/better-auth/create-auth.js +59 -5
- package/dist/better-auth/refresh-refusals.js +43 -0
- package/dist/better-auth/staff-checks.js +1 -0
- package/dist/create-admin.js +11 -1
- package/dist/forms/rate-limit.js +33 -11
- package/dist/forms/routes.js +1 -1
- package/dist/functions/verify-signature.js +4 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/locale/en.js +3 -0
- package/dist/locale/nl.js +3 -0
- package/dist/mcp/mcp-tools.d.ts +2 -1
- package/dist/mcp/mcp-tools.js +4 -2
- package/dist/notifications/history-routes.js +1 -1
- package/dist/package.js +1 -1
- package/dist/personal-mail/linked-accounts.js +17 -13
- package/dist/types.d.ts +2 -0
- package/dist/webhooks/attempt.js +2 -2
- package/dist/webhooks/table/webhook-table.js +1 -1
- package/dist/workflows/connections/connection-accounts.js +18 -0
- package/dist/workflows/connections/connection-consent.js +49 -0
- package/dist/workflows/connections/connection-provider.js +10 -0
- package/dist/workflows/connections/connection-tokens.js +54 -0
- package/dist/workflows/connections/connection-triggers.js +37 -0
- package/dist/workflows/connections/connection-wiring.js +73 -0
- package/dist/workflows/connections/enabler-authority.js +35 -0
- package/dist/workflows/connections/enabler-mailbox.js +15 -0
- package/dist/workflows/connections/single-flight.js +17 -0
- package/dist/workflows/hooks/hook-auth.js +40 -0
- package/dist/workflows/hooks/hook-event.js +51 -0
- package/dist/workflows/hooks/hook-paths.d.ts +16 -0
- package/dist/workflows/hooks/hook-paths.js +32 -0
- package/dist/workflows/hooks/hook-route.js +99 -0
- package/dist/workflows/hooks/hook-runs.js +32 -0
- package/dist/workflows/hooks/hook-secrets.js +42 -0
- package/dist/workflows/hooks/hook-store.js +35 -0
- package/dist/workflows/hooks/hooks-sql.js +20 -0
- package/dist/workflows/hooks/issue-body.js +21 -0
- package/dist/workflows/hooks/issue-route.js +23 -0
- package/dist/workflows/hooks/issue-webhook.js +46 -0
- package/dist/workflows/hooks/read-body.js +32 -0
- package/dist/workflows/may-update-workflow.js +26 -0
- package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
- package/dist/workflows/mcp/webhook-tools.js +103 -0
- package/dist/workflows/mcp/workflow-instructions.js +1 -1
- package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
- package/dist/workflows/plugin-records.js +1 -1
- package/dist/workflows/plugin-services.js +1 -1
- package/dist/workflows/ready/ready-start.js +1 -1
- package/dist/workflows/run-files.js +7 -1
- package/dist/workflows/save-hook.js +7 -2
- package/dist/workflows/schema-sql.js +17 -3
- package/dist/workflows/schema.js +3 -1
- package/dist/workflows/start-workflow.d.ts +3 -1
- package/dist/workflows/start-workflow.js +10 -4
- package/dist/workflows/triggers/delete-hook.js +15 -0
- package/dist/workflows/triggers/plugin-call.js +45 -0
- package/dist/workflows/triggers/poll-job.js +120 -0
- package/dist/workflows/triggers/poll-result.js +32 -0
- package/dist/workflows/triggers/poll-schedule.js +32 -0
- package/dist/workflows/triggers/trigger-claim.js +81 -0
- package/dist/workflows/triggers/trigger-jobs.js +53 -7
- package/dist/workflows/triggers/trigger-start.js +112 -0
- package/dist/workflows/triggers/trigger-sync.js +15 -10
- package/dist/workflows/workflow-engine.js +57 -9
- package/dist/workflows/workflow-types.d.ts +11 -4
- package/dist/workflows/workflow-types.js +21 -15
- package/dist/write-shared.js +1 -0
- package/package.json +6 -6
- package/dist/better-auth/end-replaced-session.d.ts +0 -17
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { limits } from "@protobase/workflows";
|
|
2
|
+
//#region src/workflows/hooks/hook-event.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether the call as its run sees it is over a run input's limit as JSON, which escaping makes larger than the body
|
|
5
|
+
* itself: its body is then kept as a file of the run instead.
|
|
6
|
+
*/
|
|
7
|
+
const overInputLimit = (event) => new TextEncoder().encode(JSON.stringify(event)).length > limits.inputBytes;
|
|
8
|
+
const dropped = (name, signatureHeader) => name === "authorization" || name === "proxy-authorization" || name === "cookie" || name.startsWith("webhook-") || name.startsWith("x-forwarded-") || name === "forwarded" || name === signatureHeader;
|
|
9
|
+
/** The call's headers without its credentials, lower-cased as `Headers` keeps them, so no case of a name gets through. */
|
|
10
|
+
const keptHeaders = (headers, signatureHeader) => {
|
|
11
|
+
const signature = signatureHeader?.toLowerCase();
|
|
12
|
+
return Object.fromEntries([...headers].filter(([name]) => !dropped(name, signature)));
|
|
13
|
+
};
|
|
14
|
+
const formFields = (params) => Object.fromEntries([...new Set(params.keys())].map((name) => {
|
|
15
|
+
const values = params.getAll(name);
|
|
16
|
+
return [name, values.length === 1 ? values[0] : values];
|
|
17
|
+
}));
|
|
18
|
+
const isJson = (type) => /^application\/([\w.+-]+\+)?json\b/i.test(type);
|
|
19
|
+
const parsedBody = (bytes, type) => {
|
|
20
|
+
const text = new TextDecoder().decode(bytes);
|
|
21
|
+
if (isJson(type)) try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
if (/^application\/x-www-form-urlencoded\b/i.test(type)) return formFields(new URLSearchParams(text));
|
|
28
|
+
return text;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The call as its run sees it: method, query, headers without credentials, the body, or `file` when the caller kept the
|
|
32
|
+
* body as a file of the run, as for a call over the run input's 1 MiB, and when it came. The address is left out: it holds the key.
|
|
33
|
+
*/
|
|
34
|
+
const hookEvent = (request, bytes, signatureHeader, file) => ({
|
|
35
|
+
method: request.method,
|
|
36
|
+
query: Object.fromEntries(new URL(request.url).searchParams),
|
|
37
|
+
headers: keptHeaders(request.headers, signatureHeader),
|
|
38
|
+
body: file ?? parsedBody(bytes, request.headers.get("content-type") ?? ""),
|
|
39
|
+
received_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
40
|
+
});
|
|
41
|
+
/** The same call for a plugin's `receive`: the headers without credentials, the bytes read, and no key in its address. */
|
|
42
|
+
const receivedRequest = (request, bytes, signatureHeader) => {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
return new Request(`${url.origin}/?${url.searchParams}`, {
|
|
45
|
+
method: request.method,
|
|
46
|
+
headers: keptHeaders(request.headers, signatureHeader),
|
|
47
|
+
body: bytes
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { hookEvent, keptHeaders, overInputLimit, receivedRequest };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/workflows/hooks/hook-paths.d.ts
|
|
2
|
+
/** The names of the parameters in the webhook addresses. */
|
|
3
|
+
export declare const webhookParams: {
|
|
4
|
+
readonly workflow: "workflow";
|
|
5
|
+
readonly key: "key";
|
|
6
|
+
};
|
|
7
|
+
/** Where a workflow's webhook URL is issued, `POST <systemPath>/workflows/<id>/webhook`; the route's pattern without an id. */
|
|
8
|
+
export declare const webhookIssuePath: (systemPath: string, workflow?: string) => string;
|
|
9
|
+
/** The public address a webhook trigger is called at, `<systemPath>/workflows/hooks/<key>`; the route's pattern without a key. */
|
|
10
|
+
export declare const hookPath: (systemPath: string, key?: string) => string;
|
|
11
|
+
/**
|
|
12
|
+
* `text` with the key of every webhook address in it replaced by `[key]`, for a request log: the key opens the URL. A
|
|
13
|
+
* word of the line is read decoded as the router reads it, so an address written with escapes is redacted too.
|
|
14
|
+
*/
|
|
15
|
+
export declare const redactHookKeys: (text: string) => string;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/workflows/hooks/hook-paths.ts
|
|
2
|
+
/** The names of the parameters in the webhook addresses. */
|
|
3
|
+
const webhookParams = {
|
|
4
|
+
workflow: "workflow",
|
|
5
|
+
key: "key"
|
|
6
|
+
};
|
|
7
|
+
/** Where a workflow's webhook URL is issued, `POST <systemPath>/workflows/<id>/webhook`; the route's pattern without an id. */
|
|
8
|
+
const webhookIssuePath = (systemPath, workflow = `:${webhookParams.workflow}`) => `${systemPath}/workflows/${workflow}/webhook`;
|
|
9
|
+
/** Where the webhook addresses live, the route the public address is mounted at. */
|
|
10
|
+
const hooksBase = (systemPath) => `${systemPath}/workflows/hooks`;
|
|
11
|
+
/** The public address a webhook trigger is called at, `<systemPath>/workflows/hooks/<key>`; the route's pattern without a key. */
|
|
12
|
+
const hookPath = (systemPath, key = `:${webhookParams.key}`) => `${hooksBase(systemPath)}/${key}`;
|
|
13
|
+
const hookAddress = /\/workflows\/hooks\/[^/?#]+/g;
|
|
14
|
+
const decodedEscapes = (part) => part.replace(/(?:%[0-9A-Fa-f]{2})+/g, (escapes) => {
|
|
15
|
+
try {
|
|
16
|
+
return decodeURI(escapes);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (!(error instanceof URIError)) throw error;
|
|
19
|
+
return escapes;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* `text` with the key of every webhook address in it replaced by `[key]`, for a request log: the key opens the URL. A
|
|
24
|
+
* word of the line is read decoded as the router reads it, so an address written with escapes is redacted too.
|
|
25
|
+
*/
|
|
26
|
+
const redactHookKeys = (text) => text.replace(/\S+/g, (part) => {
|
|
27
|
+
const decoded = decodedEscapes(part);
|
|
28
|
+
const redacted = decoded.replace(hookAddress, "/workflows/hooks/[key]");
|
|
29
|
+
return redacted === decoded ? part : redacted;
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { hookPath, hooksBase, redactHookKeys, webhookIssuePath, webhookParams };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { HttpProblem, badRequest, notFound, problemResponse, unauthorized } from "../../problem.js";
|
|
2
|
+
import { clientAddress, createRateLimit } from "../../forms/rate-limit.js";
|
|
3
|
+
import { callPlugin } from "../triggers/plugin-call.js";
|
|
4
|
+
import { itemsProblem } from "../triggers/poll-result.js";
|
|
5
|
+
import { removeRunFile, storeRunFile } from "../run-files.js";
|
|
6
|
+
import { hookMode } from "./hook-secrets.js";
|
|
7
|
+
import { authenticated, hmacOptions, idempotencyKey } from "./hook-auth.js";
|
|
8
|
+
import { hookEvent, overInputLimit, receivedRequest } from "./hook-event.js";
|
|
9
|
+
import { webhookParams } from "./hook-paths.js";
|
|
10
|
+
import { findHook } from "./hook-store.js";
|
|
11
|
+
import { startHookRuns } from "./hook-runs.js";
|
|
12
|
+
import { readBody } from "./read-body.js";
|
|
13
|
+
import { Hono } from "hono";
|
|
14
|
+
import { limits } from "@protobase/workflows";
|
|
15
|
+
import { webhookBodyCeiling, webhookBodyDefault } from "@protobase/workflows/plugin";
|
|
16
|
+
//#region src/workflows/hooks/hook-route.ts
|
|
17
|
+
/** Calls an address may have refused (401, 404 and, once a URL was found, 400 and 413) a minute before it waits. */
|
|
18
|
+
const refusalsPerMinute = 30;
|
|
19
|
+
/** Calls a minute that start runs, per trigger, when its input names no `per_minute`. */
|
|
20
|
+
const callsPerMinute = 60;
|
|
21
|
+
const tooMany = (seconds, detail) => new HttpProblem(429, "too-many-calls", "Too Many Requests", detail, {}, { "Retry-After": String(seconds) });
|
|
22
|
+
const bodyLimit = (input) => Math.min(typeof input.body_limit === "number" ? input.body_limit : webhookBodyDefault, webhookBodyCeiling);
|
|
23
|
+
/**
|
|
24
|
+
* The public address a webhook trigger is called at, `POST .../workflows/hooks/<key>`, with no session: only POST; an
|
|
25
|
+
* address that was refused 30 times this minute waits, before any lookup; the key must open a running webhook trigger
|
|
26
|
+
* of an enabled version (404 for anything else, alike); the body is read up to the trigger's limit (413); the call
|
|
27
|
+
* must prove it comes from the sender (401); then the trigger's own rate (429), spent only by calls that did; the run
|
|
28
|
+
* starts once per signed id (202 `{ run_id }`). Each refusal starts no run. Its errors are its own: reported without
|
|
29
|
+
* the request, answered with nothing of it.
|
|
30
|
+
*/
|
|
31
|
+
const hookRoute = (input) => {
|
|
32
|
+
const { db, schema } = input;
|
|
33
|
+
const refusals = createRateLimit();
|
|
34
|
+
const calls = createRateLimit();
|
|
35
|
+
const app = new Hono();
|
|
36
|
+
app.onError((error) => {
|
|
37
|
+
if (error instanceof HttpProblem) return problemResponse(error, "/workflows/hooks");
|
|
38
|
+
input.report(error);
|
|
39
|
+
return problemResponse(new HttpProblem(500, "internal-error", "Internal Server Error", "The call could not be taken; send it again"), "/workflows/hooks");
|
|
40
|
+
});
|
|
41
|
+
app.all(`/:${webhookParams.key}`, async (c) => {
|
|
42
|
+
if (c.req.method !== "POST") return c.body(null, 405, { Allow: "POST" });
|
|
43
|
+
const request = c.req.raw;
|
|
44
|
+
const address = clientAddress(request.headers);
|
|
45
|
+
const wait = refusals.spent(address, refusalsPerMinute);
|
|
46
|
+
if (wait > 0) throw tooMany(wait, "Too many refused calls from this address; wait a minute");
|
|
47
|
+
const refused = (problem) => {
|
|
48
|
+
refusals.take(address, refusalsPerMinute);
|
|
49
|
+
return problem;
|
|
50
|
+
};
|
|
51
|
+
const target = await findHook(db, schema, c.req.param(webhookParams.key), input.webhookTypes());
|
|
52
|
+
const type = target && input.webhooks(target.type);
|
|
53
|
+
if (!target || !type) throw refused(notFound("There is no webhook at this address"));
|
|
54
|
+
const bytes = await readBody(request, bodyLimit(target.input));
|
|
55
|
+
if (!bytes) throw refused(new HttpProblem(413, "body-too-large", "Payload Too Large", `The body is over ${bodyLimit(target.input)} bytes, the most this webhook takes`));
|
|
56
|
+
const mode = hookMode(target.input);
|
|
57
|
+
const signed = new Request(request.url, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: request.headers,
|
|
60
|
+
body: bytes
|
|
61
|
+
});
|
|
62
|
+
if (!mode || !await authenticated(signed, mode, target)) throw refused(unauthorized("The call does not carry this webhook's secret or signature"));
|
|
63
|
+
const perMinute = typeof target.input.per_minute === "number" ? target.input.per_minute : callsPerMinute;
|
|
64
|
+
const rate = calls.take(target.trigger_id, perMinute);
|
|
65
|
+
if (rate > 0) throw tooMany(rate, `This webhook takes ${perMinute} calls a minute`);
|
|
66
|
+
const key = idempotencyKey(request, mode, target.input);
|
|
67
|
+
if (key !== void 0 && key.length > 200) throw refused(badRequest("invalid-idempotency-key", `The call's id is over 200 characters`));
|
|
68
|
+
const runId = crypto.randomUUID();
|
|
69
|
+
const signatureHeader = mode === "hmac" ? hmacOptions(target.input).header : void 0;
|
|
70
|
+
const inline = hookEvent(request, bytes, signatureHeader);
|
|
71
|
+
const large = overInputLimit(inline);
|
|
72
|
+
if (large && !input.files()?.providers.private) throw refused(new HttpProblem(413, "body-too-large", "Payload Too Large", `The call is over a run input's ${limits.inputBytes} bytes as JSON, and this app keeps no files for larger ones`));
|
|
73
|
+
const file = large ? await storeRunFile(input.files(), runId, bytes, "body", request.headers.get("content-type") ?? "application/octet-stream") : void 0;
|
|
74
|
+
let started = false;
|
|
75
|
+
try {
|
|
76
|
+
const event = file ? hookEvent(request, bytes, signatureHeader, file) : inline;
|
|
77
|
+
const received = await callPlugin(() => type.receive({
|
|
78
|
+
request: receivedRequest(request, bytes, signatureHeader),
|
|
79
|
+
event,
|
|
80
|
+
idempotencyKey: key,
|
|
81
|
+
input: target.input
|
|
82
|
+
}), input.redact);
|
|
83
|
+
if (!("value" in received)) throw new Error(`The webhook trigger ${target.type} failed: ${"failed" in received ? received.failed : received.expired}`);
|
|
84
|
+
const { items, response } = received.value;
|
|
85
|
+
const problem = itemsProblem(items, file ? 1 : 100, false);
|
|
86
|
+
if (problem) throw new Error(`The webhook trigger ${target.type} ${problem}`);
|
|
87
|
+
const ids = await startHookRuns(db, schema, target, items, runId);
|
|
88
|
+
if (!ids) throw refused(notFound("There is no webhook at this address"));
|
|
89
|
+
started = ids[0] === runId;
|
|
90
|
+
if (response) return response;
|
|
91
|
+
return ids.length === 1 ? c.json({ run_id: ids[0] }, 202) : c.json({ run_ids: ids }, 202);
|
|
92
|
+
} finally {
|
|
93
|
+
if (file && !started) await removeRunFile(input.files(), file);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return app;
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
export { hookRoute };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { startWorkflow } from "../start-workflow.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/workflows/hooks/hook-runs.ts
|
|
4
|
+
const stillLive = async (trx, schema, target) => {
|
|
5
|
+
const { rows } = await sql`select 1 from ${sql.id(schema, "triggers")} t
|
|
6
|
+
join ${sql.id(schema, "workflows")} w on w.id = t.workflow_id and w.enabled_version = t.version
|
|
7
|
+
where t.id = ${target.trigger_id} and t.version = ${target.version} and t.status in ('active', 'failing') for share`.execute(trx);
|
|
8
|
+
return rows.length > 0;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Starts a run per item of a call, in one transaction: only while the trigger the call found still runs on the enabled
|
|
12
|
+
* version, the version and tenant from the trigger row, never from the call, each item's key once per workflow. The
|
|
13
|
+
* first run takes `firstId`, under which the call's large body was kept. The ids of the runs, a run already started
|
|
14
|
+
* for a key included, or `undefined` when the workflow was turned off, changed or deleted since the call found it.
|
|
15
|
+
*/
|
|
16
|
+
const startHookRuns = (db, schema, target, items, firstId) => db.transaction().execute(async (trx) => {
|
|
17
|
+
if (!await stillLive(trx, schema, target)) return;
|
|
18
|
+
const ids = [];
|
|
19
|
+
for (const [index, item] of items.entries()) ids.push(await startWorkflow(trx, {
|
|
20
|
+
workflow: target.workflow_id,
|
|
21
|
+
version: target.version,
|
|
22
|
+
input: item.data,
|
|
23
|
+
...target.tenant_id !== null && { tenant: target.tenant_id },
|
|
24
|
+
startedBy: `trigger:${target.trigger_id}`,
|
|
25
|
+
...item.key !== void 0 && { key: item.key },
|
|
26
|
+
schema,
|
|
27
|
+
...index === 0 && { id: firstId }
|
|
28
|
+
}));
|
|
29
|
+
return ids;
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { startHookRuns };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { badRequest } from "../../problem.js";
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
//#region src/workflows/hooks/hook-secrets.ts
|
|
4
|
+
const modes = [
|
|
5
|
+
"standard_webhooks",
|
|
6
|
+
"hmac",
|
|
7
|
+
"bearer"
|
|
8
|
+
];
|
|
9
|
+
/** The mode a webhook trigger's input names, `standard_webhooks` by default; `undefined` for one the route does not know. */
|
|
10
|
+
const hookMode = (input) => {
|
|
11
|
+
const auth = input.auth;
|
|
12
|
+
if (auth === void 0) return "standard_webhooks";
|
|
13
|
+
return typeof auth?.mode === "string" && modes.includes(auth.mode) ? auth.mode : void 0;
|
|
14
|
+
};
|
|
15
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
16
|
+
/**
|
|
17
|
+
* Whether `secret` is one this mode can check signatures with: a Standard Webhooks secret is `whsec_` and 24+ bytes in
|
|
18
|
+
* padded base64, as strict as the signature check decodes it.
|
|
19
|
+
*/
|
|
20
|
+
const usable = (mode, secret) => {
|
|
21
|
+
if (mode !== "standard_webhooks") return secret.length >= 32 && secret.length <= 512;
|
|
22
|
+
const match = /^whsec_((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$/.exec(secret);
|
|
23
|
+
return Boolean(match) && Buffer.from(match[1], "base64").length >= 24 && secret.length <= 512;
|
|
24
|
+
};
|
|
25
|
+
/** The secret of a new URL: the caller's, checked for its mode, or a new one of 32 random bytes. */
|
|
26
|
+
const hookSecret = (mode, given) => {
|
|
27
|
+
if (given === void 0) return mode === "standard_webhooks" ? `whsec_${randomBytes(32).toString("base64")}` : randomBytes(32).toString("base64url");
|
|
28
|
+
if (!usable(mode, given)) throw badRequest("invalid-secret", mode === "standard_webhooks" ? "A standard_webhooks secret is whsec_ followed by at least 24 bytes in base64" : "A secret is 32 to 512 characters");
|
|
29
|
+
return given;
|
|
30
|
+
};
|
|
31
|
+
/** Whether a stored secret is still one its mode can check with, as after the mode changed since the URL was issued. */
|
|
32
|
+
const usableSecret = (mode, secret) => secret !== null && usable(mode, secret);
|
|
33
|
+
/** A new URL key: 32 random bytes, which only the URL holds; the app keeps its SHA-256. */
|
|
34
|
+
const hookKey = () => randomBytes(32).toString("base64url");
|
|
35
|
+
/** Whether a bearer token's SHA-256 is the kept one, compared in constant time on two digests of one length. */
|
|
36
|
+
const bearerMatches = (token, secretHash) => {
|
|
37
|
+
const given = createHash("sha256").update(token).digest();
|
|
38
|
+
const kept = Buffer.from(secretHash, "hex");
|
|
39
|
+
return kept.length === given.length && timingSafeEqual(given, kept);
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { bearerMatches, hookKey, hookMode, hookSecret, sha256, usableSecret };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { hookKey, hookSecret, sha256 } from "./hook-secrets.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/workflows/hooks/hook-store.ts
|
|
4
|
+
/**
|
|
5
|
+
* Gives the workflow a new URL key and secret, replacing any it had, so its old URL stops at once: the key's SHA-256 is
|
|
6
|
+
* kept, the secret itself only where checking a signature needs it (`standard_webhooks`, `hmac`), and its SHA-256
|
|
7
|
+
* always. The key and secret come back once.
|
|
8
|
+
*/
|
|
9
|
+
const saveHook = async (db, schema, hook) => {
|
|
10
|
+
const key = hookKey();
|
|
11
|
+
const secret = hookSecret(hook.mode, hook.secret);
|
|
12
|
+
const kept = hook.mode === "bearer" ? null : secret;
|
|
13
|
+
await sql`insert into ${sql.id(schema, "hooks")} (workflow_id, key_hash, secret, secret_hash, tenant_id)
|
|
14
|
+
values (${hook.workflow}, ${sha256(key)}, ${kept}, ${sha256(secret)}, ${hook.tenant})
|
|
15
|
+
on conflict (workflow_id) do update set key_hash = excluded.key_hash, secret = excluded.secret, secret_hash = excluded.secret_hash, tenant_id = excluded.tenant_id, created_at = now()`.execute(db);
|
|
16
|
+
return {
|
|
17
|
+
key,
|
|
18
|
+
secret
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The webhook trigger a URL key opens, in one statement: the key's workflow in the same tenant, its enabled version's
|
|
23
|
+
* trigger of a webhook type, running or failing. `undefined` for an unknown key, a workflow turned off, deleted or
|
|
24
|
+
* moved to another trigger, and a trigger stopping or waiting for a sign-in, alike.
|
|
25
|
+
*/
|
|
26
|
+
const findHook = async (db, schema, key, webhookTypes) => {
|
|
27
|
+
const { rows } = await sql`select h.workflow_id, h.tenant_id, h.secret, h.secret_hash, t.id as trigger_id, t.version, t.type, t.input
|
|
28
|
+
from ${sql.id(schema, "hooks")} h
|
|
29
|
+
join ${sql.id(schema, "workflows")} w on w.id = h.workflow_id and w.tenant_id is not distinct from h.tenant_id
|
|
30
|
+
join ${sql.id(schema, "triggers")} t on t.workflow_id = w.id and t.version = w.enabled_version and t.status in ('active', 'failing') and t.type = any(${webhookTypes})
|
|
31
|
+
where h.key_hash = ${sha256(key)}`.execute(db);
|
|
32
|
+
return rows[0];
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { findHook, saveHook };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { quoteIdent } from "../../changes/capture-sql.js";
|
|
2
|
+
//#region src/workflows/hooks/hooks-sql.ts
|
|
3
|
+
/**
|
|
4
|
+
* The webhook URL each workflow has, at most one: the SHA-256 of its key, which only the URL holds, and its secret.
|
|
5
|
+
* A `bearer` secret is kept as its SHA-256 alone; `standard_webhooks` and `hmac` keep the secret itself, which checking
|
|
6
|
+
* a signature needs. No API resource reads it. A workflow's delete removes its row, so its URL stops at once.
|
|
7
|
+
*/
|
|
8
|
+
const hooksSql = (schema) => {
|
|
9
|
+
const s = quoteIdent(schema);
|
|
10
|
+
return `create table if not exists ${s}."hooks" (
|
|
11
|
+
"workflow_id" uuid primary key references ${s}."workflows" ("id") on delete cascade,
|
|
12
|
+
"key_hash" text not null unique,
|
|
13
|
+
"secret" text,
|
|
14
|
+
"secret_hash" text not null,
|
|
15
|
+
"tenant_id" text,
|
|
16
|
+
"created_at" timestamptz not null default now()
|
|
17
|
+
)`;
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { hooksSql };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { badRequest } from "../../problem.js";
|
|
2
|
+
//#region src/workflows/hooks/issue-body.ts
|
|
3
|
+
const invalidBody = () => badRequest("invalid-body", "Send nothing, or { \"secret\": \"<secret>\" } to keep a secret the sender gives");
|
|
4
|
+
/** The issue request's body: nothing, or `{ secret }`; anything else, malformed JSON included, is a 400 `invalid-body`. */
|
|
5
|
+
const parseIssueBody = (text) => {
|
|
6
|
+
if (text === "") return {};
|
|
7
|
+
let body;
|
|
8
|
+
try {
|
|
9
|
+
body = JSON.parse(text);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error instanceof SyntaxError) throw invalidBody();
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalidBody();
|
|
15
|
+
const { secret } = body;
|
|
16
|
+
if (secret === void 0) return {};
|
|
17
|
+
if (typeof secret !== "string") throw invalidBody();
|
|
18
|
+
return { secret };
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { parseIssueBody };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { webhookParams } from "./hook-paths.js";
|
|
2
|
+
import { parseIssueBody } from "./issue-body.js";
|
|
3
|
+
import { issueWebhook } from "./issue-webhook.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
//#region src/workflows/hooks/issue-route.ts
|
|
6
|
+
/**
|
|
7
|
+
* `POST <systemPath>/workflows/<id>/webhook` with an optional `{ secret }`: a new URL and secret for the workflow's
|
|
8
|
+
* webhook trigger, answered once and never stored by any cache.
|
|
9
|
+
*/
|
|
10
|
+
const issueRoute = (input) => {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.post(`/:${webhookParams.workflow}/webhook`, async (c) => {
|
|
13
|
+
const body = parseIssueBody(await c.req.text());
|
|
14
|
+
const issued = await issueWebhook(input, c.get("session"), c.req.param(webhookParams.workflow), {
|
|
15
|
+
...body,
|
|
16
|
+
origin: new URL(c.req.url).origin
|
|
17
|
+
});
|
|
18
|
+
return c.json(issued, 200, { "Cache-Control": "no-store" });
|
|
19
|
+
});
|
|
20
|
+
return app;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { issueRoute };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { badRequest, notFound } from "../../problem.js";
|
|
2
|
+
import { requestAccess } from "../../request-access.js";
|
|
3
|
+
import { deletedRows } from "../../params.js";
|
|
4
|
+
import { fetchReadable } from "../../fetch-readable.js";
|
|
5
|
+
import { hookMode } from "./hook-secrets.js";
|
|
6
|
+
import { hookPath } from "./hook-paths.js";
|
|
7
|
+
import { saveHook } from "./hook-store.js";
|
|
8
|
+
import { mayUpdateWorkflow } from "../may-update-workflow.js";
|
|
9
|
+
//#region src/workflows/hooks/issue-webhook.ts
|
|
10
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const missing = () => notFound("No workflows with this key that you may change");
|
|
12
|
+
/**
|
|
13
|
+
* Gives a workflow a new webhook URL and secret, as `POST .../workflows/<id>/webhook` and `new_webhook_url` do: only for
|
|
14
|
+
* a caller who may change that workflow in their tenant, as its update access decides; 404 for anyone else, as for a
|
|
15
|
+
* workflow that is not there. Its current definition must start from a webhook trigger that does not register its own
|
|
16
|
+
* URL. The secret is the caller's, checked for the trigger's mode, or a new one. Both come back once; the old URL stops.
|
|
17
|
+
*/
|
|
18
|
+
const issueWebhook = async ({ deps, schema, webhooks, origin }, session, id, request) => {
|
|
19
|
+
if (!uuid.test(id)) throw missing();
|
|
20
|
+
const entry = deps().registry.find("workflows");
|
|
21
|
+
const access = await requestAccess(deps(), entry, session);
|
|
22
|
+
if (!access.resolved.operations.update) throw missing();
|
|
23
|
+
return deps().db.transaction().execute(async (trx) => {
|
|
24
|
+
const found = await fetchReadable(trx, access, [id], deletedRows(access.full, false), { lock: true });
|
|
25
|
+
if (!found || !await mayUpdateWorkflow(trx, access, id, found.record)) throw missing();
|
|
26
|
+
const record = found.record;
|
|
27
|
+
const [type, input] = Object.entries(record.definition.trigger)[0];
|
|
28
|
+
const trigger = webhooks(type);
|
|
29
|
+
if (!trigger) throw badRequest("not-a-webhook", "This workflow does not start from a webhook trigger, such as app:webhook");
|
|
30
|
+
if (trigger.subscribe) throw badRequest("not-a-webhook", `${type} registers its own URL with its service as the workflow is turned on`);
|
|
31
|
+
const mode = hookMode(input);
|
|
32
|
+
if (!mode) throw badRequest("invalid-workflow", "The webhook trigger names an auth mode this app does not know");
|
|
33
|
+
const { key, secret } = await saveHook(trx, schema, {
|
|
34
|
+
workflow: id,
|
|
35
|
+
tenant: record.tenant_id ?? null,
|
|
36
|
+
mode,
|
|
37
|
+
...request.secret !== void 0 && { secret: request.secret }
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
url: `${origin() ?? request.origin}${hookPath(deps().systemPath, key)}`,
|
|
41
|
+
secret
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { issueWebhook };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/workflows/hooks/read-body.ts
|
|
2
|
+
/**
|
|
3
|
+
* The request's body, at most `limit` bytes, counted as they come off the stream whether or not the request declares
|
|
4
|
+
* its length; `undefined` past the limit, having kept no more than `limit` bytes and cancelled the rest.
|
|
5
|
+
*/
|
|
6
|
+
const readBody = async (request, limit) => {
|
|
7
|
+
const declared = request.headers.get("content-length");
|
|
8
|
+
if (declared !== null && Number(declared) > limit) return void 0;
|
|
9
|
+
if (!request.body) return /* @__PURE__ */ new Uint8Array();
|
|
10
|
+
const reader = request.body.getReader();
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
for (;;) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) break;
|
|
16
|
+
size += value.length;
|
|
17
|
+
if (size > limit) {
|
|
18
|
+
await reader.cancel();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
chunks.push(value);
|
|
22
|
+
}
|
|
23
|
+
const bytes = new Uint8Array(size);
|
|
24
|
+
let at = 0;
|
|
25
|
+
for (const chunk of chunks) {
|
|
26
|
+
bytes.set(chunk, at);
|
|
27
|
+
at += chunk.length;
|
|
28
|
+
}
|
|
29
|
+
return bytes;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { readBody };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { tenantScope } from "../tenant.js";
|
|
2
|
+
import { recordDecision, targetFilter } from "../request-access.js";
|
|
3
|
+
import { fetchRecord } from "../records.js";
|
|
4
|
+
//#region src/workflows/may-update-workflow.ts
|
|
5
|
+
/**
|
|
6
|
+
* Whether the caller may change this workflow as the record update path decides it: the update row filter and the
|
|
7
|
+
* record-level update rule, beside the read access `fetchReadable` checked.
|
|
8
|
+
*/
|
|
9
|
+
const mayUpdateWorkflow = async (trx, access, id, record) => {
|
|
10
|
+
const tenant = tenantScope(access.entry, access.session)?.tenantValue;
|
|
11
|
+
const key = [access.full.fields[access.full.primaryKey[0]]];
|
|
12
|
+
const filter = targetFilter(access, "update");
|
|
13
|
+
if (filter && !await fetchRecord(trx, access.entry, [id], tenant, {
|
|
14
|
+
filter,
|
|
15
|
+
fields: key
|
|
16
|
+
})) return false;
|
|
17
|
+
if (!access.resolved.recordChecks.includes("update")) return true;
|
|
18
|
+
const decision = await recordDecision(access, "update", record);
|
|
19
|
+
if (typeof decision === "boolean") return decision;
|
|
20
|
+
return Boolean(await fetchRecord(trx, access.entry, [id], tenant, {
|
|
21
|
+
filter: decision,
|
|
22
|
+
fields: key
|
|
23
|
+
}));
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { mayUpdateWorkflow };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Session } from "../../types.js";
|
|
2
|
+
import "../../index.js";
|
|
3
|
+
//#region src/workflows/mcp/webhook-tools.d.ts
|
|
4
|
+
/** Issues a workflow's webhook URL as the caller; the server answers 404 for a workflow they may not change. */
|
|
5
|
+
export type IssueWebhook = (session: Session, id: string, secret: string | undefined) => Promise<{
|
|
6
|
+
url: string;
|
|
7
|
+
secret: string;
|
|
8
|
+
}>;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { HttpProblem, notFound, preconditionFailed } from "../../problem.js";
|
|
2
|
+
import { defineTool } from "../../assistant/tools.js";
|
|
3
|
+
import { refused } from "../../assistant/record-tool-args.js";
|
|
4
|
+
import { recordName, updateApproval } from "../../assistant/write-prompts.js";
|
|
5
|
+
import { approvalTexts, recordBefore } from "../../assistant/record-before.js";
|
|
6
|
+
import { notApproved, onlyNote, outside } from "../../assistant/write-tool-limits.js";
|
|
7
|
+
import { idOf, idParameter, textOf } from "./workflow-tool-args.js";
|
|
8
|
+
//#region src/workflows/mcp/webhook-tools.ts
|
|
9
|
+
/**
|
|
10
|
+
* `enable_workflow` and `new_webhook_url`: turning a workflow's trigger on or off, and issuing its webhook URL, each as
|
|
11
|
+
* the caller and after the user approves it in their client.
|
|
12
|
+
*/
|
|
13
|
+
const writes = {
|
|
14
|
+
readOnlyHint: false,
|
|
15
|
+
destructiveHint: false,
|
|
16
|
+
openWorldHint: false
|
|
17
|
+
};
|
|
18
|
+
const enableRefused = (error) => {
|
|
19
|
+
if (error instanceof HttpProblem && error.slug === "own-sign-in-only") return JSON.stringify({
|
|
20
|
+
error: "own-sign-in-only",
|
|
21
|
+
message: "This workflow signs in through a connected account, so it was not turned on: the account's owner turns it on in the admin app, on the workflow's page, signed in as themselves. Saving it stays open to you."
|
|
22
|
+
});
|
|
23
|
+
return refused(error);
|
|
24
|
+
};
|
|
25
|
+
/** Turns a workflow's trigger on, on its current version, or off, once the user approves it. */
|
|
26
|
+
const enableWorkflowTool = (input = {}) => defineTool({
|
|
27
|
+
name: "enable_workflow",
|
|
28
|
+
description: `Turns a workflow's trigger on (enabled: true) on version, which must be its current version as create_workflow, update_workflow or get_workflow gave it, or off (enabled: false), and returns JSON { id, enabled_version }. A workflow saved since that version is refused; read it again. While on, its trigger starts runs: a webhook trigger whose sender you give a URL once it is issued with new_webhook_url, one that registers its own URL with its service as soon as it did, a polling trigger at its next poll. A later save keeps it on, on the new version. A workflow that signs in through a connected account (its connections) is turned on only by that account's owner in the admin app: here it is refused with error own-sign-in-only. The user approves it in their client first.${onlyNote(input)}`,
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
id: idParameter,
|
|
33
|
+
enabled: {
|
|
34
|
+
type: "boolean",
|
|
35
|
+
description: "true to turn it on, false to turn it off."
|
|
36
|
+
},
|
|
37
|
+
version: {
|
|
38
|
+
type: "integer",
|
|
39
|
+
description: "With enabled: true, the version to turn on: the workflow's current version."
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: ["id", "enabled"]
|
|
43
|
+
},
|
|
44
|
+
annotations: writes,
|
|
45
|
+
run: async (args, context) => {
|
|
46
|
+
const id = idOf(args);
|
|
47
|
+
const version = args.version;
|
|
48
|
+
if (!id || typeof args.enabled !== "boolean" || args.enabled && !Number.isInteger(version)) return "Pass { \"id\": \"<id>\", \"enabled\": true, \"version\": <its current version> } or { \"id\": \"<id>\", \"enabled\": false }.";
|
|
49
|
+
const refusal = outside(input, "workflows");
|
|
50
|
+
if (refusal) return refusal;
|
|
51
|
+
const before = await recordBefore(context, "workflows", id, void 0);
|
|
52
|
+
if (typeof before === "string") return before;
|
|
53
|
+
if (!before) return refused(notFound("No workflows with this key that you may read"));
|
|
54
|
+
if (args.enabled && before.record.version !== version) return refused(preconditionFailed(`The workflow was saved since version ${version}; its current version is ${before.record.version}`));
|
|
55
|
+
const data = { enabled_version: args.enabled ? version : null };
|
|
56
|
+
const { text, described } = await approvalTexts(context, "workflows");
|
|
57
|
+
const answer = await context.approve(updateApproval(text, described, id, before.record, data));
|
|
58
|
+
if (answer !== "approved") return notApproved(answer);
|
|
59
|
+
return context.records.update("workflows", id, data, args.enabled ? { etag: before.etag } : {}).then(({ record }) => JSON.stringify({
|
|
60
|
+
id: record.id,
|
|
61
|
+
enabled_version: record.enabled_version
|
|
62
|
+
}), enableRefused);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
/** Issues a new webhook URL and secret for a workflow, once the user approves it. */
|
|
66
|
+
const newWebhookUrlTool = (issue, input = {}) => defineTool({
|
|
67
|
+
name: "new_webhook_url",
|
|
68
|
+
description: `Issues a new URL and secret for a workflow that starts from a webhook trigger whose sender you give a URL (such as app:webhook), and returns JSON { url, secret }, shown this once: the app keeps no way to show them again, and the workflow's old URL stops. Pass secret to keep one the sender makes itself (a Standard Webhooks sender's whsec_ secret); without it the app makes one. The sender then POSTs to the url, signed as the trigger's auth mode says. A trigger that registers its own URL with its service is refused: turning the workflow on is all it needs. The user approves it in their client first.${onlyNote(input)}`,
|
|
69
|
+
parameters: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
id: idParameter,
|
|
73
|
+
secret: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "The sender's own secret, when it makes one; omit it to have the app make one."
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
required: ["id"]
|
|
79
|
+
},
|
|
80
|
+
annotations: writes,
|
|
81
|
+
run: async (args, context) => {
|
|
82
|
+
const id = idOf(args);
|
|
83
|
+
if (!id) return "Pass { \"id\": \"<id>\" }, and a secret when the sender makes its own.";
|
|
84
|
+
const refusal = outside(input, "workflows");
|
|
85
|
+
if (refusal) return refusal;
|
|
86
|
+
const before = await recordBefore(context, "workflows", id, void 0);
|
|
87
|
+
if (typeof before === "string") return before;
|
|
88
|
+
if (!before) return refused(notFound("No workflows with this key that you may read"));
|
|
89
|
+
const { text, described } = await approvalTexts(context, "workflows");
|
|
90
|
+
const answer = await context.approve({
|
|
91
|
+
title: text("webhookTitle", { record: recordName(described, id, before.record) }),
|
|
92
|
+
body: text("webhookBody"),
|
|
93
|
+
approveLabel: text("issue"),
|
|
94
|
+
rejectLabel: text("reject")
|
|
95
|
+
});
|
|
96
|
+
if (answer !== "approved") return notApproved(answer);
|
|
97
|
+
return issue(context.session, id, textOf(args.secret) || void 0).then((issued) => JSON.stringify(issued), refused);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
/** The tools that turn workflows on and issue their webhook URLs; `new_webhook_url` only where the app can issue them. */
|
|
101
|
+
const webhookTools = (input = {}, issue) => [enableWorkflowTool(input), ...issue ? [newWebhookUrlTool(issue, input)] : []];
|
|
102
|
+
//#endregion
|
|
103
|
+
export { enableWorkflowTool, newWebhookUrlTool, webhookTools };
|
|
@@ -4,7 +4,7 @@ import { limits } from "@protobase/workflows";
|
|
|
4
4
|
* profile of the States Language with JSONata, and how the workflow tools find, make, change and test-run them.
|
|
5
5
|
*/
|
|
6
6
|
const workflowInstructions = [
|
|
7
|
-
"Workflows: list_workflow_types names the step types and triggers with their arguments and inputs; list_workflows and get_workflow read workflows; validate_workflow checks a definition without saving it; create_workflow and update_workflow save one; run_workflow starts a run with an input, and get_workflow_run reads it with each step's input, output, logs and error. A run goes on in the background: read it again until its status is succeeded or failed, then fix the step that failed with update_workflow and run it again.",
|
|
7
|
+
"Workflows: list_workflow_types names the step types and triggers with their arguments and inputs; list_workflows and get_workflow read workflows; validate_workflow checks a definition without saving it; create_workflow and update_workflow save one; run_workflow starts a run with an input, and get_workflow_run reads it with each step's input, output, logs and error. A run goes on in the background: read it again until its status is succeeded or failed, then fix the step that failed with update_workflow and run it again. A trigger starts runs only once enable_workflow turned the workflow on. A webhook trigger whose sender you give a URL, such as app:webhook, gets its URL and secret from new_webhook_url, shown once; one that registers its own URL with its service as the workflow is turned on needs none, and new_webhook_url refuses it.",
|
|
8
8
|
"A definition is one JSON document: { \"trigger\": <trigger>, \"stateMachine\": { \"QueryLanguage\": \"JSONata\", \"StartAt\": \"<state>\", \"States\": { \"<state>\": <state>, ... } } }, the States Language with JSONata expressions, in a profile where every value is read by name. It holds no layout. <trigger> is an object with one key: { \"<trigger id>\": <its input> } for a trigger list_workflow_types names, or { \"manual\": {} } for a workflow that runs only when someone starts it.",
|
|
9
9
|
"States: Task { Resource: <step type>, Arguments, TimeoutSeconds, Retry, Catch }; Pass { Output, Assign }; Choice { Choices: [{ Condition, Next, Output?, Assign? }], Default }; Map { Items, ItemProcessor: { StartAt, States }, MaxConcurrency }; Wait { Seconds | Timestamp }; Succeed { Output }; Fail { Error, Cause }. Every state may have Comment, Output and Assign. Task, Pass, Map and Wait have exactly one of Next (a state name) and End: true. State names are unique across the document, Maps included, and every state must be reachable from StartAt. InputPath, Parameters, ResultPath and the other JSONPath fields, and Parallel, are not part of the profile.",
|
|
10
10
|
"Expressions: a string that is whole \"{% ... %}\" is a JSONata expression whose value, of any type, the field takes; any other string is literal text. Build text inside one expression with &: \"{% 'Order ' & $order.id %}\". Expressions sit at any depth in Arguments, Output and Assign, and in Items, Condition, Seconds, Timestamp, Error and Cause.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AssistantTool, ToolContext } from "../../assistant/tools.js";
|
|
2
|
+
import { IssueWebhook } from "./webhook-tools.js";
|
|
2
3
|
import "../../index.js";
|
|
3
4
|
import { ResourceModel } from "@protobase/schema";
|
|
4
5
|
import { CheckOptions, TypeEntry } from "@protobase/workflows";
|
|
@@ -10,6 +11,8 @@ import { CheckOptions, TypeEntry } from "@protobase/workflows";
|
|
|
10
11
|
export type WorkflowCatalog = {
|
|
11
12
|
types: readonly TypeEntry[];
|
|
12
13
|
checkWith: (models: (name: string) => ResourceModel | undefined) => CheckOptions;
|
|
14
|
+
/** Issues a workflow's webhook URL as the caller, for `new_webhook_url`. */
|
|
15
|
+
issueWebhook?: IssueWebhook;
|
|
13
16
|
};
|
|
14
17
|
/** The tools that read workflows, their types and runs, and check definitions. */
|
|
15
18
|
export declare const workflowReadTools: (catalog: WorkflowCatalog) => AssistantTool<ToolContext>[];
|