@protobase/cli 0.68.6 → 0.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.68.6";
2
+ var version = "0.69.1";
3
3
  //#endregion
4
4
  export { version };
@@ -11,7 +11,7 @@ const projectMailer = async (config) => (await config.authFiles?.module("auth/ma
11
11
  * The app's sign-in, built from `auth` with the app's URL, secrets, signing key rotation and name, the hooks of its
12
12
  * auth/hooks.ts and the app's mailer when it has them; none without `auth`.
13
13
  */
14
- const projectAuth = async (config, { db, pool }) => {
14
+ const projectAuth = async (config, { db, pool }, workflows) => {
15
15
  if (!config.auth) return void 0;
16
16
  if (!pool) throw new Error("auth needs the app's pool, which its store shares");
17
17
  const { url, secret, previousSecrets, signingKeyDays, signingKeyGraceDays, name } = config;
@@ -30,9 +30,19 @@ const projectAuth = async (config, { db, pool }) => {
30
30
  pool,
31
31
  db,
32
32
  ...hooks && { hooks },
33
- ...mailer && { mailer }
33
+ ...mailer && { mailer },
34
+ ...workflows && { workflowConnections: workflowConnectionsOf(workflows) }
34
35
  });
35
36
  };
37
+ /** The connections the workflows' plugins declare, which people connect accounts through at sign-in. */
38
+ const workflowConnectionsOf = (workflows) => ({
39
+ declared: (workflows.plugins ?? []).flatMap(({ plugin }) => Object.entries(plugin.connections ?? {}).map(([name, declaration]) => ({
40
+ plugin: plugin.name,
41
+ name,
42
+ declaration
43
+ }))),
44
+ allowPrivateAddresses: workflows.allowPrivateAddresses === true
45
+ });
36
46
  /**
37
47
  * How the API verifies tokens: the default export of the app's auth/authenticate.ts when it has one, such as a
38
48
  * `jwtAuthenticator` for tokens of another system; otherwise the app's sign-in with the `authenticate` settings.
@@ -44,14 +54,14 @@ const projectAuthenticate = async (config, auth) => {
44
54
  };
45
55
  /** The app, built from its merged config over its database: every reference loaded, auth and the authenticator made. */
46
56
  const buildProject = async (config, database) => {
47
- const [resources, functions, files, options, auth, webhooks, workflows, notifications, mailer] = await Promise.all([
57
+ const workflows = await projectWorkflows(config);
58
+ const [resources, functions, files, options, auth, webhooks, notifications, mailer] = await Promise.all([
48
59
  projectResources(config),
49
60
  projectFunctions(config),
50
61
  projectFiles(config),
51
62
  projectOptions(config),
52
- projectAuth(config, database),
63
+ projectAuth(config, database, workflows),
53
64
  projectWebhooks(config),
54
- projectWorkflows(config),
55
65
  projectNotifications(config),
56
66
  projectMailer(config)
57
67
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.68.6",
3
+ "version": "0.69.1",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,13 +43,13 @@
43
43
  "dependencies": {
44
44
  "@better-auth/core": "^1.7.12",
45
45
  "@hono/node-server": "^2.1.3",
46
- "@protobase/config": "0.68.6",
47
- "@protobase/layout": "0.68.6",
48
- "@protobase/query": "0.68.6",
49
- "@protobase/schema": "0.68.6",
50
- "@protobase/server": "0.68.6",
51
- "@protobase/ui": "0.68.6",
52
- "@protobase/workflows": "0.68.6",
46
+ "@protobase/config": "0.69.1",
47
+ "@protobase/layout": "0.69.1",
48
+ "@protobase/query": "0.69.1",
49
+ "@protobase/schema": "0.69.1",
50
+ "@protobase/server": "0.69.1",
51
+ "@protobase/ui": "0.69.1",
52
+ "@protobase/workflows": "0.69.1",
53
53
  "@react-email/render": "^2.1.0",
54
54
  "@scalar/hono-api-reference": "^0.12.10",
55
55
  "@sentry/vite-plugin": "^11.10.0",
@@ -6,6 +6,7 @@ Buttons that change records, ask for input or run your own code, per role.
6
6
 
7
7
  - [Record actions](../reference/ui-config.md#actions-and-views-per-role): Buttons that update fields, delete, open a link, run an AI action or call your own code.
8
8
  - [Actions that ask for input](../reference/ui-config.md#actions-that-ask-for-input): An action opens a dialog for text, numbers, dates, choices or records before it runs.
9
+ - [Actions that send from someone's mailbox](../reference/ui-config.md#actions-that-send-from-someones-mailbox) (beta): An action that sends from the person's own mailbox offers to connect it right in its dialog while none is linked or its link expired.
9
10
  - [Conditional and bulk actions](../reference/ui-config.md#where-an-action-applies): Actions show, or show disabled with a reason, only where a condition holds, and run on several selected rows at once.
10
11
  - [Views per role](../reference/ui-config.md#actions-and-views-per-role): Each role can get its own view of a resource, with other columns, filters, layout and actions.
11
12
  - [Named actions](../reference/layouts.md#named-actions): An action is declared once in a view and used as a button on lists, record pages and composed pages.
@@ -11,7 +11,7 @@ Mail the app sends, the PDF documents it makes, and forms for people without an
11
11
  - [Unsubscribe headers](../reference/email.md#templates): Your emails carry header fields of their own, such as a one-click unsubscribe link for bulk mail.
12
12
  - [Attachments](../reference/email.md#attachments): A message carries files, such as an invoice's PDF, through the app's mail or someone's own mailbox.
13
13
  - [PDF documents](../reference/pdf.md#documents): PDF files such as invoices and quotes are React components with typed data, rendered from jobs, hooks or functions and attached to mail as they are.
14
- - [Mail from your own mailbox](../reference/email.md#from-someones-own-mailbox) (beta): People link their Gmail on the account page, and the app's functions send mail from it, with a clear error when the link is missing or expired.
14
+ - [Mail from your own mailbox](../reference/email.md#from-someones-own-mailbox) (beta): People link their Gmail on the account page, and the app's functions send mail from it; an action offers to connect the mailbox right there when the link is missing or expired.
15
15
  - [Public forms](../reference/forms.md#what-the-visitor-sees): A form at its own address lets people without an account create a record, such as a contact request.
16
16
  - [Bot check](../reference/forms.md#bot-check): Each submission passes a check in the browser, without a third-party service or a puzzle.
17
17
  - [Form rate limit](../reference/forms.md#rate-limit): Each client sends only a set number of submissions per minute.
@@ -24,3 +24,4 @@ Graphs of steps the server runs along their edges, written in a language a model
24
24
  - [Guarded requests for plugins](../reference/workflow-plugins.md#sending-requests) (beta): A plugin's requests reach only public addresses, follow no redirect and read at most 10 MiB.
25
25
  - [Webhook trigger](../reference/workflows.md#appwebhook) (beta): A workflow starts from a signed call to a URL of its own, limited in size and rate.
26
26
  - [Polling triggers](../reference/workflow-plugins.md#polling-and-webhook) (beta): A workflow starts once for each new item a plugin finds on a schedule, or at each time of a cron with `app:schedule`.
27
+ - [Connected accounts](../reference/workflows.md#connecting-an-account) (beta): People connect their own accounts at other services through OAuth from the account page, and a workflow's steps and triggers sign in with one only once its owner turned the workflow on, with tokens encrypted and refreshed. Disconnecting deletes the token, and revokes it at a provider that has a revocation endpoint and answers.
@@ -65,4 +65,4 @@ export default defineFunction(async (request, { personalMail }) => {
65
65
  })
66
66
  ```
67
67
 
68
- The message goes out from the linked Gmail, and shows in its Sent folder. When the person linked nothing the call answers `409` with `code: "not_linked"`, and when Google no longer honours the link, as after they removed the app from their Google account, `code: "link_expired"`: point them to Account → Mailbox to connect Gmail again.
68
+ The message goes out from the linked Gmail, and shows in its Sent folder. When the person linked nothing the call answers `409` with `code: "not_linked"`, and when Google no longer honours the link, as after they removed the app from their Google account, `code: "link_expired"`. An action whose handler calls the function offers to connect Gmail right in its dialog then; mark it `mailbox: true` and it offers that before it runs, while nothing is linked ([Actions that send from someone's mailbox](../reference/ui-config.md#actions-that-send-from-someones-mailbox)).
@@ -189,12 +189,12 @@ Everyone signed in has an account page at `/-/account`, which "Account" in the u
189
189
 
190
190
  | Tab | Address | Blocks |
191
191
  | --- | --- | --- |
192
- | Profile | `/-/account` | `Profile`, `PersonalMail`, `DeleteAccount` |
192
+ | Profile | `/-/account` | `Profile`, `PersonalMail`, `Connections`, `DeleteAccount` |
193
193
  | Sign-in & security | `/-/account/security` | `Password`, `Passkeys`, `TwoFactor`, `ConnectedAccounts` |
194
194
  | Devices | `/-/account/devices` | `Devices` |
195
195
  | Connected apps | `/-/account/apps` | `ConnectedApps` |
196
196
 
197
- A tab whose blocks all leave themselves out is not offered, such as Connected apps in an app without [MCP](#oauth-for-mcp-clients). `DeleteAccount` leaves itself out when the app turns [account deletion](#deleting-the-account) off, and `PersonalMail` in an app without [personal mail](email.md#from-someones-own-mailbox).
197
+ A tab whose blocks all leave themselves out is not offered, such as Connected apps in an app without [MCP](#oauth-for-mcp-clients). `DeleteAccount` leaves itself out when the app turns [account deletion](#deleting-the-account) off, `PersonalMail` in an app without [personal mail](email.md#from-someones-own-mailbox), and `Connections` when no workflow plugin declares a [connection](workflows.md#connecting-an-account).
198
198
 
199
199
  To change it, export a page named `account` of your own: it replaces the standard one at `/-/account`, and is neither at `/account` nor in the sidebar. `accountTabs` holds the standard tabs by name (`profile`, `security`, `devices`, `apps`), so the page lists the ones it keeps beside its own:
200
200
 
@@ -399,7 +399,7 @@ Everyone who can sign in, for admins: find them by address, tick their roles, bl
399
399
 
400
400
  ### Account blocks
401
401
 
402
- `Profile`, `Password`, `Passkeys`, `TwoFactor`, `ConnectedAccounts`, `PersonalMail`, `Devices`, `ConnectedApps` and `DeleteAccount` are the parts of the signed-in person's own account: their name and address (which they can change, the address with an [emailed code](auth.md#changing-the-address)), their [password](auth.md#name-password-and-devices) (changed, or set for the first time), their [passkeys](auth.md#passkeys), [two-factor authentication](auth.md#two-factor-authentication), the sign-in providers they linked, the [mailbox](email.md#from-someones-own-mailbox) they linked for the app to send mail from, the devices signed in as them (each one but this one to [sign out](auth.md#name-password-and-devices)), the apps such as MCP clients they allowed to act as them, below the app's MCP address to connect one, and the [deletion of their account](auth.md#deleting-the-account). Each takes no props or children, reads its own data, and leaves itself out where it does not apply: `ConnectedAccounts` without an OAuth sign-in provider, `PersonalMail` without `auth.personalMail`, `ConnectedApps` without MCP, `DeleteAccount` with `auth.accountDeletion: false`. They act on whoever is signed in, so they are allowed on the page named `account` alone; anywhere else stops the config. That page is the [account page](auth.md#account-page) at `/-/account`, which `accountTabs` helps build from the standard tabs.
402
+ `Profile`, `Password`, `Passkeys`, `TwoFactor`, `ConnectedAccounts`, `PersonalMail`, `Connections`, `Devices`, `ConnectedApps` and `DeleteAccount` are the parts of the signed-in person's own account: their name and address (which they can change, the address with an [emailed code](auth.md#changing-the-address)), their [password](auth.md#name-password-and-devices) (changed, or set for the first time), their [passkeys](auth.md#passkeys), [two-factor authentication](auth.md#two-factor-authentication), the sign-in providers they linked, the [mailbox](email.md#from-someones-own-mailbox) they linked for the app to send mail from, the [accounts they connected](workflows.md#connecting-an-account) for their workflows, the devices signed in as them (each one but this one to [sign out](auth.md#name-password-and-devices)), the apps such as MCP clients they allowed to act as them, below the app's MCP address to connect one, and the [deletion of their account](auth.md#deleting-the-account). Each takes no props or children, reads its own data, and leaves itself out where it does not apply: `ConnectedAccounts` without an OAuth sign-in provider, `PersonalMail` without `auth.personalMail`, `Connections` when no workflow plugin declares a connection, `ConnectedApps` without MCP, `DeleteAccount` with `auth.accountDeletion: false`. They act on whoever is signed in, so they are allowed on the page named `account` alone; anywhere else stops the config. That page is the [account page](auth.md#account-page) at `/-/account`, which `accountTabs` helps build from the standard tabs.
403
403
 
404
404
  ```tsx
405
405
  export const account = page('account',
@@ -218,7 +218,7 @@ An attachment is only the bytes given: a file path or URL is never read in its p
218
218
  ## From someone's own mailbox
219
219
 
220
220
  :::caution[Beta]
221
- Gmail is the only mail provider so far, and only API functions send from a mailbox: jobs cannot yet.
221
+ Gmail is the only mail provider so far. API functions, and workflow steps that the person turned on themselves, send from a mailbox. A job's own code cannot yet.
222
222
  :::
223
223
 
224
224
  An app can send mail from the signed-in person's own mailbox instead of the app's address, such as an invoice from the account holder's Gmail, so replies reach them. They link the mailbox once on the [account page](auth.md#account-page) ("Connect Gmail" under **Mailbox**), which asks the provider for leave to send mail and nothing else; the app's functions then send through it. `auth.personalMail` names the providers people may link, each through the app's sign-in provider of the same company; Gmail links through Google sign-in (see [Send mail from Gmail](../guides/send-from-gmail.md) for the Google setup):
@@ -247,7 +247,7 @@ export default defineFunction(async (request, { personalMail }) => {
247
247
  ```
248
248
 
249
249
  - **Tokens:** the link is the sign-in provider's account Better Auth keeps for the person, with its access and refresh tokens, encrypted: `auth.personalMail` needs `encryptOAuthTokens: true`, and startup stops without it. The tokens never leave the server. An access token that has expired is refreshed before the message goes out, and the new one is stored.
250
- - **When it cannot send:** `send` rejects with a `PersonalMailError` and sends nothing. Its `code` is `not_linked` when the person linked no mailbox, or signed in with Google without allowing it to send mail, and `link_expired` when the provider no longer honours the link, as when they revoked it or its refresh token expired: they connect it again under Account → Mailbox. Thrown on, it answers the function's caller as a `409` problem with that `code` (`urn:protobase:problem:personal-mail-not-linked` or `-link-expired`), so the page can offer to link the mailbox:
250
+ - **When it cannot send:** `send` rejects with a `PersonalMailError` and sends nothing. Its `code` is `not_linked` when the person linked no mailbox, or signed in with Google without allowing it to send mail, and `link_expired` when the provider no longer honours the link, as when they revoked it or its refresh token expired: they connect it again under Account → Mailbox. Thrown on, it answers the function's caller as a `409` problem with that `code` (`urn:protobase:problem:personal-mail-not-linked` or `-link-expired`), and an [action](ui-config.md#actions-that-send-from-someones-mailbox) whose handler gets it offers to connect the mailbox right there:
251
251
 
252
252
  ```json
253
253
  { "type": "urn:protobase:problem:personal-mail-link-expired", "status": 409, "code": "link_expired", "provider": "gmail" }
@@ -255,10 +255,10 @@ export default defineFunction(async (request, { personalMail }) => {
255
255
 
256
256
  - **Sender:** the provider sends as the linked address; Gmail sets the From header to it.
257
257
  - **Which account:** mail goes out through the account the person connected for mail last. Connecting Gmail with another Google account takes the mail scopes off the earlier one in the app's store, and signing in with the earlier one does not take the mail back; connecting it again does. When the chosen account's link fails, sending fails: it never falls back to another account. Of two accounts that both still hold the mail scopes, as when both connected before this rule came in, mail goes through the one linked to the person last, until they connect one again.
258
- - **Only the person:** mail goes out from a mailbox only for someone signed in as themselves. Staff [signed in as them](auth.md#staff-sign-in), or an outside app acting for them, is refused with a `403` problem (`own-sign-in-only`), and nothing is sent.
258
+ - **Only the person:** mail goes out from a mailbox only for someone signed in as themselves, or from a [workflow step's `mailbox`](workflow-plugins.md#step-types) in a version that person turned on while signed in as themselves, and only while they may still change the workflow. Staff [signed in as them](auth.md#staff-sign-in), an outside app acting for them, and a job's own code cannot send from it; a request is refused with a `403` problem (`own-sign-in-only`), a step fails with `not_linked`, and nothing is sent. Turning the workflow off, or someone else turning a new version on, ends the first person's consent.
259
259
  - **Providers:** Gmail, through Google sign-in. A provider is added to Protobase behind the same `PersonalMailProvider` shape: its sign-in provider, the scopes linking asks for, and how it sends.
260
+ - **Sign-in is not a mailbox:** under **Mailbox**, the account page says when someone signs in with Google without having allowed it to send mail, so a linked Google sign-in is not taken for a connected mailbox.
260
261
  - <gap>The account page cannot show which account a mailbox is linked through or disconnect it because unlinking a mailbox is not implemented.</gap>
261
- - <gap>Jobs cannot send from someone's own mailbox because a job's context has no personal mailer.</gap>
262
262
  - <gap>Mailboxes other than Gmail cannot be linked because no other mail provider is implemented.</gap>
263
263
 
264
264
  ## In a bundle
@@ -100,7 +100,7 @@ In an app with [workflows](workflows.md), the endpoint also offers tools to find
100
100
  | `create_workflow` | Creates a workflow from its `name`, `description` and `definition`, and returns its `id`, `version` and ETag. |
101
101
  | `update_workflow` | Changes a workflow's definition by an `edit` or whole, as `definition`, and its `name` or `description`, and returns its `id`, new `version` and ETag. With the `etag` that `get_workflow` gave, it fails when someone changed the workflow since; an edit without one is saved against the version it was applied to. |
102
102
  | `run_workflow` | Starts a run of a workflow's current version with an `input`, and returns its `run_id` and `status`. With `rerun_of`, a failed run's id, it [re-runs](workflows.md#re-running-a-failed-run) that run from its failed step instead, on its input. |
103
- | `enable_workflow` | Turns a workflow's trigger on (`enabled: true`) on the `version` it names, which must be the workflow's current version, or off, and returns its `enabled_version`. A workflow saved since that version is refused, so an approval never turns on a version the person did not see. |
103
+ | `enable_workflow` | Turns a workflow's trigger on (`enabled: true`) on the `version` it names, which must be the workflow's current version, or off, and returns its `enabled_version`. A workflow saved since that version is refused, so an approval never turns on a version the person did not see. One that uses a [connected account](workflows.md#connecting-an-account) is turned on only by the account's owner in the admin app: the tool answers `own-sign-in-only` and says so. |
104
104
  | `new_webhook_url` | Issues a new URL and secret for a workflow that starts from a [webhook trigger](workflows.md#appwebhook), keeping a `secret` the sender makes when given one, and returns `{ url, secret }`, shown this once; the old URL stops. |
105
105
 
106
106
  An `edit` is a [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7396) of the saved definition: only what changes, at its place in the document. An object merges into the object there, `null` removes a member, and any other value, a list included, replaces it. Changing one argument of one state:
@@ -1139,6 +1139,16 @@ An empty answer is `null`. Before the handler runs, the dialog refuses a require
1139
1139
 
1140
1140
  A custom input's component gets the answer as `value` (`null` when empty), `onChange`, `invalid` while its answer is refused, the input's `props` and the `input` itself (`ActionInputProps`), as a [field type's](custom-components.md#field-types) editor does. Without that component the button is disabled, with the reason as its tooltip. In `/api/meta`, a user who cannot read the resource of a choice does not get the action.
1141
1141
 
1142
+ ### Actions that send from someone's mailbox
1143
+
1144
+ An `a.action` whose handler sends mail from the signed-in person's [own mailbox](email.md#from-someones-own-mailbox), such as an invoice from their Gmail, says so with `mailbox: true`. While they have linked no mailbox, its dialog offers to connect one instead of running: **Connect Gmail** starts the same sign-in as the account page's and comes back to the page they were on, where the action runs. Someone who signs in with Google without having allowed it to send mail is told that sign-in is no mailbox. The check needs a dialog, so give the action `confirm` or inputs:
1145
+
1146
+ ```ts
1147
+ a.action('send_invoice', { label: 'Send', icon: 'send', mailbox: true, confirm: 'Send the invoice from your own mailbox?' })
1148
+ ```
1149
+
1150
+ Any action whose handler fails with a function's [mailbox problem](email.md#from-someones-own-mailbox) (`not_linked` or `link_expired`) gets the same offer in place of the bare error, worded **Connect Gmail again** for an expired link: in its dialog, or as its error toast's button. A failure that is not about the mailbox shows as it is. <gap>An action that comes back from connecting a mailbox cannot reopen its dialog because keeping an open dialog in the page's address is not implemented; the person presses the button again.</gap>
1151
+
1142
1152
  ### Where an action applies
1143
1153
 
1144
1154
  `when` limits an action to the records it applies to, with the same [AIP-160](https://google.aip.dev/160) conditions a section's [`when`](#form-and-read-view) takes, over the record's own fields: `status = 'running'`, `status != 'stopped' AND locked = false`. On a record it does not hold for, the button is left out, or, with `otherwise: 'disable'`, shown disabled with `reason` as its tooltip. `variant: 'danger'` draws the button in red, as Delete is, and its question's button too:
@@ -5,7 +5,7 @@
5
5
  Add trigger and step types to workflows from a file of the app or a package, through the same public interface the built-in types use.
6
6
 
7
7
  :::caution[Beta]
8
- Plugins add step types and record triggers. They also add polling and webhook triggers, which run within the limits named in their section. The interface declares connections that sign in to other services, but the app does not run them.
8
+ Plugins add step types and record triggers. They also add polling and webhook triggers, which run within the limits named in their section, and connections that sign in to other services as an account a person connected, within the limits named under [Connections](#connections).
9
9
  :::
10
10
 
11
11
  A **workflow plugin** adds trigger and step types to an app's [workflows](workflows.md). Its types are named `<plugin>:<key>`, such as `acme:create_invoice`: a Task names a step type as its `Resource`, and a definition's `trigger` holds a trigger type with its input. The built-in types, `app:http`, `app:records.*`, `app:mail.send` and `app:record`, are a plugin like any other, written on this interface alone, so a plugin's types run, are checked and are listed exactly as they are.
@@ -76,6 +76,7 @@ What `run` gets, and nothing more:
76
76
  | `files` | `keep(bytes, name, type)` stores a file of the run and returns its file value; `read(value, field)` reads one the run kept. No other file of the app is reachable. |
77
77
  | `mail` | `send(message)` from the app's own sender; `undefined` when the app sends no mail. |
78
78
  | `connection` | `accessToken()` of the connection the step declares, or `undefined`. |
79
+ | `mailbox` | `send(message)` from the [own mailbox](email.md#from-someones-own-mailbox) of the person who turned on the version the run runs, signed in as themselves, while they may still change the workflow; otherwise it fails `not_linked` and sends nothing. `undefined` when the app links no mailboxes. |
79
80
  | `runId`, `idempotencyKey` | The run, and a key that every retry of this visit of the step shares, while a later visit of the same state, such as in a loop, gets a new one: send it where the receiver deduplicates. |
80
81
  | `signal` | Aborted at the step's timeout, or when the worker stops; pass it on to `fetch`. |
81
82
  | `log(message)`, `redact(text)` | A line in the step's logs, and the text with the app's secrets blanked out, both as the runner records them. |
@@ -142,9 +143,62 @@ triggers: {
142
143
 
143
144
  A webhook trigger takes calls of the workflow's URL, [as `app:webhook` does](workflows.md#appwebhook): the route checks the call's signature by the trigger's `auth` before any plugin code sees it, so spread `webhookInputProperties` from `@protobase/workflows/plugin` into the trigger's input schema. `receive({ request, event, idempotencyKey, input })` gets the call without its credentials and returns the runs it starts, at most 100, each `{ key?, data }`, and may return a `response` to answer with instead of `202`, such as a service's URL check. A trigger that registers its URL with its service itself declares `subscribe({ url, secret })`, which the app calls as the workflow is turned on with a URL it makes for the plugin alone, and returns the subscription's state; `renew` runs every `renewEvery` (default `1 day`) and `unsubscribe` as the workflow is turned off or deleted. Such a trigger has no URL to issue by hand. <gap>A webhook trigger that registers its own URL cannot subscribe in an app without sign-in because reading the app's public address from elsewhere is not implemented.</gap> <gap>A poll job that failed all its tries cannot start polling again by itself because a sweep of stopped triggers is not implemented; retrying the job in the admin app's failed jobs resumes it.</gap>
144
145
 
145
- ## Connections (coming soon)
146
+ ## Connections
146
147
 
147
- `connections` declares the sign-ins a plugin's types use: one of the app's sign-in providers by name (`signIn`), or an OAuth provider of its own (`oauth`), with the `scopes` it asks for. A step or trigger names one as its `connection`, and gets only that one, through the workflow's own binding: the context takes no connection id, so a plugin cannot reach another workflow's sign-in. <gap>A type that declares a connection cannot run because connections are not implemented; the type list marks it unavailable, and a save that uses it is refused.</gap>
148
+ A plugin's `connections` declare the sign-ins its types use, each under a name of lowercase letters, digits and `_`:
149
+
150
+ ```ts
151
+ connections: {
152
+ // Through one of the app's sign-in providers (auth.socialProviders), with the scopes connecting asks for:
153
+ google: {
154
+ label: 'Google account',
155
+ signIn: 'google',
156
+ scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
157
+ link_params: { access_type: 'offline', prompt: 'consent' },
158
+ revocation_url: 'https://oauth2.googleapis.com/revoke',
159
+ },
160
+ // Or through an OAuth 2.0 provider of its own, whose client the app's owner sets in auth.connections:
161
+ account: {
162
+ label: 'Acme account',
163
+ scopes: ['read'],
164
+ oauth: {
165
+ authorization_url: 'https://acme.example/oauth/authorize',
166
+ token_url: 'https://acme.example/oauth/token',
167
+ user_info_url: 'https://acme.example/oauth/me',
168
+ revocation_url: 'https://acme.example/oauth/revoke',
169
+ },
170
+ },
171
+ },
172
+ ```
173
+
174
+ | Key | What it is |
175
+ | --- | --- |
176
+ | `label` | What the account page and the type list call it. |
177
+ | `scopes` | What connecting asks for. An account counts for the connection only while it holds every one of them. |
178
+ | `link_params` | More authorization parameters, such as those that ask for a refresh token. |
179
+ | `signIn` | One of the app's sign-in providers, whose client and endpoints it uses. |
180
+ | `oauth` | The provider's `authorization_url`, `token_url` and `user_info_url`, each required (`user_info_url` answers the account's `sub` or `id`), its `revocation_url`, and `pkce` (default `true`). |
181
+ | `revocation_url` | Where disconnecting revokes the token ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)). Without one, disconnecting deletes it only. |
182
+
183
+ Every URL is `https`. A host given by its address, or `localhost`, is refused at startup unless `workflows.allowPrivateAddresses` is on, for development and tests. The app sends each request only to the URL the plugin declares, through the [guarded request](#sending-requests). That request resolves the host once, refuses a private address and follows no redirect, so a declaration can never send a code, a token or the client secret to the app's own network.
184
+
185
+ A type that names a connection is available once the app can make it. An `oauth` connection needs `auth.encryptOAuthTokens` and its client in `auth.connections`. A `signIn` connection needs `auth.encryptOAuthTokens` and its provider in `auth.socialProviders`. The type list gives the reason a type is not available. The client of an `oauth` connection is set by `<plugin>:<connection>`, and its redirect URI is `<baseURL>/api/auth/callback/workflow-<plugin>-<connection>`:
186
+
187
+ ```ts
188
+ auth: {
189
+ encryptOAuthTokens: true,
190
+ connections: { 'acme:account': { clientId: process.env.ACME_CLIENT_ID!, clientSecret: process.env.ACME_CLIENT_SECRET! } },
191
+ },
192
+ ```
193
+
194
+ A step or a trigger hook gets the connection its type declares as `connection`, and nothing else. It has no connection id, so a plugin cannot reach another workflow's account. `connection.accessToken()` gives a token that is valid now, refreshed first when it expired, and checks every time that the workflow may still use the account, as [Connecting an account](workflows.md#connecting-an-account) says. It throws a `ConnectionError`:
195
+
196
+ - **`not_connected`:** the workflow may not use an account for the connection.
197
+ - **`link_expired`:** the provider refused the refresh, so the account's owner connects it again. A trigger that throws this waits for that sign-in.
198
+
199
+ The token is blanked out of the step's logs, error and output, and out of the trigger's error, before they are kept. A plugin never puts it in its input or output.
200
+
201
+ An account connected through a provider of a connection's own never signs anyone in to the app, and never signs anyone up. <gap>A connection cannot be shared between people of an organization because shared connections are not implemented; each account is its owner's alone.</gap>
148
202
 
149
203
  ## The type list
150
204
 
@@ -215,7 +215,30 @@ A workflow's record page in the admin app, `/workflows/<id>`, and its create for
215
215
  - **A step's form.** Choosing a step opens its form under the graph: its name, which renames every edge to it, then what its type takes. A Task's form has a field for each argument of its step type's input schema, the ones it needs marked, as listed under [Step types](#step-types) or by its plugin: one line of text for a string, one line for a number or `true` and `false`, a menu for an `enum`, names separated by commas for a list of strings, and JSON for anything else; a Choice's its rules and `Default`; a Map's `Items` and `MaxConcurrency`; a Wait's `Seconds`; a Pass's and a Succeed's `Output`; a Fail's `Error` and `Cause`. A field takes text, an expression (`{% ... %}`) or JSON; an emptied field is left out. *The step as JSON* edits every field of the step, such as `Retry`, `Assign` and `TimeoutSeconds`. The trigger's form picks among the app's triggers, its plugins' included, and draws a field for each input of its schema the same way, such as a record trigger's resource, `when` and `key`.
216
216
  - **Problems on their step.** The editor runs the same check as a save on every change, and shows each problem on the step it names, as a count on its box and a list and a marked field in its form; a problem with the trigger or the first step shows on the trigger, and one about the whole document above the graph. Saving writes the definition through the API, where the save hook checks it again: a definition with a problem is refused with the 400 above, whose `errors` hold each problem for the `definition` field, with its state and field in `params`.
217
217
 
218
- A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap> The editor reads the app's types from `GET /api/workflows/types`, `client.workflowTypes()` of `@protobase/client`: the same list `list_workflow_types` gives over MCP, `{ step_types, triggers }`, which only a user who may read workflows gets. A type the app cannot run, such as one that signs in to another service through a connection, is not offered, and the check refuses it with the reason; a stored trigger of such a type, or of a type the app does not have, shows its type and input, read-only. <gap>A box on the graph cannot name a plugin's trigger or step type by its label because handing the type list to the canvas is not implemented; it shows the type's id, such as `acme:shout`.</gap>
218
+ A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap> The editor reads the app's types from `GET /api/workflows/types`, `client.workflowTypes()` of `@protobase/client`: the same list `list_workflow_types` gives over MCP, `{ step_types, triggers }`, which only a user who may read workflows gets. A type the app cannot run, such as one that signs in through a connection whose OAuth client the app does not set, is not offered, and the check refuses it with the reason; a stored trigger of such a type, or of a type the app does not have, shows its type and input, read-only. <gap>A box on the graph cannot name a plugin's trigger or step type by its label because handing the type list to the canvas is not implemented; it shows the type's id, such as `acme:shout`.</gap>
219
+
220
+ ## Connecting an account
221
+
222
+ :::caution[Beta]
223
+ Each connected account belongs to the person who connected it, and only that person can turn on a workflow that uses it.
224
+ :::
225
+
226
+ A trigger or step type that signs in to another service, such as a mailbox, uses an account that someone connected. Each person connects their own accounts on the **Connections** section of the account page's Profile tab. Connecting takes them to the provider and back. The app keeps the account's tokens on the server, encrypted, and refreshes them when they expire. **Disconnect** always deletes the token, so no step or trigger can use it again. When the connection has a revocation endpoint, it first asks the provider to revoke the token. A provider that does not answer or refuses keeps the token valid on its side, and the answer's `revoked` is false. A trigger that uses the account stops as `needs_sign_in`. Connecting again makes a new account with a new id: bind it in the workflow's `connections` and turn the workflow on again. Revoking at Google ends everything the account allowed the app, the sign-in and a linked mailbox included, and the dialog says so.
227
+
228
+ A workflow names the account each of its connections uses in its definition's `connections`, by `<plugin>:<connection>` and the account's id, which the Connections section shows:
229
+
230
+ ```json
231
+ { "trigger": { "gmail:new_mail": {} }, "connections": { "gmail:google": "<account id>" }, "stateMachine": { ... } }
232
+ ```
233
+
234
+ - **Saving:** anyone who may change the workflow saves it, an MCP client included. A save refuses a binding that no trigger or step of the workflow uses.
235
+ - **Turning it on is the consent.** A workflow that uses a connection is turned on only by the owner of every account it names, signed in as themselves. Someone else, staff signed in as the owner, and an outside app acting for them get a `403` problem (`own-sign-in-only`), the same for an account that is not there. Over MCP, `enable_workflow` answers that the owner turns it on in the admin app. A save that keeps such a workflow on is turned on again by the person saving it, so only the owner saves it while it is on; anyone else turns it off first.
236
+ - **At run time:** each time a step or trigger asks for a token, the app checks again. The version it runs must name the account, the person who turned that version on must own it, and that person must still be allowed to change the workflow in its tenant. Otherwise the step fails with `not_connected`. The same happens once the workflow is turned off, once the account is disconnected, and once that person lost the right to change workflows, even between two requests of one step. A run never records the token.
237
+ - **Signing in again:** when the provider refuses a refresh token (`invalid_grant`), the step fails with `link_expired`, and every trigger on that account waits as `needs_sign_in`. The Connections section marks the account. Connecting the same account again, without disconnecting it first, resumes the triggers from where they stopped. A refresh that fails otherwise, as when the provider is down, fails only that try, which a `Retry` or the next poll repeats, and the account stays connected.
238
+
239
+ A workflow uses one person's accounts: one whose connections name accounts of two people can never be turned on.
240
+
241
+ <gap>Turning off a workflow whose webhook trigger signs in through a connection cannot end its subscription at the provider because the consent ends with the workflow and an unsubscribe that outlives it is not implemented; the trigger stays `stopping` with that error in `workflow_triggers`.</gap>
219
242
 
220
243
  ## Step types
221
244