diff --git a/.changeset/25097-cloud-connection-platform-managed.md b/.changeset/25097-cloud-connection-platform-managed.md new file mode 100644 index 00000000000..c4608342d9f --- /dev/null +++ b/.changeset/25097-cloud-connection-platform-managed.md @@ -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) + + + +**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. diff --git a/content/docs/deployment/cloud-connection.mdx b/content/docs/deployment/cloud-connection.mdx index 949ea4094d9..53309d188a3 100644 --- a/content/docs/deployment/cloud-connection.mdx +++ b/content/docs/deployment/cloud-connection.mdx @@ -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. @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index a62536caaae..51f769f2b63 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -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. diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 48773423735..8b02f37493a 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -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": [ @@ -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" }, { @@ -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" } ] }, diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts index a823e29401c..a3a6b58f061 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -28,7 +28,10 @@ * - HELD: under both postures, every session door answers `403` with the * gate's `code` and `message`, and no effect fires (no control-plane call, * no credential cleared; no registry, schema, ledger, seed or delete write). - * - UN-HELD: the same caller and grants, without a posture, are served. + * - UN-HELD: the same caller and grants, without a posture, are served — + * except `unbind` on a platform-managed runtime (the service key), which + * answers its own `409` past the gate (ruling 甲, #25097); the self-hosted + * shape, served by all seven, is read beside it. * - Anonymous is refused `401` as before, and `/status` answers a held * session `200` with its narrowed shape (#24148). * @@ -155,7 +158,14 @@ const CC_SESSION_DOORS = [ { route: `GET ${CC}/org-packages`, url: `${ORIGIN}${CC}/org-packages`, body: {} }, ] as const; -async function mountCloudConnection(mode: 'single' | 'multi') { +/** + * `serviceKey` is the plugin's `controlPlaneApiKey`. The default, a key, is the + * cloud-hosted (platform-managed) shape every case below has always run in; + * `''` (no key, and `OS_CLOUD_API_KEY` not consulted) is the self-hosted shape, + * whose credential is the stored device token — the one shape in which + * `unbind` still disconnects (ruling 甲, #25097). + */ +async function mountCloudConnection(mode: 'single' | 'multi', serviceKey = 'svc-key') { const rawApp = makeRawApp(); const hooks = new Map(); const auth = sessionAuth(); @@ -194,7 +204,7 @@ async function mountCloudConnection(mode: 'single' | 'multi') { })); await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', - controlPlaneApiKey: 'svc-key', + controlPlaneApiKey: serviceKey, credentialPath, ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), }).start(ctx as any); @@ -232,11 +242,36 @@ describe('ADR-0069 at CloudConnectionPlugin', () => { } } - it('UN-HELD: the same caller without a posture is served by all seven doors', async () => { + it('UN-HELD: the same caller without a posture is served by all seven doors (self-hosted: the stored device credential)', async () => { + const { rawApp, fetchCalls } = await mountCloudConnection('single', ''); + // `unbind` really disconnects here, clearing the stored credential the + // install-side doors present — so it is driven last. + const UNBIND = `POST ${CC}/unbind`; + const lastUnbind = [...CC_SESSION_DOORS.filter((d) => d.route !== UNBIND), ...CC_SESSION_DOORS.filter((d) => d.route === UNBIND)]; + expect(lastUnbind).toHaveLength(CC_SESSION_DOORS.length); + for (const door of lastUnbind) { + const before = fetchCalls.length; + const res = await rawApp.routes.get(door.route)!(makeC(door.url, 'u_member', door.body)); + expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(200); + expect(fetchCalls.length, `${door.route} did not reach the control plane`).toBeGreaterThan(before); + } + }); + + it('UN-HELD (platform-managed: the service key): six doors are served, and unbind is the platform-managed 409 — not the gate', async () => { + // The shape the HELD cases above run in. Ruling 甲 (#25097) refuses + // `unbind` here for every caller, so this door's control is that + // refusal rather than a 200: still past the gate (not its 403), and + // still before any control-plane call. const { rawApp, fetchCalls } = await mountCloudConnection('single'); for (const door of CC_SESSION_DOORS) { const before = fetchCalls.length; const res = await rawApp.routes.get(door.route)!(makeC(door.url, 'u_member', door.body)); + if (door.route === `POST ${CC}/unbind`) { + expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(409); + expect(res.payload?.error?.code, door.route).toBe('RESOURCE_CONFLICT'); + expect(fetchCalls.length, `${door.route} reached the control plane`).toBe(before); + continue; + } expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(200); expect(fetchCalls.length, `${door.route} did not reach the control plane`).toBeGreaterThan(before); } diff --git a/packages/cloud-connection/src/cloud-connection-platform-managed.pin.test.ts b/packages/cloud-connection/src/cloud-connection-platform-managed.pin.test.ts new file mode 100644 index 00000000000..2a49f551cc3 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-platform-managed.pin.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A runtime whose control-plane credential is the environment service key is + * PLATFORM-MANAGED: its cloud connection belongs to the platform, so this + * runtime registers no Cloud Connection Setup page and refuses to disconnect. + * Everywhere else the page registers, under Setup → Integrations. + * (Ruling 甲 on #25097.) + * + * ## What this file pins + * + * The one predicate (`CloudConnectionPlugin`: the effective credential is the + * service key, `controlPlaneApiKey` or `OS_CLOUD_API_KEY`) read at both of its + * uses, each against a runtime that does not hold the key as the control: + * + * - Registration, read back from a REAL kernel's `manifest` service and + * metadata protocol (an `ObjectKernel` with `ObjectQLPlugin`), after the + * plugin's own `kernel:ready`, through the merge `GET /api/v1/meta/app/setup` + * serves: + * - managed: no `cloud_connection_settings` page and no + * `nav_cloud_connection` entry anywhere in Setup's tree; + * - not managed: the page is served and the entry sits in + * `group_integrations`, not `group_apps`. + * Both readings carry a lit control in the same composition: a page and a + * contributed `group_apps` entry that ARE served, so "absent" is read off a + * listing that carries plugin metadata, not off an empty one. + * - `POST /unbind`, driven through the route the plugin mounts, under both + * resolution modes, by a caller who holds `setup.access`: + * - managed: `409 RESOURCE_CONFLICT` in the declared envelope, its + * message naming the platform as the connection's manager, with no + * control-plane call (no revoke) and the stored credential untouched; + * - not managed: the control plane's revoke is called and the stored + * credential cleared, as before. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { CloudConnectionSettingsPage } from './cloud-connection-ui.js'; +import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { MARKETPLACE_BROWSE_UI_BUNDLE } from './marketplace-ui.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; + +type Bag = Record; +type Handler = (c: any) => Promise; + +const CC = '/api/v1/cloud-connection'; +const SERVICE_KEY = 'svc-key-platform'; + +/** The organization the session acts in, and the one the grant below is owned by. */ +const ORG = 'o1'; +/** Signed in, holding `setup.access`: the caller the Setup page is for. */ +const ADMIN = 'u_setup_admin'; +const GRANTS: Record = { + sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], + sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], +}; + +/** The control page: the plugin's own page under another name, so its shape is one the registry admits. */ +const CONTROL_PAGE = { ...structuredClone(CloudConnectionSettingsPage), name: 'control_settings', label: 'Control' }; + +/** + * A Setup shell carrying the two group anchors this file reads, and the control + * page. The real shell (`SETUP_APP` in `@objectstack/platform-objects`) is not a + * dependency of this package; the merge under test is the engine's, which + * reads an anchor by id. + */ +const SETUP_SHELL = { + id: 'test.setup-shell', + namespace: 'sys', + version: '0.0.0', + type: 'plugin', + scope: 'system', + name: 'Setup shell (test)', + apps: [{ + name: 'setup', + label: 'Setup', + navigation: [ + { id: 'group_apps', type: 'group', label: 'Apps', children: [] }, + { id: 'group_integrations', type: 'group', label: 'Integrations', children: [] }, + ], + }], + pages: [CONTROL_PAGE], +}; + +/** A driver holding no row of anything: every answer below comes from the registry. */ +const EMPTY_STORE = { + name: 'empty-store', + version: '0.0.0', + supports: {}, + connect: async () => undefined, + disconnect: async () => undefined, + find: async () => [], + findOne: async () => null, + count: async () => 0, +}; + +let dir: string; +let savedEnvKey: string | undefined; +const kernels: ObjectKernel[] = []; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cc-platform-managed-')); + savedEnvKey = process.env.OS_CLOUD_API_KEY; + delete process.env.OS_CLOUD_API_KEY; + delete process.env.OS_ENVIRONMENT_ID; +}); +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + if (savedEnvKey === undefined) delete process.env.OS_CLOUD_API_KEY; + else process.env.OS_CLOUD_API_KEY = savedEnvKey; + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + }; +} + +// ── Registration ──────────────────────────────────────────────────────────── + +/** + * A real kernel carrying the Setup shell and the marketplace browse bundle (the + * contributed `group_apps` control), with the plugin booted to `kernel:ready` + * against that kernel's own `manifest` service. `serviceKey` is the plugin's + * `controlPlaneApiKey`; left `undefined`, the plugin reads `OS_CLOUD_API_KEY`. + */ +async function composeRegistration(serviceKey: string | undefined) { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + ql.registerDriver(EMPTY_STORE as never, true); + const manifest = kernel.getService<{ register(m: unknown): void }>('manifest'); + manifest.register(structuredClone(SETUP_SHELL)); + manifest.register(structuredClone(MARKETPLACE_BROWSE_UI_BUNDLE)); + + const rawApp = makeRawApp(); + const hooks = new Map Promise>(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const ctx = { + hook: (e: string, h: () => Promise) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name === 'manifest') return manifest; + throw new Error(`no ${name}`); + }, + logger, + }; + await new CloudConnectionPlugin({ + singleEnvironment: true, + controlPlaneUrl: 'http://cloud.test', + controlPlaneApiKey: serviceKey, + credentialPath: join(dir, 'cloud-connection.json'), + }).start(ctx as never); + await hooks.get('kernel:ready')?.(); + return { protocol: kernel.getService('protocol'), logger, routes: rawApp.routes }; +} + +/** The page names `GET /api/v1/meta/page` serves. */ +async function servedPageNames(protocol: any): Promise { + const res = await protocol.getMetaItems({ type: 'page' }); + const items: Bag[] = Array.isArray(res) ? res : res?.items ?? []; + return items.map((it) => it?.name).filter((n): n is string => typeof n === 'string'); +} + +/** Setup's merged navigation, as the metadata protocol serves the app. */ +async function setupNavigation(protocol: any): Promise { + const res = await protocol.getMetaItem({ type: 'app', name: 'setup' }); + return (res?.item?.navigation ?? []) as Bag[]; +} + +const groupIds = (navigation: Bag[], groupId: string): string[] => { + const group = navigation.find((g) => g?.id === groupId); + expect(group, `the Setup shell's ${groupId} anchor is served`).toBeDefined(); + return ((group?.children ?? []) as Bag[]).map((i) => i?.id); +}; + +/** Every nav id in the tree, depth-first, groups and top-level entries included. */ +const allNavIds = (items: Bag[]): string[] => + items.flatMap((i) => [i?.id, ...allNavIds(Array.isArray(i?.children) ? i.children : [])]); + +describe('platform-managed runtime — no Setup page, no nav entry (ruling 甲)', () => { + it('managed (controlPlaneApiKey): no cloud_connection_settings page and no nav_cloud_connection entry are served', async () => { + const { protocol } = await composeRegistration(SERVICE_KEY); + const pages = await servedPageNames(protocol); + // Control: the listing carries plugin-registered pages. + expect(pages).toContain('control_settings'); + expect(pages).not.toContain('cloud_connection_settings'); + + const navigation = await setupNavigation(protocol); + // Control: the read reaches contributed entries. + expect(groupIds(navigation, 'group_apps')).toContain('nav_marketplace_browse'); + expect(allNavIds(navigation)).not.toContain('nav_cloud_connection'); + }); + + it('managed (OS_CLOUD_API_KEY): the env spelling of the key is the same predicate', async () => { + process.env.OS_CLOUD_API_KEY = SERVICE_KEY; + const { protocol } = await composeRegistration(undefined); + const pages = await servedPageNames(protocol); + expect(pages).toContain('control_settings'); + expect(pages).not.toContain('cloud_connection_settings'); + const navigation = await setupNavigation(protocol); + expect(groupIds(navigation, 'group_apps')).toContain('nav_marketplace_browse'); + expect(allNavIds(navigation)).not.toContain('nav_cloud_connection'); + }); + + it('managed: says so once at boot, naming what it did not register', async () => { + const { logger } = await composeRegistration(SERVICE_KEY); + const lines = logger.info.mock.calls.map((call) => String(call[0])); + expect(lines.filter((l) => l.includes('platform-managed'))).toHaveLength(1); + }); + + it('not managed: the page is served and the entry sits in group_integrations, not group_apps', async () => { + const { protocol } = await composeRegistration(undefined); + const pages = await servedPageNames(protocol); + expect(pages).toContain('cloud_connection_settings'); + + const navigation = await setupNavigation(protocol); + expect(groupIds(navigation, 'group_integrations')).toEqual(['nav_cloud_connection']); + const apps = groupIds(navigation, 'group_apps'); + expect(apps).toContain('nav_marketplace_browse'); + expect(apps).not.toContain('nav_cloud_connection'); + // Exactly one entry by that id in the whole tree: moved, not copied. + expect(allNavIds(navigation).filter((id) => id === 'nav_cloud_connection')).toHaveLength(1); + }); +}); + +// ── Unbind ────────────────────────────────────────────────────────────────── + +function makeC(path: string, user?: string) { + const url = `https://env.example.test${path}`; + const headers = new Headers(); + if (user) headers.set('x-test-user', user); + return { + req: { + url, + raw: new Request(url, { headers }), + header: (n: string) => headers.get(n) ?? undefined, + json: async () => ({}), + text: async () => '{}', + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +/** + * The plugin mounted in either resolution mode, holding a stored device + * credential, with every control-plane call recorded. `serviceKey` decides + * whether the runtime is platform-managed. + */ +async function mountUnbind(mode: 'single' | 'multi', serviceKey: string | undefined) { + const rawApp = makeRawApp(); + const hooks = new Map Promise>(); + const auth = { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const uid = headers?.get?.('x-test-user'); + return uid ? { user: { id: uid }, session: { id: `sess_${uid}`, activeOrganizationId: ORG } } : null; + }, + }, + }; + const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS); + const envKernelServices: Record = { auth, objectql }; + const services: Record = mode === 'single' + ? { auth, objectql, manifest: { register: vi.fn() } } + : { + 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) }, + manifest: { register: vi.fn() }, + }; + const ctx = { + hook: (e: string, h: () => Promise) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name in services) return services[name]; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const credentialPath = join(dir, 'cloud-connection.json'); + new ConnectionCredentialStore(credentialPath).write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' }); + const fetchCalls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + fetchCalls.push(String(url)); + return new Response(JSON.stringify({ success: true, data: {} }), { status: 200, headers: { 'content-type': 'application/json' } }); + })); + await new CloudConnectionPlugin({ + controlPlaneUrl: 'http://cloud.test', + controlPlaneApiKey: serviceKey, + credentialPath, + ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), + }).start(ctx as never); + await hooks.get('kernel:ready')?.(); + const unbind = () => rawApp.routes.get(`POST ${CC}/unbind`)!(makeC(`${CC}/unbind`, ADMIN)); + return { unbind, fetchCalls, store: new ConnectionCredentialStore(credentialPath) }; +} + +for (const mode of ['single', 'multi'] as const) { + describe(`platform-managed runtime — unbind is refused (${mode}-environment)`, () => { + it('managed: 409 RESOURCE_CONFLICT naming the platform, before any revoke and before the local clear', async () => { + const { unbind, fetchCalls, store } = await mountUnbind(mode, SERVICE_KEY); + const res = await unbind(); + const shown = JSON.stringify(res.payload); + // The exit's identity. + expect(res.status, shown).toBe(409); + expect(res.payload?.error?.code, shown).toBe('RESOURCE_CONFLICT'); + // The declared ADR-0112 envelope, code vocabulary included. + expect(BaseResponseSchema.safeParse(res.payload).success, shown).toBe(true); + expect(envelopeViolations(res.payload), shown).toEqual([]); + expect(ApiErrorSchema.safeParse(res.payload.error).success, shown).toBe(true); + expect(res.payload.error.message, shown).toMatch(/^This runtime's cloud connection is managed by the platform/); + // Nothing happened: no control-plane call (no revoke), credential untouched. + expect(fetchCalls, 'a refused unbind reached the control plane').toEqual([]); + expect(store.read()?.runtimeToken, 'a refused unbind touched the stored credential').toBe('oscc_pin'); + }); + + it('CONTROL — not managed: the revoke is called and the stored credential cleared', async () => { + const { unbind, fetchCalls, store } = await mountUnbind(mode, undefined); + const res = await unbind(); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data).toMatchObject({ revoked: true, cleared: true }); + expect(fetchCalls).toEqual(['http://cloud.test/api/v1/cloud-connection/revoke']); + expect(store.read()?.runtimeToken).toBe(''); + }); + }); +} diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 233061fffcc..3db74ce36b1 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -58,6 +58,17 @@ * platform's one authorization resolver (`resolveAuthzContext`), against the * session this surface already read — see `holdsSetupAccess`. * + * ## A platform-managed connection is not this runtime's to manage (ruling 甲, #25097) + * + * A runtime holding the env→cloud service key (`controlPlaneApiKey` / + * `OS_CLOUD_API_KEY`, injected by the cloud into the runtimes it hosts) is + * connected by the platform, and that key — not a device-bound credential — + * is what it presents. Such a runtime registers no Cloud Connection Setup page + * or nav entry, and `unbind` answers `409 RESOURCE_CONFLICT` + * (`PLATFORM_MANAGED_UNBIND_REFUSAL`). Everywhere else — self-hosted or local, + * bound through the device-code flow — the page registers under Setup → + * Integrations and every door behaves as described above. + * * ## Why a same-origin proxy at all * * The SPA cannot call the control plane from a tenant subdomain — that is a @@ -158,6 +169,29 @@ const SETUP_ACCESS_REFUSAL = { message: `Managing this runtime's cloud connection requires the \`${SETUP_ACCESS_CAPABILITY}\` capability.`, } as const; +/** + * Ruling 甲 (#25097): the refusal `POST /unbind` gives on a platform-managed + * runtime — one whose control-plane credential is the env→cloud service key + * (`platformManaged` in `start()`) — before the control-plane revoke and before + * the local credential store is touched. + * + * `RESOURCE_CONFLICT` / 409, no code minted, on the same rule + * {@link SETUP_ACCESS_REFUSAL} follows: the ADR-0112 ledger sends a generic + * condition to the standard catalog rather than to a registered synonym, and + * the catalog's 409 member is the one for a refusal that comes from the + * runtime's state rather than from the request — the same answer this + * package's install-local door gives when a writable base holds a package's + * namespace (`marketplace-install-local-plugin.ts`). The message carries the + * meaning: the platform manages the connection, so nothing here can end it. + * It names no remedy, because this runtime has none to offer. + */ +const PLATFORM_MANAGED_UNBIND_REFUSAL = { + code: 'RESOURCE_CONFLICT', + status: 409, + message: 'This runtime\'s cloud connection is managed by the platform, so it cannot be disconnected here: its ' + + 'control-plane credential is the environment service key the platform provides. Nothing was revoked or cleared.', +} as const; + /** * The slice of a kernel this surface reads services from: the environment's * own kernel (multi-tenant, through `kernel-manager`) or the host's. @@ -271,12 +305,29 @@ export class CloudConnectionPlugin implements Plugin { const deviceClientId = (this.cfg.deviceClientId ?? process.env.OS_CLI_CLIENT_ID ?? 'objectstack-cli').trim(); const deviceScope = 'openid profile email'; + /** + * Ruling 甲 (#25097): is this runtime's cloud connection + * PLATFORM-MANAGED? It is exactly when the effective control-plane + * credential is the env→cloud service key + * (`controlPlaneApiKey` / `OS_CLOUD_API_KEY`, which the cloud + * injects into the runtimes it hosts) — the one fact `credential()` + * below is written in terms of, so the two can never disagree. + * + * A managed connection belongs to the platform, so this runtime + * offers no way to manage it: the Setup page and its nav entry are + * not registered, and `POST /unbind` is refused + * ({@link PLATFORM_MANAGED_UNBIND_REFUSAL}). Read once, here: the + * key is resolved at `kernel:ready` and does not change while the + * runtime is up. + */ + const platformManaged = cloudApiKey !== ''; + // Effective control-plane credential, resolved PER REQUEST: // cloud-hosted runtimes carry the env→cloud service key; a // self-hosted runtime presents the `oscc_…` bearer the bind flow // persisted. Lazy so a bind that completes while the runtime is // up takes effect without a restart. - const credential = (): string => cloudApiKey || this.store.read()?.runtimeToken || ''; + const credential = (): string => (platformManaged ? cloudApiKey : this.store.read()?.runtimeToken || ''); const authHeaders = (): Record => { const cred = credential(); return cred ? { Authorization: `Bearer ${cred}` } : {}; @@ -341,10 +392,23 @@ export class CloudConnectionPlugin implements Plugin { // plugin as metadata (ADR-0029 K2); the console only provides the // registered `cloud-connection:panel` widget. Best-effort — a // kernel without a manifest service simply has no Setup entry. - try { - const manifest = ctx.getService<{ register(m: any): void }>('manifest'); - manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE); - } catch { /* no manifest service — headless kernel */ } + // + // Ruling 甲 (#25097): a platform-managed runtime registers neither. + // Its connection is the platform's, so a page offering to connect + // or disconnect it would offer what this runtime cannot do. The + // absence is said once, here, rather than left to be diagnosed. + if (platformManaged) { + ctx.logger?.info?.( + '[CloudConnectionPlugin] the cloud connection is platform-managed (the control-plane credential is the ' + + 'environment service key): the Cloud Connection Setup page and its navigation entry are not registered, ' + + 'and disconnecting is refused', + ); + } else { + try { + const manifest = ctx.getService<{ register(m: any): void }>('manifest'); + manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE); + } catch { /* no manifest service — headless kernel */ } + } const sessionFromAuthService = async ( authSvc: any, @@ -750,7 +814,8 @@ export class CloudConnectionPlugin implements Plugin { // POST /unbind — disconnect this runtime from the control plane. // Revokes the connection server-side (best-effort) and clears the // local credential. Requires an environment session holding - // `setup.access`, mirroring bind/start. + // `setup.access`, mirroring bind/start. Refused on a + // platform-managed runtime (ruling 甲, #25097). rawApp.post(`${CLOUD_CONNECTION_PREFIX}/unbind`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); if (!environmentId && !this.cfg.singleEnvironment) { @@ -761,9 +826,20 @@ export class CloudConnectionPlugin implements Plugin { if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in.' } }, 401); // [#24148] Refused before the revoke and before the local clear. if (!(await holdsSetupAccess(c.req.raw, session))) return refuseWithoutSetupAccess(c); + // Ruling 甲 (#25097): the connection is the platform's — refused + // before the revoke and before the local clear, so nothing + // changes on either side. After the identity and capability + // checks, so a caller who may not manage the connection learns + // nothing about how it is held. + if (platformManaged) { + return c.json({ + success: false, + error: { code: PLATFORM_MANAGED_UNBIND_REFUSAL.code, message: PLATFORM_MANAGED_UNBIND_REFUSAL.message }, + }, PLATFORM_MANAGED_UNBIND_REFUSAL.status); + } // Revoke cloud-side FIRST (the oscc_ bearer self-identifies; - // env-keyed bindings name the environment), then clear the + // a known environment id rides along), then clear the // local credential regardless — a device signing out must not // leave a live token behind when the control plane is up. let revoked = false; diff --git a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts index 4095b296df8..127d01e704f 100644 --- a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts @@ -170,7 +170,14 @@ afterEach(() => { vi.restoreAllMocks(); }); -async function mount(mode: 'single' | 'multi', opts: { outage?: boolean } = {}) { +/** + * `serviceKey` is the plugin's `controlPlaneApiKey`. The default, a key, is the + * cloud-hosted (platform-managed) shape every case here has always run in; + * `''` (no key, and `OS_CLOUD_API_KEY` not consulted) is the self-hosted shape, + * whose credential is the stored device token — the one shape in which + * `unbind` still disconnects (ruling 甲, #25097). + */ +async function mount(mode: 'single' | 'multi', opts: { outage?: boolean; serviceKey?: string } = {}) { const rawApp = makeRawApp(); const hooks = new Map(); const auth = sessionAuth(); @@ -210,7 +217,7 @@ async function mount(mode: 'single' | 'multi', opts: { outage?: boolean } = {}) })); await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', - controlPlaneApiKey: 'svc-key', + controlPlaneApiKey: 'serviceKey' in opts ? opts.serviceKey : 'svc-key', credentialPath, ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), }).start(ctx as any); @@ -249,24 +256,39 @@ for (const mode of ['single', 'multi'] as const) { }); } - it('CONTROL — a caller holding setup.access is served by all three doors as before', async () => { - const { drive, fetchCalls, credentialPath } = await mount(mode); - const start = await drive(DOORS.bindStart.route, DOORS.bindStart.path, { user: ADMIN }); - expect(start.status, JSON.stringify(start.payload)).toBe(200); - expect(start.payload.data.device_code).toBe('dc_1'); - - const poll = await drive(DOORS.bindPoll.route, DOORS.bindPoll.path, { user: ADMIN, body: DOORS.bindPoll.body }); - expect(poll.status, JSON.stringify(poll.payload)).toBe(200); - expect(poll.payload.success).toBe(true); - expect(poll.payload.data.runtime_token, 'the runtime bearer reached the browser').toBeUndefined(); - expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe('oscc_new'); - - const unbind = await drive(DOORS.unbind.route, DOORS.unbind.path, { user: ADMIN }); - expect(unbind.status, JSON.stringify(unbind.payload)).toBe(200); - expect(unbind.payload.data).toMatchObject({ revoked: true, cleared: true }); - expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe(''); - expect(fetchCalls.length).toBeGreaterThan(0); - }); + for (const shape of ['self-hosted', 'platform-managed'] as const) { + it(`CONTROL — a caller holding setup.access is served by all three doors as before (${shape})`, async () => { + // Ruling 甲 (#25097): on a platform-managed runtime (the service + // key) `unbind` is refused for every caller, so there its control + // is that refusal — past this file's 403, before any revoke — + // while the self-hosted shape disconnects exactly as before. + const { drive, fetchCalls, credentialPath } = await mount(mode, shape === 'self-hosted' ? { serviceKey: '' } : {}); + const start = await drive(DOORS.bindStart.route, DOORS.bindStart.path, { user: ADMIN }); + expect(start.status, JSON.stringify(start.payload)).toBe(200); + expect(start.payload.data.device_code).toBe('dc_1'); + + const poll = await drive(DOORS.bindPoll.route, DOORS.bindPoll.path, { user: ADMIN, body: DOORS.bindPoll.body }); + expect(poll.status, JSON.stringify(poll.payload)).toBe(200); + expect(poll.payload.success).toBe(true); + expect(poll.payload.data.runtime_token, 'the runtime bearer reached the browser').toBeUndefined(); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe('oscc_new'); + + const beforeUnbind = fetchCalls.length; + const unbind = await drive(DOORS.unbind.route, DOORS.unbind.path, { user: ADMIN }); + if (shape === 'self-hosted') { + expect(unbind.status, JSON.stringify(unbind.payload)).toBe(200); + expect(unbind.payload.data).toMatchObject({ revoked: true, cleared: true }); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe(''); + expect(fetchCalls.length, 'unbind did not reach the control plane').toBeGreaterThan(beforeUnbind); + } else { + expect(unbind.status, JSON.stringify(unbind.payload)).toBe(409); + expect(unbind.payload?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe('oscc_new'); + expect(fetchCalls.length, 'a refused unbind reached the control plane').toBe(beforeUnbind); + } + expect(fetchCalls.length).toBeGreaterThan(0); + }); + } it('CONTROL — a grant scoped to the organization the session is active in counts', async () => { const { drive } = await mount(mode); diff --git a/packages/cloud-connection/src/cloud-connection-ui.ts b/packages/cloud-connection/src/cloud-connection-ui.ts index d84ed969b16..beffc3d700e 100644 --- a/packages/cloud-connection/src/cloud-connection-ui.ts +++ b/packages/cloud-connection/src/cloud-connection-ui.ts @@ -56,11 +56,27 @@ export const CloudConnectionSettingsPage: Page = { ], }; -/** Setup-nav contribution: System group, after the marketplace entries. */ +/** + * Setup-nav contribution: the Integrations group, after its other entries + * (ruling 甲, #25097 — the connection is an optional integration of a + * self-hosted or local runtime, not an app). + * + * `priority` orders contributions within a group, lowest first. The group's + * other contributors, read where they are declared: Webhooks / HTTP Deliveries + * (`@objectstack/plugin-webhooks`) and Datasources + * (`@objectstack/service-datasource`) at 100, Connect an Agent + * (`@objectstack/mcp`) at 110 — so 200 sorts this entry last. + * + * The group's own `requiredPermissions` (`manage_platform_settings`, on + * `SETUP_APP` in `@objectstack/platform-objects`) now applies to the entry. + * + * A platform-managed runtime registers none of this + * (`CloudConnectionPlugin`, `platformManaged`). + */ export const CLOUD_CONNECTION_NAV_CONTRIBUTIONS = [ { app: 'setup', - group: 'group_apps', + group: 'group_integrations', priority: 200, items: [ { diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index b3f1d610ece..c3c51ad27a5 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -304,6 +304,19 @@ describe('plugin-route door — CloudConnectionPlugin error exits (#9267)', () = }); } + /** + * Ruling 甲 (#25097): a platform-managed runtime (the service key) refuses + * `unbind` with the standard catalog's 409 member, past the session and + * `setup.access` checks. + */ + it('POST /unbind on a platform-managed runtime → 409 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1', controlPlaneApiKey: 'svc-key' }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/unbind`, { body: {} }), + { status: 409, code: 'RESOURCE_CONFLICT' }, + ); + }); + it('POST /install without a session → 401 in the declared envelope', async () => { const routes = await mountCloudConnection({ resolvesEnvironment: true }); expectDeclaredErrorEnvelope( diff --git a/packages/cloud-connection/src/marketplace-installed-ui-entry.pin.test.ts b/packages/cloud-connection/src/marketplace-installed-ui-entry.pin.test.ts index 6d974935c2f..3d2234e82fb 100644 --- a/packages/cloud-connection/src/marketplace-installed-ui-entry.pin.test.ts +++ b/packages/cloud-connection/src/marketplace-installed-ui-entry.pin.test.ts @@ -49,7 +49,7 @@ import '@objectstack/runtime'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; +import { MARKETPLACE_BROWSE_UI_BUNDLE, MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; @@ -66,9 +66,12 @@ const FILTERED_PACKAGES_ENTRY = { }; /** - * A Setup shell with the one group anchor the entry lands in. The real shell - * (`SETUP_APP` in `@objectstack/platform-objects`) is not a dependency of this - * package; the merge under test is the engine's, which reads the anchor by id. + * A Setup shell with the group anchor the entry lands in, and the one the Cloud + * Connection UI bundle's entry lands in (`group_integrations`, ruling 甲 on + * #25097), so no contribution in this composition misses its group. The real + * shell (`SETUP_APP` in `@objectstack/platform-objects`) is not a dependency of + * this package; the merge under test is the engine's, which reads an anchor by + * id. */ const SETUP_SHELL = { id: 'test.setup-shell', @@ -80,7 +83,10 @@ const SETUP_SHELL = { apps: [{ name: 'setup', label: 'Setup', - navigation: [{ id: 'group_apps', type: 'group', label: 'Apps', children: [] }], + navigation: [ + { id: 'group_apps', type: 'group', label: 'Apps', children: [] }, + { id: 'group_integrations', type: 'group', label: 'Integrations', children: [] }, + ], }], }; @@ -113,8 +119,10 @@ afterEach(async () => { }); /** - * A real kernel carrying the Setup shell and the Cloud Connection UI bundle, - * with the install-local plugin booted to `kernel:ready` when `withPlugin`. + * A real kernel carrying the Setup shell, the Cloud Connection UI bundle (the + * page-listing control) and the marketplace browse bundle (the `group_apps` + * control: a plugin-contributed entry in the group this file reads), with the + * install-local plugin booted to `kernel:ready` when `withPlugin`. */ async function compose(withPlugin: boolean) { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); @@ -127,6 +135,7 @@ async function compose(withPlugin: boolean) { const manifest = kernel.getService<{ register(m: unknown): void }>('manifest'); manifest.register(structuredClone(SETUP_SHELL)); manifest.register(structuredClone(CLOUD_CONNECTION_UI_BUNDLE)); + manifest.register(structuredClone(MARKETPLACE_BROWSE_UI_BUNDLE)); const protocol = kernel.getService('protocol'); if (withPlugin) { @@ -210,7 +219,9 @@ describe('#23933 — "Installed Apps" opens Setup → Packages filtered to Sourc const ids = (await setupAppsGroup(protocol)).map((i) => i?.id); expect(ids).not.toContain('nav_marketplace_installed'); // Control: the read reaches the group's plugin-contributed entries. - expect(ids).toContain('nav_cloud_connection'); + // (Browse Marketplace, not Cloud Connection: that entry sits in + // `group_integrations` since ruling 甲 on #25097.) + expect(ids).toContain('nav_marketplace_browse'); }); it('the bundle ships the entry and no page', () => {