@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.
- 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/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/view-access.js +2 -1
- 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/issue-webhook.js +3 -20
- package/dist/workflows/may-update-workflow.js +26 -0
- package/dist/workflows/mcp/webhook-tools.js +10 -3
- package/dist/workflows/save-hook.js +7 -2
- package/dist/workflows/schema-sql.js +3 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/triggers/plugin-call.js +19 -9
- package/dist/workflows/triggers/poll-job.js +7 -7
- package/dist/workflows/triggers/trigger-jobs.js +23 -7
- package/dist/workflows/triggers/trigger-start.js +16 -5
- package/dist/workflows/triggers/trigger-sync.js +15 -10
- package/dist/workflows/workflow-engine.js +20 -5
- package/dist/workflows/workflow-types.d.ts +7 -3
- package/dist/workflows/workflow-types.js +12 -12
- 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,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 {
|
|
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
|
|
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
|
-
}),
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/workflows/schema.js
CHANGED
|
@@ -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) ===
|
|
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
|
|
26
|
-
*
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
|
45
|
+
export { callPlugin, redactAlso, triggerCall };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineJob } from "../../jobs/define-job.js";
|
|
2
|
-
import { callPlugin,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
...
|
|
103
|
+
...call.context,
|
|
97
104
|
state: row.store
|
|
98
|
-
}),
|
|
99
|
-
if ("
|
|
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
|
});
|