Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/25097-cloud-connection-platform-managed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/cloud-connection': minor
---

fix(cloud-connection)!: a runtime holding the environment service key is platform-managed, so it registers no Cloud Connection Setup page and refuses to disconnect; everywhere else the Cloud Connection entry moves to Setup's Integrations group

A runtime whose control-plane credential is the environment service key (`controlPlaneApiKey`, or `OS_CLOUD_API_KEY`, which the cloud sets on the environments it hosts) is connected by the platform, not by anyone on the runtime. It still registered the Cloud Connection page in Setup's Apps group, which read "Connected" and offered Disconnect. Disconnecting there cleared the runtime's stored device credential while the service key stayed in use, so the page went on reading "Connected", and the control plane was asked to revoke a connection the platform had made.

Such a runtime now registers neither the page nor its navigation entry, and says so once at boot. A request to disconnect it is refused with `409` and `RESOURCE_CONFLICT`, after the sign-in and Setup Access checks and before anything is revoked or cleared. The message says the platform manages the connection.

A self-hosted or local runtime, connected through the device-code flow, keeps the page and its disconnect unchanged. Its Cloud Connection entry moves from the Apps group to the Integrations group, after Webhooks, Datasources and Connect an Agent. That group requires `manage_platform_settings`, so a user who holds Setup Access without it, such as an organization administrator, no longer sees the entry. The connect and disconnect requests still take Setup Access alone.

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is what a runtime holding the environment service key serves: it no longer registers the Cloud Connection page or its navigation entry, and it refuses a disconnect it used to carry out. The navigation entry's new group is a value inside an exported constant whose type is unchanged. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (on a runtime holding the environment service key, a disconnect request it carried out is now refused, and the Cloud Connection page and its navigation entry are no longer served), shipped as `minor` under the launch-window convention for breaking changes.

- **Unchanged:** a runtime without the service key, apart from the entry's group. The connection status answer is unchanged on every runtime.
- **Nothing to rewrite:** a cloud-hosted environment's connection is the platform's to manage. A self-hosted server leaves `OS_CLOUD_API_KEY` unset, as the deployment guide says; one that sets it is platform-managed by this rule.
30 changes: 20 additions & 10 deletions content/docs/deployment/cloud-connection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ description: Connect your own ObjectStack server to an ObjectStack Cloud account
---

