diff --git a/.changeset/cloud-connection-package.md b/.changeset/cloud-connection-package.md new file mode 100644 index 0000000000..2dd8d35439 --- /dev/null +++ b/.changeset/cloud-connection-package.md @@ -0,0 +1,5 @@ +--- +"@objectstack/cloud-connection": minor +--- + +New package: `@objectstack/cloud-connection` — the open runtime-side client for an ObjectStack cloud control plane (ADR-0008 Phase 2). Carries the marketplace browse proxy, install-local, the `/api/v1/cloud-connection/*` surface (status, RFC 8628 device-code bind, org catalog, installed views, control-plane install), and `RuntimeConfigPlugin` with a `resolvePlanFeatures` policy seam (plan entitlements stay host-side). Canonical sources move here from the cloud distribution's `@objectstack/objectos-runtime`, which now re-exports them. diff --git a/.changeset/config.json b/.changeset/config.json index 1aa079b5ad..5ebfaca322 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -12,6 +12,7 @@ "@objectstack/spec", "@objectstack/cli", "@objectstack/console", + "@objectstack/cloud-connection", "@objectstack/core", "@objectstack/client", "@objectstack/client-react", diff --git a/packages/cloud-connection/LICENSE b/packages/cloud-connection/LICENSE new file mode 100644 index 0000000000..16bc23f404 --- /dev/null +++ b/packages/cloud-connection/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cloud-connection/README.md b/packages/cloud-connection/README.md new file mode 100644 index 0000000000..64efb21d7b --- /dev/null +++ b/packages/cloud-connection/README.md @@ -0,0 +1,52 @@ +# @objectstack/cloud-connection + +The runtime-side client for an ObjectStack cloud control plane (ADR-0008). + +Connects any ObjectStack runtime — vanilla `objectstack dev`, a self-hosted +single-environment deployment, or a multi-tenant fleet — to a control plane +for package distribution. Capability progresses with binding: + +| State | Capability | Plugin / routes | +|---|---|---| +| Unbound (anonymous) | Browse the public marketplace catalog | `MarketplaceProxyPlugin` → `/api/v1/marketplace/*` | +| Unbound (anonymous) | Install public packages into THIS runtime | `MarketplaceInstallLocalPlugin` → `/api/v1/marketplace/install-local` | +| Bound (device-code) | Status, bind, org catalog, installed views, control-plane installs | `CloudConnectionPlugin` → `/api/v1/cloud-connection/*` | +| Always | SPA feature discovery | `RuntimeConfigPlugin` → `/api/v1/runtime/config` | + +## Usage + +```ts +import { + MarketplaceProxyPlugin, + MarketplaceInstallLocalPlugin, + CloudConnectionPlugin, + RuntimeConfigPlugin, + resolveCloudUrl, +} from '@objectstack/cloud-connection'; + +const cloudUrl = resolveCloudUrl(); // OS_CLOUD_URL, 'off' disables + +const plugins = [ + ...(cloudUrl ? [ + new MarketplaceProxyPlugin({ controlPlaneUrl: cloudUrl }), + new MarketplaceInstallLocalPlugin({ controlPlaneUrl: cloudUrl }), + new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: cloudUrl }), + ] : []), + new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }), +]; +``` + +## Boundary (open mechanism, closed intelligence) + +This package is **mechanism**: proxying a catalog, installing into the local +kernel, performing an RFC 8628 device-code bind, and reporting flags to the +SPA. The **policy** stays server-side in whatever control plane you point it +at: org-catalog filtering, entitlements for paid packages, quotas, and plan +rules. Plan-derived feature flags are injected by the host via +`RuntimeConfigPluginConfig.resolvePlanFeatures`. + +`OS_CLOUD_URL=off` disables every remote call; air-gapped installs keep +working via inline manifests handed to `install-local`. + +See `docs/adr` in the cloud repository (ADR-0008) for the full architecture +decision. diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json new file mode 100644 index 0000000000..c911b36492 --- /dev/null +++ b/packages/cloud-connection/package.json @@ -0,0 +1,57 @@ +{ + "name": "@objectstack/cloud-connection", + "version": "9.2.0", + "license": "Apache-2.0", + "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (ADR-0008): the hub service, plan policy, and entitlements stay server-side.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup --config ../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/runtime": "workspace:*", + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.2", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + }, + "keywords": [ + "objectstack", + "cloud-connection", + "marketplace", + "package-install", + "device-code", + "control-plane" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/framework.git", + "directory": "packages/cloud-connection" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/framework/issues", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/cloud-connection/src/cloud-connection-plugin.test.ts b/packages/cloud-connection/src/cloud-connection-plugin.test.ts new file mode 100644 index 0000000000..9fdc149aa4 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-plugin.test.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * CloudConnectionPlugin — route mounting + mode behavior (ADR-0008 Phase 1). + * + * Exercises the consolidated /api/v1/cloud-connection/* surface in both + * resolution modes: + * - multi-tenant: env-registry hostname lookup + per-env kernel session + * - single-environment: fixed env id + host-kernel auth session + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (path: string, h: Handler) => routes.set(`GET ${path}`, h), + post: (path: string, h: Handler) => routes.set(`POST ${path}`, h), + }; +} + +function makeCtx(opts: { + rawApp: ReturnType; + services?: Record; +}) { + const hooks = new Map any>(); + return { + ctx: { + hook: (event: string, handler: (...args: any[]) => any) => hooks.set(event, handler), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => opts.rawApp }; + const svc = opts.services?.[name]; + if (svc === undefined) throw new Error(`service ${name} not registered`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }, + fireKernelReady: async () => { await hooks.get('kernel:ready')?.(); }, + }; +} + +function makeC(url: string, body?: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url, + raw: new Request(url), + json: async () => body ?? {}, + }, + json, + }; +} + +const sessionAuth = (userId?: string) => ({ + api: { getSession: async () => (userId ? { user: { id: userId } } : null) }, +}); + +beforeEach(() => { + delete process.env.OS_ENVIRONMENT_ID; +}); +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OS_ENVIRONMENT_ID; +}); + +describe('CloudConnectionPlugin — mounting', () => { + it('mounts all 7 routes on kernel:ready', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp }); + const plugin = new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test' }); + await plugin.start(ctx as any); + await fireKernelReady(); + const keys = [...rawApp.routes.keys()]; + expect(keys).toEqual(expect.arrayContaining([ + 'GET /api/v1/cloud-connection/status', + 'POST /api/v1/cloud-connection/bind/start', + 'POST /api/v1/cloud-connection/bind/poll', + 'POST /api/v1/cloud-connection/install', + 'GET /api/v1/cloud-connection/installation', + 'GET /api/v1/cloud-connection/installed', + 'GET /api/v1/cloud-connection/org-packages', + ])); + expect(keys).toHaveLength(7); + }); + + it('warns and mounts nothing without an http-server', async () => { + const hooks = new Map(); + const warn = vi.fn(); + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: () => { throw new Error('nope'); }, + logger: { warn }, + }; + await createCloudConnectionPlugin().start(ctx as any); + await hooks.get('kernel:ready')?.(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('http-server unavailable')); + }); +}); + +describe('multi-tenant mode (env-registry + per-env kernel auth)', () => { + const ENV = 'env-123'; + function mtServices(userId?: string) { + return { + 'env-registry': { resolveByHostname: async () => ({ environmentId: ENV }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => sessionAuth(userId) }) }, + }; + } + + it('status falls back to implicit binding from the service key when the control plane is down', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: mtServices() }); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); })); + await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('https://t1.example.com/api/v1/cloud-connection/status')); + expect(res.payload).toEqual({ success: true, data: { environmentId: ENV, bound: true, provider: 'objectstack-cloud', connection: null } }); + }); + + it('status 404s for an unknown hostname', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ + rawApp, + services: { 'env-registry': { resolveByHostname: async () => null } }, + }); + await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('https://nope.example.com/x')); + expect(res.status).toBe(404); + expect(res.payload.error.code).toBe('environment_not_found'); + }); + + it('install rejects unauthenticated callers with 401 (no control-plane call)', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: mtServices(undefined) }); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('POST /api/v1/cloud-connection/install')!( + makeC('https://t1.example.com/api/v1/cloud-connection/install', { package_id: 'pkg_1' }), + ); + expect(res.status).toBe(401); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('install forwards to the control plane install action for an authenticated session', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: mtServices('user-1') }); + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { installed: true } }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('POST /api/v1/cloud-connection/install')!( + makeC('https://t1.example.com/api/v1/cloud-connection/install', { package_id: 'pkg_1', seed_sample_data: true }), + ); + expect(res.payload.success).toBe(true); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe('http://cloud.test/api/v1/actions/sys_package/install_package'); + expect(JSON.parse((init as any).body)).toEqual({ recordId: 'pkg_1', params: { environment_id: ENV, seed_sample_data: true } }); + expect((init as any).headers.Authorization).toBe('Bearer svc-key'); + }); + + it('installed degrades to an empty disconnected list without cloud credentials', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: mtServices('user-1') }); + await new CloudConnectionPlugin({ controlPlaneUrl: '', controlPlaneApiKey: '' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/installed')!( + makeC('https://t1.example.com/api/v1/cloud-connection/installed'), + ); + expect(res.payload).toEqual({ success: true, data: { packages: [], total: 0, connected: false } }); + }); + + it('org-packages forwards environment_id and unwraps items', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: mtServices('user-1') }); + const items = [{ id: 'pkg_a', manifest_id: 'com.acme.crm' }]; + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { items } }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/org-packages')!( + makeC('https://t1.example.com/api/v1/cloud-connection/org-packages'), + ); + expect(String(fetchSpy.mock.calls[0]![0])).toBe(`http://cloud.test/api/v1/cloud/org-packages?environment_id=${ENV}`); + expect(res.payload.data).toEqual({ items, total: 1, connected: true }); + }); +}); + +describe('single-environment mode (host auth, fixed env id)', () => { + it('status reports bound:false (200, not 404) before the runtime is bound', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp }); + await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x')); + expect(res.status).toBe(200); + expect(res.payload.data).toEqual({ environmentId: null, bound: false, provider: 'objectstack-cloud', connection: null }); + }); + + it('uses the configured environment id + the host kernel auth session', async () => { + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1') } }); + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { packages: [{ id: 'p1' }] } }), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + await new CloudConnectionPlugin({ + singleEnvironment: true, + environmentId: 'env-ee-1', + controlPlaneUrl: 'http://cloud.test', + controlPlaneApiKey: 'svc-key', + }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/installed')!(makeC('http://localhost:3000/api/v1/cloud-connection/installed')); + expect(String(fetchSpy.mock.calls[0]![0])).toContain('/api/v1/cloud/environments/env-ee-1/packages'); + expect(res.payload.data.total).toBe(1); + }); + + it('falls back to OS_ENVIRONMENT_ID when no config id is given', async () => { + process.env.OS_ENVIRONMENT_ID = 'env-from-env-var'; + const rawApp = makeRawApp(); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1') } }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true, data: {} }), { status: 200 }))); + await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'k' }).start(ctx as any); + await fireKernelReady(); + const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x')); + expect(res.payload.data.environmentId).toBe('env-from-env-var'); + }); +}); diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts new file mode 100644 index 0000000000..e22ffd3f41 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -0,0 +1,455 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * CloudConnectionPlugin — the runtime-side client surface for a cloud + * control plane (ADR-0008 Phase 1). + * + * Mounts the same-origin `/api/v1/cloud-connection/*` routes the Console + * marketplace depends on: + * + * GET /api/v1/cloud-connection/status — is this env bound to a cloud account? + * POST /api/v1/cloud-connection/bind/start — begin an RFC 8628 device-code bind + * POST /api/v1/cloud-connection/bind/poll — poll device token + persist the binding + * POST /api/v1/cloud-connection/install — install a package via the control plane + * GET /api/v1/cloud-connection/installation — single-package installed-state probe + * GET /api/v1/cloud-connection/installed — env's full installed list (Installed view) + * GET /api/v1/cloud-connection/org-packages — owning org's own catalog ("Your organization") + * + * History: these routes started as app-level wiring in + * `apps/objectos/cloud-runtime-plugins.ts` (two ad-hoc plugins). ADR-0008 + * Phase 1 consolidates them here as ONE plugin so both deployment shapes — + * `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment) + * — wire the same canonical implementation. ADR-0008 Phase 2 moves this + * surface into the open `@objectstack/cloud-connection` package; keep this + * file dependency-light (structural Plugin types, no @objectstack/core) so + * the move is mechanical. + * + * ## Environment & session resolution — two modes + * + * Multi-tenant (default): the request hostname is resolved to an environment + * via the host kernel's `env-registry` service, and the caller's session is + * validated against that environment's OWN per-env kernel (via + * `kernel-manager`) — auth is per-environment (ADR-0006). + * + * Single-environment (`singleEnvironment: true`, e.g. objectos-ee): there is + * no registry/manager; the environment id comes from config + * (`environmentId` / `OS_ENVIRONMENT_ID`, typically assigned when the + * runtime is bound to a control plane) and sessions are validated against + * the host kernel's own `auth` service. Routes degrade gracefully when the + * id is not (yet) configured: status reports `bound:false`; the others 404. + * + * ## Why a same-origin proxy at all + * + * The SPA cannot call the control plane from a tenant subdomain — that is a + * cross-origin, cross-site-cookie request the browser blocks. So the runtime + * answers on its own origin, authorizes against the environment's session, + * and talks to the control plane server-to-server (env→cloud service + * credential today; the device-code-bound `sys_cloud_connection` token for + * self-hosted runtimes). + */ + +// Minimal structural plugin contract — keep this module independent of +// @objectstack/core so the ADR-0008 Phase 2 move to the open package stays +// mechanical (mirrors the other plugins in this package). +interface PluginContext { + hook(event: string, handler: (...args: any[]) => any): void; + getService(name: string): T; + logger?: { + info?: (msg: string) => void; + warn?: (msg: string) => void; + error?: (msg: string, err?: unknown) => void; + }; +} +interface Plugin { + readonly name: string; + readonly version: string; + init(ctx: PluginContext): Promise; + start(ctx: PluginContext): Promise; +} + +const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; + +export interface CloudConnectionPluginConfig { + /** Control-plane base URL. Default: `OS_CLOUD_URL` (read lazily at kernel:ready). */ + controlPlaneUrl?: string; + /** env→cloud service credential. Default: `OS_CLOUD_API_KEY`. */ + controlPlaneApiKey?: string; + /** OAuth device-flow client id. Default: `OS_CLI_CLIENT_ID` → `objectstack-cli`. */ + deviceClientId?: string; + /** + * Fixed environment id (single-environment runtimes). Default: + * `OS_ENVIRONMENT_ID`. In multi-tenant mode leave unset — the id is + * resolved per-request from the hostname via `env-registry`. + */ + environmentId?: string; + /** + * Single-environment mode (objectos-ee shipped shape): skip the + * env-registry/kernel-manager lookups and validate sessions against the + * host kernel's own `auth` service. + */ + singleEnvironment?: boolean; +} + +export class CloudConnectionPlugin implements Plugin { + readonly name = 'com.objectstack.cloud.connection'; + readonly version = '0.2.0'; + + private readonly cfg: CloudConnectionPluginConfig; + + constructor(config: CloudConnectionPluginConfig = {}) { + this.cfg = config; + } + + init = async (_ctx: PluginContext): Promise => { /* HTTP wiring on kernel:ready */ }; + + start = async (ctx: PluginContext): Promise => { + ctx.hook('kernel:ready', async () => { + const httpServer: any = (() => { try { return ctx.getService('http-server'); } catch { return undefined; } })(); + if (!httpServer || typeof httpServer.getRawApp !== 'function') { + ctx.logger?.warn?.('[CloudConnectionPlugin] http-server unavailable — routes not mounted'); + return; + } + const rawApp = httpServer.getRawApp(); + // Env vars are read here (not at construction) so a host can set + // them between config load and kernel boot — preserves the + // behavior of the original app-level wiring. + const cloudUrl = (this.cfg.controlPlaneUrl ?? process.env.OS_CLOUD_URL ?? '').trim().replace(/\/+$/, ''); + const cloudApiKey = (this.cfg.controlPlaneApiKey ?? process.env.OS_CLOUD_API_KEY ?? '').trim(); + const deviceClientId = (this.cfg.deviceClientId ?? process.env.OS_CLI_CLIENT_ID ?? 'objectstack-cli').trim(); + const deviceScope = 'openid profile email'; + + const hostOf = (c: any): string => { + try { return new URL(c.req.url).hostname; } catch { return ''; } + }; + + const resolveEnvironmentId = async (c: any): Promise => { + const fixed = (this.cfg.environmentId ?? process.env.OS_ENVIRONMENT_ID ?? '').trim(); + if (fixed) return fixed; + if (this.cfg.singleEnvironment) return undefined; + try { + const envRegistry = ctx.getService('env-registry'); + const env = await envRegistry?.resolveByHostname?.(hostOf(c)); + return env?.environmentId; + } catch { + return undefined; + } + }; + + const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => { + const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; + const session = await api?.getSession?.({ headers: rawReq.headers }); + const userId = session?.user?.id ? String(session.user.id) : undefined; + return userId ? { userId } : null; + }; + + const resolveSession = async (environmentId: string, rawReq: Request): Promise<{ userId?: string } | null> => { + try { + if (this.cfg.singleEnvironment) { + // Single-env: the host kernel owns auth. + let authSvc: any; + try { authSvc = ctx.getService('auth'); } catch { /* ignore */ } + if (!authSvc) return null; + return await sessionFromAuthService(authSvc, rawReq); + } + // Multi-tenant: auth lives on the environment's own kernel. + const kernelManager = ctx.getService('kernel-manager'); + const kernel = await kernelManager?.getOrCreate?.(environmentId); + let authSvc: any; + try { authSvc = await kernel?.getServiceAsync?.('auth'); } catch { /* ignore */ } + if (!authSvc) { try { authSvc = kernel?.getService?.('auth'); } catch { /* ignore */ } } + if (!authSvc) return null; + return await sessionFromAuthService(authSvc, rawReq); + } catch { + return null; + } + }; + + // GET /status — is this env bound to a cloud account? + rawApp.get(`${CLOUD_CONNECTION_PREFIX}/status`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) { + // Single-env runtimes are valid before they are bound — + // report unbound instead of failing the Setup page. + if (this.cfg.singleEnvironment) { + return c.json({ success: true, data: { environmentId: null, bound: false, provider: 'objectstack-cloud', connection: null } }); + } + return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + } + // Prefer the real, device-bound sys_cloud_connection from the + // control plane. Fall back to the implicit cloud-hosted binding + // (OS_CLOUD_API_KEY) when the control plane is unreachable. + if (cloudUrl) { + try { + const resp = await fetch(`${cloudUrl}/api/v1/cloud-connection/status?environment_id=${encodeURIComponent(environmentId)}`, { + headers: cloudApiKey ? { Authorization: `Bearer ${cloudApiKey}` } : {}, + }); + if (resp.ok) { + const json: any = await resp.json().catch(() => null); + const data = json?.data ?? {}; + const bound = Boolean(data.bound) || Boolean(cloudApiKey); + return c.json({ success: true, data: { environmentId, bound, provider: 'objectstack-cloud', connection: data.connection ?? null } }); + } + } catch { /* fall through to implicit */ } + } + const bound = Boolean(cloudApiKey); + return c.json({ success: true, data: { environmentId, bound, provider: 'objectstack-cloud', connection: null } }); + }); + + // POST /bind/start — begin a device-code bind. The runtime is the + // genuine RFC 8628 client: it asks the cloud for a device + user + // code, returns them to the Setup UI, and the operator approves in + // the cloud console. + rawApp.post(`${CLOUD_CONNECTION_PREFIX}/bind/start`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) return c.json({ success: false, error: { code: 'unauthenticated', message: 'Sign in to this environment to connect a cloud account.' } }, 401); + if (!cloudUrl) return c.json({ success: false, error: { code: 'cloud_unconfigured', message: 'No cloud control plane configured.' } }, 503); + try { + const resp = await fetch(`${cloudUrl}/api/v1/auth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: deviceClientId, scope: deviceScope }), + }); + const json: any = await resp.json().catch(() => ({})); + if (!resp.ok) return c.json({ success: false, error: { code: 'device_code_failed', message: json?.error ?? `device/code ${resp.status}` } }, 502); + return c.json({ success: true, data: { + device_code: json.device_code, + user_code: json.user_code, + verification_uri: json.verification_uri, + verification_uri_complete: json.verification_uri_complete, + interval: json.interval ?? 5, + expires_in: json.expires_in ?? 600, + } }); + } catch (err: any) { + ctx.logger?.error?.('[CloudConnectionPlugin] bind/start failed', err instanceof Error ? err : new Error(String(err))); + return c.json({ success: false, error: { code: 'device_code_failed', message: String(err?.message ?? err) } }, 502); + } + }); + + // POST /bind/poll { device_code } — polls the cloud token endpoint. + // While pending, returns { pending: true }. On success, exchanges + // the operator token for a persisted sys_cloud_connection via the + // control plane's /bind. + rawApp.post(`${CLOUD_CONNECTION_PREFIX}/bind/poll`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) return c.json({ success: false, error: { code: 'unauthenticated' } }, 401); + if (!cloudUrl) return c.json({ success: false, error: { code: 'cloud_unconfigured' } }, 503); + + let body: any = {}; + try { body = await c.req.json(); } catch { body = {}; } + const deviceCode = String(body?.device_code ?? body?.deviceCode ?? '').trim(); + if (!deviceCode) return c.json({ success: false, error: { code: 'invalid_request', message: 'device_code is required' } }, 400); + + try { + const tokResp = await fetch(`${cloudUrl}/api/v1/auth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCode, client_id: deviceClientId }), + }); + const tok: any = await tokResp.json().catch(() => ({})); + const accessToken = tok?.access_token; + if (!accessToken) { + // RFC 8628 polling errors: authorization_pending / slow_down + // are non-terminal; everything else is terminal. + const errCode = String(tok?.error ?? `device/token ${tokResp.status}`); + const pending = errCode === 'authorization_pending' || errCode === 'slow_down'; + return c.json({ success: pending, data: { pending }, error: pending ? undefined : { code: errCode } }, pending ? 200 : 400); + } + // Persist the binding through the control plane (it validates + // the token + authorizes the operator for the env's org). + const bindResp = await fetch(`${cloudUrl}/api/v1/cloud-connection/bind`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(cloudApiKey ? { Authorization: `Bearer ${cloudApiKey}` } : {}) }, + body: JSON.stringify({ environment_id: environmentId, token: accessToken, scope: deviceScope }), + }); + const bindJson: any = await bindResp.json().catch(() => ({ success: false, error: 'bind failed (no body)' })); + return c.json(bindJson, bindResp.status as any); + } catch (err: any) { + ctx.logger?.error?.('[CloudConnectionPlugin] bind/poll failed', err instanceof Error ? err : new Error(String(err))); + return c.json({ success: false, error: { code: 'bind_failed', message: String(err?.message ?? err) } }, 502); + } + }); + + // POST /install { package_id, seed_sample_data? } — in-environment + // marketplace install. Authorizes the caller against the + // environment's own session (SSO-as-owner only ever admits org + // owners/admins), then performs the install against the cloud + // control plane using the env→cloud service credential. + rawApp.post(`${CLOUD_CONNECTION_PREFIX}/install`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + + // Local authz: require a valid env session. (TODO: tighten to an + // explicit env-admin role check; today only owners/admins obtain + // a session via SSO-as-owner.) + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) { + return c.json({ success: false, error: { code: 'unauthenticated', message: 'Sign in to this environment to install apps.' } }, 401); + } + + if (!cloudUrl || !cloudApiKey) { + return c.json({ success: false, error: { code: 'cloud_unconfigured', message: 'This runtime is not connected to a cloud account; install is unavailable.' } }, 503); + } + + let body: any = {}; + try { body = await c.req.json(); } catch { body = {}; } + const packageId = String(body?.package_id ?? body?.packageId ?? '').trim(); + if (!packageId) return c.json({ success: false, error: { code: 'invalid_request', message: 'package_id is required' } }, 400); + const seedSampleData = body?.seed_sample_data === true || body?.seedSampleData === true; + + // Call the cloud install endpoint in service mode. We do NOT send + // an active org → package-install treats this as platform/CI and + // installs unrestricted (we already authorized the env admin above). + try { + const resp = await fetch(`${cloudUrl}/api/v1/actions/sys_package/install_package`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${cloudApiKey}`, + }, + body: JSON.stringify({ recordId: packageId, params: { environment_id: environmentId, seed_sample_data: seedSampleData } }), + }); + const json = await resp.json().catch(() => ({ success: false, error: 'install failed (no body)' })); + return c.json(json, resp.status as any); + } catch (err: any) { + ctx.logger?.error?.('[CloudConnectionPlugin] install failed', err instanceof Error ? err : new Error(String(err))); + return c.json({ success: false, error: { code: 'install_failed', message: String(err?.message ?? err) } }, 502); + } + }); + + // GET /installation?package_id=… — same-origin installed-state + // probe. Always returns 200 with `{ installed: boolean, … }`; + // failures to reach the control plane degrade to + // `{ installed: false }` so the marketplace page renders (worst + // case it offers an install the control plane upserts idempotently). + rawApp.get(`${CLOUD_CONNECTION_PREFIX}/installation`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) { + return c.json({ success: false, error: { code: 'unauthenticated', message: 'Sign in to this environment.' } }, 401); + } + + let packageId = ''; + try { + const u = new URL(c.req.url); + packageId = String(u.searchParams.get('package_id') ?? u.searchParams.get('packageId') ?? '').trim(); + } catch { /* malformed URL → empty */ } + if (!packageId) return c.json({ success: false, error: { code: 'invalid_request', message: 'package_id is required' } }, 400); + + // Not connected → can't know; report not-installed so the page + // still renders (a read should never hard-fail the page). + if (!cloudUrl || !cloudApiKey) return c.json({ success: true, data: { installed: false } }); + + try { + // Read installed-state from the control plane's + // service-authenticated installations route — NOT the generic + // `/api/v1/data/sys_package_installation` API. That data API + // resolves identity via better-auth sessions / `sys_api_key` + // only and rejects the `OS_CLOUD_API_KEY` service bearer (401). + // This route shares the install route's service-key auth, so + // the same credential that performed the install can read its + // result back. + const resp = await fetch( + `${cloudUrl}/api/v1/cloud/environments/${encodeURIComponent(environmentId)}/installations/${encodeURIComponent(packageId)}`, + { headers: { Accept: 'application/json', Authorization: `Bearer ${cloudApiKey}` } }, + ); + if (!resp.ok) return c.json({ success: true, data: { installed: false } }); + const json: any = await resp.json().catch(() => ({})); + const data: any = json?.data ?? json ?? {}; + if (!data.installed) return c.json({ success: true, data: { installed: false } }); + return c.json({ success: true, data: { + installed: true, + installationId: String(data.installationId ?? ''), + version: String(data.version ?? 'installed'), + withSampleData: data.withSampleData === true, + } }); + } catch (err: any) { + ctx.logger?.warn?.(`[CloudConnectionPlugin] installation probe failed: ${String(err?.message ?? err)}`); + return c.json({ success: true, data: { installed: false } }); + } + }); + + // GET /installed — env's FULL installed set from the control + // plane's `sys_package_installation`, so the Console "Installed" + // view reflects packages installed via ANY path (CLI, marketplace, + // REST) — ADR-0007 step ①. Degrades to an empty list when the + // runtime is not cloud-connected or the control plane is + // unreachable (never hard-fails the page). + rawApp.get(`${CLOUD_CONNECTION_PREFIX}/installed`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) { + return c.json({ success: false, error: { code: 'unauthenticated', message: 'Sign in to this environment.' } }, 401); + } + + if (!cloudUrl || !cloudApiKey) { + return c.json({ success: true, data: { packages: [], total: 0, connected: false } }); + } + + try { + const resp = await fetch( + `${cloudUrl}/api/v1/cloud/environments/${encodeURIComponent(environmentId)}/packages`, + { headers: { Accept: 'application/json', Authorization: `Bearer ${cloudApiKey}` } }, + ); + if (!resp.ok) return c.json({ success: true, data: { packages: [], total: 0, connected: true } }); + const json: any = await resp.json().catch(() => ({})); + const data: any = json?.data ?? json ?? {}; + const packages = Array.isArray(data.packages) ? data.packages : []; + return c.json({ success: true, data: { packages, total: packages.length, connected: true } }); + } catch (err: any) { + ctx.logger?.warn?.(`[CloudConnectionPlugin] installed-list failed: ${String(err?.message ?? err)}`); + return c.json({ success: true, data: { packages: [], total: 0, connected: true } }); + } + }); + + // GET /org-packages — org-scoped catalog for the in-env + // Marketplace's "Your organization" layer (ADR-0007 step ②): the + // env's owning org's own packages (visibility org/private), so a + // publisher can discover + install their private apps from inside + // the environment. Forwards (service-key) to the control plane, + // which derives the org from environment_id. + rawApp.get(`${CLOUD_CONNECTION_PREFIX}/org-packages`, async (c: any) => { + const environmentId = await resolveEnvironmentId(c); + if (!environmentId) return c.json({ success: false, error: { code: 'environment_not_found' } }, 404); + + const session = await resolveSession(environmentId, c.req.raw); + if (!session?.userId) { + return c.json({ success: false, error: { code: 'unauthenticated', message: 'Sign in to this environment.' } }, 401); + } + + if (!cloudUrl || !cloudApiKey) { + return c.json({ success: true, data: { items: [], total: 0, connected: false } }); + } + + try { + const resp = await fetch( + `${cloudUrl}/api/v1/cloud/org-packages?environment_id=${encodeURIComponent(environmentId)}`, + { headers: { Accept: 'application/json', Authorization: `Bearer ${cloudApiKey}` } }, + ); + if (!resp.ok) return c.json({ success: true, data: { items: [], total: 0, connected: true } }); + const json: any = await resp.json().catch(() => ({})); + const data: any = json?.data ?? json ?? {}; + const items = Array.isArray(data.items) ? data.items : []; + return c.json({ success: true, data: { items, total: items.length, connected: true } }); + } catch (err: any) { + ctx.logger?.warn?.(`[CloudConnectionPlugin] org-packages failed: ${String(err?.message ?? err)}`); + return c.json({ success: true, data: { items: [], total: 0, connected: true } }); + } + }); + + ctx.logger?.info?.(`[CloudConnectionPlugin] mounted ${CLOUD_CONNECTION_PREFIX}/{status,bind/start,bind/poll,install,installation,installed,org-packages} → ${cloudUrl || '(cloud unconfigured)'}`); + }); + }; +} + +/** Factory mirroring the package's other plugins' construction style. */ +export function createCloudConnectionPlugin(config: CloudConnectionPluginConfig = {}): CloudConnectionPlugin { + return new CloudConnectionPlugin(config); +} diff --git a/packages/cloud-connection/src/cloud-url.ts b/packages/cloud-connection/src/cloud-url.ts new file mode 100644 index 0000000000..ecc5fa5d6b --- /dev/null +++ b/packages/cloud-connection/src/cloud-url.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared marketplace / cloud control-plane defaults. + * + * Centralised so every plugin + the CLI auto-inject path agree on + * "what cloud URL do we mean when the user didn't set OS_CLOUD_URL?". + * Until we have a competing public hosted cloud, this points at the + * ObjectStack-operated control plane so a vanilla `objectstack dev` can + * browse the marketplace out of the box. + */ +export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai'; + +/** + * Resolve the effective control-plane URL from an explicit constructor + * value, the OS_CLOUD_URL env var, or the default. Returns an empty + * string when the caller explicitly disabled cloud with + * `OS_CLOUD_URL=off` / `local` — callers should treat that as + * "marketplace unavailable on this runtime". + */ +export function resolveCloudUrl(explicit?: string | null): string { + const raw = (explicit ?? process.env.OS_CLOUD_URL ?? '').trim(); + const lower = raw.toLowerCase(); + if (lower === 'off' || lower === 'none' || lower === 'local' || lower === 'disabled') { + return ''; + } + const picked = raw || DEFAULT_CLOUD_URL; + return picked.replace(/\/+$/, ''); +} diff --git a/packages/cloud-connection/src/index.ts b/packages/cloud-connection/src/index.ts new file mode 100644 index 0000000000..127d1e04fe --- /dev/null +++ b/packages/cloud-connection/src/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/cloud-connection — the runtime-side client for an ObjectStack + * cloud control plane (ADR-0008). + * + * Connects any ObjectStack runtime — vanilla `objectstack dev`, a self-hosted + * single-environment deployment, or a multi-tenant fleet — to a control plane + * for package distribution. Capability progresses with binding: + * + * Unbound (anonymous) + * - public marketplace browse proxy (MarketplaceProxyPlugin → /api/v1/marketplace/*) + * - install-local: install public packages into THIS runtime's kernel + * (MarketplaceInstallLocalPlugin → /api/v1/marketplace/install-local) + * Bound (device-code → sys_cloud_connection) + * - status / bind/start / bind/poll, org catalog ("Your organization"), + * installed views, control-plane installs + * (CloudConnectionPlugin → /api/v1/cloud-connection/*) + * SPA discovery + * - RuntimeConfigPlugin → /api/v1/runtime/config feature flags + * + * The mechanism is open; the *policy* (which plan unlocks what, entitlement + * for paid packages, org catalog filtering) lives server-side in the control + * plane and — for plan-derived flags — in the host via + * `RuntimeConfigPluginConfig.resolvePlanFeatures`. `OS_CLOUD_URL=off` + * disables every remote call (air-gapped installs keep working via inline + * manifests). + */ + +export { DEFAULT_CLOUD_URL, resolveCloudUrl } from './cloud-url.js'; +export { + resolveMarketplacePublicBaseUrl, + publicMarketplaceKeyForApiPath, +} from './marketplace-public-url.js'; +export { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; +export type { MarketplaceProxyPluginConfig } from './marketplace-proxy-plugin.js'; +export { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +export type { MarketplaceInstallLocalPluginConfig } from './marketplace-install-local-plugin.js'; +export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js'; +export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js'; +export { RuntimeConfigPlugin } from './runtime-config-plugin.js'; +export type { RuntimeConfigPluginConfig, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js'; diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts new file mode 100644 index 0000000000..429b81706d --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -0,0 +1,829 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MarketplaceInstallLocalPlugin + * + * Installs marketplace packages into THIS runtime's kernel as opposed to a + * remote cloud environment. Conceptually different from cloud install in + * three important ways: + * + * 1. Single target — the local kernel is the only install target; there + * is no `sys_environment` picker. + * 2. Manifests are cached on disk — once installed, the package is + * runnable offline. Cloud is only needed during the install action + * itself (to fetch the manifest snapshot). + * 3. Coexists with user-authored apps — the local runtime usually has + * its own `objectstack.config.ts` declared apps. Install refuses to + * overwrite a manifest_id that's already registered to avoid silently + * replacing user code. + * + * Endpoints (mounted by `start()` on the `kernel:ready` hook): + * + * POST /api/v1/marketplace/install-local + * body: { packageId: string, versionId?: string } (default: "latest") + * → fetches manifest from cloud, caches to disk, registers via + * the kernel's `manifest` service. Returns the installed entry. + * + * GET /api/v1/marketplace/install-local + * → lists currently installed marketplace packages + * + * DELETE /api/v1/marketplace/install-local/:manifestId + * → removes the cached manifest. Kernel must be restarted to fully + * unload — `engine.registerApp` is additive only. We document + * this in the response message. + * + * Persistence layout: + * /.objectstack/installed-packages/.json + * Each file: { packageId, versionId, manifestId, version, manifest, installedAt, installedBy } + * + * On `kernel:ready`, the plugin scans the directory and re-registers each + * cached manifest so installs survive process restarts without further + * cloud round-trips. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { readEnvWithDeprecation } from '@objectstack/types'; +import { resolveCloudUrl } from './cloud-url.js'; +import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; + +const ROUTE_BASE = '/api/v1/marketplace/install-local'; +const DEFAULT_DIR = '.objectstack/installed-packages'; + +export interface MarketplaceInstallLocalPluginConfig { + /** Cloud control-plane base URL. When unset, falls back to OS_CLOUD_URL + * and then to the public ObjectStack cloud so a fresh `objectstack dev` + * can install from the marketplace without configuration. Set + * OS_CLOUD_URL=off to disable (the install endpoint then returns 503). */ + controlPlaneUrl?: string; + /** Override the on-disk cache directory. Defaults to + * `/.objectstack/installed-packages`. */ + storageDir?: string; +} + +interface InstalledEntry { + packageId: string; + versionId: string; + manifestId: string; + version: string; + manifest: any; + installedAt: string; + installedBy: string | null; + /** Whether the bundled seed datasets have been loaded into the kernel + * database. True after install (seedNow=true) or an explicit reseed; + * false after a purge. Persisted so the UI can show "Add" vs "Re-seed". */ + withSampleData?: boolean; +} + +function safeFilename(manifestId: string): string { + return manifestId.replace(/[^a-zA-Z0-9._-]/g, '_') + '.json'; +} + +export class MarketplaceInstallLocalPlugin implements Plugin { + readonly name = 'com.objectstack.runtime.marketplace-install-local'; + readonly version = '1.0.0'; + + private readonly cloudUrl: string; + private readonly storageDir: string; + + constructor(config: MarketplaceInstallLocalPluginConfig = {}) { + this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl); + this.storageDir = config.storageDir + ? resolve(config.storageDir) + : resolve(process.cwd(), DEFAULT_DIR); + } + + init = async (_ctx: PluginContext): Promise => { + // No services registered — pure HTTP wiring during start(). + }; + + start = async (ctx: PluginContext): Promise => { + ctx.hook('kernel:ready', async () => { + // 1. Rehydrate previously installed packages so they survive restart. + await this.rehydrate(ctx); + + // 2. Mount HTTP endpoints. + let httpServer: any; + try { + httpServer = ctx.getService('http-server'); + } catch { + ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server not available — install endpoints not mounted'); + return; + } + if (!httpServer || typeof httpServer.getRawApp !== 'function') { + ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server missing getRawApp() — install endpoints not mounted'); + return; + } + const rawApp = httpServer.getRawApp(); + + const postHandler = async (c: any) => this.handleInstall(c, ctx); + const getHandler = async (c: any) => this.handleList(c); + const deleteHandler = async (c: any) => this.handleUninstall(c, ctx); + + const reseedHandler = async (c: any) => this.handleReseed(c, ctx); + const purgeHandler = async (c: any) => this.handlePurge(c, ctx); + + if (typeof rawApp.post === 'function') rawApp.post(ROUTE_BASE, postHandler); + if (typeof rawApp.get === 'function') rawApp.get(ROUTE_BASE, getHandler); + if (typeof rawApp.delete === 'function') rawApp.delete(`${ROUTE_BASE}/:manifestId`, deleteHandler); + if (typeof rawApp.post === 'function') { + rawApp.post(`${ROUTE_BASE}/:manifestId/reseed-sample-data`, reseedHandler); + rawApp.post(`${ROUTE_BASE}/:manifestId/purge-sample-data`, purgeHandler); + } + + ctx.logger?.info?.(`[MarketplaceInstallLocal] mounted at ${ROUTE_BASE} (storage: ${this.storageDir})`); + }); + }; + + /** + * Re-register every cached manifest with the kernel's manifest service. + * Safe to call on a kernel that already has the same manifest_id (the + * underlying ObjectQL registry overwrites by id, but we still warn so + * a developer can spot the dev-time clash between their config.ts and + * a marketplace package). + */ + private rehydrate = async (ctx: PluginContext): Promise => { + const entries = this.readAll(); + if (entries.length === 0) return; + + let manifestService: { register(m: any): void } | null = null; + try { + manifestService = ctx.getService('manifest') as any; + } catch { + ctx.logger?.warn?.('[MarketplaceInstallLocal] no `manifest` service — rehydrate skipped'); + return; + } + + for (const entry of entries) { + try { + manifestService!.register(entry.manifest); + // Sync schemas so the driver creates tables for the newly- + // registered objects (idempotent — already-synced tables + // are no-ops). + try { + const ql: any = ctx.getService('objectql'); + if (ql && typeof ql.syncSchemas === 'function') await ql.syncSchemas(); + } catch { /* non-fatal */ } + // Replay translations + register seed datasets, but don't + // re-run seeding — existing rows are already in the DB from + // the original install, and multi-tenant orgs will replay + // via the security middleware on next sys_organization insert. + await this.applySideEffects(ctx, entry.manifest, { seedNow: false }); + ctx.logger?.info?.(`[MarketplaceInstallLocal] rehydrated ${entry.manifestId}@${entry.version}`); + } catch (err: any) { + ctx.logger?.error?.(`[MarketplaceInstallLocal] rehydrate failed for ${entry.manifestId}`, err instanceof Error ? err : new Error(String(err))); + } + } + }; + + private handleInstall = async (c: any, ctx: PluginContext): Promise => { + const userId = await this.requireAuthenticatedUser(c, ctx); + if (!userId) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Authentication required to install packages.' } }, 401); + } + + let body: any = {}; + try { body = await c.req.json(); } catch { /* empty body */ } + + // ── Offline path: an inline manifest was supplied (file import). ── + // Bypass the cloud-fetch entirely; no OS_CLOUD_URL required. + const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null; + + let manifest: any; + let resolvedVersionId: string; + let version: string; + let packageId: string; + + if (inlineManifest) { + manifest = inlineManifest; + packageId = String(manifest.id ?? manifest.name ?? '').trim(); + version = String(manifest.version ?? 'unknown'); + resolvedVersionId = String(body?.versionId ?? version); + if (!packageId) { + return c.json({ success: false, error: { code: 'invalid_manifest', message: 'Inline manifest must have an "id" or "name".' } }, 400); + } + } else { + if (!this.cloudUrl) { + return c.json({ success: false, error: { code: 'marketplace_unavailable', message: 'OS_CLOUD_URL not configured.' } }, 503); + } + packageId = String(body?.packageId ?? '').trim(); + const versionId = String(body?.versionId ?? 'latest').trim() || 'latest'; + if (!packageId) { + return c.json({ success: false, error: { code: 'bad_request', message: 'packageId is required.' } }, 400); + } + + // 1. Fetch manifest snapshot — prefer public R2 fast-path so + // install works even when cloud is asleep or down. Fall back + // to cloud on miss/error. + let payload: any; + const publicBase = resolveMarketplacePublicBaseUrl(); + const fetchAttempts: { label: string; url: string }[] = []; + if (publicBase) { + fetchAttempts.push({ + label: 'public-r2', + url: `${publicBase}/packages/${encodeURIComponent(packageId)}/versions/${encodeURIComponent(versionId)}/manifest.json`, + }); + } + fetchAttempts.push({ + label: 'cloud', + url: `${this.cloudUrl}/api/v1/marketplace/packages/${encodeURIComponent(packageId)}/versions/${encodeURIComponent(versionId)}/manifest`, + }); + + let lastErrStatus = 0; + let lastErrText = ''; + for (const attempt of fetchAttempts) { + try { + const resp = await fetch(attempt.url, { headers: { 'Accept': 'application/json' } }); + if (!resp.ok) { + lastErrStatus = resp.status; + lastErrText = (await resp.text().catch(() => '')).slice(0, 200); + // 404 from public R2 is not fatal — fall through to cloud. + if (attempt.label === 'public-r2' && resp.status === 404) { + ctx.logger?.info?.(`[MarketplaceInstallLocal] public-r2 miss for ${packageId}@${versionId}, falling back to cloud`); + continue; + } + if (attempt.label === 'public-r2' && resp.status >= 500) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] public-r2 ${resp.status}, falling back to cloud`); + continue; + } + break; // cloud non-ok → surface error + } + payload = await resp.json(); + lastErrStatus = 0; + break; + } catch (err: any) { + if (attempt.label === 'public-r2') { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] public-r2 fetch error: ${err?.message ?? err}, falling back to cloud`); + continue; + } + return c.json({ + success: false, + error: { code: 'cloud_fetch_failed', message: err?.message ?? String(err) }, + }, 502); + } + } + if (!payload) { + return c.json({ + success: false, + error: { code: 'cloud_fetch_failed', message: `Cloud returned ${lastErrStatus}: ${lastErrText}` }, + }, lastErrStatus === 404 ? 404 : 502); + } + + const data = payload?.data ?? payload; + manifest = data?.manifest; + resolvedVersionId = String(data?.version_id ?? versionId); + version = String(data?.version ?? 'unknown'); + } + + const manifestId = String(manifest?.id ?? manifest?.name ?? ''); + if (!manifest || !manifestId) { + return c.json({ success: false, error: { code: 'invalid_manifest', message: 'Invalid manifest payload.' } }, inlineManifest ? 400 : 502); + } + + // 2. Conflict check — refuse to overwrite user-authored apps + const conflict = this.findConflict(ctx, manifestId); + if (conflict === 'user-code') { + return c.json({ + success: false, + error: { + code: 'manifest_conflict', + message: `manifest_id "${manifestId}" is already defined by this runtime's local code. Refusing to overwrite. Uninstall the local definition first.`, + }, + }, 409); + } + + // 3. Hot-register FIRST so a malformed inline manifest fails the + // install loudly rather than persisting a broken record that + // would also fail on every subsequent rehydrate. + try { + const manifestService = ctx.getService('manifest') as any; + manifestService.register(manifest); + } catch (err: any) { + // For offline file imports we treat a register failure as a hard + // failure (don't persist). Cloud installs historically tolerated + // this (the on-disk record survives a restart), so keep that path + // lenient for backwards compatibility. + if (inlineManifest) { + return c.json({ + success: false, + error: { code: 'register_failed', message: `Failed to register imported manifest: ${err?.message ?? err}` }, + }, 422); + } + ctx.logger?.warn?.(`[MarketplaceInstallLocal] hot-register failed for ${manifestId} (will load on next restart): ${err?.message ?? err}`); + } + + // 4. Persist on disk + const entry: InstalledEntry = { + packageId, + versionId: resolvedVersionId, + manifestId, + version, + manifest, + installedAt: new Date().toISOString(), + installedBy: userId, + withSampleData: false, + }; + try { + mkdirSync(this.storageDir, { recursive: true }); + writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8'); + } catch (err: any) { + return c.json({ + success: false, + error: { code: 'storage_failed', message: `Failed to persist manifest: ${err?.message ?? err}` }, + }, 500); + } + + // 4b. Sync schemas to physical tables — registerApp only adds the + // object definitions to the in-memory registry; the driver + // must be asked to materialize tables/columns before any seed + // insert (or user write) succeeds. + try { + const ql: any = ctx.getService('objectql'); + if (ql && typeof ql.syncSchemas === 'function') { + await ql.syncSchemas(); + ctx.logger?.info?.(`[MarketplaceInstallLocal] syncSchemas() ran after registering ${manifestId}`); + } + } catch (err: any) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] syncSchemas failed for ${manifestId}: ${err?.message ?? err}`); + } + + // 5. Replicate the AppPlugin start-time side-effects that the + // `manifest` service does NOT do on its own: + // • load translation bundles into the i18n service + // • stash seed datasets on the kernel + run them now so the + // installed app has demo data on first paint. + const seededSummary = await this.applySideEffects(ctx, manifest, { seedNow: true, c }); + if (seededSummary.seeded.mode === 'inline' && (seededSummary.seeded.inserted ?? 0) + (seededSummary.seeded.updated ?? 0) > 0) { + entry.withSampleData = true; + try { + writeFileSync(join(this.storageDir, safeFilename(manifestId)), JSON.stringify(entry, null, 2), 'utf8'); + } catch { /* non-fatal — entry already on disk */ } + } + + return c.json({ + success: true, + data: { + manifestId, + version, + versionId: resolvedVersionId, + installedAt: entry.installedAt, + hotLoaded: true, + upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null, + translationsLoaded: seededSummary.translationsLoaded, + seeded: seededSummary.seeded, + note: 'App is now available in this runtime. Refresh the console to see it in the app switcher.', + }, + }, 200); + }; + + private handleList = async (c: any): Promise => { + const entries = this.readAll(); + return c.json({ + success: true, + data: { + items: entries.map(e => ({ + packageId: e.packageId, + versionId: e.versionId, + manifestId: e.manifestId, + version: e.version, + installedAt: e.installedAt, + installedBy: e.installedBy, + withSampleData: e.withSampleData ?? false, + })), + total: entries.length, + storageDir: this.storageDir, + }, + }, 200); + }; + + private handleUninstall = async (c: any, ctx: PluginContext): Promise => { + const userId = await this.requireAuthenticatedUser(c, ctx); + if (!userId) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Authentication required.' } }, 401); + } + const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); + if (!manifestId) { + return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400); + } + const file = join(this.storageDir, safeFilename(manifestId)); + if (!existsSync(file)) { + return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404); + } + try { + unlinkSync(file); + } catch (err: any) { + return c.json({ success: false, error: { code: 'storage_failed', message: err?.message ?? String(err) } }, 500); + } + ctx.logger?.info?.(`[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; restart runtime to unload from running kernel)`); + return c.json({ + success: true, + data: { + manifestId, + note: 'Cached manifest removed. The app remains loaded in the running kernel until the next restart (the kernel API does not support unregistering apps in-place).', + }, + }, 200); + }; + + /** + * Detect whether `manifestId` is already known to the kernel and classify + * the source so we can refuse vs upgrade gracefully. + * + * 'none' — fresh install + * 'marketplace' — previously installed by this plugin (allow upgrade) + * 'user-code' — defined by AppPlugin from objectstack.config.ts + * (refuse to avoid silently overwriting authored code) + */ + private findConflict = (ctx: PluginContext, manifestId: string): 'none' | 'marketplace' | 'user-code' => { + // First check: do we already have a marketplace install file? + if (existsSync(join(this.storageDir, safeFilename(manifestId)))) { + return 'marketplace'; + } + // Then check: is the manifest_id already in the engine's registry? + try { + const ql: any = ctx.getService('objectql'); + const packages: any[] = ql?.registry?.getAllPackages?.() ?? []; + const hit = packages.find((p: any) => + (p?.manifest?.id ?? p?.id ?? p?.manifest?.name) === manifestId, + ); + if (hit) return 'user-code'; + } catch { /* objectql not registered yet — treat as fresh */ } + return 'none'; + }; + + /** + * Pull a userId out of the request's better-auth session, if any. + * Returns null when there is no signed-in user. v1 does not check + * admin role — UI gating + the auth requirement is sufficient for + * dev / single-tenant runtimes. Stricter checks can be layered on + * via a middleware in cloud-hosted multi-tenant deployments. + */ + /** + * POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data + * + * Re-runs SeedLoaderService against the cached manifest's `data` arrays. + * Idempotent (upsert by id). Useful when: + * • The user installed an app and skipped sample data + * • A purge was undone + * • The user wants a clean baseline back after editing demo rows + * + * Multi-tenant: requires an active organization on the session (same + * rule as install seed path). + */ + private handleReseed = async (c: any, ctx: PluginContext): Promise => { + const userId = await this.requireAuthenticatedUser(c, ctx); + if (!userId) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Authentication required.' } }, 401); + } + const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); + if (!manifestId) { + return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400); + } + const file = join(this.storageDir, safeFilename(manifestId)); + if (!existsSync(file)) { + return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404); + } + + let entry: InstalledEntry; + try { + entry = JSON.parse(readFileSync(file, 'utf8')); + } catch (err: any) { + return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500); + } + + const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c }); + if (summary.seeded.mode === 'skipped') { + return c.json({ + success: false, + error: { + code: 'reseed_skipped', + message: `Reseed did not run: ${summary.seeded.reason ?? 'unknown reason'}`, + }, + }, 400); + } + + // Persist flag flip + try { + entry.withSampleData = true; + writeFileSync(file, JSON.stringify(entry, null, 2), 'utf8'); + } catch { /* non-fatal */ } + + return c.json({ + success: true, + data: { + manifestId, + inserted: summary.seeded.inserted ?? 0, + updated: summary.seeded.updated ?? 0, + errors: summary.seeded.errors ?? 0, + withSampleData: true, + }, + }, 200); + }; + + /** + * POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data + * + * Deletes every record whose id is declared in the cached manifest's + * seed datasets. Uses the `driver` service directly to bypass ACL / + * lifecycle hooks (same pattern as cloud purge). User-created records + * are never touched — only ids declared in the package's bundled + * datasets are removed. Already-deleted rows count as `skipped`. + */ + private handlePurge = async (c: any, ctx: PluginContext): Promise => { + const userId = await this.requireAuthenticatedUser(c, ctx); + if (!userId) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Authentication required.' } }, 401); + } + const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); + if (!manifestId) { + return c.json({ success: false, error: { code: 'bad_request', message: 'manifestId path param required.' } }, 400); + } + const file = join(this.storageDir, safeFilename(manifestId)); + if (!existsSync(file)) { + return c.json({ success: false, error: { code: 'not_found', message: `No marketplace install for ${manifestId}.` } }, 404); + } + + let entry: InstalledEntry; + try { + entry = JSON.parse(readFileSync(file, 'utf8')); + } catch (err: any) { + return c.json({ success: false, error: { code: 'storage_failed', message: `Failed to read manifest cache: ${err?.message ?? err}` } }, 500); + } + + const datasets = Array.isArray(entry.manifest?.data) + ? entry.manifest.data.filter((d: any) => d && d.object && Array.isArray(d.records)) + : []; + + if (datasets.length === 0) { + return c.json({ + success: false, + error: { code: 'nothing_to_purge', message: 'This package declares no seed datasets.' }, + }, 400); + } + + let driver: any; + try { driver = ctx.getService('driver'); } catch { /* none */ } + if (!driver || typeof driver.delete !== 'function') { + return c.json({ + success: false, + error: { code: 'driver_missing', message: 'driver service unavailable — cannot purge.' }, + }, 500); + } + + let deleted = 0; + let skipped = 0; + let errors = 0; + for (const ds of datasets) { + const object = String(ds.object); + for (const rec of ds.records as any[]) { + const id = rec?.id; + if (id === undefined || id === null || id === '') { skipped++; continue; } + try { + const r = await driver.delete(object, id); + if (r === false || r === 0 || r?.deleted === 0) skipped++; + else deleted++; + } catch (err: any) { + // Treat "not found" as skipped; anything else as error. + const msg = String(err?.message ?? err); + if (/not.?found|no row/i.test(msg)) skipped++; + else { errors++; ctx.logger?.warn?.(`[MarketplaceInstallLocal] purge ${object}#${id}: ${msg}`); } + } + } + } + + // Flip flag so UI reflects the empty baseline + try { + entry.withSampleData = false; + writeFileSync(file, JSON.stringify(entry, null, 2), 'utf8'); + } catch { /* non-fatal */ } + + ctx.logger?.info?.(`[MarketplaceInstallLocal] purged ${manifestId}: deleted=${deleted} skipped=${skipped} errors=${errors}`); + return c.json({ + success: true, + data: { manifestId, deleted, skipped, errors, withSampleData: false }, + }, 200); + }; + + /** + * Replicate the start-time side-effects that AppPlugin runs for + * statically-declared apps but the `manifest` service does NOT: + * + * 1. Load `manifest.translations` (array of `Record`) + * into the i18n service — auto-creating an in-memory fallback if + * none is registered, matching AppPlugin's behaviour. + * + * 2. Merge `manifest.data` (an array of seed datasets) into the + * kernel's `seed-datasets` service so SecurityPlugin's per-org + * replay middleware picks them up on every future + * sys_organization insert. + * + * 3. When `seedNow=true`, also run the seed immediately so the user + * sees demo data without having to create a new org: + * • single-tenant: run SeedLoaderService inline (mirrors + * AppPlugin single-tenant branch) + * • multi-tenant: invoke `seed-replayer` for the caller's + * active org (resolved from the request session) + * + * Errors are logged but never thrown — install succeeds even if + * post-register side-effects partially fail (the manifest itself is + * already registered + cached). Returns a small summary for the + * response envelope. + */ + private applySideEffects = async ( + ctx: PluginContext, + manifest: any, + opts: { seedNow: boolean; c?: any }, + ): Promise<{ translationsLoaded: number; seeded: { mode: 'inline' | 'replayer' | 'skipped'; inserted?: number; updated?: number; errors?: number; reason?: string } }> => { + const appId = String(manifest?.id ?? 'unknown'); + let translationsLoaded = 0; + let seedSummary: any = { mode: 'skipped', reason: 'no-datasets' }; + + // ── 1. i18n bundles ───────────────────────────────────────────── + try { + const bundles: Array> = []; + if (Array.isArray(manifest?.translations)) bundles.push(...manifest.translations); + if (Array.isArray(manifest?.i18n)) bundles.push(...manifest.i18n); + + if (bundles.length > 0) { + let i18nService: any; + try { i18nService = ctx.getService('i18n'); } catch { /* not registered */ } + if (!i18nService) { + try { + const mod = await import('@objectstack/core'); + const createMemoryI18n = (mod as any).createMemoryI18n; + if (typeof createMemoryI18n === 'function') { + i18nService = createMemoryI18n(); + (ctx as any).registerService?.('i18n', i18nService); + ctx.logger?.info?.(`[MarketplaceInstallLocal] auto-registered in-memory i18n fallback for "${appId}"`); + } + } catch { /* fallback unavailable */ } + } + if (i18nService?.loadTranslations) { + for (const bundle of bundles) { + for (const [locale, data] of Object.entries(bundle)) { + if (data && typeof data === 'object') { + try { + i18nService.loadTranslations(locale, data as Record); + translationsLoaded++; + } catch (err: any) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to load ${appId} translations for ${locale}: ${err?.message ?? err}`); + } + } + } + } + ctx.logger?.info?.(`[MarketplaceInstallLocal] loaded ${translationsLoaded} locale bundle(s) for ${appId}`); + } + } + } catch (err: any) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] i18n side-effect failed for ${appId}: ${err?.message ?? err}`); + } + + // ── 2. Seed datasets — merge into kernel service ───────────────── + const datasets = Array.isArray(manifest?.data) + ? manifest.data.filter((d: any) => d && d.object && Array.isArray(d.records)) + : []; + + if (datasets.length > 0) { + try { + const kernel: any = (ctx as any).kernel; + let existing: any[] = []; + try { + const v = kernel?.getService?.('seed-datasets'); + if (Array.isArray(v)) existing = v; + } catch { /* unset */ } + const merged = [...existing, ...datasets]; + if (kernel?.registerService) kernel.registerService('seed-datasets', merged); + else (ctx as any).registerService?.('seed-datasets', merged); + ctx.logger?.info?.(`[MarketplaceInstallLocal] merged ${datasets.length} seed dataset(s) into kernel (total: ${merged.length})`); + } catch (err: any) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] failed to merge seed-datasets: ${err?.message ?? err}`); + } + } + + // ── 3. Optional immediate seed ─────────────────────────────────── + // Always seed inline via SeedLoaderService — don't rely on the + // `seed-replayer` registered by AppPlugin since (a) it isn't + // registered when the host runtime has no AppPlugin app with + // seed data, and (b) its closure may use stale datasets. In + // multi-tenant mode we pass `organizationId` so the loader + // writes tenant-scoped rows the same way AppPlugin's + // single-tenant branch + SecurityPlugin's per-org replay do. + if (opts.seedNow && datasets.length > 0) { + const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; + try { + const ql: any = ctx.getService('objectql'); + let metadata: any; + try { metadata = ctx.getService('metadata'); } catch { /* none */ } + if (!ql || !metadata) { + seedSummary = { mode: 'skipped', reason: 'objectql-or-metadata-missing' }; + } else { + let organizationId: string | undefined; + if (multiTenant) { + const resolved = await this.resolveActiveOrgId(opts.c, ctx); + if (resolved) organizationId = resolved; + else { + seedSummary = { mode: 'skipped', reason: 'multi-tenant-no-active-org' }; + ctx.logger?.warn?.('[MarketplaceInstallLocal] multi-tenant: no active org on request — data not seeded'); + } + } + if (!multiTenant || organizationId) { + const [{ SeedLoaderService }, { SeedLoaderRequestSchema }] = await Promise.all([ + import('@objectstack/runtime'), + import('@objectstack/spec/data'), + ]); + const seedLoader = new (SeedLoaderService as any)(ql, metadata, ctx.logger); + const request = (SeedLoaderRequestSchema as any).parse({ + // ADR-0036 / seed rename: the field is `seeds` (was `datasets`). + seeds: datasets, + config: { + defaultMode: 'upsert', + multiPass: true, + ...(organizationId ? { organizationId } : {}), + }, + }); + const result = await seedLoader.load(request); + seedSummary = { + mode: 'inline', + inserted: result.summary.totalInserted, + updated: result.summary.totalUpdated, + errors: result.errors.length, + }; + ctx.logger?.info?.(`[MarketplaceInstallLocal] inline seed for ${appId}${organizationId ? ` (org=${organizationId})` : ''}: inserted=${seedSummary.inserted} updated=${seedSummary.updated} errors=${seedSummary.errors}`); + } + } + } catch (err: any) { + seedSummary = { mode: 'skipped', reason: `seed-error: ${err?.message ?? err}` }; + ctx.logger?.warn?.(`[MarketplaceInstallLocal] seed run failed for ${appId}: ${err?.message ?? err}`); + } + } + + return { translationsLoaded, seeded: seedSummary }; + }; + + /** + * Best-effort active-org resolution. Reads the better-auth session + * (same path as requireAuthenticatedUser) and returns + * `session.activeOrganizationId`, falling back to the user's first + * org membership. + */ + private resolveActiveOrgId = async (c: any, ctx: PluginContext): Promise => { + if (!c?.req?.raw?.headers) return null; + try { + const authService: any = ctx.getService('auth'); + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); + if (!api?.getSession) return null; + const session = await api.getSession({ headers: c.req.raw.headers }); + const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null; + if (direct) return String(direct); + // Fall back to the user's first membership row. + const userId = session?.user?.id; + if (!userId) return null; + try { + const ql: any = ctx.getService('objectql'); + if (ql?.find) { + const rows = await ql.find('sys_organization_member', { where: { user_id: userId }, limit: 1, context: { isSystem: true } } as any); + const row = Array.isArray(rows) ? rows[0] : (rows?.items?.[0] ?? null); + return row?.organization_id ? String(row.organization_id) : null; + } + } catch { /* ignore */ } + } catch { /* ignore */ } + return null; + }; + + private requireAuthenticatedUser = async (c: any, ctx: PluginContext): Promise => { + try { + // Mirror `hono-plugin.ts` resolveCtx: pull the better-auth `api` + // off the auth service and call `getSession({ headers })`. The + // earlier guess `c.get('auth').session` is wrong — AuthPlugin + // does not pre-populate the Hono context. + const authService: any = ctx.getService('auth'); + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') { + api = await authService.getApi(); + } + if (api?.getSession && c?.req?.raw?.headers) { + const session = await api.getSession({ headers: c.req.raw.headers }); + const userId = session?.user?.id ?? null; + if (userId) return String(userId); + } + } catch { /* ignore — fall through */ } + // Header fallback for cases where auth is disabled (e.g. test stubs) + const xUserId = c?.req?.header?.('x-user-id'); + if (xUserId) return String(xUserId); + return null; + }; + + private readAll = (): InstalledEntry[] => { + if (!existsSync(this.storageDir)) return []; + const out: InstalledEntry[] = []; + for (const name of readdirSync(this.storageDir)) { + if (!name.endsWith('.json')) continue; + try { + const raw = readFileSync(join(this.storageDir, name), 'utf8'); + out.push(JSON.parse(raw)); + } catch { /* skip corrupt files */ } + } + return out; + }; +} diff --git a/packages/cloud-connection/src/marketplace-proxy-browse-only.test.ts b/packages/cloud-connection/src/marketplace-proxy-browse-only.test.ts new file mode 100644 index 0000000000..f2ba721d57 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-proxy-browse-only.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MarketplaceProxyPlugin is a BROWSE-ONLY mechanism (ADR §5.2): it forwards + * GET/HEAD marketplace reads and must NOT own install *policy*. Non-GET + * requests pass through (`next()`) so a host-supplied install route (mounted + * via the createObjectOSStack `extraPlugins` seam) can claim them — instead of + * the old 405 "install via cloud" dead-end (framework#1548). + */ + +import { describe, it, expect } from 'vitest'; +import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; + +/** Drive the plugin's kernel:ready wiring and capture the registered handler. */ +async function captureHandler(plugin: MarketplaceProxyPlugin) { + let handler: any; + let readyFn: any; + const rawApp = { + all: (_path: string, h: any) => { handler = h; }, + get: () => {}, post: () => {}, head: () => {}, + }; + const ctx: any = { + hook: (ev: string, fn: any) => { if (ev === 'kernel:ready') readyFn = fn; }, + getService: (name: string) => (name === 'http-server' ? { getRawApp: () => rawApp } : undefined), + logger: { info() {}, warn() {}, error() {} }, + }; + await plugin.start(ctx); + await readyFn(); + return handler; +} + +function fakeCtx(method: string, path: string) { + return { + req: { + url: `http://env.test${path}`, + method, + header: () => undefined, + raw: {}, + }, + json: (body: any, status: number) => ({ __status: status, __body: body }), + }; +} + +describe('MarketplaceProxyPlugin — browse-only (no install dead-end)', () => { + it('passes through a non-GET marketplace request (next()) instead of 405', async () => { + const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'http://cloud.test', cacheDisabled: true }); + const handler = await captureHandler(plugin); + expect(typeof handler).toBe('function'); + + let nextCalled = false; + const result = await handler( + fakeCtx('POST', '/api/v1/marketplace/packages/pkg_1/install'), + async () => { nextCalled = true; return 'PASSED_THROUGH'; }, + ); + + // The handler delegates to the next route rather than emitting a 405. + expect(nextCalled).toBe(true); + expect(result).toBe('PASSED_THROUGH'); + }); + + it('still passes install-local through (owned by the local install plugin)', async () => { + const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'http://cloud.test', cacheDisabled: true }); + const handler = await captureHandler(plugin); + + let nextCalled = false; + await handler( + fakeCtx('POST', '/api/v1/marketplace/install-local'), + async () => { nextCalled = true; return 'LOCAL'; }, + ); + expect(nextCalled).toBe(true); + }); + + it('503s when no control plane is configured (unchanged behaviour)', async () => { + const plugin = new MarketplaceProxyPlugin({ controlPlaneUrl: 'off', cacheDisabled: true }); + const handler = await captureHandler(plugin); + const result: any = await handler(fakeCtx('GET', '/api/v1/marketplace/packages'), async () => 'NEXT'); + expect(result.__status).toBe(503); + expect(result.__body?.error?.code).toBe('marketplace_unavailable'); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-proxy-plugin.ts b/packages/cloud-connection/src/marketplace-proxy-plugin.ts new file mode 100644 index 0000000000..7428b75bee --- /dev/null +++ b/packages/cloud-connection/src/marketplace-proxy-plugin.ts @@ -0,0 +1,514 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MarketplaceProxyPlugin + * + * Forwards `GET /api/v1/marketplace/*` from a tenant ObjectOS runtime to + * the configured ObjectStack Cloud control-plane URL. The cloud endpoint + * is unauthenticated and only exposes packages whose owner has opted in + * to the public catalog (`sys_package.marketplace_listed = true`) — so the + * proxy passes through without any credentials. + * + * Why proxy instead of direct browser → cloud: + * - The Console SPA stays on the tenant origin, so no CORS configuration + * is required on the cloud side. + * - Local-dev `os serve` works regardless of whether the developer's + * browser has cookies for cloud.objectos.ai. + * - Adds a single, easily auditable network seam between tenant and + * control plane. + * + * Install is NOT proxied here. Installing a package mutates control-plane + * state and requires a cloud session + active organization context — the + * Console SPA performs install by opening the cloud's install dialog in a + * new tab so the user authenticates against cloud directly. A future + * iteration may introduce a delegated install token; until then, browse + * here and install on cloud. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveCloudUrl } from './cloud-url.js'; +import { + resolveMarketplacePublicBaseUrl, + publicMarketplaceKeyForApiPath, +} from './marketplace-public-url.js'; + +const MARKETPLACE_PREFIX = '/api/v1/marketplace'; + +/** + * In-memory cache for GET/HEAD marketplace responses. + * + * Marketplace data changes infrequently (new package versions are + * audited & published in ~hours, not seconds), so we cache aggressively + * with conditional revalidation: + * + * - listing/search → 30 min hard TTL + * - package detail → 2 h + * - version detail / + * readme / assets → 24 h (versions are immutable once published) + * + * After TTL expiry the next request issues an `If-None-Match` / + * `If-Modified-Since` and, on `304 Not Modified`, simply refreshes the + * TTL without re-downloading the body. + * + * Bypass conditions (always re-fetch, no cache write): + * - `OS_MARKETPLACE_CACHE=off` + * - Request header `Cache-Control: no-cache` (the Console SPA's + * "Refresh" button sets this) + * - Non-2xx upstream responses (avoid pinning transient errors) + * + * Every response carries `X-Cache: HIT|REVALIDATED|MISS|BYPASS` to make + * the layer observable in browser devtools. + */ +const DEFAULT_LRU_MAX = 200; +const LIST_TTL_MS = 30 * 60 * 1000; // 30 min +const PACKAGE_TTL_MS = 2 * 60 * 60 * 1000; // 2 h +const VERSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h + +interface CacheEntry { + status: number; + body: ArrayBuffer; + headers: Record; + etag?: string; + lastModified?: string; + expiresAt: number; + ttlMs: number; +} + +function ttlForPath(pathname: string): number { + // `/api/v1/marketplace/packages/:id/versions/:v(.../...)?` → 24h + if (/\/packages\/[^/]+\/versions\//.test(pathname)) return VERSION_TTL_MS; + // `/api/v1/marketplace/packages/:id(/readme)?` → 2h + if (/\/packages\/[^/]+/.test(pathname)) return PACKAGE_TTL_MS; + // Listings, search, categories, anything else → 30 min + return LIST_TTL_MS; +} + +class LruTtlCache { + private readonly map = new Map(); + constructor(private readonly max: number) {} + + get(key: string): CacheEntry | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + // LRU touch: re-insert to move to end. + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + set(key: string, entry: CacheEntry): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, entry); + while (this.map.size > this.max) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + this.map.delete(oldest); + } + } + + clear(): void { + this.map.clear(); + } +} + +export interface MarketplaceProxyPluginConfig { + /** + * Control-plane base URL (e.g. https://cloud.objectos.ai). When the + * caller passes nothing AND the runtime has no OS_CLOUD_URL set, the + * plugin falls back to the public ObjectStack-operated cloud so that + * `objectstack dev` can browse the marketplace out of the box. Set + * OS_CLOUD_URL=off (or `local`) to opt out — the plugin then mounts + * a stub that responds 503 and the SPA renders an empty-state + * explaining marketplace is unavailable in this runtime. + */ + controlPlaneUrl?: string; + + /** + * Disable the in-memory response cache (testing / debugging). + * Defaults to the value of `OS_MARKETPLACE_CACHE` (anything in + * {"off","false","0","no"} disables). + */ + cacheDisabled?: boolean; + + /** + * Override the LRU upper bound. Defaults to 200 entries. + */ + cacheMaxEntries?: number; + /** + * Public R2 base URL for marketplace snapshots. When set, GETs for + * snapshot-backed paths (`/packages`, `/packages/:id`, + * `/packages/:id/versions/:vid/manifest`) are fetched directly from + * R2 (CF edge) — bypassing the cloud control plane entirely. + * Defaults to the value of OS_MARKETPLACE_PUBLIC_BASE_URL. Empty + * string disables the public fast-path (legacy cloud-proxy only). + */ + publicMarketplaceBaseUrl?: string; +} + +export class MarketplaceProxyPlugin implements Plugin { + readonly name = 'com.objectstack.runtime.marketplace-proxy'; + readonly version = '1.1.0'; + + private readonly cloudUrl: string; + private readonly publicBaseUrl: string; + private readonly cache: LruTtlCache | null; + + constructor(config: MarketplaceProxyPluginConfig = {}) { + this.cloudUrl = resolveCloudUrl(config.controlPlaneUrl); + this.publicBaseUrl = resolveMarketplacePublicBaseUrl(config.publicMarketplaceBaseUrl); + + const envFlag = (process.env.OS_MARKETPLACE_CACHE ?? '').trim().toLowerCase(); + const envDisabled = ['off', 'false', '0', 'no', 'disable', 'disabled'].includes(envFlag); + const disabled = config.cacheDisabled ?? envDisabled; + this.cache = disabled + ? null + : new LruTtlCache(Math.max(8, config.cacheMaxEntries ?? DEFAULT_LRU_MAX)); + } + + init = async (_ctx: PluginContext): Promise => { + // No services registered — pure HTTP wiring during start(). + }; + + start = async (ctx: PluginContext): Promise => { + ctx.hook('kernel:ready', async () => { + let httpServer: any; + try { + httpServer = ctx.getService('http-server'); + } catch { + ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server not available — marketplace routes not mounted'); + return; + } + if (!httpServer || typeof httpServer.getRawApp !== 'function') { + ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server missing getRawApp() — marketplace routes not mounted'); + return; + } + + const rawApp = httpServer.getRawApp(); + const cloudUrl = this.cloudUrl; + const publicBaseUrl = this.publicBaseUrl; + const cache = this.cache; + + if (publicBaseUrl) { + ctx.logger?.info?.(`[MarketplaceProxyPlugin] public R2 fast-path enabled → ${publicBaseUrl}`); + } + + const handler = async (c: any, next: any) => { + if (!cloudUrl) { + return c.json({ + success: false, + error: { + code: 'marketplace_unavailable', + message: 'No control-plane URL configured for this runtime (OS_CLOUD_URL).', + }, + }, 503); + } + try { + const incomingUrl = new URL(c.req.url); + // Do NOT proxy install-local — those are owned by + // MarketplaceInstallLocalPlugin and must hit this + // runtime, never cloud. Pass through so Hono can match + // the install-local route registered on the same app. + if (incomingUrl.pathname.startsWith(`${MARKETPLACE_PREFIX}/install-local`)) { + return next(); + } + + const method = String(c.req.method ?? 'GET').toUpperCase(); + + // ── Public R2 fast-path ───────────────────────── + // When OS_MARKETPLACE_PUBLIC_BASE_URL is configured, + // GETs for snapshot-backed paths fetch directly from + // R2 → CF edge cache. This skips the cloud control + // plane entirely so marketplace browse + install + // work even when cloud is asleep or completely down. + if (publicBaseUrl && (method === 'GET' || method === 'HEAD')) { + const r2Resp = await tryPublicMarketplaceFetch( + publicBaseUrl, incomingUrl, method, c.req.header('accept'), + ctx.logger, + ); + if (r2Resp) return r2Resp; + // Fall through on miss / error to the cloud path. + } + + // Preserve the full /api/v1/marketplace/... path on cloud. + const target = `${cloudUrl}${incomingUrl.pathname}${incomingUrl.search}`; + + // Browse-only mechanism: this plugin forwards only safe, + // idempotent GET/HEAD (it carries no credentialled cloud + // auth). It does NOT own install *policy* — that is a host + // concern (ObjectStack Cloud supplies a credentialled + // install route via the `extraPlugins` seam; see ADR + // docs/design/cloud-account-binding-marketplace-install.md + // §5.2). So instead of dead-ending non-GET with a 405 + // "install via cloud", we PASS THROUGH: a host-supplied + // handler mounted after this plugin can claim the request; + // if none does, the app returns its normal 404. This + // removes the browse-only install dead-end (framework#1548) + // without this plugin pretending to know install policy. + if (method !== 'GET' && method !== 'HEAD') { + return next(); + } + + // Cache lookup. Key includes accept-language because + // cloud may serve locale-specific copy in the future; + // HEAD shares the cache slot with GET (we just elide the + // body in the response). + const accept = c.req.header('accept') ?? 'application/json'; + const acceptLang = c.req.header('accept-language') ?? ''; + const cacheKey = `${incomingUrl.pathname}${incomingUrl.search}|al=${acceptLang}|a=${accept}`; + const reqCacheCtl = (c.req.header('cache-control') ?? '').toLowerCase(); + const bypass = !cache || reqCacheCtl.includes('no-cache') || reqCacheCtl.includes('no-store'); + const now = Date.now(); + + if (cache && !bypass) { + const hit = cache.get(cacheKey); + if (hit && hit.expiresAt > now) { + return buildCachedResponse(hit, method, 'HIT'); + } + if (hit) { + // TTL expired — try conditional revalidate so we + // don't pay for the body when nothing changed. + const revalHeaders: Record = { + 'Accept': accept, + 'User-Agent': `objectos-marketplace-proxy/${MarketplaceProxyPlugin.prototype.version ?? '1.0.0'}`, + }; + if (acceptLang) revalHeaders['Accept-Language'] = acceptLang; + if (hit.etag) revalHeaders['If-None-Match'] = hit.etag; + if (hit.lastModified) revalHeaders['If-Modified-Since'] = hit.lastModified; + const revalResp = await fetch(target, { method: 'GET', headers: revalHeaders }); + if (revalResp.status === 304) { + hit.expiresAt = now + hit.ttlMs; + // Refresh ETag/Last-Modified if the upstream + // re-issued them on the 304 (per RFC 7232 §4.1). + const newEtag = revalResp.headers.get('etag'); + const newLm = revalResp.headers.get('last-modified'); + if (newEtag) hit.etag = newEtag; + if (newLm) hit.lastModified = newLm; + cache.set(cacheKey, hit); + return buildCachedResponse(hit, method, 'REVALIDATED'); + } + // 200 (or anything else): fall through to the + // normal fetch+store path below, using the + // revalidation response we already have in hand. + return await consumeAndMaybeCache(revalResp, cacheKey, incomingUrl.pathname, method, cache); + } + } + + // MISS (or BYPASS): origin fetch. + const reqHeaders: Record = { + // Strip the inbound Host header — fetch will set + // it to the cloud host. Forward only the + // identifying headers cloud might log. + 'Accept': accept, + 'User-Agent': `objectos-marketplace-proxy/${MarketplaceProxyPlugin.prototype.version ?? '1.0.0'}`, + }; + if (acceptLang) reqHeaders['Accept-Language'] = acceptLang; + const resp = await fetch(target, { method: 'GET', headers: reqHeaders }); + + if (bypass || !cache) { + // Don't write to cache; just stream back. + return await passthroughResponse(resp, method, bypass ? 'BYPASS' : 'MISS'); + } + return await consumeAndMaybeCache(resp, cacheKey, incomingUrl.pathname, method, cache); + } catch (err: any) { + const errObj = err instanceof Error ? err : new Error(err?.message ?? String(err)); + ctx.logger?.error?.('[MarketplaceProxyPlugin] proxy failed', errObj); + return c.json({ + success: false, + error: { + code: 'marketplace_proxy_failed', + message: err?.message ?? String(err), + }, + }, 502); + } + }; + + if (typeof rawApp.all === 'function') { + rawApp.all(`${MARKETPLACE_PREFIX}/*`, handler); + } else { + for (const m of ['get', 'head'] as const) { + try { rawApp[m]?.(`${MARKETPLACE_PREFIX}/*`, handler); } catch { /* best effort */ } + } + } + + ctx.logger?.info?.(`[MarketplaceProxyPlugin] mounted at ${MARKETPLACE_PREFIX}/* → ${cloudUrl || '(unconfigured)'} (cache=${this.cache ? 'on' : 'off'})`); + }); + }; +} + +// --------------------------------------------------------------------------- +// Public R2 fast-path (module-private) +// --------------------------------------------------------------------------- + +/** + * Fetch a marketplace API path from the public R2 base URL and return + * the response shaped exactly like the cloud API endpoint would. The + * snapshots are already in `{ success: true, data: ... }` shape so for + * direct lookups we stream bytes verbatim. The list endpoint applies + * query-string filters (q / category / limit / offset) client-side + * from the full snapshot. + * + * Returns `null` for any of: + * - path is not snapshot-backed (e.g. featured / categories / etc.) + * - R2 returned 404 (snapshot not yet generated for this id) + * - network error fetching from R2 + * + * On `null`, the caller falls back to the cloud proxy path. + */ +async function tryPublicMarketplaceFetch( + publicBaseUrl: string, + incomingUrl: URL, + method: string, + acceptHeader: string | undefined, + logger: any, +): Promise { + const key = publicMarketplaceKeyForApiPath(incomingUrl.pathname); + if (!key) return null; + + const target = `${publicBaseUrl}/${key}`; + let resp: Response; + try { + resp = await fetch(target, { + method: 'GET', + headers: { + 'Accept': acceptHeader || 'application/json', + 'User-Agent': `objectos-marketplace-proxy/public-r2`, + }, + }); + } catch (err: any) { + logger?.warn?.(`[MarketplaceProxyPlugin] public R2 fetch failed (${target}): ${err?.message ?? err}`); + return null; + } + if (resp.status === 404) return null; + if (!resp.ok) { + logger?.warn?.(`[MarketplaceProxyPlugin] public R2 ${target} returned ${resp.status} — falling back to cloud`); + return null; + } + + // List endpoint: apply optional q/category/limit/offset filters on + // the full snapshot. Detail + manifest snapshots stream verbatim. + const isList = key === 'packages.json'; + const hasFilters = isList && ( + incomingUrl.searchParams.has('q') || + incomingUrl.searchParams.has('category') || + incomingUrl.searchParams.has('limit') || + incomingUrl.searchParams.has('offset') + ); + + if (!hasFilters) { + // Verbatim passthrough — preserve cache headers from R2 / CF. + const headers = new Headers(); + const ct = resp.headers.get('content-type') ?? 'application/json; charset=utf-8'; + headers.set('content-type', ct); + const cc = resp.headers.get('cache-control'); + if (cc) headers.set('cache-control', cc); + const etag = resp.headers.get('etag'); + if (etag) headers.set('etag', etag); + headers.set('x-cache', 'PUBLIC-R2'); + const body = method === 'HEAD' ? null : resp.body; + return new Response(body, { status: 200, headers }); + } + + // Filtered list — parse, filter, re-serialize. + let snapshot: any; + try { snapshot = await resp.json(); } + catch (err: any) { + logger?.warn?.(`[MarketplaceProxyPlugin] public R2 list snapshot parse failed: ${err?.message ?? err}`); + return null; + } + const items: any[] = Array.isArray(snapshot?.data?.items) ? snapshot.data.items : []; + + const q = (incomingUrl.searchParams.get('q') ?? '').trim().toLowerCase(); + const category = (incomingUrl.searchParams.get('category') ?? '').trim(); + const limit = Math.min(Math.max(Number(incomingUrl.searchParams.get('limit') ?? 50), 1), 100); + const offset = Math.max(Number(incomingUrl.searchParams.get('offset') ?? 0), 0); + + let filtered = items; + if (q) { + filtered = filtered.filter((r) => { + const dn = String(r?.display_name ?? '').toLowerCase(); + const mid = String(r?.manifest_id ?? '').toLowerCase(); + return dn.includes(q) || mid.includes(q); + }); + } + if (category) { + filtered = filtered.filter((r) => String(r?.category ?? '') === category); + } + const total = filtered.length; + const page = filtered.slice(offset, offset + limit); + const body = JSON.stringify({ success: true, data: { items: page, total, limit, offset } }); + + const headers = new Headers({ + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'public, max-age=30', + 'x-cache': 'PUBLIC-R2-FILTERED', + }); + return new Response(method === 'HEAD' ? null : body, { status: 200, headers }); +} + +// --------------------------------------------------------------------------- +// Cache helpers (module-private) +// --------------------------------------------------------------------------- + +const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'etag', 'last-modified', 'vary'] as const; + +function collectHeaders(src: Response): Record { + const out: Record = {}; + for (const h of PASSTHROUGH_HEADERS) { + const v = src.headers.get(h); + if (v) out[h] = v; + } + return out; +} + +function buildCachedResponse(entry: CacheEntry, method: string, xCache: 'HIT' | 'REVALIDATED'): Response { + const headers = new Headers(entry.headers); + headers.set('X-Cache', xCache); + // Surface remaining freshness so downstream HTTP caches / devtools + // can reason about it (clamped at 0). + const ageSec = Math.max(0, Math.floor((entry.expiresAt - entry.ttlMs - Date.now()) / -1000)); + headers.set('Age', String(Math.max(0, ageSec))); + const body = method === 'HEAD' ? null : entry.body; + return new Response(body, { status: entry.status, headers }); +} + +async function passthroughResponse(resp: Response, method: string, xCache: 'MISS' | 'BYPASS'): Promise { + const headers = new Headers(collectHeaders(resp)); + headers.set('X-Cache', xCache); + if (method === 'HEAD') { + // Drain to release the connection. + try { await resp.arrayBuffer(); } catch { /* ignore */ } + return new Response(null, { status: resp.status, headers }); + } + const body = await resp.arrayBuffer(); + return new Response(body, { status: resp.status, headers }); +} + +async function consumeAndMaybeCache( + resp: Response, + key: string, + pathname: string, + method: string, + cache: LruTtlCache, +): Promise { + const body = await resp.arrayBuffer(); + const headers = collectHeaders(resp); + // Only cache success responses — pinning a 404 / 5xx would just + // amplify a transient failure. + if (resp.status >= 200 && resp.status < 300) { + const ttlMs = ttlForPath(pathname); + const entry: CacheEntry = { + status: resp.status, + body, + headers, + etag: resp.headers.get('etag') ?? undefined, + lastModified: resp.headers.get('last-modified') ?? undefined, + expiresAt: Date.now() + ttlMs, + ttlMs, + }; + cache.set(key, entry); + } + const respHeaders = new Headers(headers); + respHeaders.set('X-Cache', 'MISS'); + const outBody = method === 'HEAD' ? null : body; + return new Response(outBody, { status: resp.status, headers: respHeaders }); +} diff --git a/packages/cloud-connection/src/marketplace-public-url.test.ts b/packages/cloud-connection/src/marketplace-public-url.test.ts new file mode 100644 index 0000000000..0fed771e92 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-public-url.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Unit tests for the public marketplace URL resolver and API-path-to-key + * mapper. The mapper is the single source of truth for the snapshot + * keyspace used by both the framework consumer (this file) and the + * cloud snapshot writer (cloud/packages/service-cloud/src/marketplace-snapshot.ts). + * Any change here must match there. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + resolveMarketplacePublicBaseUrl, + publicMarketplaceKeyForApiPath, +} from './marketplace-public-url.js'; + +describe('resolveMarketplacePublicBaseUrl', () => { + const original = process.env.OS_MARKETPLACE_PUBLIC_BASE_URL; + afterEach(() => { + if (original === undefined) delete process.env.OS_MARKETPLACE_PUBLIC_BASE_URL; + else process.env.OS_MARKETPLACE_PUBLIC_BASE_URL = original; + }); + + it('returns empty when env unset (legacy cloud-proxy fallback)', () => { + delete process.env.OS_MARKETPLACE_PUBLIC_BASE_URL; + expect(resolveMarketplacePublicBaseUrl()).toBe(''); + }); + + it('returns empty for explicit disable values', () => { + for (const v of ['off', 'none', 'disabled', 'false', 'OFF']) { + process.env.OS_MARKETPLACE_PUBLIC_BASE_URL = v; + expect(resolveMarketplacePublicBaseUrl()).toBe(''); + } + }); + + it('strips trailing slashes', () => { + process.env.OS_MARKETPLACE_PUBLIC_BASE_URL = 'https://marketplace.objectos.app///'; + expect(resolveMarketplacePublicBaseUrl()).toBe('https://marketplace.objectos.app'); + }); + + it('explicit constructor argument wins over env', () => { + process.env.OS_MARKETPLACE_PUBLIC_BASE_URL = 'https://env.example'; + expect(resolveMarketplacePublicBaseUrl('https://explicit.example/')) + .toBe('https://explicit.example'); + }); + + it('explicit "off" overrides env', () => { + process.env.OS_MARKETPLACE_PUBLIC_BASE_URL = 'https://env.example'; + expect(resolveMarketplacePublicBaseUrl('off')).toBe(''); + }); +}); + +describe('publicMarketplaceKeyForApiPath', () => { + it('maps the bare list path', () => { + expect(publicMarketplaceKeyForApiPath('/api/v1/marketplace/packages')) + .toBe('packages.json'); + }); + + it('maps per-package detail', () => { + expect(publicMarketplaceKeyForApiPath('/api/v1/marketplace/packages/pkg_abc')) + .toBe('packages/pkg_abc.json'); + }); + + it('URL-encodes id segments safely', () => { + // ids with reserved characters get re-encoded — never crosses + // into a different snapshot key by accident. + expect(publicMarketplaceKeyForApiPath('/api/v1/marketplace/packages/com.acme%2Fcrm')) + .toBe('packages/com.acme%2Fcrm.json'); + }); + + it('maps per-version manifest', () => { + expect(publicMarketplaceKeyForApiPath('/api/v1/marketplace/packages/p1/versions/v1/manifest')) + .toBe('packages/p1/versions/v1/manifest.json'); + }); + + it('maps latest alias', () => { + expect(publicMarketplaceKeyForApiPath('/api/v1/marketplace/packages/p1/versions/latest/manifest')) + .toBe('packages/p1/versions/latest/manifest.json'); + }); + + it('returns null for non-snapshot-backed paths', () => { + for (const p of [ + '/api/v1/marketplace/packages/p1/versions/v1', // missing /manifest + '/api/v1/marketplace/packages/p1/extras', // unknown segment + '/api/v1/marketplace/install-local', + '/api/v1/marketplace/featured', + '/api/v1/other', + '/api/v1/marketplace/packagesextra', // boundary check + ]) { + expect(publicMarketplaceKeyForApiPath(p)).toBeNull(); + } + }); +}); diff --git a/packages/cloud-connection/src/marketplace-public-url.ts b/packages/cloud-connection/src/marketplace-public-url.ts new file mode 100644 index 0000000000..9de3746506 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-public-url.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Marketplace public R2 base URL — when set, points at a Cloudflare R2 + * bucket (custom domain or `pub-*.r2.dev`) that serves pre-rendered + * marketplace browse + install JSON snapshots. The snapshots are + * written by ObjectStack Cloud (`packages/service-cloud/src/marketplace-snapshot.ts`). + * + * Architecture: cloud writes, tenants read directly from R2 → CDN. + * Marketplace browse + install never touch the cloud control plane, + * so Cloud cold-start (~12s) is bypassed entirely and the read path + * scales to CF's edge capacity for free. + * + * Default: when OS_MARKETPLACE_PUBLIC_BASE_URL is unset we skip the + * public fast-path and fall back to the legacy cloud proxy. Once the + * R2 bucket public domain is wired up in operations, set + * `OS_MARKETPLACE_PUBLIC_BASE_URL=https://marketplace.objectos.ai` + * (or your own custom domain) to enable. Set to "off" / "none" to + * explicitly disable even if a default is configured. + * + * Path layout under the base URL (matches the snapshot writer): + * /packages.json + * /packages/{id}.json + * /packages/{id}/versions/{versionId}/manifest.json + * /packages/{id}/versions/latest/manifest.json + * + * Each JSON file is already in the same `{ success: true, data: ... }` + * shape as the corresponding cloud API endpoint, so callers can + * substitute one for the other transparently. + */ + +/** + * Resolve the effective public marketplace base URL. Returns an empty + * string when the public fast-path is disabled — callers should fall + * back to fetching via the cloud control plane. + */ +export function resolveMarketplacePublicBaseUrl(explicit?: string | null): string { + const raw = (explicit ?? process.env.OS_MARKETPLACE_PUBLIC_BASE_URL ?? '').trim(); + const lower = raw.toLowerCase(); + if (!raw || lower === 'off' || lower === 'none' || lower === 'disabled' || lower === 'false') { + return ''; + } + return raw.replace(/\/+$/, ''); +} + +/** + * Map an incoming `/api/v1/marketplace/...` API path to a public R2 + * object key (relative — caller prepends the base URL). + * + * Returns `null` when the path is not snapshot-backed. Today three + * paths are covered (list, detail, manifest); anything else (search + * with non-trivial filters, install actions, etc.) routes via cloud. + * + * Query strings are NOT included in the returned key — R2 is static. + * Callers that need filtering must fetch the full snapshot and filter + * client-side. + */ +export function publicMarketplaceKeyForApiPath(pathname: string): string | null { + const prefix = '/api/v1/marketplace/packages'; + if (pathname === prefix) return 'packages.json'; + if (!pathname.startsWith(`${prefix}/`)) return null; + const tail = pathname.slice(prefix.length + 1); + if (!tail) return null; + const parts = tail.split('/'); + // /packages/{id} + if (parts.length === 1) { + const id = decodeURIComponent(parts[0] ?? ''); + if (!id) return null; + return `packages/${encodeURIComponent(id)}.json`; + } + // /packages/{id}/versions/{versionId}/manifest + if (parts.length === 4 && parts[1] === 'versions' && parts[3] === 'manifest') { + const id = decodeURIComponent(parts[0] ?? ''); + const versionId = decodeURIComponent(parts[2] ?? ''); + if (!id || !versionId) return null; + return `packages/${encodeURIComponent(id)}/versions/${encodeURIComponent(versionId)}/manifest.json`; + } + return null; +} diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts new file mode 100644 index 0000000000..d02bd653eb --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * RuntimeConfigPlugin + * + * Serves `GET /api/v1/runtime/config` (and the legacy alias + * `GET /api/v1/studio/runtime-config`) so the Console / Studio SPA can learn + * the upstream cloud URL and capability flags **at boot time**, instead of + * sniffing `window.location.hostname` or reading Vite-time env vars. + * + * Response shape: + * + * { + * cloudUrl: string, // base URL of the upstream cloud ('' = same origin) + * singleEnvironment: boolean, + * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname + * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds }, + * branding: { productName, productShortName } + * } + * + * ## Policy seam (ADR-0008 / open-mechanism-closed-intelligence) + * + * Which features a *plan* unlocks is distribution policy, not mechanism — it + * intentionally does NOT live in this open package. Hosts inject it via + * {@link RuntimeConfigPluginConfig.resolvePlanFeatures}: the cloud + * distribution passes its plan-entitlement rules there; a self-hosted or + * vanilla deployment omits it and gets static config-driven flags. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveCloudUrl } from './cloud-url.js'; + +/** Capability flags a host's plan policy can derive per request. */ +export interface RuntimeConfigPlanFeatures { + /** Whether the SPA should surface AI-driven metadata authoring. */ + aiStudio?: boolean; + /** Whether AI-built apps auto-publish in the author's own environment. */ + autoPublishAiBuilds?: boolean; +} + +export interface RuntimeConfigPluginConfig { + /** + * Upstream cloud base URL. Falls back to `resolveCloudUrl()` (reads + * `OS_CLOUD_URL` / built-in default) when omitted. Pass an explicit + * empty string to declare "this runtime IS the cloud" (same-origin + * for marketplace + install). + */ + controlPlaneUrl?: string; + /** Override the `features.installLocal` flag. Default: false. */ + installLocal?: boolean; + /** + * Override the `features.aiStudio` flag — whether the SPA should surface + * AI-driven metadata authoring ("online development") affordances. + * Default: true (the actual authoring capability is still gated + * server-side; set false to force-hide the authoring UI). + */ + aiStudio?: boolean; + /** + * Report this runtime as a single-environment deployment (CLI + * `objectstack dev` / `os serve`). Defaults to `false` for + * multi-tenant deployments. + */ + singleEnvironment?: boolean; + /** + * Product name shown in browser title, splash screen, and other + * client chrome. Operators can override per-deployment (white-label, + * regional rebrands). Falls back to `OS_PRODUCT_NAME` env var, then + * to the default `'ObjectOS'`. + */ + productName?: string; + /** Short product name (PWA shortName, compact spots). Defaults to productName. */ + productShortName?: string; + /** + * Plan → feature policy hook. Called with `undefined` for the static + * default (no environment resolved / no plan known) and with the + * environment's plan string once hostname resolution provides one. + * Returned flags override the static config defaults; omitted keys keep + * them. When the hook itself is omitted, flags are purely config-driven. + */ + resolvePlanFeatures?: (plan: string | undefined) => RuntimeConfigPlanFeatures; +} + +export class RuntimeConfigPlugin implements Plugin { + readonly name = 'com.objectstack.runtime.runtime-config'; + readonly version = '1.0.0'; + + private readonly cloudUrl: string; + private readonly installLocal: boolean; + private readonly aiStudio: boolean; + private readonly singleEnvironment: boolean; + private readonly productName: string; + private readonly productShortName: string; + private readonly resolvePlanFeatures?: (plan: string | undefined) => RuntimeConfigPlanFeatures; + + constructor(config: RuntimeConfigPluginConfig = {}) { + // An explicit empty string means "stay on this origin" — bypass the + // resolver which would otherwise fall back to the default cloud URL. + this.cloudUrl = config.controlPlaneUrl === '' + ? '' + : (resolveCloudUrl(config.controlPlaneUrl) ?? ''); + this.installLocal = !!config.installLocal; + this.aiStudio = config.aiStudio !== false; // default true (override-to-hide) + this.singleEnvironment = !!config.singleEnvironment; + this.resolvePlanFeatures = config.resolvePlanFeatures; + const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim(); + const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim(); + this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS'; + this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName; + } + + init = async (_ctx: PluginContext): Promise => {}; + + start = async (ctx: PluginContext): Promise => { + ctx.hook('kernel:ready', async () => { + let httpServer: any; + try { + httpServer = ctx.getService('http-server'); + } catch { + ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server not available — runtime/config not mounted'); + return; + } + if (!httpServer || typeof httpServer.getRawApp !== 'function') { + ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server missing getRawApp() — runtime/config not mounted'); + return; + } + const rawApp = httpServer.getRawApp(); + + // A multi-tenant runtime serves many subdomains, each mapped to + // one environment. Telling the SPA *which* environment it is + // attached to (per-request) lets the App Marketplace skip the + // env-picker dialog and install directly into "this" env — the + // operator's domain already identifies it. + // + // Hostname → env is resolved by the same registry the per-env + // kernel router uses (env-registry). Falls back to the static + // payload when the host doesn't map to any env (e.g. a marketing + // root or a CLI-served single-env runtime). + let envRegistry: any = null; + try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ } + + const featuresFor = (plan: string | undefined, base: { aiStudio: boolean; autoPublishAiBuilds: boolean }) => { + const derived = this.resolvePlanFeatures?.(plan); + return { + aiStudio: derived?.aiStudio ?? base.aiStudio, + autoPublishAiBuilds: derived?.autoPublishAiBuilds ?? base.autoPublishAiBuilds, + }; + }; + + const handler = async (c: any) => { + const rawHost = c.req.header('host') ?? ''; + const host = rawHost.split(':')[0].toLowerCase().trim(); + let defaultEnvironmentId: string | undefined; + let defaultOrgId: string | undefined; + let resolvedSingleEnv = this.singleEnvironment; + // Static defaults: config-driven, optionally shaped by the + // host's policy hook for the "no plan known" case. + let features = featuresFor(undefined, { aiStudio: this.aiStudio, autoPublishAiBuilds: false }); + // EnvironmentDriverRegistry exposes `resolveByHostname()`; + // older code paths used `resolveHostname()` on the client. + // Accept either so production runtimes don't silently no-op + // and leave the SPA showing the env picker. + const resolveFn: ((h: string) => Promise) | null = + typeof envRegistry?.resolveByHostname === 'function' + ? envRegistry.resolveByHostname.bind(envRegistry) + : typeof envRegistry?.resolveHostname === 'function' + ? envRegistry.resolveHostname.bind(envRegistry) + : null; + if (resolveFn && host) { + try { + const resolved = await resolveFn(host); + if (resolved?.environmentId) { + defaultEnvironmentId = String(resolved.environmentId); + const orgId = resolved.organizationId ?? resolved.organization_id; + if (orgId) defaultOrgId = String(orgId); + // Each subdomain is one environment from the + // operator's POV: surface as single-environment + // so the SPA hides multi-env affordances. + resolvedSingleEnv = true; + // Plan-derived features — only an explicit + // non-empty plan re-runs the policy hook. + if (typeof resolved.plan === 'string' && resolved.plan.trim() !== '') { + features = featuresFor(resolved.plan, features); + } + } + } catch { + // Resolver failures are non-fatal — fall through + // to the static payload so /runtime/config never + // 500s. Worst case the SPA shows its env picker. + } + } + return c.json({ + cloudUrl: this.cloudUrl, + singleEnvironment: resolvedSingleEnv, + defaultOrgId, + defaultEnvironmentId, + features: { + installLocal: this.installLocal, + marketplace: true, + aiStudio: features.aiStudio, + autoPublishAiBuilds: features.autoPublishAiBuilds, + }, + branding: { + productName: this.productName, + productShortName: this.productShortName, + }, + }); + }; + rawApp.get('/api/v1/runtime/config', handler); + // Legacy alias for older Studio bundles. + rawApp.get('/api/v1/studio/runtime-config', handler); + ctx.logger?.info?.('[RuntimeConfigPlugin] mounted /api/v1/runtime/config', { + cloudUrl: this.cloudUrl || '(empty)', + installLocal: this.installLocal, + perHostEnvResolution: !!envRegistry, + }); + }); + }; + + destroy = async (): Promise => {}; +} diff --git a/packages/cloud-connection/tsconfig.json b/packages/cloud-connection/tsconfig.json new file mode 100644 index 0000000000..dad9eb869f --- /dev/null +++ b/packages/cloud-connection/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccbd5f0285..83c857a892 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -699,6 +699,31 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/cloud-connection: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../core + '@objectstack/runtime': + specifier: workspace:* + version: link:../runtime + '@objectstack/spec': + specifier: workspace:* + version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types + devDependencies: + '@types/node': + specifier: ^25.9.2 + version: 25.9.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.2)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/connectors/connector-mcp: dependencies: '@modelcontextprotocol/sdk':