@protobase/server 0.68.4 → 0.68.6

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.
Files changed (52) hide show
  1. package/dist/create-admin.js +11 -1
  2. package/dist/forms/rate-limit.js +33 -11
  3. package/dist/forms/routes.js +1 -1
  4. package/dist/functions/verify-signature.js +4 -2
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +3 -2
  7. package/dist/locale/en.js +3 -0
  8. package/dist/locale/nl.js +3 -0
  9. package/dist/mcp/mcp-tools.d.ts +2 -1
  10. package/dist/mcp/mcp-tools.js +4 -2
  11. package/dist/package.js +1 -1
  12. package/dist/user-tables/definitions-resources.js +1 -1
  13. package/dist/user-tables/definitions-views.js +1 -1
  14. package/dist/user-tables/schema-sql.js +6 -1
  15. package/dist/user-tables/schema.js +6 -4
  16. package/dist/workflows/hooks/hook-auth.js +40 -0
  17. package/dist/workflows/hooks/hook-event.js +51 -0
  18. package/dist/workflows/hooks/hook-paths.d.ts +16 -0
  19. package/dist/workflows/hooks/hook-paths.js +32 -0
  20. package/dist/workflows/hooks/hook-route.js +99 -0
  21. package/dist/workflows/hooks/hook-runs.js +32 -0
  22. package/dist/workflows/hooks/hook-secrets.js +42 -0
  23. package/dist/workflows/hooks/hook-store.js +35 -0
  24. package/dist/workflows/hooks/hooks-sql.js +20 -0
  25. package/dist/workflows/hooks/issue-body.js +21 -0
  26. package/dist/workflows/hooks/issue-route.js +23 -0
  27. package/dist/workflows/hooks/issue-webhook.js +63 -0
  28. package/dist/workflows/hooks/read-body.js +32 -0
  29. package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
  30. package/dist/workflows/mcp/webhook-tools.js +96 -0
  31. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  32. package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
  33. package/dist/workflows/plugin-records.js +1 -1
  34. package/dist/workflows/plugin-services.js +1 -1
  35. package/dist/workflows/ready/ready-start.js +1 -1
  36. package/dist/workflows/run-files.js +7 -1
  37. package/dist/workflows/schema-sql.js +15 -3
  38. package/dist/workflows/schema.js +3 -1
  39. package/dist/workflows/start-workflow.d.ts +3 -1
  40. package/dist/workflows/start-workflow.js +10 -4
  41. package/dist/workflows/triggers/delete-hook.js +15 -0
  42. package/dist/workflows/triggers/plugin-call.js +35 -0
  43. package/dist/workflows/triggers/poll-job.js +120 -0
  44. package/dist/workflows/triggers/poll-result.js +32 -0
  45. package/dist/workflows/triggers/poll-schedule.js +32 -0
  46. package/dist/workflows/triggers/trigger-claim.js +81 -0
  47. package/dist/workflows/triggers/trigger-jobs.js +37 -7
  48. package/dist/workflows/triggers/trigger-start.js +101 -0
  49. package/dist/workflows/workflow-engine.js +38 -5
  50. package/dist/workflows/workflow-types.d.ts +4 -1
  51. package/dist/workflows/workflow-types.js +9 -3
  52. package/package.json +6 -6
