@protobase/server 0.68.6 → 0.69.1

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 (47) hide show
  1. package/dist/better-auth/auth-settings.d.ts +155 -28
  2. package/dist/better-auth/auth-settings.js +3 -2
  3. package/dist/better-auth/connections/connection-account-hooks.js +62 -0
  4. package/dist/better-auth/connections/connection-events.d.ts +11 -0
  5. package/dist/better-auth/connections/connection-events.js +16 -0
  6. package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
  7. package/dist/better-auth/connections/connections-plugin.js +98 -0
  8. package/dist/better-auth/connections/declared-connections.d.ts +21 -0
  9. package/dist/better-auth/connections/declared-connections.js +54 -0
  10. package/dist/better-auth/connections/revoke-token.js +34 -0
  11. package/dist/better-auth/create-auth.d.ts +162 -17
  12. package/dist/better-auth/create-auth.js +59 -5
  13. package/dist/better-auth/refresh-refusals.js +43 -0
  14. package/dist/better-auth/staff-checks.js +1 -0
  15. package/dist/notifications/history-routes.js +1 -1
  16. package/dist/package.js +1 -1
  17. package/dist/personal-mail/linked-accounts.js +17 -13
  18. package/dist/types.d.ts +2 -0
  19. package/dist/view-access.js +2 -1
  20. package/dist/webhooks/attempt.js +2 -2
  21. package/dist/webhooks/table/webhook-table.js +1 -1
  22. package/dist/workflows/connections/connection-accounts.js +18 -0
  23. package/dist/workflows/connections/connection-consent.js +49 -0
  24. package/dist/workflows/connections/connection-provider.js +10 -0
  25. package/dist/workflows/connections/connection-tokens.js +54 -0
  26. package/dist/workflows/connections/connection-triggers.js +37 -0
  27. package/dist/workflows/connections/connection-wiring.js +73 -0
  28. package/dist/workflows/connections/enabler-authority.js +35 -0
  29. package/dist/workflows/connections/enabler-mailbox.js +15 -0
  30. package/dist/workflows/connections/single-flight.js +17 -0
  31. package/dist/workflows/hooks/issue-webhook.js +3 -20
  32. package/dist/workflows/may-update-workflow.js +26 -0
  33. package/dist/workflows/mcp/webhook-tools.js +10 -3
  34. package/dist/workflows/save-hook.js +7 -2
  35. package/dist/workflows/schema-sql.js +3 -1
  36. package/dist/workflows/schema.js +1 -1
  37. package/dist/workflows/triggers/plugin-call.js +19 -9
  38. package/dist/workflows/triggers/poll-job.js +7 -7
  39. package/dist/workflows/triggers/trigger-jobs.js +23 -7
  40. package/dist/workflows/triggers/trigger-start.js +16 -5
  41. package/dist/workflows/triggers/trigger-sync.js +15 -10
  42. package/dist/workflows/workflow-engine.js +20 -5
  43. package/dist/workflows/workflow-types.d.ts +7 -3
  44. package/dist/workflows/workflow-types.js +12 -12
  45. package/dist/write-shared.js +1 -0
  46. package/package.json +6 -6
  47. package/dist/better-auth/end-replaced-session.d.ts +0 -17
