@protobase/server 0.69.2 → 0.69.3
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/package.js +1 -1
- package/dist/workflows/connections/connection-tokens.js +1 -2
- package/dist/workflows/connections/connection-wiring.js +32 -7
- package/dist/workflows/connections/stop-connection.js +25 -0
- package/dist/workflows/save-hook.js +1 -1
- package/dist/workflows/schema-sql.js +3 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/triggers/trigger-jobs.js +11 -7
- package/dist/workflows/triggers/trigger-sync.js +7 -5
- package/dist/workflows/workflow-engine.js +1 -1
- package/package.json +6 -6
package/dist/package.js
CHANGED
|
@@ -27,8 +27,7 @@ const sharedRefresh = (accounts) => {
|
|
|
27
27
|
* `not_connected`, with one message that tells nothing of whether the account exists; a refresh the provider refuses
|
|
28
28
|
* is `link_expired`. The token is blanked out of all the step records (`secret`).
|
|
29
29
|
*/
|
|
30
|
-
const connectionTokens = ({ deps, schema, roles, accounts }) => {
|
|
31
|
-
const tokenOf = sharedRefresh(accounts);
|
|
30
|
+
const connectionTokens = ({ deps, schema, roles, accounts, refresh: tokenOf }) => {
|
|
32
31
|
return (use) => {
|
|
33
32
|
const key = `${use.plugin}:${use.name}`;
|
|
34
33
|
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`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { connectionConsent } from "./connection-consent.js";
|
|
2
2
|
import { enablerMayUpdate } from "./enabler-authority.js";
|
|
3
3
|
import { enablerMailbox } from "./enabler-mailbox.js";
|
|
4
|
-
import { connectionTokens } from "./connection-tokens.js";
|
|
4
|
+
import { connectionTokens, sharedRefresh } from "./connection-tokens.js";
|
|
5
|
+
import { stopConnection } from "./stop-connection.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
import { ConnectionError } from "@protobase/workflows/plugin";
|
|
7
8
|
//#region src/workflows/connections/connection-wiring.ts
|
|
@@ -10,8 +11,10 @@ const noAccounts = {
|
|
|
10
11
|
accessToken: async () => void 0
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
13
|
-
* The connections of an app's workflows: the consent a save checks as a version is turned on,
|
|
14
|
-
* step and trigger that declares one gets, bound by the version its run or trigger runs
|
|
14
|
+
* The connections of an app's workflows: the consent a save checks as a version is turned on, the connection each
|
|
15
|
+
* step and trigger that declares one gets, bound by the version its run or trigger runs, and the one a stopped trigger's
|
|
16
|
+
* unsubscribe gets (`stopping`), bound by the account and person its row recorded, which only the trigger stop job takes.
|
|
17
|
+
* One refresh of an account is shared by them all.
|
|
15
18
|
*/
|
|
16
19
|
const connectionWiring = ({ deps, schema, roles, accounts = noAccounts, plugins, connectionOf, personalMailer }) => {
|
|
17
20
|
const declarationOf = (key) => {
|
|
@@ -23,21 +26,33 @@ const connectionWiring = ({ deps, schema, roles, accounts = noAccounts, plugins,
|
|
|
23
26
|
declarationOf,
|
|
24
27
|
connectionOf
|
|
25
28
|
});
|
|
29
|
+
const refresh = sharedRefresh(accounts);
|
|
26
30
|
const tokens = connectionTokens({
|
|
27
31
|
deps,
|
|
28
32
|
schema,
|
|
29
33
|
roles,
|
|
30
|
-
accounts
|
|
34
|
+
accounts,
|
|
35
|
+
refresh
|
|
31
36
|
});
|
|
32
|
-
const
|
|
37
|
+
const stopTokens = stopConnection({
|
|
38
|
+
accounts,
|
|
39
|
+
refresh
|
|
40
|
+
});
|
|
41
|
+
const connectionFor = (type) => {
|
|
33
42
|
const key = connectionOf[type];
|
|
34
43
|
const declaration = key === void 0 ? void 0 : declarationOf(key);
|
|
35
44
|
if (!key || !declaration) return;
|
|
36
45
|
const [plugin, name] = key.split(":");
|
|
37
|
-
return
|
|
46
|
+
return {
|
|
38
47
|
plugin,
|
|
39
48
|
name,
|
|
40
|
-
declaration
|
|
49
|
+
declaration
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const use = (type, version, secret) => {
|
|
53
|
+
const connection = connectionFor(type);
|
|
54
|
+
return connection && tokens({
|
|
55
|
+
...connection,
|
|
41
56
|
version,
|
|
42
57
|
secret
|
|
43
58
|
});
|
|
@@ -55,6 +70,15 @@ const connectionWiring = ({ deps, schema, roles, accounts = noAccounts, plugins,
|
|
|
55
70
|
workflowId: row.workflow_id,
|
|
56
71
|
version: row.version
|
|
57
72
|
}, secret);
|
|
73
|
+
const stopping = (row, secret) => {
|
|
74
|
+
const connection = connectionFor(row.type);
|
|
75
|
+
return connection && stopTokens({
|
|
76
|
+
...connection,
|
|
77
|
+
accountId: row.connection_id,
|
|
78
|
+
userId: row.enabled_by,
|
|
79
|
+
secret
|
|
80
|
+
});
|
|
81
|
+
};
|
|
58
82
|
const runEnabler = async (runId) => {
|
|
59
83
|
const { rows } = await sql`select r.workflow_id, v.enabled_by, v.tenant_id from ${sql.id(schema, "runs")} r
|
|
60
84
|
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);
|
|
@@ -66,6 +90,7 @@ const connectionWiring = ({ deps, schema, roles, accounts = noAccounts, plugins,
|
|
|
66
90
|
consent,
|
|
67
91
|
step,
|
|
68
92
|
trigger,
|
|
93
|
+
stopping,
|
|
69
94
|
mailbox: personalMailer && ((task) => enablerMailbox(() => runEnabler(task.runId), personalMailer))
|
|
70
95
|
};
|
|
71
96
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { connectionProviderId, holdsScopes } from "./connection-provider.js";
|
|
2
|
+
import { ConnectionError } from "@protobase/workflows/plugin";
|
|
3
|
+
//#region src/workflows/connections/stop-connection.ts
|
|
4
|
+
/**
|
|
5
|
+
* The connection a stopped trigger's unsubscribe signs in with, once its workflow's consent was withdrawn: only the
|
|
6
|
+
* account its row recorded, while that account still belongs to the person its row recorded as having turned it on,
|
|
7
|
+
* of the connection's provider and with its scopes. A row that recorded neither, as one from before they were kept,
|
|
8
|
+
* gets nothing. A refresh the provider refuses is `not_connected` too: the subscription stays, recorded, until its
|
|
9
|
+
* owner ends it. Each `accessToken()` checks afresh, caching nothing; only the trigger stop job holds one.
|
|
10
|
+
*/
|
|
11
|
+
const stopConnection = ({ accounts, refresh }) => (use) => {
|
|
12
|
+
const key = `${use.plugin}:${use.name}`;
|
|
13
|
+
const notConnected = () => new ConnectionError("not_connected", `This trigger has no connection it may end its subscription with for ${key}: the account it was made with is no longer connected`);
|
|
14
|
+
return { accessToken: async () => {
|
|
15
|
+
if (!use.accountId || !use.userId) throw notConnected();
|
|
16
|
+
const account = await accounts.find(use.userId, use.accountId);
|
|
17
|
+
if (!account || account.providerId !== connectionProviderId(use.plugin, use.name, use.declaration) || !holdsScopes(account.scopes, use.declaration)) throw notConnected();
|
|
18
|
+
const token = await refresh(use.userId, account.id, account.refreshToken);
|
|
19
|
+
if (!token) throw new ConnectionError("not_connected", `The account connected for ${key} no longer signs in, so this trigger cannot end its subscription with it`);
|
|
20
|
+
use.secret(token);
|
|
21
|
+
return token;
|
|
22
|
+
} };
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { stopConnection };
|
|
@@ -40,7 +40,7 @@ const workflowSaveHook = (schema, types, jobs, consent) => async (event, trx) =>
|
|
|
40
40
|
const enabled = event.after.enabled_version;
|
|
41
41
|
const enabledBy = enabled === null ? null : await consent(event.session, event.after.definition);
|
|
42
42
|
await saveConsent(trx, schema, id, enabled, enabledBy);
|
|
43
|
-
await syncTrigger(trx, schema, jobs, event.after, types.connections);
|
|
43
|
+
await syncTrigger(trx, schema, jobs, event.after, enabledBy, types.connections);
|
|
44
44
|
};
|
|
45
45
|
//#endregion
|
|
46
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 9`;
|
|
4
4
|
const commentVersion = (comment) => {
|
|
5
5
|
const match = comment?.match(/^Protobase workflows (\d+)$/);
|
|
6
6
|
return match ? Number(match[1]) : void 0;
|
|
@@ -119,6 +119,8 @@ create table if not exists ${s}."triggers" (
|
|
|
119
119
|
"updated_at" timestamptz not null default now()
|
|
120
120
|
);
|
|
121
121
|
alter table ${s}."triggers" add column if not exists "poll_attempt" integer;
|
|
122
|
+
-- Who turned the trigger's version on: the only person whose account its stop job ends the subscription through.
|
|
123
|
+
alter table ${s}."triggers" add column if not exists "enabled_by" text;
|
|
122
124
|
-- A deleted workflow's trigger stays, without its workflow, until its stop job unsubscribed it at its provider.
|
|
123
125
|
alter table ${s}."triggers" alter column "workflow_id" drop not null;
|
|
124
126
|
do $$ begin
|
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) === 9 && row.triggers === workflowsTriggers.length
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
22
|
/**
|
|
@@ -17,9 +17,9 @@ const passSize = 100;
|
|
|
17
17
|
* for a polling one its first poll; for a subscribing webhook one its subscription), `workflows.trigger-stop`
|
|
18
18
|
* (unsubscribes a stopped webhook trigger at its provider and removes it), `workflows.ready-check` (starts a run of one
|
|
19
19
|
* record when it is ready, at the time its time terms say), and the poll and renewal jobs. Each acts only on the
|
|
20
|
-
* generation it was queued for.
|
|
20
|
+
* generation it was queued for. `stopConnect` is the stop job's alone, kept out of the `input` every other job gets.
|
|
21
21
|
*/
|
|
22
|
-
const triggerJobs = (input) => {
|
|
22
|
+
const triggerJobs = (input, stopConnect) => {
|
|
23
23
|
const { schema } = input;
|
|
24
24
|
const polls = pollJobs(input);
|
|
25
25
|
const readyCheck = defineJob({
|
|
@@ -87,18 +87,22 @@ const triggerJobs = (input) => {
|
|
|
87
87
|
});
|
|
88
88
|
/**
|
|
89
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.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
90
|
+
* Any error is tried again, as the job's, until the provider ends it. It signs in through the account its row recorded,
|
|
91
|
+
* as the person who turned it on, even once the workflow was turned off or deleted (`stopConnect`). An account that is
|
|
92
|
+
* no longer theirs, or a sign-in the provider refused, cannot end it: the trigger then stays `stopping` with that
|
|
93
|
+
* error, as the record of a subscription the provider may still hold.
|
|
93
94
|
*/
|
|
94
95
|
const unsubscribe = async (db, trigger, signal) => {
|
|
95
|
-
const { rows } = await sql`select type, input, workflow_id, version, store, store is not null as subscribed
|
|
96
|
+
const { rows } = await sql`select type, input, workflow_id, version, connection_id, enabled_by, store, store is not null as subscribed
|
|
96
97
|
from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
|
|
97
98
|
const row = rows[0];
|
|
98
99
|
const type = row?.subscribed ? input.webhooks(row.type) : void 0;
|
|
99
100
|
if (!row || !type?.unsubscribe) return true;
|
|
100
101
|
const end = type.unsubscribe;
|
|
101
|
-
const call = triggerCall(
|
|
102
|
+
const call = triggerCall({
|
|
103
|
+
allowPrivateAddresses: input.allowPrivateAddresses,
|
|
104
|
+
connect: (_, secret) => stopConnect?.(row, secret)
|
|
105
|
+
}, row, signal, input.redact);
|
|
102
106
|
const outcome = await callPlugin(() => end({
|
|
103
107
|
...call.context,
|
|
104
108
|
state: row.store
|
|
@@ -17,9 +17,11 @@ const triggerOf = (definition, connectionOf) => {
|
|
|
17
17
|
* same write: turning it on inserts a running trigger and queues its start; a new version with the same type and input
|
|
18
18
|
* keeps the row, so its state carries over, and passes over the records again; any other change, or turning it off, stops the old row and starts a new one.
|
|
19
19
|
* The row keeps the account the trigger's connection binds (`connectionOf` names each type's connection), so an
|
|
20
|
-
* expired sign-in stops every trigger on it, and connecting it again resumes them
|
|
20
|
+
* expired sign-in stops every trigger on it, and connecting it again resumes them; and who turned it on (`enabledBy`,
|
|
21
|
+
* which the save's consent decided), through whose account its stop job ends its subscription once it stops. A stopped
|
|
22
|
+
* row keeps both. Only the current version can be turned on.
|
|
21
23
|
*/
|
|
22
|
-
const syncTrigger = async (trx, schema, jobs, workflow, connectionOf = {}) => {
|
|
24
|
+
const syncTrigger = async (trx, schema, jobs, workflow, enabledBy, connectionOf = {}) => {
|
|
23
25
|
const enabled = workflow.enabled_version;
|
|
24
26
|
if (enabled !== null && enabled !== workflow.version) throw badRequest("invalid-workflow", `Only the current version can be enabled: set enabled_version to ${String(workflow.version)}, or clear it to turn the workflow off`);
|
|
25
27
|
const wanted = enabled === null ? void 0 : triggerOf(workflow.definition, connectionOf);
|
|
@@ -30,7 +32,7 @@ const syncTrigger = async (trx, schema, jobs, workflow, connectionOf = {}) => {
|
|
|
30
32
|
const existing = rows[0];
|
|
31
33
|
if (existing?.same) {
|
|
32
34
|
if (existing.version === enabled) return;
|
|
33
|
-
await sql`update ${triggers} set version = ${enabled}, status = 'active', consecutive_failures = 0, last_error = null, updated_at = now() where id = ${existing.id}`.execute(trx);
|
|
35
|
+
await sql`update ${triggers} set version = ${enabled}, enabled_by = ${enabledBy}, status = 'active', consecutive_failures = 0, last_error = null, updated_at = now() where id = ${existing.id}`.execute(trx);
|
|
34
36
|
await jobs.start.enqueue(trx, {
|
|
35
37
|
trigger: existing.id,
|
|
36
38
|
generation: existing.generation
|
|
@@ -42,8 +44,8 @@ const syncTrigger = async (trx, schema, jobs, workflow, connectionOf = {}) => {
|
|
|
42
44
|
await jobs.stop.enqueue(trx, { trigger: existing.id });
|
|
43
45
|
}
|
|
44
46
|
if (!wanted) return;
|
|
45
|
-
const trigger = (await sql`insert into ${triggers} (workflow_id, version, tenant_id, type, input, connection_id, status, generation, consecutive_failures)
|
|
46
|
-
values (${id}, ${enabled}, ${workflow.tenant_id ?? null}, ${wanted.type}, ${JSON.stringify(wanted.input)}::jsonb, ${wanted.connection}, 'active', 1, 0) returning id`.execute(trx)).rows[0].id;
|
|
47
|
+
const trigger = (await sql`insert into ${triggers} (workflow_id, version, tenant_id, type, input, connection_id, enabled_by, status, generation, consecutive_failures)
|
|
48
|
+
values (${id}, ${enabled}, ${workflow.tenant_id ?? null}, ${wanted.type}, ${JSON.stringify(wanted.input)}::jsonb, ${wanted.connection}, ${enabledBy}, 'active', 1, 0) returning id`.execute(trx)).rows[0].id;
|
|
47
49
|
await jobs.start.enqueue(trx, {
|
|
48
50
|
trigger,
|
|
49
51
|
generation: 1
|
|
@@ -60,7 +60,7 @@ const workflowEngine = (options, deps, auth) => {
|
|
|
60
60
|
redact: redactor.text,
|
|
61
61
|
origin: () => auth?.origins[0],
|
|
62
62
|
systemPath: () => deps().systemPath
|
|
63
|
-
});
|
|
63
|
+
}, connections.stopping);
|
|
64
64
|
auth?.connections.events.set({
|
|
65
65
|
...connectionTriggers(() => deps().db, schema, triggers),
|
|
66
66
|
uses: connectionUses({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.69.
|
|
3
|
+
"version": "0.69.3",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"@better-auth/sso": "^1.7.12",
|
|
45
45
|
"@livekit/protocol": "1.53.1",
|
|
46
46
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
47
|
-
"@protobase/layout": "0.69.
|
|
48
|
-
"@protobase/query": "0.69.
|
|
49
|
-
"@protobase/schema": "0.69.
|
|
50
|
-
"@protobase/workflow-plugins": "0.69.
|
|
51
|
-
"@protobase/workflows": "0.69.
|
|
47
|
+
"@protobase/layout": "0.69.3",
|
|
48
|
+
"@protobase/query": "0.69.3",
|
|
49
|
+
"@protobase/schema": "0.69.3",
|
|
50
|
+
"@protobase/workflow-plugins": "0.69.3",
|
|
51
|
+
"@protobase/workflows": "0.69.3",
|
|
52
52
|
"@react-email/render": "^2.1.0",
|
|
53
53
|
"@react-pdf/primitives": "^4.4.0",
|
|
54
54
|
"@react-pdf/renderer": "^4.9.0",
|