Skip to content

Commit 000a4c0

Browse files
fix(cloud-connection): name the cloud-binding service in its contract, and read an empty controlPlaneUrl one way (#30956)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DD5m3UuAxayNiHrDSFDaU94)_ Fixes #30762 Clause-②: yes (widening) Metadata-protocol: none domain:server#5 card thread · session `session_016WV4cwsvPqjJfJthsRiYpt` · branch `claude/project-thread-ms359b` (named in the seat's re-posted claim on the card, with the widened file surface). Branched from `main` at 33043bd; `origin/main` merged at d1e3182. The two should-fixes of the contract review on PR #30749 (record 6673473934, ④ items 1 and 2). Item 2 takes the card's first route (A): the routes read the binding. ## 维护者速读 - 改了什么:① `cloud-binding` 这个服务名现在只在 spec 契约里写一次(`CLOUD_BINDING_SERVICE`,和另外六个契约的做法一样),cloud-connection 注册、service-push 推送中继读取都用它;② cloud-connection 插件的路由不再自己读 `controlPlaneUrl`,改读它自己注册的 `cloud-binding` 服务,所以「显式传空字符串」对路由和推送中继都是「没配云端」。 - 为什么改:PR #30749 的契约复核留下两条 should-fix:服务名两处各写一份、互不约束;同一个空选项,路由当「未配置」,绑定服务却当「用默认云地址」,同一插件里两种读法。 - 风险与回滚:`objectstack serve` 和 showcase 行为不变(它们只用 `resolveCloudUrl()` 的非空结果组装插件,两种读法对它返回同一值);只有手工组装内核时才有差别,见下方 Acceptance notes 三条。spec 只多一个导出;service-push 原有的 `CLOUD_BINDING_SERVICE` 导出保留(改为转导出,值不变)。回滚即 revert,无迁移。 - 先看:`packages/cloud-connection/src/cloud-binding.ts` 的 `resolveBindingUrl`;`packages/cloud-connection/src/cloud-connection-plugin.ts` 里 `const cloudUrl = this.binding.controlPlaneUrl();` 那一行;`packages/spec/src/contracts/cloud-binding-service.ts` 末尾的新导出。 - 两分钟验证:`pnpm --filter @objectstack/cloud-connection exec vitest run src/cloud-connection-plugin.test.ts src/cloud-binding.test.ts`,预期全绿,其中「the routes and the binding read the control-plane URL once」一组 6 条覆盖空值、空白、`OS_CLOUD_URL=off`、显式 URL、仅环境变量五种情况。 - 需要你做的:无,席位落地(本 PR 触 `packages/spec/src/**` 且 `Clause-②: yes`,席位 ACCEPT 前需契约复核)。 ## What changes **1. One spelling of the service name.** `packages/spec/src/contracts/cloud-binding-service.ts` exports `CLOUD_BINDING_SERVICE = 'cloud-binding'` beside `ICloudBindingService`, as `EMBEDDER_SERVICE`, `KNOWLEDGE_SERVICE`, `OWNERSHIP_QUEUE_SERVICE`, `RLS_MEMBERSHIP_RESOLVER_SERVICE`, `SEED_SETTLEMENT_SERVICE` and `FILE_ATTACHMENT_SERVICE` do. `api-surface/contracts.json` and `export-origins/contracts.json` regenerated (`gen:api-surface`, `gen:export-origins`): one row each, `CLOUD_BINDING_SERVICE (const)`. - `@objectstack/cloud-connection`: the local constant in `cloud-binding.ts` is gone. It was not a public export of the package (`src/index.ts` never re-exported it), so nothing outside the package loses a name. `CloudConnectionPlugin` imports the contract's constant for `providesServices` and `registerService`. - `@objectstack/service-push`: `transports/relay.ts` imports the contract's constant. `CLOUD_BINDING_SERVICE` **was** a public export of the package (`src/index.ts`), so `relay.ts` keeps it as a re-export (`export { CLOUD_BINDING_SERVICE }`), same name, same value; `index.ts` is untouched. **2. One reading of an empty `controlPlaneUrl` (route A).** At `main`, `createCloudBinding` answered `resolveCloudUrl(option)`, which reads an explicit `''` as unset and falls through to `OS_CLOUD_URL` and the production default, while the plugin's routes read `(option ?? OS_CLOUD_URL ?? '')` and treated `''` as "cloud not configured". - `createCloudBinding`'s `controlPlaneUrl()` is now `resolveCloudUrl`'s answer except that an explicit empty or blank option answers `''`, the reading the routes always gave it. - The routes take `cloudUrl` from `this.binding.controlPlaneUrl()` (still read once at `kernel:ready`, where the env was read before), so the routes and the push relay sender read one answer. - The contract's docblock already says `controlPlaneUrl()` is `''` when the deployment declared no control plane; a host composing the plugin with `controlPlaneUrl: ''` is that declaration, so the contract text is unchanged and the reading is written down on the option (`CloudBindingOptions.controlPlaneUrl`, `CloudConnectionPluginConfig.controlPlaneUrl`). One changeset, `.changeset/30762-cloud-binding-service-name.md`: `@objectstack/spec`, `@objectstack/cloud-connection`, `@objectstack/service-push`, all `patch`. ## Tests - `cloud-connection/src/cloud-connection-plugin.test.ts`, new top-level describe "the routes and the binding read the control-plane URL once": boots the plugin through `init()` and `start()`, drives `GET /installed` (multi-tenant, service key set) and reads the registered binding. An explicit `''` and `' '` with `OS_CLOUD_URL` set: binding `''`, no control-plane call, the disconnected answer. A declined `OS_CLOUD_URL=off` under an absent option: binding `''`, no call. A configured URL and an env-only URL: the route calls exactly `${binding}/api/v1/cloud/environments/env-123/packages`. - `cloud-connection/src/cloud-binding.test.ts`: `controlPlaneUrl()` is `''` for an explicit `''` or `' '`, with and without `OS_CLOUD_URL`; `CLOUD_BINDING_SERVICE` is `'cloud-binding'` (the wire name), and the registration test reads the contract's constant. - `service-push/src/push-plugin.test.ts`: the two tests that place a binding now register it under the contract's `CLOUD_BINDING_SERVICE` instead of the literal, so the registrar's test and the reader's test meet on one constant (the cross-package pin the review asked for). <details><summary>Ablations (each red, each restored; <code>scripts/ablation-replace.mjs</code> WRAP mode, <code>git diff HEAD</code> empty after each)</summary> | # | Mutation | Test run | Red | |:--|:--|:--|:--| | A1 | routes read the option `main`'s way (`option ?? OS_CLOUD_URL ?? ''`) | `cloud-connection-plugin.test.ts` | 1 of 17: the declined `OS_CLOUD_URL` case (`main`'s routes called `off/api/...`) | | A2 | binding answers `resolveCloudUrl(option)` (`main`'s shape) | `cloud-binding.test.ts` + `cloud-connection-plugin.test.ts` | 4 of 26: both empty-option binding pins, both empty-option route pins | | B1 | `relay.ts` declares its own `CLOUD_BINDING_SERVICE = 'cloud_binding'` | `push-plugin.test.ts` | 2 of 19: the bound send and the bind-without-restart case | | B2 | the plugin registers under a local `'cloud_binding'` | `cloud-binding.test.ts` + `cloud-connection-plugin.test.ts` | 6 of 26: the registration test and all five route/binding pins | `cloud-connection` tests import the plugin and the binding from `src/` by relative path, and `service-push` aliases `@objectstack/spec/contracts` to source (below), so no dist rebuild sits between a mutation and its run. </details> ## Acceptance notes - **Route A, and what it moves beyond `''`.** Reading the routes' URL through the binding also aligns two other options, both reachable only on a hand-composed kernel: (a) an absent option with a declined `OS_CLOUD_URL` (`off`, `none`, `local`, `disabled`): the routes used `'off'` as a base URL and called `off/api/v1/...`; they now answer `CLOUD_UNCONFIGURED`, as the binding already did. (b) an absent option with `OS_CLOUD_URL` unset or empty: the routes answered `CLOUD_UNCONFIGURED`; they now use the ObjectStack-operated default, as the binding, `MarketplaceProxyPlugin` and `MarketplaceInstallLocalPlugin` (`resolveCloudUrl(config.controlPlaneUrl)`) already did, and as the config docblock now says. - **`objectstack serve` is unchanged.** `planMarketplaceWiring` composes the plugin only when `resolveCloudUrl()` is non-empty and passes that value (`serve.ts`, `createCloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: marketplaceUrl })`); `examples/app-showcase` does the same. That value is already trimmed, slash-stripped and not a decline spelling, so `main`'s route expression and `resolveBindingUrl` both return it byte-for-byte. - **Not changed:** `MarketplaceProxyPlugin` and `MarketplaceInstallLocalPlugin` still read an explicit `''` through `resolveCloudUrl` (as the default). They are outside this card's surface and are never composed with `''` by `serve`; noted, not filed. - **Surface widened (claim re-posted by the seat):** `packages/services/service-push/vitest.config.ts` gains one anchored alias, `/^@objectstack\/spec\/contracts$/` → `../../spec/src/contracts/index.ts`. Gate-forced: `relay.ts` now imports a VALUE from that subpath, and without the alias `check:test-source-alias` exits 1 on service-push ("tests import 1 workspace package(s) that resolve to `dist/` with no source alias"); `KNOWN_UNALIASED_TEST_IMPORTS` is shrink-only. With it the gate exits 0. `push-plugin.test.ts` is the other widened path (above). - `Metadata-protocol: none`, re-derived from the diff: no metadata author writes a key, value or type this changes. `Clause-②: yes (widening)`: one added export on `@objectstack/spec/contracts`. - Serial check before the first push: open PRs touching `packages/cloud-connection/**` or `service-push/**`: only #30935, which edits `cloud-connection-ui.ts` alone. ## Verification At head `d1e31826` (the merge of `origin/main` 3479c33; the two incoming commits touch none of this diff's packages). Package tests and ablations ran at `9fdd39ad`, the fix commit, which this head carries unchanged. <details><summary>Builds, typecheck, package tests</summary> - `pnpm --filter @objectstack/spec build` (lock VERDICT command-exit 0), then `pnpm --filter '@objectstack/cloud-connection^...' --filter '@objectstack/service-push^...' build` (VERDICT command-exit 0). - `pnpm --filter @objectstack/cloud-connection test`: Test Files 72 passed (72), Tests 925 passed (925). `typecheck` exit 0. - `pnpm --filter @objectstack/service-push test`: Test Files 5 passed (5), Tests 74 passed (74). `typecheck` exit 0. - `pnpm --filter @objectstack/spec check:generated` before regeneration named exactly `api-surface/` and `export-origins/` (plus `test-typecheck-debt.json`, whose `tsc` exited without diagnostics against a stale dist; after a rebuild on the final head `check:generated` exits 0 with all 14 artifacts current). </details> <details><summary>Gates (dispatch-gates derivation, 88 families + 3 named)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 88 families at `d1e31826` (11 changed paths vs merge base 3479c33). All 88 plus `pnpm lint`, `pnpm check:cli-command-ids` and `pnpm check:adr-symbol-anchors`: **91 of 91 exit 0**. - First pass: 84 exit 0; six `packages/spec` dist readers exited 3 or 1 on a dist built before the `origin/main` merge (declaration stamp mismatch: `check:api-surface`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any` exit 3, `check:generated` exit 1 naming only `api-surface/`), and `check:dual-build-cjs-loads` exited 3 (no whole-tree dist). After `pnpm --filter @objectstack/spec build` the six exit 0; after `turbo run build --concurrency=2` (82 of 82 tasks) `check:dual-build-cjs-loads` exits 0 (116 require entries across 71 packages load). - `--ran` reconciliation: 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). - `pnpm check:test-source-alias` exits 1 without the new service-push alias and 0 with it. - Exit-code file: `gates-ran-d1e31826.txt` (seat project folder). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cwsvPqjJfJthsRiYpt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d313f3 commit 000a4c0

11 files changed

Lines changed: 158 additions & 19 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/cloud-connection': patch
4+
'@objectstack/service-push': patch
5+
---
6+
7+
fix(cloud-connection): name the `cloud-binding` service once, in its contract, and read an empty `controlPlaneUrl` one way
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
`@objectstack/spec/contracts` now exports `CLOUD_BINDING_SERVICE` (`'cloud-binding'`) beside `ICloudBindingService`, the way the sibling contracts export their service names. `@objectstack/cloud-connection` registers the binding under it and `@objectstack/service-push`'s relay sender reads it, so the registrar and the reader can no longer spell the name two ways. `@objectstack/service-push` keeps exporting `CLOUD_BINDING_SERVICE`, now a re-export of the contract's constant with the same value.
13+
14+
`CloudConnectionPlugin`'s routes now take their control-plane URL from the `cloud-binding` service they register, so the routes and the binding read the `controlPlaneUrl` option the same way:
15+
16+
- An explicit empty (or blank) `controlPlaneUrl` means "cloud not configured" for both: the routes answer `CLOUD_UNCONFIGURED` as before, and `controlPlaneUrl()` now answers `''` instead of the production default.
17+
- A declined `OS_CLOUD_URL` (`off`, `none`, `local`, `disabled`) under an absent option is "cloud not configured" for the routes too, as it already was for the binding.
18+
- An absent option falls through to `OS_CLOUD_URL`, then the ObjectStack-operated default, for the routes too, as it already did for the binding and for the marketplace plugins.
19+
20+
`objectstack serve` and the showcase app are unchanged: they compose the plugin only with `resolveCloudUrl()`'s non-empty answer, which both readings return as is.

‎packages/cloud-connection/src/cloud-binding.test.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
1111
import { tmpdir } from 'node:os';
1212
import { join } from 'node:path';
1313
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14-
import type { ICloudBindingService } from '@objectstack/spec/contracts';
15-
import { CLOUD_BINDING_SERVICE, createCloudBinding } from './cloud-binding.js';
14+
import { CLOUD_BINDING_SERVICE, type ICloudBindingService } from '@objectstack/spec/contracts';
15+
import { createCloudBinding } from './cloud-binding.js';
1616
import { CloudConnectionPlugin } from './cloud-connection-plugin.js';
1717
import { ConnectionCredentialStore } from './connection-credential-store.js';
1818
import { DEFAULT_CLOUD_URL } from './cloud-url.js';
@@ -70,9 +70,22 @@ describe('createCloudBinding — the control-plane URL', () => {
7070
vi.stubEnv('OS_CLOUD_URL', 'off');
7171
expect(createCloudBinding({ store }).controlPlaneUrl()).toBe('');
7272
});
73+
74+
// [#30762] An explicit empty option is the host saying "no control plane",
75+
// the reading the plugin's routes always gave it. It does not fall through
76+
// to OS_CLOUD_URL or the default the way an absent option does.
77+
it.each(['', ' '])('is empty for an explicit empty option %j, over OS_CLOUD_URL and the default', (option) => {
78+
expect(createCloudBinding({ store, controlPlaneUrl: option }).controlPlaneUrl()).toBe('');
79+
vi.stubEnv('OS_CLOUD_URL', 'https://env.example.com');
80+
expect(createCloudBinding({ store, controlPlaneUrl: option }).controlPlaneUrl()).toBe('');
81+
});
7382
});
7483