@@ -0,0 +1,49 @@
1
+ import { badRequest, forbidden } from "../../problem.js";
2
+ import { connectionProviderId, holdsScopes } from "./connection-provider.js";
3
+ import { ownSignInUser } from "../../notifications/caller.js";
4
+ import { sql } from "kysely";
5
+ import { usedConnections } from "@protobase/workflows";
6
+ //#region src/workflows/connections/connection-consent.ts
7
+ /** The refusal of every account the caller may not turn on, the same whether it exists or not. */
8
+ const notOwnConnection = () => forbidden("own-sign-in-only", "Only the owner of each connection this workflow uses, signed in as themselves, can turn it on: turn it off to save it, and its owner turns it on again in the admin app");
9
+ /**
10
+ * Turning a version on is the consent that lets its runs sign in through the accounts it binds: it is refused unless
11
+ * the caller is signed in as themselves (not staff signed in as them, nor an outside app acting for them) and owns
12
+ * every bound account, of its connection's provider and holding its scopes. Returns who consented, which the version
13
+ * keeps as `enabled_by`: the caller when they are signed in as themselves, else nobody, as for a version that binds
14
+ * no account turned on by an outside app.
15
+ */
16
+ const connectionConsent = ({ accounts, declarationOf, connectionOf }) => async (session, definition) => {
17
+ const keys = [...usedConnections(definition, connectionOf)];
18
+ if (keys.length === 0) {
19
+ if (session.anonymous || session.client || session.signIn?.staff !== false) return null;
20
+ return String(session.user.id);
21
+ }
22
+ const unbound = keys.filter((key) => !definition.connections?.[key]);
23
+ if (unbound.length > 0) throw badRequest("invalid-workflow", `Connect an account for ${unbound.join(", ")} in the workflow's connections before turning it on`);
24
+ const userId = ownSignInUser(session, "turn on a workflow that signs in through their connection");
25
+ for (const key of keys) {
26
+ const declaration = declarationOf(key);
27
+ const [plugin, name] = key.split(":");
28
+ const account = await accounts.find(userId, definition.connections[key]);
29
+ if (!account || account.providerId !== connectionProviderId(plugin, name, declaration) || !holdsScopes(account.scopes, declaration)) throw notOwnConnection();
30
+ }
31
+ return userId;
32
+ };
33
+ /**
34
+ * Keeps who turned a version on, as its runs' steps read it, and withdraws the consent when the workflow is turned
35
+ * off: every version's runs then sign in through no account until it is turned on again. Someone else turning a
36
+ * version on withdraws the earlier person's consent on every other version, so a waiting run of one of them signs in
37
+ * through no account of theirs; the earlier versions the same person turned on keep it.
38
+ */
39
+ const saveConsent = async (trx, schema, workflowId, enabled, enabledBy) => {
40
+ const versions = sql.id(schema, "versions");
41
+ if (enabled === null) {
42
+ await sql`update ${versions} set enabled_by = null where workflow_id = ${workflowId} and enabled_by is not null`.execute(trx);
43
+ return;
44
+ }
45
+ await sql`update ${versions} set enabled_by = null where workflow_id = ${workflowId} and version <> ${enabled} and enabled_by is distinct from ${enabledBy}`.execute(trx);
46
+ await sql`update ${versions} set enabled_by = ${enabledBy} where workflow_id = ${workflowId} and version = ${enabled}`.execute(trx);
47
+ };
48
+ //#endregion
49
+ export { connectionConsent, notOwnConnection, saveConsent };
@@ -0,0 +1,10 @@
1
+ //#region src/workflows/connections/connection-provider.ts
2
+ /**
3
+ * The Better Auth provider id a connection's accounts are kept under: the app's sign-in provider it names, or one of
4
+ * its own, `workflow-<plugin>-<connection>`, for an `oauth` connection; plugin and connection names hold no `-`.
5
+ */
6
+ const connectionProviderId = (plugin, name, declaration) => "signIn" in declaration ? declaration.signIn : `workflow-${plugin}-${name}`;
7
+ /** Whether an account's stored scopes hold every scope the connection asks for. */
8
+ const holdsScopes = (stored, declaration) => (declaration.scopes ?? []).every((scope) => stored.includes(scope));
9
+ //#endregion
10
+ export { connectionProviderId, holdsScopes };
@@ -0,0 +1,54 @@
1
+ import { connectionProviderId, holdsScopes } from "./connection-provider.js";
2
+ import { enablerMayUpdate } from "./enabler-authority.js";
3
+ import { singleFlight } from "./single-flight.js";
4
+ import { sql } from "kysely";
5
+ import { ConnectionError } from "@protobase/workflows/plugin";
6
+ //#region src/workflows/connections/connection-tokens.ts
7
+ /**
8
+ * An account's valid access token, refreshed when it expired, with overlapping refreshes of one account joined into
9
+ * one. Another worker may rotate the refresh token between this one's read (`before`, the stored refresh token) and its
10
+ * refresh, which the provider then refuses: reading the account once more tells that apart, and the stored token is
11
+ * then the fresh one. Undefined when the provider refused it.
12
+ */
13
+ const sharedRefresh = (accounts) => {
14
+ const refresh = singleFlight();
15
+ return async (userId, accountId, before) => {
16
+ const token = await refresh(accountId, () => accounts.accessToken(userId, accountId));
17
+ if (token) return token;
18
+ const after = await accounts.find(userId, accountId);
19
+ if (!after || after.refreshToken === before) return;
20
+ return refresh(accountId, () => accounts.accessToken(userId, accountId));
21
+ };
22
+ };
23
+ /**
24
+ * The connection a step or trigger declared, bound by its workflow. Each `accessToken()` checks afresh, caching nothing:
25
+ * the version it runs binds an account to the connection, the person who turned that version on owns that account of
26
+ * the connection's provider with its scopes, and may still update the workflow in its tenant. Anything else is
27
+ * `not_connected`, with one message that tells nothing of whether the account exists; a refresh the provider refuses
28
+ * is `link_expired`. The token is blanked out of all the step records (`secret`).
29
+ */
30
+ const connectionTokens = ({ deps, schema, roles, accounts }) => {
31
+ const tokenOf = sharedRefresh(accounts);
32
+ return (use) => {
33
+ const key = `${use.plugin}:${use.name}`;
34
+ const notConnected = () => new ConnectionError("not_connected", `This workflow has no connection it may use for ${key}: its owner connects an account and turns the workflow on`);
35
+ return { accessToken: async () => {
36
+ const ref = await use.version();
37
+ if (!ref) throw notConnected();
38
+ const db = deps().db;
39
+ const { rows } = await sql`select v.definition -> 'connections' ->> ${key} as account, v.enabled_by, v.tenant_id
40
+ from ${sql.id(schema, "versions")} v where v.workflow_id = ${ref.workflowId} and v.version = ${ref.version}`.execute(db);
41
+ const consent = rows[0];
42
+ if (!consent?.account || !consent.enabled_by) throw notConnected();
43
+ if (!await enablerMayUpdate(db, deps(), roles, consent.enabled_by, consent.tenant_id ?? void 0, ref.workflowId)) throw notConnected();
44
+ const account = await accounts.find(consent.enabled_by, consent.account);
45
+ if (!account || account.providerId !== connectionProviderId(use.plugin, use.name, use.declaration) || !holdsScopes(account.scopes, use.declaration)) throw notConnected();
46
+ const token = await tokenOf(consent.enabled_by, account.id, account.refreshToken);
47
+ if (!token) throw new ConnectionError("link_expired", `The account connected for ${key} no longer signs in: its owner connects it again`);
48
+ use.secret(token);
49
+ return token;
50
+ } };
51
+ };
52
+ };
53
+ //#endregion
54
+ export { connectionTokens, sharedRefresh };
@@ -0,0 +1,37 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/connections/connection-triggers.ts
3
+ /**
4
+ * The triggers of connected accounts, as sign-in tells of them: a link resumes the triggers waiting on that account's
5
+ * sign-in, which poll again from the cursor they kept or renew the subscription they kept; a disconnect makes the
6
+ * running ones wait for a new sign-in.
7
+ */
8
+ const connectionTriggers = (db, schema, jobs) => {
9
+ const triggers = sql.id(schema, "triggers");
10
+ return {
11
+ linked: async (accountId) => {
12
+ await db().transaction().execute(async (trx) => {
13
+ const { rows } = await sql`update ${triggers} set status = 'active', consecutive_failures = 0, last_error = null, updated_at = now()
14
+ where connection_id = ${accountId} and status = 'needs_sign_in' returning id, generation`.execute(trx);
15
+ for (const row of rows) await jobs.start.enqueue(trx, {
16
+ trigger: row.id,
17
+ generation: row.generation
18
+ });
19
+ });
20
+ },
21
+ disconnected: async (accountId) => {
22
+ const error = JSON.stringify({
23
+ code: "link_expired",
24
+ message: "The connected account was disconnected: its owner connects it again",
25
+ at: (/* @__PURE__ */ new Date()).toISOString()
26
+ });
27
+ await sql`update ${triggers} set status = 'needs_sign_in', last_error = ${error}::jsonb, poll_job_id = null, poll_attempt = null, next_poll_at = null, updated_at = now()
28
+ where connection_id = ${accountId} and status in ('active', 'failing')`.execute(db());
29
+ },
30
+ needsSignIn: async (accountIds) => {
31
+ const { rows } = await sql`select distinct connection_id from ${triggers} where status = 'needs_sign_in' and connection_id in (${sql.join(accountIds)})`.execute(db());
32
+ return rows.map((row) => row.connection_id);
33
+ }
34
+ };
35
+ };
36
+ //#endregion
37
+ export { connectionTriggers };
@@ -0,0 +1,73 @@
1
+ import { connectionConsent } from "./connection-consent.js";
2
+ import { enablerMayUpdate } from "./enabler-authority.js";
3
+ import { enablerMailbox } from "./enabler-mailbox.js";
4
+ import { connectionTokens } from "./connection-tokens.js";
5
+ import { sql } from "kysely";
6
+ import { ConnectionError } from "@protobase/workflows/plugin";
7
+ //#region src/workflows/connections/connection-wiring.ts
8
+ const noAccounts = {
9
+ find: async () => void 0,
10
+ accessToken: async () => void 0
11
+ };
12
+ /**
13
+ * The connections of an app's workflows: the consent a save checks as a version is turned on, and the connection each
14
+ * step and trigger that declares one gets, bound by the version its run or trigger runs.
15
+ */
16
+ const connectionWiring = ({ deps, schema, roles, accounts = noAccounts, plugins, connectionOf, personalMailer }) => {
17
+ const declarationOf = (key) => {
18
+ const [plugin, name] = key.split(":");
19
+ return plugins.find((candidate) => candidate.name === plugin)?.connections?.[name];
20
+ };
21
+ const consent = connectionConsent({
22
+ accounts,
23
+ declarationOf,
24
+ connectionOf
25
+ });
26
+ const tokens = connectionTokens({
27
+ deps,
28
+ schema,
29
+ roles,
30
+ accounts
31
+ });
32
+ const use = (type, version, secret) => {
33
+ const key = connectionOf[type];
34
+ const declaration = key === void 0 ? void 0 : declarationOf(key);
35
+ if (!key || !declaration) return;
36
+ const [plugin, name] = key.split(":");
37
+ return tokens({
38
+ plugin,
39
+ name,
40
+ declaration,
41
+ version,
42
+ secret
43
+ });
44
+ };
45
+ const runVersion = async (runId) => {
46
+ const { rows } = await sql`select workflow_id, version from ${sql.id(schema, "runs")} where id = ${runId}`.execute(deps().db);
47
+ const run = rows[0];
48
+ return run?.version == null ? void 0 : {
49
+ workflowId: run.workflow_id,
50
+ version: run.version
51
+ };
52
+ };
53
+ const step = (stepId, name, task) => use(stepId, () => runVersion(task.runId), task.secret) ?? { accessToken: () => Promise.reject(new ConnectionError("not_connected", `${stepId} signs in through "${name}", which this app does not declare`)) };
54
+ const trigger = (row, secret) => use(row.type, async () => row.workflow_id === null ? void 0 : {
55
+ workflowId: row.workflow_id,
56
+ version: row.version
57
+ }, secret);
58
+ const runEnabler = async (runId) => {
59
+ const { rows } = await sql`select r.workflow_id, v.enabled_by, v.tenant_id from ${sql.id(schema, "runs")} r
60
+ join ${sql.id(schema, "versions")} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${runId}`.execute(deps().db);
61
+ const row = rows[0];
62
+ if (!row?.enabled_by) return null;
63
+ return await enablerMayUpdate(deps().db, deps(), roles, row.enabled_by, row.tenant_id ?? void 0, row.workflow_id) ? row.enabled_by : null;
64
+ };
65
+ return {
66
+ consent,
67
+ step,
68
+ trigger,
69
+ mailbox: personalMailer && ((task) => enablerMailbox(() => runEnabler(task.runId), personalMailer))
70
+ };
71
+ };
72
+ //#endregion
73
+ export { connectionWiring };
@@ -0,0 +1,35 @@
1
+ import { requestAccess } from "../../request-access.js";
2
+ import { deletedRows } from "../../params.js";
3
+ import { fetchReadable } from "../../fetch-readable.js";
4
+ import { mayUpdateWorkflow } from "../may-update-workflow.js";
5
+ //#region src/workflows/connections/enabler-authority.ts
6
+ /**
7
+ * Whether the person who turned a workflow on may still do so: with the roles they hold now in its tenant, they may
8
+ * read this one and update it, as its update row filter and record-level rule decide. Losing either, as when they were
9
+ * demoted or left the organization, withdraws the connection their consent gave its runs.
10
+ */
11
+ const enablerMayUpdate = async (db, deps, roles, enabledBy, tenant, workflowId) => {
12
+ const held = await roles({
13
+ id: enabledBy,
14
+ ...tenant !== void 0 && { tenant }
15
+ });
16
+ if (held.length === 0) return false;
17
+ const entry = deps.registry.find("workflows");
18
+ const session = {
19
+ user: {
20
+ id: enabledBy,
21
+ roles: held
22
+ },
23
+ ...tenant !== void 0 && {
24
+ tenant,
25
+ organization: { id: tenant }
26
+ }
27
+ };
28
+ const access = await requestAccess(deps, entry, session);
29
+ if (!access.resolved.operations.update) return false;
30
+ const found = await fetchReadable(db, access, [workflowId], deletedRows(access.full, false), {});
31
+ if (!found) return false;
32
+ return mayUpdateWorkflow(db, access, workflowId, found.record);
33
+ };
34
+ //#endregion
35
+ export { enablerMayUpdate };
@@ -0,0 +1,15 @@
1
+ import { PersonalMailError } from "../../personal-mail/personal-mail-error.js";
2
+ //#region src/workflows/connections/enabler-mailbox.ts
3
+ /**
4
+ * The step context's `mailbox`: each message goes out from the own mailbox of the person who turned the run's version
5
+ * on, signed in as themselves, as `enablerOf` reads them afresh; nobody (a version an outside app turned on, a workflow
6
+ * turned off, or someone who may no longer change it) sends nothing and fails `not_linked`. A job's own code gets no
7
+ * such mailer: only this consent lets mail leave someone's mailbox without their own request.
8
+ */
9
+ const enablerMailbox = (enablerOf, personalMailer) => ({ send: async (message) => {
10
+ const enabledBy = await enablerOf();
11
+ if (!enabledBy) throw new PersonalMailError("not_linked");
12
+ await personalMailer(enabledBy).send(message);
13
+ } });
14
+ //#endregion
15
+ export { enablerMailbox };
@@ -0,0 +1,17 @@
1
+ //#region src/workflows/connections/single-flight.ts
2
+ /**
3
+ * Joins calls of one key that overlap: the second waits for the first's result instead of starting its own, such as a
4
+ * second refresh of one account's token. An entry lives only while its call runs, so the map holds the calls in flight.
5
+ */
6
+ const singleFlight = () => {
7
+ const running = /* @__PURE__ */ new Map();
8
+ return (key, call) => {
9
+ const joined = running.get(key);
10
+ if (joined) return joined;
11
+ const started = call().finally(() => running.delete(key));
12
+ running.set(key, started);
13
+ return started;
14
+ };
15
+ };
16
+ //#endregion
17
+ export { singleFlight };
@@ -1,31 +1,14 @@
1
1
  import { badRequest, notFound } from "../../problem.js";
