@protobase/server 0.69.1 → 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/better-auth/auth-settings.d.ts +22 -0
- package/dist/better-auth/connections/connection-events.d.ts +7 -0
- package/dist/better-auth/connections/connection-events.js +2 -1
- package/dist/better-auth/connections/connections-plugin.js +9 -19
- package/dist/better-auth/connections/list-connections.js +34 -0
- package/dist/better-auth/create-auth.d.ts +31 -2
- package/dist/better-auth/create-auth.js +11 -2
- package/dist/create-admin.js +1 -0
- package/dist/package.js +1 -1
- package/dist/workflows/connections/connection-tokens.js +1 -2
- package/dist/workflows/connections/connection-uses.js +28 -0
- package/dist/workflows/connections/connection-wiring.js +32 -7
- package/dist/workflows/connections/stop-connection.js +25 -0
- package/dist/workflows/mcp/connection-tools.d.ts +15 -0
- package/dist/workflows/mcp/connection-tools.js +32 -0
- package/dist/workflows/mcp/workflow-instructions.js +1 -1
- package/dist/workflows/mcp/workflow-read-tools.d.ts +4 -1
- package/dist/workflows/mcp/workflow-read-tools.js +4 -2
- 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 +9 -1
- package/package.json +6 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UserColumn } from "./user-table.js";
|
|
2
2
|
import { Mailer } from "../mail/mail-message.js";
|
|
3
|
+
import { Session } from "../types.js";
|
|
3
4
|
import { SignInPolicy } from "./sign-in-policy.js";
|
|
4
5
|
import { StaffSignIn } from "./staff-log.js";
|
|
5
6
|
import { Person } from "./organizations/people-endpoint.js";
|
|
@@ -3327,6 +3328,10 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
3327
3328
|
scopes: string[];
|
|
3328
3329
|
created_at: Date;
|
|
3329
3330
|
needs_sign_in: boolean;
|
|
3331
|
+
used_by: {
|
|
3332
|
+
id: string;
|
|
3333
|
+
name: string;
|
|
3334
|
+
}[];
|
|
3330
3335
|
}[];
|
|
3331
3336
|
}[];
|
|
3332
3337
|
}>;
|
|
@@ -9157,6 +9162,23 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
9157
9162
|
events: {
|
|
9158
9163
|
set: (next: ConnectionListener) => undefined;
|
|
9159
9164
|
};
|
|
9165
|
+
list: (session: Session) => Promise<{
|
|
9166
|
+
key: string;
|
|
9167
|
+
label: string;
|
|
9168
|
+
provider: string;
|
|
9169
|
+
scopes: string[];
|
|
9170
|
+
link_params: Record<string, string>;
|
|
9171
|
+
accounts: {
|
|
9172
|
+
id: string;
|
|
9173
|
+
scopes: string[];
|
|
9174
|
+
created_at: Date;
|
|
9175
|
+
needs_sign_in: boolean;
|
|
9176
|
+
used_by: {
|
|
9177
|
+
id: string;
|
|
9178
|
+
name: string;
|
|
9179
|
+
}[];
|
|
9180
|
+
}[];
|
|
9181
|
+
}[]>;
|
|
9160
9182
|
};
|
|
9161
9183
|
mcp: {
|
|
9162
9184
|
resource: string;
|
|
@@ -7,5 +7,12 @@ export type ConnectionListener = {
|
|
|
7
7
|
disconnected: (accountId: string) => Promise<void>;
|
|
8
8
|
/** The accounts among `accountIds` that a trigger waits on to be signed in again. */
|
|
9
9
|
needsSignIn: (accountIds: string[]) => Promise<string[]>;
|
|
10
|
+
/** The workflows among those `userId` turned on, and may still change, whose enabled version binds one of `accountIds`, by account. */
|
|
11
|
+
uses: (userId: string, accountIds: string[]) => Promise<AccountUses>;
|
|
10
12
|
};
|
|
13
|
+
/** The workflows that use each account, by its id. */
|
|
14
|
+
export type AccountUses = Record<string, {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}[]>;
|
|
11
18
|
//#endregion
|
|
@@ -9,7 +9,8 @@ const connectionEvents = () => {
|
|
|
9
9
|
set: (next) => void (listener = next),
|
|
10
10
|
linked: async (accountId) => listener?.linked(accountId),
|
|
11
11
|
disconnected: async (accountId) => listener?.disconnected(accountId),
|
|
12
|
-
needsSignIn: async (accountIds) => listener && accountIds.length > 0 ? listener.needsSignIn(accountIds) : []
|
|
12
|
+
needsSignIn: async (accountIds) => listener && accountIds.length > 0 ? listener.needsSignIn(accountIds) : [],
|
|
13
|
+
uses: async (userId, accountIds) => listener && accountIds.length > 0 ? listener.uses(userId, accountIds) : {}
|
|
13
14
|
};
|
|
14
15
|
};
|
|
15
16
|
//#endregion
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { holdsScopes } from "../../workflows/connections/connection-provider.js";
|
|
1
|
+
import { listConnections } from "./list-connections.js";
|
|
3
2
|
import { revokeToken } from "./revoke-token.js";
|
|
4
3
|
import * as z$1 from "zod";
|
|
5
4
|
import { decryptOAuthToken } from "better-auth/oauth2";
|
|
@@ -15,7 +14,8 @@ const notFound = () => new APIError("NOT_FOUND", {
|
|
|
15
14
|
});
|
|
16
15
|
/**
|
|
17
16
|
* The signed-in person's connections: `GET /connections` lists each connection the app's workflow plugins declare with
|
|
18
|
-
* the person's accounts that fit it,
|
|
17
|
+
* the person's accounts that fit it, whether a trigger waits for one to be signed in again and the workflows that use
|
|
18
|
+
* it, never a token (see `listConnections`).
|
|
19
19
|
* `POST /connections/disconnect` revokes an account's token at its provider, then deletes it whatever the provider
|
|
20
20
|
* answered, and stops the triggers on it; their own account only, and never the last account they sign in with. Connecting is
|
|
21
21
|
* Better Auth's `/link-social`; a sign-in (not a link) through a provider only connections use is refused.
|
|
@@ -29,22 +29,12 @@ const connectionsPlugin = ({ connections, events, allowPrivateAddresses }) => {
|
|
|
29
29
|
method: "GET",
|
|
30
30
|
use: [sessionMiddleware]
|
|
31
31
|
}, async (ctx) => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
provider: connection.providerId,
|
|
39
|
-
scopes: connection.declaration.scopes ?? [],
|
|
40
|
-
link_params: connection.declaration.link_params ?? {},
|
|
41
|
-
accounts: fitting(connection).map((account) => ({
|
|
42
|
-
id: account.id,
|
|
43
|
-
scopes: storedScopes(account.scope),
|
|
44
|
-
created_at: account.createdAt,
|
|
45
|
-
needs_sign_in: waiting.has(account.id)
|
|
46
|
-
}))
|
|
47
|
-
})) });
|
|
32
|
+
const adapter = ctx.context.internalAdapter;
|
|
33
|
+
return ctx.json({ connections: await listConnections({
|
|
34
|
+
findAccounts: (userId) => adapter.findAccounts(userId),
|
|
35
|
+
connections,
|
|
36
|
+
events
|
|
37
|
+
}, ctx.context.session.user.id) });
|
|
48
38
|
}),
|
|
49
39
|
disconnectConnection: createAuthEndpoint(connectionPaths.disconnect, {
|
|
50
40
|
method: "POST",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { storedScopes } from "../../personal-mail/linked-accounts.js";
|
|
2
|
+
import { holdsScopes } from "../../workflows/connections/connection-provider.js";
|
|
3
|
+
//#region src/better-auth/connections/list-connections.ts
|
|
4
|
+
/**
|
|
5
|
+
* A person's connections: each connection the app's workflow plugins declare with the person's accounts that fit it,
|
|
6
|
+
* whether a trigger waits for one to be signed in again, and the workflows that use it. Each account's fields are picked
|
|
7
|
+
* one by one, so no token leaves. `user` is the signed-in person's own id, from their session; reading without one fails,
|
|
8
|
+
* since the store would read every person's accounts.
|
|
9
|
+
*/
|
|
10
|
+
const listConnections = async ({ findAccounts, connections, events }, user) => {
|
|
11
|
+
if (user === "" || user === void 0 || user === null) throw new Error("Connections are listed for a signed-in person's id");
|
|
12
|
+
const userId = String(user);
|
|
13
|
+
const accounts = await findAccounts(userId);
|
|
14
|
+
const fitting = (connection) => accounts.filter((account) => account.providerId === connection.providerId && holdsScopes(storedScopes(account.scope), connection.declaration));
|
|
15
|
+
const ids = connections.flatMap((connection) => fitting(connection).map((account) => account.id));
|
|
16
|
+
const waiting = new Set(await events.needsSignIn(ids));
|
|
17
|
+
const uses = await events.uses(userId, ids);
|
|
18
|
+
return connections.map((connection) => ({
|
|
19
|
+
key: connection.key,
|
|
20
|
+
label: connection.declaration.label,
|
|
21
|
+
provider: connection.providerId,
|
|
22
|
+
scopes: connection.declaration.scopes ?? [],
|
|
23
|
+
link_params: connection.declaration.link_params ?? {},
|
|
24
|
+
accounts: fitting(connection).map((account) => ({
|
|
25
|
+
id: account.id,
|
|
26
|
+
scopes: storedScopes(account.scope),
|
|
27
|
+
created_at: account.createdAt,
|
|
28
|
+
needs_sign_in: waiting.has(account.id),
|
|
29
|
+
used_by: uses[account.id] ?? []
|
|
30
|
+
}))
|
|
31
|
+
}));
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { listConnections };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UserColumn, UserTableOptions } from "./user-table.js";
|
|
2
2
|
import { Mailer } from "../mail/mail-message.js";
|
|
3
|
+
import { Session } from "../types.js";
|
|
3
4
|
import { SignInPolicy } from "./sign-in-policy.js";
|
|
4
5
|
import { StaffSignIn } from "./staff-log.js";
|
|
5
6
|
import { Person } from "./organizations/people-endpoint.js";
|
|
@@ -3215,7 +3216,11 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
3215
3216
|
email: string;
|
|
3216
3217
|
emailVerified: boolean;
|
|
3217
3218
|
name: string;
|
|
3218
|
-
image
|
|
3219
|
+
image? /**
|
|
3220
|
+
* Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
|
|
3221
|
+
* an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
|
|
3222
|
+
* the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
|
|
3223
|
+
*/ : string | null | undefined;
|
|
3219
3224
|
};
|
|
3220
3225
|
};
|
|
3221
3226
|
}>>[];
|
|
@@ -3472,6 +3477,10 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
3472
3477
|
scopes: string[];
|
|
3473
3478
|
created_at: Date;
|
|
3474
3479
|
needs_sign_in: boolean;
|
|
3480
|
+
used_by: {
|
|
3481
|
+
id: string;
|
|
3482
|
+
name: string;
|
|
3483
|
+
}[];
|
|
3475
3484
|
}[];
|
|
3476
3485
|
}[];
|
|
3477
3486
|
}>;
|
|
@@ -9339,12 +9348,32 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
9339
9348
|
signInProviders: string[];
|
|
9340
9349
|
clients: string[];
|
|
9341
9350
|
};
|
|
9342
|
-
/**
|
|
9351
|
+
/**
|
|
9352
|
+
* The workflow connections people connect accounts through, the listener (`set`) the workflows give for their links
|
|
9353
|
+
* and disconnects, and `list`, on the server only, of the session's own person's accounts, as `GET /connections` lists them.
|
|
9354
|
+
*/
|
|
9343
9355
|
connections: {
|
|
9344
9356
|
declared: ResolvedConnection[];
|
|
9345
9357
|
events: {
|
|
9346
9358
|
set: (next: ConnectionListener) => undefined;
|
|
9347
9359
|
};
|
|
9360
|
+
list: (session: Session) => Promise<{
|
|
9361
|
+
key: string;
|
|
9362
|
+
label: string;
|
|
9363
|
+
provider: string;
|
|
9364
|
+
scopes: string[];
|
|
9365
|
+
link_params: Record<string, string>;
|
|
9366
|
+
accounts: {
|
|
9367
|
+
id: string;
|
|
9368
|
+
scopes: string[];
|
|
9369
|
+
created_at: Date;
|
|
9370
|
+
needs_sign_in: boolean;
|
|
9371
|
+
used_by: {
|
|
9372
|
+
id: string;
|
|
9373
|
+
name: string;
|
|
9374
|
+
}[];
|
|
9375
|
+
}[];
|
|
9376
|
+
}[]>;
|
|
9348
9377
|
};
|
|
9349
9378
|
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
|
|
9350
9379
|
mcp: {
|
|
@@ -12,6 +12,7 @@ import { personalMailer } from "../personal-mail/personal-mailer.js";
|
|
|
12
12
|
import { mailLinkHooks } from "../personal-mail/mail-link-hooks.js";
|
|
13
13
|
import { bothAccountHooks, connectionAccountHooks } from "./connections/connection-account-hooks.js";
|
|
14
14
|
import { connectionEvents } from "./connections/connection-events.js";
|
|
15
|
+
import { listConnections } from "./connections/list-connections.js";
|
|
15
16
|
import { connectionProvidersPlugin } from "./connections/connection-oauth-provider.js";
|
|
16
17
|
import { connectionsPlugin } from "./connections/connections-plugin.js";
|
|
17
18
|
import { resolveConnections } from "./connections/declared-connections.js";
|
|
@@ -552,10 +553,18 @@ const createAuth = (options, plugins = {}) => {
|
|
|
552
553
|
signInProviders: socialProviders,
|
|
553
554
|
clients: connectionClients
|
|
554
555
|
},
|
|
555
|
-
/**
|
|
556
|
+
/**
|
|
557
|
+
* The workflow connections people connect accounts through, the listener (`set`) the workflows give for their links
|
|
558
|
+
* and disconnects, and `list`, on the server only, of the session's own person's accounts, as `GET /connections` lists them.
|
|
559
|
+
*/
|
|
556
560
|
connections: {
|
|
557
561
|
declared: resolvedConnections,
|
|
558
|
-
events: { set: events.set }
|
|
562
|
+
events: { set: events.set },
|
|
563
|
+
list: async (session) => listConnections({
|
|
564
|
+
findAccounts: async (userId) => (await auth.$context).internalAdapter.findAccounts(userId),
|
|
565
|
+
connections: resolvedConnections,
|
|
566
|
+
events
|
|
567
|
+
}, session.user.id)
|
|
559
568
|
},
|
|
560
569
|
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
|
|
561
570
|
mcp: mcp && {
|
package/dist/create-admin.js
CHANGED
|
@@ -391,6 +391,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
|
|
|
391
391
|
commented,
|
|
392
392
|
...engine && { workflows: {
|
|
393
393
|
...engine.types,
|
|
394
|
+
listConnections: auth.connections.list,
|
|
394
395
|
issueWebhook: (session, id, secret) => issueWebhook(engine.issue, session, id, {
|
|
395
396
|
...secret !== void 0 && { secret },
|
|
396
397
|
origin: new URL(auth.mcp.resource).origin
|
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`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { enablerMayUpdate } from "./enabler-authority.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/workflows/connections/connection-uses.ts
|
|
4
|
+
/**
|
|
5
|
+
* The workflows that use a person's accounts: those whose enabled version they turned on and binds one of the
|
|
6
|
+
* accounts in its `connections`, kept only while they may still update the workflow, as a step's token needs.
|
|
7
|
+
*/
|
|
8
|
+
const connectionUses = ({ deps, schema, roles }) => async (userId, accountIds) => {
|
|
9
|
+
const uses = {};
|
|
10
|
+
if (accountIds.length === 0) return uses;
|
|
11
|
+
const db = deps().db;
|
|
12
|
+
const { rows } = await sql`select w.id, w.name, v.tenant_id, c.value as account
|
|
13
|
+
from ${sql.id(schema, "workflows")} w
|
|
14
|
+
join ${sql.id(schema, "versions")} v on v.workflow_id = w.id and v.version = w.enabled_version
|
|
15
|
+
cross join lateral jsonb_each_text(coalesce(v.definition -> 'connections', '{}'::jsonb)) c
|
|
16
|
+
where v.enabled_by = ${userId} and c.value in (${sql.join(accountIds)})
|
|
17
|
+
order by w.name, w.id`.execute(db);
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
if (!await enablerMayUpdate(db, deps(), roles, userId, row.tenant_id ?? void 0, row.id)) continue;
|
|
20
|
+
uses[row.account] = [...uses[row.account] ?? [], {
|
|
21
|
+
id: row.id,
|
|
22
|
+
name: row.name
|
|
23
|
+
}];
|
|
24
|
+
}
|
|
25
|
+
return uses;
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { connectionUses };
|
|
@@ -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 };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Session } from "../../types.js";
|
|
2
|
+
import "../../index.js";
|
|
3
|
+
//#region src/workflows/mcp/connection-tools.d.ts
|
|
4
|
+
type Connection = {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
accounts: {
|
|
8
|
+
id: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
needs_sign_in: boolean;
|
|
11
|
+
}[];
|
|
12
|
+
};
|
|
13
|
+
/** The session's own person's connections, each with their accounts that fit it, such as `auth.connections.list`. */
|
|
14
|
+
export type ListConnections = (session: Session) => Promise<Connection[]>;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineTool } from "../../assistant/tools.js";
|
|
2
|
+
//#region src/workflows/mcp/connection-tools.ts
|
|
3
|
+
/**
|
|
4
|
+
* The caller's own accounts, by connection, which a definition's `connections` binds by id. Each field is picked one by
|
|
5
|
+
* one, so nothing more than the list reads leaves, and never a token; it takes no argument, so it lists no one else's.
|
|
6
|
+
*/
|
|
7
|
+
const listConnectionsTool = (list) => defineTool({
|
|
8
|
+
name: "list_connections",
|
|
9
|
+
description: "Lists the connections the app's workflow plugins declare with your own connected accounts, as JSON ({ connections }), each with its key, label and accounts, each account with its id, scopes and needs_sign_in. A workflow binds an account in its definition's connections, as { \"<key>\": \"<account id>\" }; an account is connected, or connected again when it needs a sign-in, on the account page.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {}
|
|
13
|
+
},
|
|
14
|
+
annotations: {
|
|
15
|
+
readOnlyHint: true,
|
|
16
|
+
openWorldHint: false
|
|
17
|
+
},
|
|
18
|
+
run: async (_args, context) => {
|
|
19
|
+
const connections = list ? await list(context.session) : [];
|
|
20
|
+
return JSON.stringify({ connections: connections.map(({ key, label, accounts }) => ({
|
|
21
|
+
key,
|
|
22
|
+
label,
|
|
23
|
+
accounts: accounts.map(({ id, scopes, needs_sign_in }) => ({
|
|
24
|
+
id,
|
|
25
|
+
scopes,
|
|
26
|
+
needs_sign_in
|
|
27
|
+
}))
|
|
28
|
+
})) });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { listConnectionsTool };
|
|
@@ -4,7 +4,7 @@ import { limits } from "@protobase/workflows";
|
|
|
4
4
|
* profile of the States Language with JSONata, and how the workflow tools find, make, change and test-run them.
|
|
5
5
|
*/
|
|
6
6
|
const workflowInstructions = [
|
|
7
|
-
"Workflows: list_workflow_types names the step types and triggers with their arguments and inputs; list_workflows and get_workflow read workflows; validate_workflow checks a definition without saving it; create_workflow and update_workflow save one; run_workflow starts a run with an input, and get_workflow_run reads it with each step's input, output, logs and error. A run goes on in the background: read it again until its status is succeeded or failed, then fix the step that failed with update_workflow and run it again. A trigger starts runs only once enable_workflow turned the workflow on. A webhook trigger whose sender you give a URL, such as app:webhook, gets its URL and secret from new_webhook_url, shown once; one that registers its own URL with its service as the workflow is turned on needs none, and new_webhook_url refuses it.",
|
|
7
|
+
"Workflows: list_workflow_types names the step types and triggers with their arguments and inputs; list_workflows and get_workflow read workflows; validate_workflow checks a definition without saving it; create_workflow and update_workflow save one; run_workflow starts a run with an input, and get_workflow_run reads it with each step's input, output, logs and error. A run goes on in the background: read it again until its status is succeeded or failed, then fix the step that failed with update_workflow and run it again. A trigger starts runs only once enable_workflow turned the workflow on. A webhook trigger whose sender you give a URL, such as app:webhook, gets its URL and secret from new_webhook_url, shown once; one that registers its own URL with its service as the workflow is turned on needs none, and new_webhook_url refuses it. A step or trigger that signs in through a connection uses the account the definition's \"connections\" binds, { \"<connection key>\": \"<account id>\" }: list_connections gives your accounts.",
|
|
8
8
|
"A definition is one JSON document: { \"trigger\": <trigger>, \"stateMachine\": { \"QueryLanguage\": \"JSONata\", \"StartAt\": \"<state>\", \"States\": { \"<state>\": <state>, ... } } }, the States Language with JSONata expressions, in a profile where every value is read by name. It holds no layout. <trigger> is an object with one key: { \"<trigger id>\": <its input> } for a trigger list_workflow_types names, or { \"manual\": {} } for a workflow that runs only when someone starts it.",
|
|
9
9
|
"States: Task { Resource: <step type>, Arguments, TimeoutSeconds, Retry, Catch }; Pass { Output, Assign }; Choice { Choices: [{ Condition, Next, Output?, Assign? }], Default }; Map { Items, ItemProcessor: { StartAt, States }, MaxConcurrency }; Wait { Seconds | Timestamp }; Succeed { Output }; Fail { Error, Cause }. Every state may have Comment, Output and Assign. Task, Pass, Map and Wait have exactly one of Next (a state name) and End: true. State names are unique across the document, Maps included, and every state must be reachable from StartAt. InputPath, Parameters, ResultPath and the other JSONPath fields, and Parallel, are not part of the profile.",
|
|
10
10
|
"Expressions: a string that is whole \"{% ... %}\" is a JSONata expression whose value, of any type, the field takes; any other string is literal text. Build text inside one expression with &: \"{% 'Order ' & $order.id %}\". Expressions sit at any depth in Arguments, Output and Assign, and in Items, Condition, Seconds, Timestamp, Error and Cause.",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssistantTool, ToolContext } from "../../assistant/tools.js";
|
|
2
2
|
import { IssueWebhook } from "./webhook-tools.js";
|
|
3
|
+
import { ListConnections } from "./connection-tools.js";
|
|
3
4
|
import "../../index.js";
|
|
4
5
|
import { ResourceModel } from "@protobase/schema";
|
|
5
6
|
import { CheckOptions, TypeEntry } from "@protobase/workflows";
|
|
@@ -13,7 +14,9 @@ export type WorkflowCatalog = {
|
|
|
13
14
|
checkWith: (models: (name: string) => ResourceModel | undefined) => CheckOptions;
|
|
14
15
|
/** Issues a workflow's webhook URL as the caller, for `new_webhook_url`. */
|
|
15
16
|
issueWebhook?: IssueWebhook;
|
|
17
|
+
/** The caller's own connected accounts, for `list_connections`; none without it. */
|
|
18
|
+
listConnections?: ListConnections;
|
|
16
19
|
};
|
|
17
|
-
/** The tools that read workflows, their types and runs, and check definitions. */
|
|
20
|
+
/** The tools that read workflows, their types and runs and the caller's connections, and check definitions. */
|
|
18
21
|
export declare const workflowReadTools: (catalog: WorkflowCatalog) => AssistantTool<ToolContext>[];
|
|
19
22
|
//#endregion
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from "../../assistant/tools.js";
|
|
2
2
|
import { refused } from "../../assistant/record-tool-args.js";
|
|
3
|
+
import { listConnectionsTool } from "./connection-tools.js";
|
|
3
4
|
import { mergePatch } from "./merge-patch.js";
|
|
4
5
|
import { definitionParameter, editParameter, idOf, idParameter, objectOf, pageSizeOf, textOf } from "./workflow-tool-args.js";
|
|
5
6
|
import { checkDefinition, typeCatalog } from "@protobase/workflows";
|
|
@@ -194,13 +195,14 @@ const getWorkflowRunTool = defineTool({
|
|
|
194
195
|
}), refused);
|
|
195
196
|
}
|
|
196
197
|
});
|
|
197
|
-
/** The tools that read workflows, their types and runs, and check definitions. */
|
|
198
|
+
/** The tools that read workflows, their types and runs and the caller's connections, and check definitions. */
|
|
198
199
|
const workflowReadTools = (catalog) => [
|
|
199
200
|
listWorkflowTypesTool(catalog),
|
|
200
201
|
listWorkflowsTool,
|
|
201
202
|
getWorkflowTool,
|
|
202
203
|
validateWorkflowTool(catalog),
|
|
203
|
-
getWorkflowRunTool
|
|
204
|
+
getWorkflowRunTool,
|
|
205
|
+
listConnectionsTool(catalog.listConnections)
|
|
204
206
|
];
|
|
205
207
|
//#endregion
|
|
206
208
|
export { getWorkflowRunTool, getWorkflowTool, listWorkflowsTool, workflowReadTools };
|
|
@@ -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
|
|
@@ -11,6 +11,7 @@ import { triggerJobs } from "./triggers/trigger-jobs.js";
|
|
|
11
11
|
import { workflowTypes } from "./workflow-types.js";
|
|
12
12
|
import { betterAuthConnectionAccounts } from "./connections/connection-accounts.js";
|
|
13
13
|
import { connectionWiring } from "./connections/connection-wiring.js";
|
|
14
|
+
import { connectionUses } from "./connections/connection-uses.js";
|
|
14
15
|
import { connectionTriggers } from "./connections/connection-triggers.js";
|
|
15
16
|
import { secretRedactor } from "@protobase/workflows";
|
|
16
17
|
//#region src/workflows/workflow-engine.ts
|
|
@@ -59,8 +60,15 @@ const workflowEngine = (options, deps, auth) => {
|
|
|
59
60
|
redact: redactor.text,
|
|
60
61
|
origin: () => auth?.origins[0],
|
|
61
62
|
systemPath: () => deps().systemPath
|
|
63
|
+
}, connections.stopping);
|
|
64
|
+
auth?.connections.events.set({
|
|
65
|
+
...connectionTriggers(() => deps().db, schema, triggers),
|
|
66
|
+
uses: connectionUses({
|
|
67
|
+
deps,
|
|
68
|
+
schema,
|
|
69
|
+
roles
|
|
70
|
+
})
|
|
62
71
|
});
|
|
63
|
-
auth?.connections.events.set(connectionTriggers(() => deps().db, schema, triggers));
|
|
64
72
|
const webhooks = usable(types.webhooks);
|
|
65
73
|
const issue = {
|
|
66
74
|
deps,
|
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",
|