@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.
@@ -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 { storedScopes } from "../../personal-mail/linked-accounts.js";
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, and whether a trigger waits for one to be signed in again, never a token.
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 accounts = await ctx.context.internalAdapter.findAccounts(ctx.context.session.user.id);
33
- const fitting = (connection) => accounts.filter((account) => account.providerId === connection.providerId && holdsScopes(storedScopes(account.scope), connection.declaration));
34
- const waiting = new Set(await events.needsSignIn(connections.flatMap((connection) => fitting(connection).map((account) => account.id))));
35
- return ctx.json({ connections: connections.map((connection) => ({
36
- key: connection.key,
37
- label: connection.declaration.label,
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?: string | null | undefined;
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
- /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
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
- /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
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 && {
@@ -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
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.69.1";
2
+ var version = "0.69.3";
3
3
  //#endregion
4
4
  export { version };
@@ -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, and the connection each
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 use = (type, version, secret) => {
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 tokens({
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 8`;
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
@@ -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) === 8 && row.triggers === workflowsTriggers.length
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. 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.
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(input, row, signal, input.redact);
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. Only the current version can be turned on.
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.1",
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.1",
48
- "@protobase/query": "0.69.1",
49
- "@protobase/schema": "0.69.1",
50
- "@protobase/workflow-plugins": "0.69.1",
51
- "@protobase/workflows": "0.69.1",
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",