2
- import { tenantScope } from "../../tenant.js";
3
- import { recordDecision, requestAccess, targetFilter } from "../../request-access.js";
2
+ import { requestAccess } from "../../request-access.js";
4
3
  import { deletedRows } from "../../params.js";
5
- import { fetchRecord } from "../../records.js";
6
4
  import { fetchReadable } from "../../fetch-readable.js";
7
5
  import { hookMode } from "./hook-secrets.js";
8
6
  import { hookPath } from "./hook-paths.js";
9
7
  import { saveHook } from "./hook-store.js";
8
+ import { mayUpdateWorkflow } from "../may-update-workflow.js";
10
9
  //#region src/workflows/hooks/issue-webhook.ts
11
10
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
11
  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
12
  /**
30
13
  * Gives a workflow a new webhook URL and secret, as `POST .../workflows/<id>/webhook` and `new_webhook_url` do: only for
31
14
  * a caller who may change that workflow in their tenant, as its update access decides; 404 for anyone else, as for a
@@ -39,7 +22,7 @@ const issueWebhook = async ({ deps, schema, webhooks, origin }, session, id, req
39
22
  if (!access.resolved.operations.update) throw missing();
40
23
  return deps().db.transaction().execute(async (trx) => {
41
24
  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();
25
+ if (!found || !await mayUpdateWorkflow(trx, access, id, found.record)) throw missing();
43
26
  const record = found.record;
44
27
  const [type, input] = Object.entries(record.definition.trigger)[0];
45
28
  const trigger = webhooks(type);
@@ -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 };
@@ -1,4 +1,4 @@
1
- import { notFound, preconditionFailed } from "../../problem.js";
1
+ import { HttpProblem, notFound, preconditionFailed } from "../../problem.js";
2
2
  import { defineTool } from "../../assistant/tools.js";
3
3
  import { refused } from "../../assistant/record-tool-args.js";
4
4
  import { recordName, updateApproval } from "../../assistant/write-prompts.js";
@@ -15,10 +15,17 @@ const writes = {
15
15
  destructiveHint: false,
16
16
  openWorldHint: false
17
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
+ };
18
25
  /** Turns a workflow's trigger on, on its current version, or off, once the user approves it. */
