Skip to content

Commit b6c6ff2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-e0x3bb
2 parents 1c0cadd + 3ba5206 commit b6c6ff2

101 files changed

Lines changed: 6879 additions & 444 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(service-automation): `oauth2` connector instances are served per dispatch, and an administrator connects an `authorization_code` one for their organization (ADR-0143 S1, part of #26702)
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
ADR-0143 S0 (#28137) let a declarative connector instance declare `auth: { type: 'oauth2', ... }`, and this server held every such instance degraded. This slice serves it.
13+
14+
- **`@objectstack/service-automation`**: an `oauth2` instance is materialized with a `credentialHandle` and no static `auth` (D4). The handle obtains the access token from `tokenUrl` (a confidential client authenticates with HTTP Basic, a public client sends its `client_id`), keeps it in memory only, refreshes it within the 30-second skew margin and once on a `401`, and shares one token request among concurrent dispatches. With a cluster service a refresh runs under the cluster lock. An unreachable or `5xx` token endpoint is `CONNECTOR_UPSTREAM_UNAVAILABLE`.
15+
- `client_credentials` needs nothing more than the client secret its `credentialRef` names.
16+
- `authorization_code` keeps its refresh token in a new object, `sys_connector_connection` (D3): one row per organization and instance, the token a `Field.secret`, never an access token, never a sign-in account (D6). An instance nobody connected is degraded "not connected", never a failed boot. A refused grant (`invalid_grant`) marks the row `reconnect_required` and degrades the instance until it is connected again. A dispatch uses its run's organization's connection; a run with no organization uses the only one, or is refused `422` when there are several.
17+
- A provider opts in with `registerConnectorProvider(key, factory, { credentialHandle: true })`, saying it asks the handle on every request. An `oauth2` instance on any other provider stays degraded, so it is never called without its token. The built-in `rest`, `openapi` and `mcp` providers do not opt in yet (S2), and `app-credential` stays degraded until S2.
18+
- A new plugin option, `connectorOAuthRedirectUri`, fixes the redirect URI; otherwise it is `OS_BASE_URL` (or `OS_AUTH_URL`) plus `/api/v1/automation/connectors/oauth/callback`.
19+
- **`@objectstack/spec`**: `IAutomationService` gains four optional members, `getConnectorConnection`, `beginConnectorConnection`, `completeConnectorConnection` and `revokeConnectorConnection`, with their request and result types (`ConnectorConnectionState`, `ConnectorConnectionBegin`, …). `sys_connector_connection` joins the `service-automation` platform object names.
20+
- **`@objectstack/runtime`**: the connection door (D5), every route needing `manage_metadata`:
21+
- `GET` / `POST` / `DELETE /api/v1/automation/connectors/:name/connection` answer the organization's connection status, begin a connect (a fresh `state` and an `S256` PKCE challenge bound to the caller's user and organization, answered as the vendor's authorization URL), or revoke it. They take no query parameter.
22+
- `GET /api/v1/automation/connectors/oauth/callback` is the vendor's redirect. Its query set is closed (`code`, `state`, `error`, `error_description`, `error_uri`, `iss`), each parameter once. The `state` is used once, and a callback from another user or organization is refused before its code is exchanged.
23+
- No answer, refusal or log line carries a token, a code, a verifier or a client secret.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/core': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/plugin-grid': patch
6+
'@object-ui/i18n': patch
7+
---
8+
9+
feat(console): a bulk result names the records it wrote, and a required-field refusal says what to fill in
10+
11+
Clause-②: yes (widening: `ActionResult` gains the optional `fieldErrors` member, `ActionFieldError` is a new export of `@object-ui/types`, and `ActionResponseOutcome` gains `fieldErrors`; no accept set moves)
12+
Metadata-protocol: none
13+
14+
- **Bulk action result.** After a bulk action such as Mark Done, the result read only "Succeeded 2 / 2". It now lists the records that succeeded, named the way the confirm step names them (the object's name field). The list appears when every failure is reported against a record of the run; when the server answered a bare count, the result keeps to the count rather than name a record that may have failed.
15+
- **Required-field refusal from an action.** Running an action whose write the server refuses for a missing required field, such as Submit for Sign-off on a draft invoice with no Issued On, used to say only "Issued On is required". It now continues "Fill in Issued On, then run “Submit for Sign-off” again." Several fields are joined in the session's language. Any other refusal is shown as the server wrote it.
16+
- For code that dispatches actions through `createServerActionHandler`: a failed `ActionResult` now carries the refusal's per-field entries as `fieldErrors` (field, code, message, label), read from the answer's `error.details.fields`. `error` is unchanged.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/plugin-report': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(plugin-report): a summary report with two or more `rows` levels draws a subtotal per outer group, and no longer repeats the outer value on every row
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
**Visible change on existing reports.** Every dataset-bound `summary` report whose `rows` names two or more dimensions now renders grouped on its next render, with nothing for the author to change:
12+
13+
- Each outer group draws a header row naming its value once, then its inner rows with the outer column left blank, then a `Subtotal` row. With three or more levels the groups nest, one header and one subtotal per level above the innermost.
14+
- The subtotals are the server's. The report asks the dataset for every proper prefix of `rows` through `totals.groupings` (`[[owner], []]` for `rows: [owner, stage]`), the way a `matrix` already asks for its row and column totals. They are never summed in the browser, so an `avg` or a distinct count subtotals correctly.
15+
- Groups keep the order their first row arrives in: the declared option order of a select dimension, or the report's own `order`. Rows of one group that an `order` by a measure brings back apart are drawn together under it.
16+
- Clicking a subtotal row drills into the whole group, scoped by the outer dimensions only. Clicking an inner row drills by every dimension, as before.
17+
- The export (CSV and XLSX) writes the grouped table as drawn.
18+
19+
Unchanged: a one-level `summary` (grand-total footer only), a `tabular` report, and a `matrix`, including a matrix without `columns`. A server that does not answer the prefix groupings gets the plain table, with the outer value repeated and no subtotal rows.
20+
21+
`@object-ui/i18n` adds `report.subtotal` to every pack.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': minor
4+
'@objectstack/cli': patch
5+
'@object-ui/auth': patch
6+
'@object-ui/app-shell': patch
7+
'@object-ui/i18n': patch
8+
'@object-ui/console': patch
9+
---
10+
11+
feat(plugin-auth): opt-in Cloudflare Turnstile on email sign-up — off unless both `OS_TURNSTILE_SITE_KEY` and `OS_TURNSTILE_SECRET_KEY` are set
12+
13+
Clause-②: yes (widening: the public auth config gains `features.turnstileSiteKey`, served only while the challenge is on; `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` and `AuthFeaturesConfigSchema` gain `turnstileSiteKey`; with the challenge on, `/sign-up/email` answers better-auth's `captcha` refusals `400 MISSING_RESPONSE`, `403 VERIFICATION_FAILED` and `500 UNKNOWN_ERROR`; two new env vars; `@object-ui/auth` gains `TurnstileWidget`, `TURNSTILE_SCRIPT_URL`, `RegisterForm`'s `turnstileSiteKey` prop and `captchaFailed`/`captchaRetry` labels, `SignUpData.captchaToken` and a fifth `signUp` argument; `@object-ui/app-shell` gains `signUpCaptchaFailedMessage`)
14+
Metadata-protocol: added
15+
16+
A deployment can now ask for a human check before an email/password account is created. Set both variables and:
17+
18+
- **The server** mounts better-auth's `captcha` plugin with the `cloudflare-turnstile` provider on `/sign-up/email` only. A sign-up without a token in the `x-captcha-response` header is refused before anything is read or written, a token is verified with Cloudflare using the secret, and a verification that cannot complete fails closed. Sign-in and password reset are not challenged (better-auth's default list would have included them; it is overridden). The per-address rate limit still runs first. Server-side registrations through `auth.api.signUpEmail` (the development admin seed, demo staff) are unaffected.
19+
- **The public auth config** (`GET /api/v1/auth/config`) serves `features.turnstileSiteKey`, the public site key, and never the secret.
20+
- **The Console** renders the challenge on `/register` and on the first-run `/setup` wizard, holds submit until it is solved, and sends the token. A failed, expired or refused challenge shows one retry message, never Cloudflare's code or the plugin's text. The Console document's content-security-policy admits `https://challenges.cloudflare.com` in `script-src` and `frame-src` only while both variables are set.
21+
22+
With either variable unset or blank nothing changes: no plugin, no config key, no widget, the same policy. A deployment that sets only one gets one boot `warn` naming the missing variable.

‎content/docs/automation/connectors.mdx‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -470,13 +470,57 @@ What the publish door checks, each refusal located at its key under `auth`:
470470
- **`app-credential`** takes `vendor` (`feishu`, `dingtalk` or `wecom`), `appId`
471471
and a required `credentialRef`. An inline `appSecret` is refused by name.
472472

473-
A server that does not yet run the per-dispatch token resolution holds such an
474-
instance **degraded**, with a reason that names the grant-bearing kind, and
475-
never calls the upstream without its credential. A datasource that borrows
476-
from it (`external.connector`) is refused with `422`. Until the server serves
477-
them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now;
478-
don't emulate a grant by putting a short-lived access token behind a `bearer`
479-
ref.
473+
**How the server serves them.** An `oauth2` instance is served through a
474+
**credential handle** the provider asks on every request: the access token is
475+
obtained from `tokenUrl` (the client authenticating with HTTP Basic, or by
476+
`client_id` alone as a public client), cached in memory only, refreshed when
477+
it is within 30 seconds of its expiry, and refreshed once more, with one
478+
retry, when the upstream answers `401`. Concurrent requests share one token
479+
request, and with a cluster service a refresh runs under the cluster lock, so
480+
a rotating refresh token is never spent twice.
481+
482+
- **`client_credentials`** needs nothing more: the client secret its
483+
`credentialRef` names re-derives the token at any time.
484+
- **`authorization_code`** needs an administrator to **connect** it, once per
485+
organization. Until then the instance is **degraded** "not connected" (never
486+
a failed boot). The connect is three calls, each needing `manage_metadata`:
487+
488+
```http
489+
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }
490+
GET /api/v1/automation/connectors/oauth/callback?code=…&state=… (the vendor redirects here)
491+
DELETE /api/v1/automation/connectors/:name/connection → revoke
492+
```
493+
494+
`GET …/:name/connection` answers the organization's status
495+
(`not_connected`, `connected`, `reconnect_required`), never a token. The
496+
`state` and the PKCE verifier are bound to the user and organization that
497+
began the connect and are used once; a callback from anyone else is refused
498+
before its code is exchanged. The redirect URI is the deployment's
499+
(`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`,
500+
or the automation plugin's `connectorOAuthRedirectUri`), never the
501+
request's; register that URI with the vendor. The refresh token is kept
502+
encrypted in `sys_connector_connection`, one row per organization; it is
503+
not a sign-in account, and signing in never touches it. A run dispatches
504+
with its own organization's connection; a run with no organization uses the
505+
instance's only connection, and is refused when there are several. When the
506+
vendor refuses the grant (`invalid_grant`), the connection is marked
507+
**reconnect required** and the instance degraded until an administrator
508+
connects it again. A revoke deletes the stored connection only: the server
509+
does not yet revoke the refresh token at the vendor (RFC 7009), so it stays
510+
valid there until it expires or is revoked in the vendor's console.
511+
512+
What is not served yet is held **degraded** with a reason that names it, and
513+
never called without its credential: `app-credential` (its vendor exchanges
514+
are not run yet), an `oauth2` instance on a provider that does not yet ask the
515+
credential handle (the built-in `rest`, `openapi` and `mcp` providers do not
516+
yet), an `mcp` instance that left its endpoints to discovery, and an `oauth2`
517+
instance whose `tokenUrl` or `authorizationUrl` is not `https://` (plain
518+
`http://` is accepted only to a loopback host, so the client secret and the
519+
refresh token never cross a network in the clear). A datasource
520+
that borrows from a grant-bearing instance (`external.connector`) is refused
521+
with `422`. Until then, declare a static kind (`bearer`, `api-key` or `basic`)
522+
to dispatch now; don't emulate a grant by putting a short-lived access token
523+
behind a `bearer` ref.
480524

481525
### Request signing: `signed`
482526

@@ -599,7 +643,9 @@ retry):
599643
|:---|:---|
600644
| `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` |
601645
| Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory |
602-
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
646+
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) this server does not serve yet (`app-credential`; `oauth2` on a provider that does not ask the credential handle) | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
647+
| An `authorization_code` instance nobody connected, or whose grant the vendor refused | **Not fatal**: the instance registers **degraded**, "not connected" or "reconnect required" — connect it (`POST …/connectors/:name/connection`), and it materializes without a restart |
648+
| The token endpoint is unreachable or answers `5xx` / `429` | **Not fatal**: the dispatch fails `CONNECTOR_UPSTREAM_UNAVAILABLE` and is retried on the existing backoff; the connection stays connected |
603649
| `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` |
604650
| `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart |
605651
| The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff |

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see
110110
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
111111
| `OS_TERMS_URL` | url | — | URL shown next to the "Terms of Service" link on the sign-up form. Empty string hides the link. |
112112
| `OS_PRIVACY_URL` | url | — | URL shown next to the "Privacy Policy" link on the sign-up form. Empty string hides the link. |
113+
| `OS_TURNSTILE_SITE_KEY` / `OS_TURNSTILE_SECRET_KEY` | string | — | Opt-in Cloudflare Turnstile on email sign-up. Set **both** and `/sign-up/email` (the register page and the first-run setup) requires a verified Turnstile token, the Console renders the challenge, and its content-security-policy admits `https://challenges.cloudflare.com`; sign-in and password reset are not challenged. Only the site key is served to the browser (`features.turnstileSiteKey`); the secret never leaves the server. With either unset nothing changes (one boot `warn` names the missing one). |
113114

114115
Auth settings precedence:
115116

0 commit comments

Comments
 (0)