7584
describe('CloudConnectionPlugin — registers the binding in init()', () => {
85+
it('registers it under the name the contract exports, the one service-push reads', () => {
86+
expect(CLOUD_BINDING_SERVICE).toBe('cloud-binding');
87+
});
88+
7689
it('registers cloud-binding, declared in providesServices, reading the plugin\'s own credential store', async () => {
7790
const services = new Map<string, unknown>();
7891
const plugin = new CloudConnectionPlugin({ credentialPath: join(dir, 'cloud-connection.json'), controlPlaneUrl: 'https://cp.example.com' });

‎packages/cloud-connection/src/cloud-binding.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import type { ICloudBindingService } from '@objectstack/spec/contracts';
1919
import { resolveCloudUrl } from './cloud-url.js';
2020
import type { ConnectionCredentialStore } from './connection-credential-store.js';
2121

22-
/** The kernel service name {@link createCloudBinding}'s answer is registered under. */
23-
export const CLOUD_BINDING_SERVICE = 'cloud-binding';
24-
2522
export interface CloudBindingOptions {
2623
/** The plugin's `controlPlaneApiKey` option (the env→cloud service key); `OS_CLOUD_API_KEY` when absent. */
2724
controlPlaneApiKey?: string;
28-
/** The plugin's `controlPlaneUrl` option; `OS_CLOUD_URL`, then the default, when absent. */
25+
/**
26+
* The plugin's `controlPlaneUrl` option; `OS_CLOUD_URL`, then the default,
27+
* when absent. An explicit empty (or blank) value declares that this
28+
* server has no control plane, the same as a decline spelling.
29+
*/
2930
controlPlaneUrl?: string;
3031
/** Where a self-hosted bind persisted its `oscc_` bearer. */
3132
store: Pick<ConnectionCredentialStore, 'read'>;
@@ -40,13 +41,26 @@ export interface CloudBindingOptions {
4041
* derivation `isCloudManaged` reads in `@objectstack/core`
4142
* (`resolveControlPlaneApiKey`), so "platform-managed" and "which bearer"
4243
* cannot disagree.
44+
*
45+
* The control-plane URL is {@link resolveCloudUrl}'s answer, with one
46+
* exception: an explicit empty option answers `''`, "no control plane".
47+
* `resolveCloudUrl` reads that `''` as unset and falls through to the
48+
* environment and the default, while this plugin's own routes read it as
49+
* "cloud not configured". The routes read their URL through this member, so
50+
* the binding and the routes give the one option one reading.
4351
*/
4452
export function createCloudBinding(options: CloudBindingOptions): ICloudBindingService {
4553
const credential = (): string =>
4654
resolveControlPlaneApiKey(options.controlPlaneApiKey) || options.store.read()?.runtimeToken || '';
4755
return {
4856
credential,
4957
bound: () => credential() !== '',
50-
controlPlaneUrl: () => resolveCloudUrl(options.controlPlaneUrl),
58+
controlPlaneUrl: () => resolveBindingUrl(options.controlPlaneUrl),
5159
};
5260
}
61+
62+
/** {@link resolveCloudUrl}, except that an explicit empty (or blank) option answers `''`. */
63+
function resolveBindingUrl(explicit: string | undefined): string {
64+
if (typeof explicit === 'string' && explicit.trim() === '') return '';
65+
return resolveCloudUrl(explicit);
66+
}

‎packages/cloud-connection/src/cloud-connection-plugin.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
13+
import { CLOUD_BINDING_SERVICE, type ICloudBindingService } from '@objectstack/spec/contracts';
1314
import { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
1415
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
1516

@@ -268,3 +269,64 @@ describe('single-environment mode (host auth, fixed env id)', () => {
268269
expect(res.payload.data.environmentId).toBe('env-from-env-var');
269270
});
270271
});
272+
273+
/**
274+
* [#30762] One reading of the control-plane URL. The routes read it through
275+
* the `cloud-binding` service the plugin registers, the same answer the push
276+
* relay sender reads, so the two cannot disagree on an option. An explicit
277+
* empty option is "cloud not configured" for both, and does not fall through
278+
* to `OS_CLOUD_URL` or the default.
279+
*/
280+
describe('CloudConnectionPlugin — the routes and the binding read the control-plane URL once', () => {
281+
const ENV = 'env-123';
282+
const services = {
283+
'env-registry': { resolveByHostname: async () => ({ environmentId: ENV }) },
284+
'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => sessionAuth('user-1') }) },
285+
};
286+
287+
afterEach(() => { vi.unstubAllEnvs(); });
288+
289+
/** Boot the plugin, drive `GET /installed` once, and answer where it went and what the binding says. */
290+
async function readBoth(controlPlaneUrl: string | undefined) {
291+
const rawApp = makeRawApp();
292+
const { ctx, fireKernelReady } = makeCtx({ rawApp, services });
293+
const registered = new Map<string, unknown>();
294+
const fetchSpy = vi.fn(async (_url: string) => new Response(JSON.stringify({ success: true, data: { packages: [], total: 0 } }), { status: 200 }));
295+
vi.stubGlobal('fetch', fetchSpy);
296+
const plugin = new CloudConnectionPlugin({ controlPlaneApiKey: 'svc-key', ...(controlPlaneUrl === undefined ? {} : { controlPlaneUrl }) });
297+
await plugin.init({ ...ctx, registerService: (name: string, svc: unknown) => registered.set(name, svc) } as any);
298+
await plugin.start(ctx as any);
299+
await fireKernelReady();
300+
const res = await rawApp.routes.get('GET /api/v1/cloud-connection/installed')!(
301+
makeC('https://t1.example.com/api/v1/cloud-connection/installed'),
302+
);
303+
const binding = registered.get(CLOUD_BINDING_SERVICE) as ICloudBindingService;
304+
const called = fetchSpy.mock.calls.map(([url]) => String(url));
305+
return { res, binding: binding.controlPlaneUrl(), called };
306+
}
307+
308+
it.each(['', ' '])('an explicit empty option %j is "cloud not configured" for both, over OS_CLOUD_URL', async (option) => {
309+
vi.stubEnv('OS_CLOUD_URL', 'https://env.example.com');
310+
const { res, binding, called } = await readBoth(option);
311+
expect(binding).toBe('');
312+
expect(called).toEqual([]);
313+
expect(res.payload).toEqual({ success: true, data: { packages: [], total: 0, connected: false } });
314+
});
315+
316+
it('a declined OS_CLOUD_URL under an absent option is "cloud not configured" for both', async () => {
317+
vi.stubEnv('OS_CLOUD_URL', 'off');
318+
const { binding, called } = await readBoth(undefined);
319+
expect(binding).toBe('');
320+
expect(called).toEqual([]);
321+
});
322+
323+
it.each([
324+
['the configured URL', 'https://cp.example.com/', 'https://env.example.com', 'https://cp.example.com'],
325+
['OS_CLOUD_URL under an absent option', undefined, 'https://env.example.com/', 'https://env.example.com'],
326+
])('%s: the routes call the URL the binding answers', async (_label, option, env, expected) => {
327+
vi.stubEnv('OS_CLOUD_URL', env);
328+
const { binding, called } = await readBoth(option);
329+
expect(binding).toBe(expected);
330+
expect(called).toEqual([`${expected}/api/v1/cloud/environments/${ENV}/packages`]);
331+
});
332+
});

‎packages/cloud-connection/src/cloud-connection-plugin.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ import {
149149
import type { AuthSessionApi, IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts';
150150
import { inProcessSessionReadInput } from '@objectstack/types';
151151
import { ConnectionCredentialStore } from './connection-credential-store.js';
152-
import { CLOUD_BINDING_SERVICE, createCloudBinding } from './cloud-binding.js';
153-
import type { ICloudBindingService } from '@objectstack/spec/contracts';
152+
import { createCloudBinding } from './cloud-binding.js';
153+
import { CLOUD_BINDING_SERVICE, type ICloudBindingService } from '@objectstack/spec/contracts';
154154
import { CLOUD_CONNECTION_CAPABILITY, CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';
155155
import { readJsonRequestBody } from './request-json-body.js';
156156

@@ -266,7 +266,13 @@ interface ResolvedSession {
266266
const ENVIRONMENT_NOT_FOUND_MESSAGE = 'Could not resolve an environment for this request host.';
267267

268268
export interface CloudConnectionPluginConfig {
269-
/** Control-plane base URL. Default: `OS_CLOUD_URL` (read lazily at kernel:ready). */
269+
/**
270+
* Control-plane base URL. Default: `OS_CLOUD_URL` (read lazily at
271+
* kernel:ready), then the ObjectStack-operated default. An explicit `''`,
272+
* or a decline spelling (`off`, `none`, `local`, `disabled`), means "cloud
273+
* not configured": the routes answer `CLOUD_UNCONFIGURED` and the
274+
* `cloud-binding` service's `controlPlaneUrl()` answers `''`.
275+
*/
270276
controlPlaneUrl?: string;
271277
/** env→cloud service credential. Default: `OS_CLOUD_API_KEY`. */
272278
controlPlaneApiKey?: string;
@@ -333,8 +339,12 @@ export class CloudConnectionPlugin implements Plugin {
333339
const rawApp = httpServer.getRawApp();
334340
// Env vars are read here (not at construction) so a host can set
335341
// them between config load and kernel boot — preserves the
336-
// behavior of the original app-level wiring.
337-
const cloudUrl = (this.cfg.controlPlaneUrl ?? process.env.OS_CLOUD_URL ?? '').trim().replace(/\/+$/, '');
342+
// behavior of the original app-level wiring. [#30762] The URL is
343+
// the `cloud-binding` service's answer, the one the push relay
344+
// sender reads too: an explicit empty option means "cloud not
345+
// configured" for both, and an absent one falls through to
346+
// `OS_CLOUD_URL`, then the default, as `resolveCloudUrl` does.
347+
const cloudUrl = this.binding.controlPlaneUrl();
338348
const cloudApiKey = resolveControlPlaneApiKey(this.cfg.controlPlaneApiKey);
339349
const deviceClientId = (this.cfg.deviceClientId ?? process.env.OS_CLI_CLIENT_ID ?? 'objectstack-cli').trim();
340350
const deviceScope = 'openid profile email';

‎packages/services/service-push/src/push-plugin.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
9-
import type { ICloudBindingService } from '@objectstack/spec/contracts';
9+
import { CLOUD_BINDING_SERVICE, type ICloudBindingService } from '@objectstack/spec/contracts';
1010
import { PushServicePlugin, PUSH_RELAY_AIR_GAPPED_MESSAGE } from './push-plugin.js';
1111
import { PushService, type IPushTransport, type PushMessage } from './push-service.js';
1212
import { envPushCredentialResolver } from './credential.js';
@@ -98,7 +98,7 @@ describe('PushServicePlugin — the hosted relay', () => {
9898

9999
it('bound: lists both vendors and sends one POST carrying the binding credential, which no log line carries', async () => {
100100
const { ctx, services, lines, fire } = context();
101-
services.set('cloud-binding', cloudBinding(() => SECRET));
101+
services.set(CLOUD_BINDING_SERVICE, cloudBinding(() => SECRET));
102102
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
103103
const relayFetch = (async (url: string, init: RequestInit) => {
104104
calls.push({ url, headers: init.headers as Record<string, string> });
@@ -122,7 +122,7 @@ describe('PushServicePlugin — the hosted relay', () => {
122122
it('unbound at kernel:ready: warns once, and the bind takes effect without a restart', async () => {
123123
const { ctx, services, lines, fire } = context();
124124
let credential = '';
125-
services.set('cloud-binding', cloudBinding(() => credential));
125+
services.set(CLOUD_BINDING_SERVICE, cloudBinding(() => credential));
126126
const plugin = new PushServicePlugin({ config: {} });
127127
await plugin.init(ctx);
128128
await plugin.start(ctx);

‎packages/services/service-push/src/transports/relay.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type PushRelayPayload,
88
type PushRelayRequest,
99
} from '@objectstack/spec/api';
10-
import type { ICloudBindingService } from '@objectstack/spec/contracts';
10+
import { CLOUD_BINDING_SERVICE, type ICloudBindingService } from '@objectstack/spec/contracts';
1111
import type { PushRelayTransportConfigParsed, PushVendor } from '@objectstack/spec/system';
1212
import type { IPushTransport, PushMessage, PushSendResult } from '../push-service.js';
1313

@@ -22,8 +22,13 @@ import type { IPushTransport, PushMessage, PushSendResult } from '../push-servic
2222
*/
2323
export const PUSH_RELAY_PATH = '/api/v1/cloud/push/send';
2424

25-
/** The kernel service the binding credential is read from (`ICloudBindingService`, `@objectstack/cloud-connection`). */
26-
export const CLOUD_BINDING_SERVICE = 'cloud-binding';
25+
/**
26+
* The kernel service the binding credential is read from (`ICloudBindingService`,
27+
* registered by `@objectstack/cloud-connection`). The name lives beside the
28+
* contract in `@objectstack/spec/contracts`; re-exported here so this
29+
* package's existing public export keeps its spelling and its value.
30+
*/
31+
export { CLOUD_BINDING_SERVICE };
2732

2833
/**
2934
* The visible line a relay delivery shows when the caller names none: the

‎packages/services/service-push/vitest.config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export default defineConfig({
2424
// from `@objectstack/core`: both from source, for the same reason.
2525
{ find: /^@objectstack\/spec\/api$/, replacement: path.resolve(__dirname, '../../spec/src/api/index.ts') },
2626
{ find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') },
27+
// The relay sender reads the `cloud-binding` service name from the
28+
// contract (`CLOUD_BINDING_SERVICE`, `@objectstack/spec/contracts`): from
29+
// source, for the same reason.
30+
{ find: /^@objectstack\/spec\/contracts$/, replacement: path.resolve(__dirname, '../../spec/src/contracts/index.ts') },
2731
],
2832
},
2933
});

‎packages/spec/api-surface/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"AuthoredRowWriteVerdict (type)",
5959
"AutomationContext (interface)",
6060
"AutomationResult (interface)",
61+
"CLOUD_BINDING_SERVICE (const)",
6162
"CRYPTO_CONTEXT_SCOPES (const)",
6263
"CacheStats (interface)",
6364
"ChatWithToolsOptions (interface)",

‎packages/spec/export-origins/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"AuthoredRowWriteVerdict": "src/contracts/security-service.ts#AuthoredRowWriteVerdict (type)",
5959
"AutomationContext": "src/contracts/automation-service.ts#AutomationContext (interface)",
6060
"AutomationResult": "src/contracts/automation-service.ts#AutomationResult (interface)",
61+
"CLOUD_BINDING_SERVICE": "src/contracts/cloud-binding-service.ts#CLOUD_BINDING_SERVICE (const)",
6162
"CRYPTO_CONTEXT_SCOPES": "src/contracts/crypto-provider.ts#CRYPTO_CONTEXT_SCOPES (const)",
6263
"CacheStats": "src/contracts/cache-service.ts#CacheStats (interface)",
6364
"ChatWithToolsOptions": "src/contracts/ai-service.ts#ChatWithToolsOptions (interface)",

0 commit comments

Comments
 (0)