19
26
  const enableWorkflowTool = (input = {}) => defineTool({
20
27
  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)}`,
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)}`,
22
29
  parameters: {
23
30
  type: "object",
24
31
  properties: {
@@ -52,7 +59,7 @@ const enableWorkflowTool = (input = {}) => defineTool({
52
59
  return context.records.update("workflows", id, data, args.enabled ? { etag: before.etag } : {}).then(({ record }) => JSON.stringify({
53
60
  id: record.id,
54
61
  enabled_version: record.enabled_version
55
- }), refused);
62
+ }), enableRefused);
56
63
  }
57
64
  });
58
65
  /** Issues a new webhook URL and secret for a workflow, once the user approves it. */
@@ -1,5 +1,6 @@
1
1
  import { badRequest } from "../problem.js";
2
2
  import { syncTrigger } from "./triggers/trigger-sync.js";
3
+ import { saveConsent } from "./connections/connection-consent.js";
3
4
  import { sql } from "kysely";
4
5
  import { checkDefinition, problemText } from "@protobase/workflows";
5
6
  //#region src/workflows/save-hook.ts
@@ -11,7 +12,7 @@ import { checkDefinition, problemText } from "@protobase/workflows";
11
12
  * `definition` field's `errors` with the state and field in their `params`, for a form to show each on its step; the