A self-hosted ObjectStack server can be connected to an ObjectStack Cloud
account. You start the connection on your own server, in **Setup → Apps →
Cloud Connection**, and approve it in the cloud account with a one-time code.
Nothing is typed into the server: no id, no key, no password. Under the hood
account. You start the connection on your own server, in **Setup →
Integrations → Cloud Connection**, and approve it in the cloud account with a
one-time code. Nothing is typed into the server: no id, no key, no password. Under the hood
this is the OAuth 2.0 device authorization grant
([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), run by the
`@objectstack/cloud-connection` package on your server.
Expand Down Expand Up @@ -56,6 +56,13 @@ const cloudUrl = resolveCloudUrl(); // OS_CLOUD_URL; '' when declared off
To check a running server, request `GET /api/v1/cloud-connection/status`. It
needs no sign-in, and a `200` means the plugin is mounted.

**The server does not hold the environment service key.** A server that holds
`OS_CLOUD_API_KEY` (the cloud sets it on every environment it hosts) is
connected by the platform, not from Setup. It registers no Cloud Connection page
or Setup entry, says so once in its boot log, and refuses a disconnect with
`409` (`RESOURCE_CONFLICT`): nothing is revoked and nothing is cleared. A
self-hosted server leaves `OS_CLOUD_API_KEY` unset.

**The server can reach the cloud.** Every call to the cloud is made by the
server, not by your browser: the code request, the polling, the connection
itself and the disconnect. The server needs outbound HTTPS to the control-plane
Expand All @@ -77,7 +84,9 @@ the server reads as not connected until you connect it again.
organization-administrator permission sets carry it. The connect and
disconnect requests take the same permission: a signed-in user without
Setup Access is refused with `403` (`PERMISSION_DENIED`) before the server
calls the cloud. A session
calls the cloud. The page's Setup entry sits in the **Integrations** group,
which Setup shows only to users who also hold `manage_platform_settings`; the
default platform-administrator permission set carries both. A session
that the authentication policy is holding (an expired password, or
multi-factor sign-in required but not yet set up) is refused with `403`
until it is fixed.
Expand All @@ -88,9 +97,9 @@ the server reads as not connected until you connect it again.

## Connect [#connect]

1. **Open the page.** Sign in to your server and go to **Setup → Apps →
Cloud Connection**. The header reads *Cloud Connection — Connect this
runtime to an ObjectStack control plane to browse your organization's
1. **Open the page.** Sign in to your server and go to **Setup →
Integrations → Cloud Connection**. The header reads *Cloud Connection —
Connect this runtime to an ObjectStack control plane to browse your organization's
private packages and install them here.* The panel shows *Checking
connection…*, then **Not connected** and a **Connect** button.
2. **Click Connect.** The server asks the cloud for a device code and hands
Expand Down Expand Up @@ -214,15 +223,16 @@ The routes the Cloud Connection page uses, all on the server's own origin:
| `GET /api/v1/cloud-connection/status` | not required | Every caller gets `bound`, `runtimeId` and `provider`. A signed-in user with Setup Access (`setup.access`) also gets `environmentId` and `connection`, the connection as the cloud reports it (the approving account and the organization). `bound` is `true` whenever the server holds a credential; `connection` is `null` when the cloud cannot be reached. |
| `POST /api/v1/cloud-connection/bind/start` | required | Asks the cloud for a device code; answers with `user_code`, `verification_uri`, `verification_uri_complete`, `interval`, `expires_in`. |
| `POST /api/v1/cloud-connection/bind/poll` | required | Body `{ "device_code": "…" }`. Answers `{ "pending": true }` while the cloud waits for approval, and the connection once it is approved. |
| `POST /api/v1/cloud-connection/unbind` | required | Disconnects; answers `{ "revoked", "cleared" }`. |
| `POST /api/v1/cloud-connection/unbind` | required | Disconnects; answers `{ "revoked", "cleared" }`. On a server holding `OS_CLOUD_API_KEY`, refused with `409` (`RESOURCE_CONFLICT`) and nothing changes. |

The settings that shape the flow are listed in
[Environment variables](/docs/deployment/environment-variables#multi-tenant--cloud-control-plane):
`OS_CLOUD_URL` picks the cloud, `OS_CLI_CLIENT_ID` overrides the OAuth client
id the server presents (default `objectstack-cli`), and `OS_RUNTIME_VERSION`
sets the version it reports. `OS_CLOUD_API_KEY` and `OS_ENVIRONMENT_ID` belong
to cloud-hosted runtimes; a self-hosted server leaves both unset, and a set
`OS_CLOUD_API_KEY` is used instead of the stored credential.
to cloud-hosted runtimes; a self-hosted server leaves both unset. A set
`OS_CLOUD_API_KEY` is used instead of the stored credential, and makes the
connection platform-managed: no Cloud Connection page, and no disconnect.

The Setup app itself, and how plugins add entries to it, is described in
[Setup app](/docs/ui/setup-app). Running the server is covered in
Expand Down
4 changes: 2 additions & 2 deletions content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ anchors are:
| Group (anchor id) | Filled by |
|:---|:---|
| **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` |
| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace and [Cloud Connection](/docs/deployment/cloud-connection) — `cloud-connection` (capability plugin) |
| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) |
| **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` |
| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
| **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` |
| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects`; Branding — `service-settings`, only where the `branding` settings namespace is offered (the `single` tenancy posture) |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Log — `plugin-audit` |
| **Integrations** (`group_integrations`) | `plugin-webhooks` |
| **Integrations** (`group_integrations`) | `plugin-webhooks`; [Cloud Connection](/docs/deployment/cloud-connection) — `cloud-connection`, except on a runtime holding the environment service key, which registers no entry |
| **Advanced** (`group_advanced`) | OAuth Applications — `platform-objects` |

The exact rendered menu depends on which capability plugins are loaded.
Expand Down
14 changes: 10 additions & 4 deletions docs/qa/platform-checklist/areas/platform-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -2045,7 +2045,7 @@
"title": "Marketplace console honesty: nav entries live and die with their capability, the disabled state renders the configuration conclusion (never a red 'failed to load') for every viewer ahead of the admin guard, Installed Apps drives the real same-origin lifecycle, and an enabled runtime turns a non-admin away with access-denied",
"since": "v17",
"status": "active",
"revision": 5,
"revision": 6,
"priority": "P2",
"surface": "browser",
"personas": [
Expand All @@ -2067,15 +2067,15 @@
"boot B (OS_CLOUD_URL=off), as admin in Setup: confirm the group_apps nav carries Installed Apps (nav_marketplace_installed — the entry rides MARKETPLACE_INSTALLED_UI_BUNDLE, registered by the install-local plugin that IS mounted) and does NOT carry Browse Marketplace (nav_marketplace_browse rides the browse proxy, which is not mounted) — the marketplace-ui.ts ownership rule: 'the entry lives and dies with the capability → no dead page'",
"boot B: navigate directly to /apps/setup/system/marketplace — the page renders the MarketplaceDisabled card (data-testid marketplace-disabled): muted informational styling, a hint addressed to the operator naming the action, a Back-home button. Screenshot",
"boot B: open the Installed Apps page — the installed CRM entry renders with its manifest identity; open that package's Details page, where reseed and purge live in the 'More install options' menu (objectui#11627 — they are not on the Installed Apps list itself), drive both and capture the same-origin POSTs to /api/v1/marketplace/install-local/:manifestId/{reseed,purge}-sample-data on the wire",
"boot A (default), as admin: the group_apps nav now carries BOTH Browse Marketplace and Installed Apps plus Cloud Connection (nav_cloud_connection); open the browse page — if the control plane is reachable the catalog renders; if not, record blocked(environment) for the catalog leg only (the page mounting and asking is this kernel's part)",
"boot A (default), as admin: the group_apps nav now carries BOTH Browse Marketplace and Installed Apps, and the group_integrations nav carries Cloud Connection (nav_cloud_connection — it moved out of group_apps; a boot holding OS_CLOUD_API_KEY is platform-managed and carries no such entry at all); open the browse page — if the control plane is reachable the catalog renders; if not, record blocked(environment) for the catalog leg only (the page mounting and asking is this kernel's part)",
"as the plain member on boot A (marketplace enabled): navigate directly to /apps/setup/system/marketplace — once the admin verdict resolves (MarketplaceResolving holds only until then) the page renders MarketplaceAccessDenied, never an indefinite spinner and never the destructive card; then open a package URL (…/system/marketplace/:packageId) and confirm the package page issues no package request for the member. On boot B the same URLs render the MarketplaceDisabled card for the member too: objectui#5557 moved the disabled return AHEAD of the !isAdmin guard, because 'there is no marketplace here' is true of every viewer",
"on boot B reload the disabled page several times: confirm no transient red 'Failed to load' card paints before the disabled card settles (the objectui#5533/#5557 resolving-state work — MarketplaceResolving covers the gap)"
],
"acceptance": [
{
"clause": "nav ownership: on the offline boot, Installed Apps is present and Browse Marketplace is ABSENT from Setup's group_apps; on the cloud boot both are present (plus Cloud Connection) — each entry registered by the plugin that owns its capability, so no nav entry ever points at a dead page",
"clause": "nav ownership: on the offline boot, Installed Apps is present and Browse Marketplace is ABSENT from Setup's group_apps; on the cloud boot both are present (plus Cloud Connection, in group_integrations) — each entry registered by the plugin that owns its capability, so no nav entry ever points at a dead page",
"oracle": "api",
"verify": "GET /api/v1/meta/app?id=setup on each boot and diff the group_apps items (nav_marketplace_browse / nav_marketplace_installed / nav_cloud_connection presence per boot); confirm the rendered sidebar matches",
"verify": "GET /api/v1/meta/app?id=setup on each boot and diff the group_apps items (nav_marketplace_browse / nav_marketplace_installed presence per boot) and the group_integrations items (nav_cloud_connection presence per boot); confirm the rendered sidebar matches",
"evidence": "the two nav payloads + sidebar screenshots"
},
{
Expand Down Expand Up @@ -2147,6 +2147,12 @@
"date": "2026-10-16",
"change": "steps[2] and acceptance[2] (clause, verify) re-pointed (checklist-accuracy finding of QA run #23939, wave #23878). Reseed and purge are not on the Installed Apps list: they are in the package Details page's 'More install options' menu (objectui#11627), which is where #23939 drove them. The Installed Apps note #23939 saw as stale was objectui#12684; the console pin since moved that note into the Uninstall tooltip (objectui#12610). Not loosened: both POSTs must still leave the page same-origin and answer 2xx",
"ref": "#24036"
},
{
"revision": 6,
"date": "2026-10-21",
"change": "steps[3], acceptance[0].clause and acceptance[0].verify re-pointed: Cloud Connection (nav_cloud_connection) moved from group_apps to group_integrations, and a runtime holding the environment service key (OS_CLOUD_API_KEY) is platform-managed and registers neither the entry nor its page (ruling 甲 on #25097). The marketplace entries' ownership rule this item judges is unchanged.",
"ref": "#25097"
}
]
},
Expand Down
Loading
Loading