@@ -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,63 @@
1
+ import { badRequest, notFound } from "../../problem.js";
2
+ import { tenantScope } from "../../tenant.js";
3
+ import { recordDecision, requestAccess, targetFilter } from "../../request-access.js";
4
+ import { deletedRows } from "../../params.js";
5
+ import { fetchRecord } from "../../records.js";
6
+ import { fetchReadable } from "../../fetch-readable.js";
7
+ import { hookMode } from "./hook-secrets.js";
8
+ import { hookPath } from "./hook-paths.js";
9
+ import { saveHook } from "./hook-store.js";
10
+ //#region src/workflows/hooks/issue-webhook.ts
11
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const missing = () => notFound("No workflows with this key that you may change");
13
+ const mayUpdate = async (trx, access, id, record) => {
14
+ const tenant = tenantScope(access.entry, access.session)?.tenantValue;
15
+ const key = [access.full.fields[access.full.primaryKey[0]]];
16
+ const filter = targetFilter(access, "update");
17
+ if (filter && !await fetchRecord(trx, access.entry, [id], tenant, {
18
+ filter,
19
+ fields: key
20
+ })) return false;
21
+ if (!access.resolved.recordChecks.includes("update")) return true;
22
+ const decision = await recordDecision(access, "update", record);
23
+ if (typeof decision === "boolean") return decision;
24
+ return Boolean(await fetchRecord(trx, access.entry, [id], tenant, {
25
+ filter: decision,
26
+ fields: key
27
+ }));
28
+ };
29
+ /**
30
+ * Gives a workflow a new webhook URL and secret, as `POST .../workflows/<id>/webhook` and `new_webhook_url` do: only for
31
+ * a caller who may change that workflow in their tenant, as its update access decides; 404 for anyone else, as for a
32
+ * workflow that is not there. Its current definition must start from a webhook trigger that does not register its own
33
+ * 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.
34
+ */
35
+ const issueWebhook = async ({ deps, schema, webhooks, origin }, session, id, request) => {
36
+ if (!uuid.test(id)) throw missing();
37
+ const entry = deps().registry.find("workflows");
38
+ const access = await requestAccess(deps(), entry, session);
39
+ if (!access.resolved.operations.update) throw missing();
40
+ return deps().db.transaction().execute(async (trx) => {
41
+ const found = await fetchReadable(trx, access, [id], deletedRows(access.full, false), { lock: true });
42
+ if (!found || !await mayUpdate(trx, access, id, found.record)) throw missing();
43
+ const record = found.record;
44
+ const [type, input] = Object.entries(record.definition.trigger)[0];
45
+ const trigger = webhooks(type);
46
+ if (!trigger) throw badRequest("not-a-webhook", "This workflow does not start from a webhook trigger, such as app:webhook");
47
+ if (trigger.subscribe) throw badRequest("not-a-webhook", `${type} registers its own URL with its service as the workflow is turned on`);
48
+ const mode = hookMode(input);
49
+ if (!mode) throw badRequest("invalid-workflow", "The webhook trigger names an auth mode this app does not know");
50
+ const { key, secret } = await saveHook(trx, schema, {
51
+ workflow: id,
52
+ tenant: record.tenant_id ?? null,
53
+ mode,
54
+ ...request.secret !== void 0 && { secret: request.secret }
55
+ });
56
+ return {
57
+ url: `${origin() ?? request.origin}${hookPath(deps().systemPath, key)}`,
58
+ secret
59
+ };
60
+ });
61
+ };
62
+ //#endregion
63
+ 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,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,96 @@
1
+ import { 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
+ /** Turns a workflow's trigger on, on its current version, or off, once the user approves it. */
19
+ const enableWorkflowTool = (input = {}) => defineTool({
20
+ name: "enable_workflow",
21
+ 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. The user approves it in their client first.${onlyNote(input)}`,
22
+ parameters: {
23
+ type: "object",
24
+ properties: {
25
+ id: idParameter,
26
+ enabled: {
27
+ type: "boolean",
28
+ description: "true to turn it on, false to turn it off."
29
+ },
30
+ version: {
31
+ type: "integer",
32
+ description: "With enabled: true, the version to turn on: the workflow's current version."
33
+ }
34
+ },
35
+ required: ["id", "enabled"]
36
+ },
37
+ annotations: writes,
38
+ run: async (args, context) => {
39
+ const id = idOf(args);
40
+ const version = args.version;
41
+ 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 }.";
42
+ const refusal = outside(input, "workflows");
43
+ if (refusal) return refusal;
44
+ const before = await recordBefore(context, "workflows", id, void 0);
45
+ if (typeof before === "string") return before;
46
+ if (!before) return refused(notFound("No workflows with this key that you may read"));
47
+ if (args.enabled && before.record.version !== version) return refused(preconditionFailed(`The workflow was saved since version ${version}; its current version is ${before.record.version}`));
48
+ const data = { enabled_version: args.enabled ? version : null };
49
+ const { text, described } = await approvalTexts(context, "workflows");
50
+ const answer = await context.approve(updateApproval(text, described, id, before.record, data));
51
+ if (answer !== "approved") return notApproved(answer);
52
+ return context.records.update("workflows", id, data, args.enabled ? { etag: before.etag } : {}).then(({ record }) => JSON.stringify({
53
+ id: record.id,
54
+ enabled_version: record.enabled_version
55
+ }), refused);
56
+ }
57
+ });
58
+ /** Issues a new webhook URL and secret for a workflow, once the user approves it. */
59
+ const newWebhookUrlTool = (issue, input = {}) => defineTool({
60
+ name: "new_webhook_url",
61
+ 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)}`,
62
+ parameters: {
63
+ type: "object",
64
+ properties: {
65
+ id: idParameter,
66
+ secret: {
67
+ type: "string",
68
+ description: "The sender's own secret, when it makes one; omit it to have the app make one."
69
+ }
70
+ },
71
+ required: ["id"]
72
+ },
73
+ annotations: writes,
74
+ run: async (args, context) => {
75
+ const id = idOf(args);
76
+ if (!id) return "Pass { \"id\": \"<id>\" }, and a secret when the sender makes its own.";
77
+ const refusal = outside(input, "workflows");
78
+ if (refusal) return refusal;
79
+ const before = await recordBefore(context, "workflows", id, void 0);
80
+ if (typeof before === "string") return before;
81
+ if (!before) return refused(notFound("No workflows with this key that you may read"));
82
+ const { text, described } = await approvalTexts(context, "workflows");
83
+ const answer = await context.approve({
84
+ title: text("webhookTitle", { record: recordName(described, id, before.record) }),
85
+ body: text("webhookBody"),
86
+ approveLabel: text("issue"),
87
+ rejectLabel: text("reject")
88
+ });
89
+ if (answer !== "approved") return notApproved(answer);
90
+ return issue(context.session, id, textOf(args.secret) || void 0).then((issued) => JSON.stringify(issued), refused);
91
+ }
92
+ });
93
+ /** The tools that turn workflows on and issue their webhook URLs; `new_webhook_url` only where the app can issue them. */
94
+ const webhookTools = (input = {}, issue) => [enableWorkflowTool(input), ...issue ? [newWebhookUrlTool(issue, input)] : []];
95
+ //#endregion
96
+ 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>[];
@@ -3,8 +3,8 @@ import { tenantScope } from "../tenant.js";
3
3
  import { issueTicket } from "../files/ticket.js";
4
4
  import { runBatch } from "../batch/run-batch.js";
5
5
  import { assistantRecords, toolEntry } from "../assistant/records.js";
6
- import { effectsStore } from "./effects-store.js";
7
6
  import { runFileRef } from "./run-files.js";
7
+ import { effectsStore } from "./effects-store.js";
8
8
  import { sql } from "kysely";
9
9
  import { StepError } from "@protobase/workflows";
10
10
  //#region src/workflows/plugin-records.ts
@@ -1,6 +1,6 @@
1
+ import { pluginFetch } from "./plugin-fetch.js";
1
2
  import { readRunFile, storeRunFile } from "./run-files.js";
2
3
  import { pluginRecords } from "./plugin-records.js";
3
- import { pluginFetch } from "./plugin-fetch.js";
4
4
  //#region src/workflows/plugin-services.ts
5
5
  /** The plugin context's `files`: those of the run alone, in the app's `private` file provider. */
6
6
  const runFiles = (files, runId) => ({
@@ -1,7 +1,7 @@
1
1
  import { HttpProblem } from "../../problem.js";
2
2
  import { assistantRecords } from "../../assistant/records.js";
3
- import { saverSession } from "../plugin-records.js";
4
3
  import { startWorkflow } from "../start-workflow.js";
4
+ import { saverSession } from "../plugin-records.js";
5
5
  import { triggerFailed, triggerRecovered } from "../triggers/trigger-row.js";
6
6
  import { encodeKey } from "@protobase/schema/internal";
7
7
  import { checkFilter, evaluateFilter, parseFilter } from "@protobase/schema";
@@ -62,5 +62,11 @@ const readRunFile = async (files, runId, value, field) => {
62
62
  bytes
63
63
  };
64
64
  };
65
+ /** Removes a file a run stored, as when the run it was kept for turned out to be started already. */
66
+ const removeRunFile = async (files, value) => {
67
+ const ref = parseUri(value.uri);
68
+ if (!ref) throw new Error(`${value.uri} is not a file URI`);
69
+ await runStore(files).delete(ref.path);
70
+ };
65
71
  //#endregion
66
- export { isFileValue, readRunFile, runFileLimit, runFileRef, storeRunFile };
72
+ export { isFileValue, readRunFile, removeRunFile, runFileLimit, runFileRef, storeRunFile };
@@ -1,6 +1,6 @@
1
1
  import { quoteIdent } from "../changes/capture-sql.js";
2
2
  /** The comment the schema's function carries, which the startup check reads. */
3
- const workflowsComment = `Protobase workflows 6`;
3
+ const workflowsComment = `Protobase workflows 7`;
4
4
  const commentVersion = (comment) => {
5
5
  const match = comment?.match(/^Protobase workflows (\d+)$/);
6
6
  return match ? Number(match[1]) : void 0;
@@ -83,7 +83,9 @@ const triggerStateBytes = 65536;
83
83
  /**
84
84
  * Which version of a workflow is on (`enabled_version`, null when off), and one row per trigger of an enabled version:
85
85
  * its type and input, and the state its kind keeps. A change raises `generation`, so a job queued for an older one
86
- * ends without acting. A trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
86
+ * ends without acting. A poll or renewal acts only as the job `poll_job_id` names, on the try `poll_attempt` claimed,
87
+ * so a try that ran past its timeout writes nothing once its retry claimed the trigger. A deleted workflow leaves its
88
+ * trigger without a workflow, which its delete hook stops. A trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
87
89
  * every statement is idempotent.
88
90
  */
89
91
  const triggersSql = (schema) => {
@@ -96,7 +98,7 @@ do $$ begin
96
98
  end $$;
97
99
  create table if not exists ${s}."triggers" (
98
100
  "id" uuid primary key default gen_random_uuid(),
99
- "workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
101
+ "workflow_id" uuid references ${s}."workflows" ("id") on delete set null,
100
102
  "version" integer not null,
101
103
  "tenant_id" text,
102
104
  "type" text not null,
@@ -107,12 +109,22 @@ create table if not exists ${s}."triggers" (
107
109
  "cursor" jsonb check (octet_length("cursor"::text) <= ${triggerStateBytes}),
108
110
  "store" jsonb check (octet_length("store"::text) <= ${triggerStateBytes}),
109
111
  "poll_job_id" text,
112
+ "poll_attempt" integer,
110
113
  "consecutive_failures" integer not null,
111
114
  "last_error" jsonb,
112
115
  "next_poll_at" timestamptz,
113
116
  "created_at" timestamptz not null default now(),
114
117
  "updated_at" timestamptz not null default now()
115
118
  );
119
+ alter table ${s}."triggers" add column if not exists "poll_attempt" integer;
120
+ -- A deleted workflow's trigger stays, without its workflow, until its stop job unsubscribed it at its provider.
121
+ alter table ${s}."triggers" alter column "workflow_id" drop not null;
122
+ do $$ begin
123
+ if exists (select 1 from pg_constraint where conname = 'triggers_workflow_id_fkey' and confdeltype = 'c' and connamespace = ${`'${schema.replaceAll("'", "''")}'`}::regnamespace) then
124
+ alter table ${s}."triggers" drop constraint "triggers_workflow_id_fkey";
125
+ alter table ${s}."triggers" add constraint "triggers_workflow_id_fkey" foreign key ("workflow_id") references ${s}."workflows" ("id") on delete set null;
126
+ end if;
127
+ end $$;
116
128
  create unique index if not exists "triggers_workflow" on ${s}."triggers" ("workflow_id") where "status" <> 'stopping';
117
129
  create index if not exists "triggers_workflow_id" on ${s}."triggers" ("workflow_id");
118
130
  create index if not exists "triggers_updated_at" on ${s}."triggers" ("updated_at");
@@ -2,6 +2,7 @@ import { quoteIdent } from "../changes/capture-sql.js";
2
2
  import { migrationCommand } from "../changes/capture-schema.js";
3
3
  import { workflowsSchema } from "./options.js";
4
4
  import { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggersSql, workflowsTriggers } from "./schema-sql.js";
5
+ import { hooksSql } from "./hooks/hooks-sql.js";
5
6
  import { sql } from "kysely";
6
7
  //#region src/workflows/schema.ts
7
8
  const readWorkflowsSchema = async (db, schema) => {
@@ -15,7 +16,7 @@ const readWorkflowsSchema = async (db, schema) => {
15
16
  const row = rows[0];
16
17
  return {
17
18
  tables: row.found,
18
- current: row.found && row.trigger_rows && commentVersion(row.comment) === 6 && row.triggers === workflowsTriggers.length
19
+ current: row.found && row.trigger_rows && commentVersion(row.comment) === 7 && row.triggers === workflowsTriggers.length
19
20
  };
20
21
  };
21
22
  /**
@@ -31,6 +32,7 @@ const workflowsMigration = async (db, options = {}) => {
31
32
  ...found.tables ? [] : [tablesSql(schema)],
32
33
  triggersSql(schema),
33
34
  effectsSql(schema),
35
+ hooksSql(schema),
34
36
  indexesSql(schema),
35
37
  functionsSql(schema)
36
38
  ].join(";\n\n")};`;
@@ -19,10 +19,12 @@ export type StartWorkflowOptions = {
19
19
  schema?: string;
20
20
  /** The version to run, as a trigger runs the enabled one; default the workflow's current version. */
21
21
  version?: number;
22
+ /** The run's id, when the caller kept something under it first, such as a webhook's large body; default a new one. */
23
+ id?: string;
22
24
  };
23
25
  /**
24
26
  * Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
25
27
  * `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
26
28
  */
27
- export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version }: StartWorkflowOptions) => Promise<string>;
29
+ export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version, id: runId }: StartWorkflowOptions) => Promise<string>;
28
30
  //#endregion
@@ -7,13 +7,13 @@ import { sql } from "kysely";
7
7
  * Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
8
8
  * `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
9
9
  */
10
- const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version }) => {
10
+ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version, id: runId }) => {
11
11
  checkRunInput(input);
12
12
  const job = currentJobs().definition(segmentJobName);
13
13
  if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
14
14
  const runs = sql.id(schema, "runs");
15
- const { rows } = await sql`insert into ${runs} (workflow_id, version, input, tenant_id, started_by, key)
16
- values (${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
15
+ const { rows } = await sql`insert into ${runs} (id, workflow_id, version, input, tenant_id, started_by, key)
16
+ values (${runId === void 0 ? sql`gen_random_uuid()` : sql`${runId}::uuid`}, ${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
17
17
  on conflict (workflow_id, key) do nothing returning id`.execute(db);
18
18
  if (rows.length === 0) {
19
19
  const existing = (await sql`select id, status from ${runs} where workflow_id = ${workflow} and key = ${key}`.execute(db)).rows[0];
@@ -24,5 +24,11 @@ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, sche
24
24
  await job.enqueue(db, { run: id }, { key: id });
25
25
  return id;
26
26
  };
27
+ /** Queues the first segment of runs a trigger inserted itself, in `db`'s transaction. */
28
+ const queueRuns = async (db, ids) => {
29
+ const job = currentJobs().definition(segmentJobName);
30
+ if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
31
+ for (const id of ids) await job.enqueue(db, { run: id }, { key: id });
32
+ };
27
33
  //#endregion
28
- export { startWorkflow };
34
+ export { queueRuns, startWorkflow };
@@ -0,0 +1,15 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/triggers/delete-hook.ts
3
+ /**
4
+ * As a workflow is deleted, in the delete's transaction: its trigger, which the delete left without a workflow, stops at
5
+ * once, so no poll or call starts a run of it, and its stop job, queued in the same write, ends any subscription at its
6
+ * provider and then removes it. The delete removed its webhook URL already.
7
+ */
8
+ const workflowDeleteHook = (schema, jobs) => async (event, trx) => {
9
+ if (event.resource.name !== "workflows" || event.operation !== "delete") return;
10
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = 'stopping', generation = generation + 1, updated_at = now()
11
+ where workflow_id is null and status <> 'stopping' returning id`.execute(trx);
12
+ for (const { id } of rows) await jobs.stop.enqueue(trx, { trigger: id });
13
+ };
14
+ //#endregion
15
+ export { workflowDeleteHook };
@@ -0,0 +1,35 @@
1
+ import { pluginFetch } from "../plugin-fetch.js";
2
+ import { ConnectionError } from "@protobase/workflows/plugin";
3
+ //#region src/workflows/triggers/plugin-call.ts
4
+ /** The longest a plugin's error message is kept on its trigger, which the workflow's readers see. */
5
+ const messageLength = 1e3;
6
+ /**
7
+ * Runs a plugin's trigger code: any error it throws is the plugin's, which the trigger records rather than the job
8
+ * retrying it; a `ConnectionError('link_expired')` is a sign-in to renew. The message goes through `redact` and is cut
9
+ * to 1 000 characters, since the trigger's readers see it.
10
+ */
11
+ const callPlugin = (work, redact) => Promise.resolve().then(work).then((value) => ({ value }), (error) => {
12
+ const message = redact(error instanceof Error ? error.message : String(error)).slice(0, messageLength);
13
+ if (error instanceof ConnectionError && error.code === "link_expired") return { expired: message };
14
+ return { failed: message };
15
+ });
16
+ /**
17
+ * `redact` after blanking out `values` that only this call knew, such as a webhook's URL key and secret, as written and
18
+ * URL-encoded, so a message that quotes them keeps neither.
19
+ */
20
+ const redactAlso = (values, redact) => {
21
+ const blanked = [...new Set(values.flatMap((value) => [value, encodeURIComponent(value)]))].filter((value) => value.length > 0);
22
+ return (text) => redact(blanked.reduce((kept, value) => kept.replaceAll(value, "[redacted]"), text));
23
+ };
24
+ /**
25
+ * What a trigger's hooks get: its input, the guarded `fetch`, and the job's signal, which aborts at its timeout. No
26
+ * type that needs a connection runs yet, so it has none.
27
+ */
28
+ const triggerContext = ({ allowPrivateAddresses }, input, signal) => ({
29
+ input,
30
+ connection: void 0,
31
+ fetch: pluginFetch({ allowPrivateAddresses }),
32
+ signal
33
+ });
34
+ //#endregion
35
+ export { callPlugin, redactAlso, triggerContext };