12
13
  * write rolls back.
13
14
  */
14
- const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
15
+ const workflowSaveHook = (schema, types, jobs, consent) => async (event, trx) => {
15
16
  if (event.resource.name !== "workflows" || !event.after) return;
16
17
  if (event.operation !== "create" && event.operation !== "update") return;
17
18
  const changed = event.operation === "create" || event.after.version !== event.before?.version;
@@ -35,7 +36,11 @@ const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
35
36
  }
36
37
  if (changed) await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
37
38
  select w.id, w.version, w.definition, ${String(event.user.id)}, w.tenant_id from ${sql.id(schema, "workflows")} w where w.id = ${String(event.after.id)}`.execute(trx);
38
- await syncTrigger(trx, schema, jobs, event.after);
39
+ const id = String(event.after.id);
40
+ const enabled = event.after.enabled_version;
41
+ const enabledBy = enabled === null ? null : await consent(event.session, event.after.definition);
42
+ await saveConsent(trx, schema, id, enabled, enabledBy);
43
+ await syncTrigger(trx, schema, jobs, event.after, types.connections);
39
44
  };
40
45
  //#endregion
41
46
  export { workflowSaveHook };
@@ -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 7`;
3
+ const workflowsComment = `Protobase workflows 8`;
4
4
  const commentVersion = (comment) => {
5
5
  const match = comment?.match(/^Protobase workflows (\d+)$/);
6
6
  return match ? Number(match[1]) : void 0;
@@ -96,6 +96,8 @@ do $$ begin
96
96
  alter table ${s}."workflows" add constraint "workflows_enabled_version" foreign key ("id", "enabled_version") references ${s}."versions" ("workflow_id", "version") deferrable initially deferred;
97
97
  end if;
98
98
  end $$;
99
+ -- Who turned the version on, signed in as themselves: the consent its runs sign in through their connections with.
100
+ alter table ${s}."versions" add column if not exists "enabled_by" text;
99
101
  create table if not exists ${s}."triggers" (
100
102
  "id" uuid primary key default gen_random_uuid(),
101
103
  "workflow_id" uuid references ${s}."workflows" ("id") on delete set null,
@@ -16,7 +16,7 @@ const readWorkflowsSchema = async (db, schema) => {
16
16
  const row = rows[0];
17
17
  return {
18
18
  tables: row.found,
19
- current: row.found && row.trigger_rows && commentVersion(row.comment) === 7 && row.triggers === workflowsTriggers.length
19
+ current: row.found && row.trigger_rows && commentVersion(row.comment) === 8 && row.triggers === workflowsTriggers.length
20
20
  };
21
21
  };
22
22
  /**
@@ -11,6 +11,10 @@ const messageLength = 1e3;
11
11
  const callPlugin = (work, redact) => Promise.resolve().then(work).then((value) => ({ value }), (error) => {
12
12
  const message = redact(error instanceof Error ? error.message : String(error)).slice(0, messageLength);
13
13
  if (error instanceof ConnectionError && error.code === "link_expired") return { expired: message };
14
+ if (error instanceof ConnectionError && error.code === "not_connected") return {
15
+ failed: message,
16
+ unconnected: true
17
+ };
14
18
  return { failed: message };
15
19
  });
16
20
  /**
@@ -22,14 +26,20 @@ const redactAlso = (values, redact) => {
22
26
  return (text) => redact(blanked.reduce((kept, value) => kept.replaceAll(value, "[redacted]"), text));
23
27
  };
24
28
  /**
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.
29
+ * What one call of a trigger's hook gets: its input, its connection, the guarded `fetch`, and the job's signal, which
30
+ * aborts at its timeout; and `redact` for its outcome, which also blanks out each token its connection gave this call.
27
31
  */
28
- const triggerContext = ({ allowPrivateAddresses }, input, signal) => ({
29
- input,
30
- connection: void 0,
31
- fetch: pluginFetch({ allowPrivateAddresses }),
32
- signal
33
- });
32
+ const triggerCall = ({ allowPrivateAddresses, connect }, trigger, signal, redact) => {
33
+ const issued = [];
34
+ return {
35
+ context: {
36
+ input: trigger.input,
37
+ connection: connect?.(trigger, (token) => void issued.push(token)),
38
+ fetch: pluginFetch({ allowPrivateAddresses }),
39
+ signal
40
+ },
41
+ redact: (text) => redactAlso(issued, redact)(text)
42
+ };
43
+ };
34
44
  //#endregion
35
- export { callPlugin, redactAlso, triggerContext };
45
+ export { callPlugin, redactAlso, triggerCall };
@@ -1,5 +1,5 @@
1
1
  import { defineJob } from "../../jobs/define-job.js";
2
- import { callPlugin, triggerContext } from "./plugin-call.js";
2
+ import { callPlugin, triggerCall } from "./plugin-call.js";
3
3
  import { pollProblem, stateProblem } from "./poll-result.js";
4
4
  import { queueRuns } from "../start-workflow.js";
5
5
  import { backoffPollAt, nextPollAt, pollEvery } from "./poll-schedule.js";
@@ -46,14 +46,14 @@ const pollJobs = (input) => {
46
46
  if (!("value" in interval)) return failed(db, poll, claim, "expired" in interval ? interval.expired : interval.failed, backoffPollAt("1 minute", claimed.consecutive_failures + 1, /* @__PURE__ */ new Date()));
47
47
  const every = interval.value;
48
48
  const limit = pollLimit(type);
49
- const context = triggerContext(input, claimed.input, signal);
49
+ const call = triggerCall(input, claimed, signal, redact);
50
50
  const outcome = await callPlugin(() => type.poll({
51
- ...context,
51
+ ...call.context,
52
52
  cursor: claimed.cursor,
53
53
  store: claimed.store,
54
54
  limit,
55
55
  due: new Date(due)
56
- }), redact);
56
+ }), call.redact);
57
57
  const now = /* @__PURE__ */ new Date();
58
58
  if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
59
59
  const backoff = backoffPollAt(every, claimed.consecutive_failures + 1, now);
@@ -90,11 +90,11 @@ const pollJobs = (input) => {
90
90
  const type = claimed && input.webhooks(claimed.type);
91
91
  if (!claimed || !type?.renew) return;
92
92
  const renewal = type.renew;
93
- const context = triggerContext(input, claimed.input, signal);
93
+ const call = triggerCall(input, claimed, signal, redact);
94
94
  const outcome = await callPlugin(() => renewal({
95
- ...context,
95
+ ...call.context,
96
96
  state: claimed.store
97
- }), redact);
97
+ }), call.redact);
98
98
  const now = /* @__PURE__ */ new Date();
99
99
  if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
100
100
  const backoff = backoffPollAt("1 minute", claimed.consecutive_failures + 1, now);
@@ -1,5 +1,5 @@
1
1
  import { defineJob } from "../../jobs/define-job.js";
2
- import { callPlugin, triggerContext } from "./plugin-call.js";
2
+ import { callPlugin, triggerCall } from "./plugin-call.js";
3
3
  import { readyAt, readyWhen, resolveTimeTerms } from "../ready/ready-when.js";
4
4
  import { activeTriggers, currentTrigger, triggerFailed } from "./trigger-row.js";
5
5
  import { orRefusal, readyContext, startIfReady } from "../ready/ready-start.js";
@@ -85,24 +85,40 @@ const triggerJobs = (input) => {
85
85
  });
86
86
  }
87
87
  });
88
+ /**
89
+ * Ends a stopped webhook trigger's subscription at its provider: whether nothing is left there, so the trigger can go.
90
+ * Any error is tried again, as the job's, until the provider ends it. A sign-in the unsubscribe can no longer use, as
91
+ * once the workflow was turned off and so its connection with it, or once the provider refused it, cannot end it:
92
+ * the trigger then stays `stopping` with that error, as the record of a subscription the provider may still hold.
93
+ */
88
94
  const unsubscribe = async (db, trigger, signal) => {
89
- const { rows } = await sql`select type, input, store, store is not null as subscribed
95
+ const { rows } = await sql`select type, input, workflow_id, version, store, store is not null as subscribed
90
96
  from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
91
97
  const row = rows[0];
92
98
  const type = row?.subscribed ? input.webhooks(row.type) : void 0;
93
- if (!row || !type?.unsubscribe) return;
99
+ if (!row || !type?.unsubscribe) return true;
94
100
  const end = type.unsubscribe;
101
+ const call = triggerCall(input, row, signal, input.redact);
95
102
  const outcome = await callPlugin(() => end({
96
- ...triggerContext(input, row.input, signal),
103
+ ...call.context,
97
104
  state: row.store
98
- }), input.redact);
99
- if ("failed" in outcome) throw new Error(`The trigger's unsubscribe failed: ${outcome.failed}`);
105
+ }), call.redact);
106
+ if ("value" in outcome) return true;
107
+ if ("failed" in outcome && !outcome.unconnected) throw new Error(`The trigger's unsubscribe failed: ${outcome.failed}`);
108
+ const reason = "expired" in outcome ? outcome.expired : outcome.failed;
109
+ const error = JSON.stringify({
110
+ code: "expired" in outcome ? "link_expired" : "not_connected",
111
+ message: `The subscription could not be ended at the provider: ${reason}`,
112
+ at: (/* @__PURE__ */ new Date()).toISOString()
113
+ });
114
+ await sql`update ${sql.id(schema, "triggers")} set last_error = ${error}::jsonb, updated_at = now() where id = ${trigger} and status = 'stopping'`.execute(db);
115
+ return false;
100
116
  };
101
117
  const stop = defineJob({
102
118
  name: "workflows.trigger-stop",
103
119
  timeout: "5 minutes",
104
120
  run: async ({ trigger }, { db, signal }) => {
105
- await unsubscribe(db, trigger, signal);
121
+ if (!await unsubscribe(db, trigger, signal)) return;
106
122
  await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
107
123
  }
108
124
  });