From 124b3fd002d05cf11f3de3e6507da6cfec76fdb4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 30 May 2026 22:32:13 +0800 Subject: [PATCH 1/3] http-dispatcher: read provisioning adapters under the canonical key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the leftover from the project→environment rename: the provisioning- adapter registry is now read under the canonical 'environment-provisioning-adapters' key (matching the cloud tenant plugin and this file's own in-code comment), falling back to the legacy 'project-provisioning-adapters' key for older hosts not yet rebuilt. Affects listRegisteredDrivers() and getRealAdapter() in handleCloud(). Runtime suite: 314 passed. --- packages/runtime/src/http-dispatcher.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index bc87e2c001..8b15713433 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1701,7 +1701,13 @@ export class HttpDispatcher { // a deeper layer (e.g. a single unified `sql` driver that covers // better-sqlite3 + libsql + pg + mysql) and collapse the user's // meaningful choices into one row, so they make a poor UI source. - const registry: any = services['project-provisioning-adapters']; + // The canonical service key is `environment-provisioning-adapters` + // (post project→environment rename — registered by the cloud tenant + // plugin and the objectos host). The legacy `project-provisioning- + // adapters` key is kept as a fallback for older hosts not yet rebuilt. + const registry: any = + services['environment-provisioning-adapters'] ?? + services['project-provisioning-adapters']; if (registry && typeof registry.list === 'function') { try { const adapters = registry.list() as Array<{ driver: string }>; @@ -1783,7 +1789,10 @@ export class HttpDispatcher { }): Promise; } | undefined> => { try { - const registry: any = await this.resolveService('project-provisioning-adapters'); + // Canonical key first, legacy `project-*` key as a fallback. + const registry: any = + (await this.resolveService('environment-provisioning-adapters')) ?? + (await this.resolveService('project-provisioning-adapters')); // Alias the generic 'sql' short name onto the SQLite // provisioning adapter. `sql` is SqlDriver's default short // name when registered via DriverPlugin; provisioning From 2c900c590032a68cb8ec085d6c17e374a023aa36 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 30 May 2026 22:46:01 +0800 Subject: [PATCH 2/3] docs: clarify turso/libsql ships as cloud-repo driver-turso, not in framework Framework bundles only memory/sql/sqlite-wasm/mongodb drivers. The libsql/turso driver was extracted to @objectstack/driver-turso in the cloud repo (May 2026) and is loaded via dynamic import on demand. Correct three stale docs that implied framework ships a turso driver, so agents don't add libsql code to framework or assume the unified SQL driver speaks libsql: - README feature list + metrics table - http-dispatcher listRegisteredDrivers comment (unified sql backends are better-sqlite3/pg/mysql2 only) Comment/doc-only; no behavior change. --- README.md | 4 ++-- packages/runtime/src/http-dispatcher.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6f2ef85a28..da7031bba0 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full microkernel and layer arch - **Protocol-first schemas** — All schemas are defined with Zod; TypeScript types are derived via `z.infer<>`. - **Versioned JSON artifacts** — TypeScript-authored metadata compiles into deployable, self-describing JSON artifacts. - **Microkernel plugin system** — DI container, EventBus, and lifecycle hooks (init -> start -> destroy). -- **Multi-database support** — In-memory, PostgreSQL, MySQL, SQLite, and Turso/libSQL drivers. +- **Multi-database support** — In-memory, PostgreSQL, MySQL, SQLite (via the unified SQL driver), and MongoDB. Turso/libSQL is not bundled here — it ships as the separate `@objectstack/driver-turso` package in the cloud repo. - **7 framework adapters** — Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit. - **Client SDK + React hooks** — `useQuery`, `useMutation`, `usePagination` out of the box. - **Built-in authentication** — [better-auth](https://www.better-auth.com/) via `plugin-auth`. @@ -254,7 +254,7 @@ Cloud, package registry, and project management subcommands (`os projects`, `os | Source packages | 51 | | Apps | 6 (objectos, cloud, studio, console, account, docs) | | Framework adapters | 7 (Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit) | -| Database drivers | 4 (Memory, SQL, Turso/libSQL, MongoDB) | +| Database drivers | 4 (Memory, SQL, SQLite-WASM, MongoDB) | | Zod schema files | 200 | | Exported schemas | 1,600+ | | `.describe()` annotations | 8,750+ | diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 8b15713433..70c145a208 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1698,9 +1698,12 @@ export class HttpDispatcher { // plugin — it exposes the *logical* storage backends the // control-plane can actually allocate a project against (memory / // sqlite / turso / ...). The raw ObjectQL `driver.*` services are - // a deeper layer (e.g. a single unified `sql` driver that covers - // better-sqlite3 + libsql + pg + mysql) and collapse the user's - // meaningful choices into one row, so they make a poor UI source. + // a deeper layer (e.g. the unified `sql` driver, whose knex + // backends are better-sqlite3 / pg / mysql2 — `libsql`/`turso` + // is NOT bundled in framework; it ships as the separate + // `@objectstack/driver-turso` package in the cloud repo) and + // collapse the user's meaningful choices into one row, so they + // make a poor UI source. // The canonical service key is `environment-provisioning-adapters` // (post project→environment rename — registered by the cloud tenant // plugin and the objectos host). The legacy `project-provisioning- From aad550168d83b3f928b27b89cc5db86fa1a39df8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 30 May 2026 23:44:23 +0800 Subject: [PATCH 3/3] refactor(runtime): remove multi-tenant /cloud control-plane from dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete handleCloud + deleteProjectCascade from HttpDispatcher and the 26 /cloud/* route registrations from dispatcher-plugin. These are cloud control-plane concerns (environment provisioning, org cascade delete, membership, per-environment package installs) that have been relocated to the cloud repo's @objectstack/service-cloud (routes/environment-crud.ts), with byte-identical /cloud/* paths and response envelopes so the @objectstack/client `projects` namespace is unaffected. Also drop the orphaned resolveCallerUserId helper, its now-unused import, and the /cloud dispatch branch. The resolveEnvironmentContext /cloud skip-guards are kept — control-plane paths must never be treated as env-scoped. --- packages/runtime/src/dispatcher-plugin.ts | 241 --- packages/runtime/src/http-dispatcher.ts | 1706 +-------------------- 2 files changed, 3 insertions(+), 1944 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 3dacca616f..c22d78aca6 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -623,247 +623,6 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); - // ── Cloud (Projects) ───────────────────────────────────── - server.get(`${prefix}/cloud/drivers`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud('/drivers', 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - // POST /cloud/admin/platform-sso/backfill — idempotent admin trigger - // for retro-fitting sys_oauth_application rows when boot-time - // backfill plugin races/fails. Authenticated by Bearer == OS_AUTH_SECRET - // (validated inside handleCloud). - server.post(`${prefix}/cloud/admin/platform-sso/backfill`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud('/admin/platform-sso/backfill', 'POST', req.body, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/cloud/templates`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud('/templates', 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/cloud/environments`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud('/projects', 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud('/projects', 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}`, 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.patch(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}`, 'PATCH', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.delete(`${prefix}/cloud/environments/:id`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}`, 'DELETE', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.delete(`${prefix}/cloud/organizations/:id`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/organizations/${req.params.id}`, 'DELETE', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/hostname`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/hostname`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.put(`${prefix}/cloud/environments/:id/hostname`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/hostname`, 'PUT', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/rotate-credential`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/rotate-credential`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - // Alias expected by @objectstack/client: POST /projects/:id/credentials/rotate - server.post(`${prefix}/cloud/environments/:id/credentials/rotate`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/credentials/rotate`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/activate`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/activate`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/retry`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/retry`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/cloud/environments/:id/members`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/members`, 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/members`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/members`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.patch(`${prefix}/cloud/environments/:id/members/:memberId`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/members/${req.params.memberId}`, 'PATCH', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.delete(`${prefix}/cloud/environments/:id/members/:memberId`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/members/${req.params.memberId}`, 'DELETE', req.body ?? {}, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - // ── Cloud — Per-project packages ───────────────────────────────── - server.get(`${prefix}/cloud/environments/:id/packages`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages`, 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/packages`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.get(`${prefix}/cloud/environments/:id/packages/:pkgId`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages/${req.params.pkgId}`, 'GET', {}, req.query, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.delete(`${prefix}/cloud/environments/:id/packages/:pkgId`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages/${req.params.pkgId}`, 'DELETE', {}, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.patch(`${prefix}/cloud/environments/:id/packages/:pkgId/enable`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages/${req.params.pkgId}/enable`, 'PATCH', {}, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.patch(`${prefix}/cloud/environments/:id/packages/:pkgId/disable`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages/${req.params.pkgId}/disable`, 'PATCH', {}, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - - server.post(`${prefix}/cloud/environments/:id/packages/:pkgId/upgrade`, async (req: any, res: any) => { - try { - const result = await dispatcher.handleCloud(`/projects/${req.params.id}/packages/${req.params.pkgId}/upgrade`, 'POST', req.body, {}, { request: req }); - sendResult(result, res); - } catch (err: any) { - errorResponse(err, res); - } - }); - // ── Storage ───────────────────────────────────────────────── server.post(`${prefix}/storage/upload`, async (req: any, res: any) => { try { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 70c145a208..d4032b4398 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core'; -import { readEnvWithDeprecation } from '@objectstack/types'; import { CoreServiceName } from '@objectstack/spec/system'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -1558,1697 +1557,6 @@ export class HttpDispatcher { } } - private async resolveCallerUserId(context: HttpProtocolContext): Promise { - try { - const authService: any = await this.resolveService(CoreServiceName.enum.auth); - const rawHeaders = context.request?.headers; - // better-auth's `getSession` expects a `Headers` instance (or a plain - // object that the better-auth SDK happens to coerce). Hono's adapter - // hands us a `Record` of lowercased header names, so - // convert to `Headers` to be safe. - let headers: any = rawHeaders; - if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') { - try { - const h = new Headers(); - for (const [k, v] of Object.entries(rawHeaders as Record)) { - if (v == null) continue; - h.set(k, Array.isArray(v) ? v.join(', ') : String(v)); - } - headers = h; - } catch { - headers = rawHeaders; - } - } - const sessionData = await (authService?.auth?.api?.getSession ?? authService?.api?.getSession)?.call( - authService?.auth?.api ?? authService?.api, - { headers }, - ); - return sessionData?.user?.id ?? sessionData?.session?.userId; - } catch (e: any) { - return undefined; - } - } - - async handleCloud(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { - const m = method.toUpperCase(); - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - - const qlService = await this.getObjectQLService(); - const ql = qlService ?? await this.resolveService('objectql'); - if (!ql) { - return { handled: true, response: this.error('Project service not available (ObjectQL missing)', 503) }; - } - - const ENV = 'sys_environment'; - const CRED = 'sys_environment_credential'; - const MEM = 'sys_environment_member'; - const PKG_INSTALL = 'sys_package_installation'; - const PKG = 'sys_package'; - const PKG_VERSION = 'sys_package_version'; - - /** - * Upsert a `sys_package` row keyed by `manifest_id`. Auto-publishes the - * package metadata from the in-memory registry the first time a project - * tries to install it. Returns the row id (UUID). - */ - const ensureSysPackage = async ( - manifestId: string, - ownerOrgId: string, - createdBy: string, - manifest?: any, - ): Promise => { - const existing = await ql.findOne(PKG, { where: { manifest_id: manifestId } } as any) as any; - if (existing?.id) return existing.id; - const id = randomUUID(); - const nowIso = new Date().toISOString(); - await ql.insert(PKG, { - id, - manifest_id: manifestId, - owner_org_id: ownerOrgId, - display_name: manifest?.name ?? manifestId, - description: manifest?.description ?? null, - visibility: 'private', - created_by: createdBy, - created_at: nowIso, - updated_at: nowIso, - }); - return id; - }; - - /** - * Upsert a `sys_package_version` row keyed by (package_id, version). - * Auto-publishes a version snapshot the first time it is referenced. - * Returns the row id (UUID). - */ - const ensureSysPackageVersion = async ( - packageId: string, - version: string, - createdBy: string, - manifest?: any, - ): Promise => { - const existing = await ql.findOne(PKG_VERSION, { - where: { package_id: packageId, version }, - } as any) as any; - if (existing?.id) return existing.id; - const id = randomUUID(); - const nowIso = new Date().toISOString(); - await ql.insert(PKG_VERSION, { - id, - package_id: packageId, - version, - status: 'published', - manifest_json: manifest ? JSON.stringify(manifest) : null, - is_pre_release: false, - published_at: nowIso, - published_by: createdBy, - created_by: createdBy, - created_at: nowIso, - updated_at: nowIso, - }); - return id; - }; - - /** - * Resolve a per-project install record by the user-facing manifest id - * (e.g. `com.objectstack.audit`). The schema stores `package_id` as a - * sys_package UUID, so we must join through sys_package first. - */ - const findInstallByManifestId = async ( - envId: string, - manifestId: string, - ): Promise => { - const pkgRow = await ql.findOne(PKG, { where: { manifest_id: manifestId } } as any) as any; - if (!pkgRow?.id) return null; - return await ql.findOne(PKG_INSTALL, { - where: { environment_id: envId, package_id: pkgRow.id }, - } as any); - }; - - // Enumerate registered ObjectQL drivers. Driver services are registered - // by `DriverPlugin` under the key `driver.` where - // `driver.name` is typically the full FQN like `com.objectstack.driver.memory`. - // We derive a short name by stripping the `com.objectstack.driver.` prefix. - const toShortName = (driverId: string): string => { - const prefix = 'com.objectstack.driver.'; - return driverId.startsWith(prefix) ? driverId.slice(prefix.length) : driverId; - }; - const listRegisteredDrivers = (): Array<{ name: string; driverId: string }> => { - const services = this.getServicesMap(); - // Prefer the provisioning-adapter registry installed by the tenant - // plugin — it exposes the *logical* storage backends the - // control-plane can actually allocate a project against (memory / - // sqlite / turso / ...). The raw ObjectQL `driver.*` services are - // a deeper layer (e.g. the unified `sql` driver, whose knex - // backends are better-sqlite3 / pg / mysql2 — `libsql`/`turso` - // is NOT bundled in framework; it ships as the separate - // `@objectstack/driver-turso` package in the cloud repo) and - // collapse the user's meaningful choices into one row, so they - // make a poor UI source. - // The canonical service key is `environment-provisioning-adapters` - // (post project→environment rename — registered by the cloud tenant - // plugin and the objectos host). The legacy `project-provisioning- - // adapters` key is kept as a fallback for older hosts not yet rebuilt. - const registry: any = - services['environment-provisioning-adapters'] ?? - services['project-provisioning-adapters']; - if (registry && typeof registry.list === 'function') { - try { - const adapters = registry.list() as Array<{ driver: string }>; - const seen = new Set(); - const drivers: Array<{ name: string; driverId: string }> = []; - for (const adapter of adapters ?? []) { - const name = adapter?.driver; - if (!name || seen.has(name)) continue; - seen.add(name); - drivers.push({ name, driverId: `com.objectstack.driver.${name}` }); - } - if (drivers.length > 0) return drivers; - } catch { - // Adapter registry unusable — fall through to the legacy scan. - } - } - const drivers: Array<{ name: string; driverId: string }> = []; - for (const [serviceKey, svc] of Object.entries(services)) { - if (!serviceKey.startsWith('driver.')) continue; - const raw = serviceKey.slice('driver.'.length); - if (!raw || raw === 'unknown') continue; - const driverId = (svc as any)?.name ?? raw; - drivers.push({ name: toShortName(driverId), driverId }); - } - return drivers; - }; - - const resolveDriver = (requested: string | undefined): { name: string; driverId: string } | undefined => { - const registered = listRegisteredDrivers(); - if (requested) { - const wanted = String(requested).toLowerCase(); - return registered.find((d) => d.name === wanted || d.driverId === wanted); - } - // Auto-pick: prefer turso, then memory, then whatever is available. - return ( - registered.find((d) => d.name === 'turso') ?? - registered.find((d) => d.name === 'memory') ?? - registered[0] - ); - }; - - const buildDatabaseUrl = (driverName: string, environmentId: string): string => { - const dbName = `env-${environmentId}`; - switch (driverName) { - case 'memory': - return `memory://${dbName}`; - case 'turso': - return `libsql://${dbName}.mock-turso.local`; - default: - // Generic placeholder for future SQL / postgres / mysql drivers. - return `${driverName}://${dbName}`; - } - }; - - /** - * Real physical-DB adapter resolver. Looks up the - * `environment-provisioning-adapters` service registered by the - * tenant plugin. When present, provisioning actually creates a file - * on disk (sqlite adapter) or a Turso cloud DB (when TURSO_ORG_NAME - * + TURSO_API_TOKEN are set). - * - * Returns `undefined` if the service is not registered. In that - * case the dispatcher falls back to the mock-URL behaviour — the - * state machine still works, but no real DB file is created. - */ - const getRealAdapter = async ( - driverName: string, - ): Promise<{ - createDatabase(params: { - environmentId: string; - databaseName: string; - region: string; - storageLimitMb: number; - }): Promise<{ databaseUrl: string; plaintextSecret: string }>; - deleteDatabase?(params: { - environmentId: string; - databaseName: string; - databaseUrl?: string; - }): Promise; - } | undefined> => { - try { - // Canonical key first, legacy `project-*` key as a fallback. - const registry: any = - (await this.resolveService('environment-provisioning-adapters')) ?? - (await this.resolveService('project-provisioning-adapters')); - // Alias the generic 'sql' short name onto the SQLite - // provisioning adapter. `sql` is SqlDriver's default short - // name when registered via DriverPlugin; provisioning - // adapters key themselves by the logical driver family - // ('sqlite', 'turso', 'memory') not the implementation id. - const aliases: Record = { sql: 'sqlite' }; - const effective = aliases[driverName] ?? driverName; - return registry?.get?.(effective) ?? registry?.get?.(driverName); - } catch { - return undefined; - } - }; - - const findOne = async (obj: string, where: Record): Promise => { - let rows = await ql.find(obj, { where } as any); - if (rows && (rows as any).value) rows = (rows as any).value; - if (!Array.isArray(rows)) return undefined; - return rows[0]; - }; - - // Data crosses the wire as snake_case — matching the canonical - // `sys_environment` schema. The only post-processing is JSON-parsing the - // `metadata` column so consumers don't need to unwrap it. - const cleanProjectRow = (row: any): any => { - if (!row) return row; - let metadata: any = row.metadata; - if (typeof metadata === 'string') { - try { metadata = JSON.parse(metadata); } catch { /* keep raw string if not JSON */ } - } - return { ...row, metadata }; - }; - - try { - // ----- /cloud/drivers ------------------------------------------ - if (parts.length === 1 && parts[0] === 'drivers' && m === 'GET') { - const drivers = listRegisteredDrivers(); - return { handled: true, response: this.success({ drivers, total: drivers.length }) }; - } - - // ----- /cloud/templates ---------------------------------------- - if (parts.length === 1 && parts[0] === 'templates' && m === 'GET') { - try { - const seeder: any = await this.resolveService('template-seeder'); - const templates = seeder?.listTemplates?.() ?? []; - return { handled: true, response: this.success({ templates, total: templates.length }) }; - } catch (err: any) { - // Don't silently mask — log the real reason. Empty templates - // here usually means MultiProjectPlugin failed to register - // `template-seeder` (e.g. control-driver init error). - try { - // eslint-disable-next-line no-console - console.error('[HttpDispatcher] /cloud/templates: failed to resolve template-seeder:', err?.message ?? err); - } catch { /* noop */ } - return { handled: true, response: this.success({ templates: [], total: 0 }) }; - } - } - - // ----- POST /cloud/admin/platform-sso/backfill ---------------- - // Idempotent admin trigger: scans `sys_environment` and ensures every - // active project has a corresponding `sys_oauth_application` row - // for platform SSO ("Airtable-style unified login"). Safe to call - // any number of times — re-running with the same project is a - // no-op for existing rows and only patches in missing - // redirect_uris. Requires the caller to bear a Bearer token - // equal to OS_AUTH_SECRET (the shared cloud/objectos secret), - // since the boot-time backfill plugin can be flaky on cold-start - // and operators need an out-of-band way to recover. - if (parts.length === 3 - && parts[0] === 'admin' - && parts[1] === 'platform-sso' - && parts[2] === 'backfill' - && m === 'POST') { - const baseSecret = (readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET']) ?? '').trim(); - if (!baseSecret) { - return { handled: true, response: this.error('OS_AUTH_SECRET not configured on this worker', 503) }; - } - const rawHeaders = _context?.request?.headers; - let authHeader: string | undefined; - if (rawHeaders && typeof (rawHeaders as any).get === 'function') { - authHeader = (rawHeaders as any).get('authorization') ?? undefined; - } else if (rawHeaders && typeof rawHeaders === 'object') { - authHeader = (rawHeaders as any)['authorization'] ?? (rawHeaders as any)['Authorization']; - } - const presented = typeof authHeader === 'string' && authHeader.startsWith('Bearer ') - ? authHeader.slice(7).trim() - : ''; - if (!presented || presented !== baseSecret) { - return { handled: true, response: this.error('forbidden: Bearer token must match OS_AUTH_SECRET', 403) }; - } - try { - const { backfillPlatformSsoClients } = await import('./cloud/platform-sso.js'); - const result = await backfillPlatformSsoClients({ - ql, - baseSecret, - logger: console, - }); - // Dump a few rows so we can confirm the data actually - // matches what better-auth's oauth-provider expects. - let sample: any[] = []; - let total = 0; - try { - const rows = await (ql as any).find('sys_oauth_application', { limit: 5 }, { context: { isSystem: true } }); - const list = Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : []; - sample = list; - total = typeof (rows as any)?.total === 'number' ? (rows as any).total : list.length; - } catch (e: any) { - sample = [{ _readErr: e?.message ?? String(e) }]; - } - return { handled: true, response: this.success({ ...result, total, sample }) }; - } catch (err: any) { - return { handled: true, response: this.error(`backfill failed: ${err?.message ?? String(err)}`, 500) }; - } - } - - // ----- /cloud/environments collection routes ----- - if (parts.length === 1 && parts[0] === 'projects' && m === 'GET') { - const where: Record = {}; - if (query?.organizationId) where.organization_id = query.organizationId; - if (query?.status) where.status = query.status; - let rows = await ql.find(ENV, Object.keys(where).length ? ({ where } as any) : undefined); - if (rows && (rows as any).value) rows = (rows as any).value; - const projects = (Array.isArray(rows) ? rows : []).map(cleanProjectRow); - return { handled: true, response: this.success({ projects, total: projects.length }) }; - } - - if (parts.length === 1 && parts[0] === 'projects' && m === 'POST') { - const req = body || {}; - // Resolve `__session__` placeholders from the active session so clients - // can omit these fields and let the server infer them. - // Use `resolveCallerUserId` which properly converts plain header - // objects to `Headers` instances and probes both - // `authService.auth.api.getSession` and `authService.api.getSession` - // (better-auth's API shape differs across wrappings). - if (req.organization_id === '__session__' || req.created_by === '__session__') { - try { - const userId = await this.resolveCallerUserId(_context); - if (req.created_by === '__session__') { - req.created_by = userId ?? 'system'; - } - if (req.organization_id === '__session__') { - // We still need the activeOrganizationId — fetch the - // session directly via the helper-built Headers. - const authService: any = await this.resolveService(CoreServiceName.enum.auth); - const rawHeaders = _context?.request?.headers; - let headers: any = rawHeaders; - if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') { - const h = new Headers(); - for (const [k, v] of Object.entries(rawHeaders as Record)) { - if (v == null) continue; - h.set(k, Array.isArray(v) ? v.join(', ') : String(v)); - } - headers = h; - } - const apiObj = authService?.auth?.api ?? authService?.api; - const sessionData = await apiObj?.getSession?.call(apiObj, { headers }); - req.organization_id = sessionData?.session?.activeOrganizationId ?? undefined; - } - } catch { - // Fall through — validation below will reject missing fields. - } - } - if (!req.organization_id || !req.display_name) { - return { handled: true, response: this.error('organization_id and display_name are required', 400) }; - } - const environmentId = randomUUID(); - const credentialId = randomUUID(); - const nowIso = new Date().toISOString(); - - // Bind environment to a driver. `req.driver` is optional — any - // registered ObjectQL driver is accepted (memory / turso / future - // sql / postgres). If omitted, pick the best default available. - const resolved = resolveDriver(req.driver); - if (!resolved) { - const available = listRegisteredDrivers().map((d) => d.name); - if (req.driver) { - return { - handled: true, - response: this.error( - `Unknown driver '${req.driver}'. Available drivers: [${available.join(', ') || 'none'}]`, - 400, - ), - }; - } - return { - handled: true, - response: this.error( - 'No ObjectQL driver is registered. Register at least one DriverPlugin (e.g. InMemoryDriver or SqlDriver).', - 503, - ), - }; - } - const driver = resolved.name; - let plaintextSecret = `mock-token-${environmentId}`; - - // Compute hostname if not provided. - // Format: {org-slug}-{short-project-id}.{rootDomain} - // Uses the first 8 chars of the UUID as a stable, collision-free - // suffix now that projects no longer carry a user-facing slug. - let computedHostname = req.hostname; - if (!computedHostname) { - const shortId = environmentId.slice(0, 8); - try { - const orgRow = await findOne('sys_organization', { id: req.organization_id }); - const orgSlug = orgRow?.slug || req.organization_id; - const rootDomain = getEnv('OS_ROOT_DOMAIN') ?? getEnv('ROOT_DOMAIN', 'objectstack.app'); - computedHostname = `${orgSlug}-${shortId}.${rootDomain}`; - } catch { - // Fallback if sys_organization doesn't exist - computedHostname = `${req.organization_id}-${shortId}.objectstack.app`; - } - } - - // Hostname pre-flight: surface a clean 409 before we - // kick off fire-and-forget provisioning, so the client - // gets an actionable error rather than a silent - // status: 'failed' + metadata.provisioningError later. - // The sys_environment.hostname column is UNIQUE at the DB - // layer but that constraint would only trip during - // insert — by which point the caller has already moved - // on from the HTTP response. - try { - const existing = await findOne('sys_environment', { - hostname: computedHostname, - }); - if (existing && existing.id !== environmentId) { - return { - handled: true, - response: this.error( - `Hostname '${computedHostname}' is already in use by another project.`, - 409, - { code: 'HOSTNAME_TAKEN', hostname: computedHostname }, - ), - }; - } - } catch { - // sys_environment table may not yet be ready (first-run cold - // boot) — fall through and let the DB enforce uniqueness. - } - - // Insert environment row in `provisioning` state first so the - // UI can show a "Provisioning…" indicator while the driver - // handshake runs in the background. Status transitions to - // `active` on success, or `failed` (+metadata.provisioningError) - // on unrecoverable errors. - const baseMetadata: Record = { ...(req.metadata ?? {}) }; - // Dev-only: callers can set `metadata.__simulateFailure = true` - // (or `__simulateDelayMs = N`) to exercise the provisioning / - // failed / retry state machine end-to-end without a real driver. - const simulateFailure = Boolean((baseMetadata as any).__simulateFailure); - const simulateDelayMs = Number((baseMetadata as any).__simulateDelayMs ?? 1500); - - // Capture project-owner identity so the per-project kernel can - // pre-seed a `sys_user` row for this person on first boot. - // Without this, the owner lands on their own project as a - // brand-new SSO JIT user (no admin role, no membership) and - // has to manually promote themselves. Best-effort: failure - // to resolve must not abort project creation. - try { - // Prefer the value already resolved by the upstream - // `__session__` block (now backed by `resolveCallerUserId`); - // fall back to `resolveCallerUserId` directly for callers - // that omitted `created_by` entirely. - let ownerUserId: string | undefined = - req.created_by && req.created_by !== 'system' - ? String(req.created_by) - : undefined; - if (!ownerUserId) { - ownerUserId = await this.resolveCallerUserId(_context); - } - if (ownerUserId) { - const userRow = await ql.find('sys_user', { where: { id: ownerUserId } } as any); - const userRows = Array.isArray(userRow) ? userRow : (userRow?.value ?? []); - const u = Array.isArray(userRows) && userRows.length > 0 ? userRows[0] : null; - if (u?.email) { - (baseMetadata as any).ownerSeed = { - userId: String(ownerUserId), - email: String(u.email), - name: u.name ? String(u.name) : null, - image: u.image ? String(u.image) : null, - }; - } - } - } catch { - // owner lookup failed entirely — skip seed; later access - // flows still work via the platform-SSO JIT path. - } - - // Also capture the OWNING cloud org so the per-project - // kernel can mirror it into the project's `sys_organization` - // table on first boot. Without this, the project's primary - // workspace would not exist locally and the owner's first - // sign-in would land on the empty "create your first - // organization" prompt instead of resolving an - // activeOrganizationId. Best-effort: a missing org row - // means the owner has to create their first org manually. - try { - const orgRow = await ql.find('sys_organization', { where: { id: req.organization_id } } as any); - const orgRows = Array.isArray(orgRow) ? orgRow : (orgRow?.value ?? []); - const org = Array.isArray(orgRows) && orgRows.length > 0 ? orgRows[0] : null; - if (org?.id && org?.name) { - (baseMetadata as any).orgSeed = { - id: String(org.id), - name: String(org.name), - slug: org.slug ? String(org.slug) : null, - logo: org.logo ? String(org.logo) : null, - }; - } - } catch { - // org lookup failed — skip the mirror. Owner seed - // still works; the user will land on the - // "create your first organization" prompt instead. - } - await ql.insert(ENV, { - id: environmentId, - organization_id: req.organization_id, - display_name: req.display_name, - is_default: req.is_default ?? false, - is_system: req.is_system ?? false, - plan: req.plan ?? 'free', - status: 'provisioning', - created_by: req.created_by ?? 'system', - metadata: JSON.stringify(baseMetadata), - created_at: nowIso, - updated_at: nowIso, - database_url: null, - database_driver: driver, - storage_limit_mb: req.storage_limit_mb ?? 1024, - provisioned_at: null, - hostname: computedHostname, - visibility: (() => { - const raw = String(req.visibility ?? 'private'); - return raw === 'unlisted' ? 'private' : raw; - })(), - }); - - // Platform SSO seed: register a `sys_oauth_application` row - // so the per-project runtime can immediately exchange - // authorization codes with this cloud control plane. Best - // effort — failures are logged but do NOT abort the - // project-create flow (the project remains usable with - // email/password sign-in as the legacy fallback). - try { - const { seedPlatformSsoClient } = await import('./cloud/platform-sso.js'); - const baseSecret = (readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET']) ?? '').trim(); - if (baseSecret) { - await seedPlatformSsoClient({ - ql, - environmentId, - hostname: computedHostname, - baseSecret, - logger: console, - }); - } - } catch (ssoErr) { - console.warn?.('[http-dispatcher] platform SSO seed failed (non-fatal)', { - environmentId, - error: (ssoErr as Error)?.message, - }); - } - - // Fire-and-forget the provisioning work so the POST returns - // immediately with a `provisioning` record. The UI can then - // refresh (or poll) to observe the transition. - const runProvisioning = async (): Promise => { - try { - if (simulateDelayMs > 0) { - await new Promise((r) => setTimeout(r, simulateDelayMs)); - } - if (simulateFailure) { - throw new Error('Simulated provisioning failure (metadata.__simulateFailure=true)'); - } - // Try a real adapter first (creates a real sqlite file - // or Turso cloud DB). Fall back to the mock URL if no - // adapter is registered for this driver. - let databaseUrl: string; - try { - const adapter = await getRealAdapter(driver); - if (adapter) { - const result = await adapter.createDatabase({ - environmentId, - databaseName: `p-${environmentId.replace(/-/g, "").slice(0, 24)}`, - region: 'us-east-1', - storageLimitMb: req.storage_limit_mb ?? 1024, - }); - databaseUrl = result.databaseUrl; - if (result.plaintextSecret) plaintextSecret = result.plaintextSecret; - } else { - databaseUrl = buildDatabaseUrl(driver, environmentId); - } - } catch (adapterErr) { - // Adapter call failed (e.g. Turso API down). Surface - // the underlying message — the outer catch will flip - // the env to `failed`. - throw adapterErr instanceof Error - ? adapterErr - : new Error(String(adapterErr)); - } - // Persist `database_url` first (still `provisioning`) so - // kernel-factory / template-seeder can resolve the - // physical DB while seeding runs. Status flips to - // `active` only AFTER seeding completes — clients - // polling `waitForActive` then know the project's - // schema and seed data are queryable. - const seedStartedAt = new Date().toISOString(); - await ql.update( - ENV, - { - database_url: databaseUrl, - updated_at: seedStartedAt, - }, - { where: { id: environmentId } } as any, - ); - await ql.insert(CRED, { - id: credentialId, - environment_id: environmentId, - secret_ciphertext: plaintextSecret, - encryption_key_id: 'noop', - authorization: 'full_access', - status: 'active', - created_at: seedStartedAt, - updated_at: seedStartedAt, - }); - - // Seed template metadata into the newly-provisioned project. - // Non-fatal: errors are stored in sys_environment.metadata so the - // project stays `active` even if template seeding fails. - const templateId = req.template_id ?? 'blank'; - if (templateId !== 'blank') { - try { - const seeder: any = await this.resolveService('template-seeder'); - if (seeder) { - await seeder.seed({ environmentId, templateId }); - } - } catch (seedErr) { - const seedMessage = seedErr instanceof Error ? seedErr.message : String(seedErr); - // Persist seed error in metadata (non-fatal — project is active). - try { - const existing = await findOne(ENV, { id: environmentId }); - const existingMeta = typeof existing?.metadata === 'string' - ? JSON.parse(existing.metadata) - : (existing?.metadata ?? {}); - await ql.update( - ENV, - { - metadata: JSON.stringify({ - ...existingMeta, - templateSeedError: { message: seedMessage, templateId }, - }), - }, - { where: { id: environmentId } } as any, - ); - } catch { - // Best-effort metadata update — ignore secondary failure. - } - } - } - - // Bind a third-party developer's locally compiled artifact - // into this project. The caller passes - // `metadata.artifact_path` (path to a compiled - // ObjectStack bundle JSON) and we delegate to the same - // seeder pipeline that templates use. Resolved relative - // to OS_PROJECT_ARTIFACT_ROOT (or process.cwd - // if unset). Uses the shared `loadArtifactBundle` - // helper (read JSON + dynamic-import sibling - // runtime ESM + merge `functions` map) so this path - // stays in lockstep with FsAppBundleResolver and - // runtime-stack basePlugins. - const artifactPathRaw = (baseMetadata as any).artifact_path; - if (typeof artifactPathRaw === 'string' && artifactPathRaw.length > 0) { - try { - const path = await import('node:path'); - const { isHttpUrl, loadArtifactBundle } = await import('./load-artifact-bundle.js'); - const root = process.env.OS_PROJECT_ARTIFACT_ROOT - ?? process.cwd(); - const resolved = isHttpUrl(artifactPathRaw) - ? artifactPathRaw - : (path.isAbsolute(artifactPathRaw) - ? artifactPathRaw - : path.resolve(root, artifactPathRaw)); - const bundle = await loadArtifactBundle(resolved, { tag: '[bind-artifact]' }); - if (!bundle) { - throw new Error(`failed to load artifact bundle at '${resolved}'`); - } - const seeder: any = await this.resolveService('template-seeder'); - if (seeder?.seedBundle) { - await seeder.seedBundle({ environmentId, bundle }); - } else { - throw new Error('template-seeder.seedBundle is unavailable'); - } - } catch (bindErr) { - const bindMessage = bindErr instanceof Error ? bindErr.message : String(bindErr); - try { - const existing = await findOne(ENV, { id: environmentId }); - const existingMeta = typeof existing?.metadata === 'string' - ? JSON.parse(existing.metadata) - : (existing?.metadata ?? {}); - await ql.update( - ENV, - { - metadata: JSON.stringify({ - ...existingMeta, - artifactBindError: { message: bindMessage, artifactPath: artifactPathRaw }, - }), - }, - { where: { id: environmentId } } as any, - ); - } catch { - // Best-effort metadata update — ignore secondary failure. - } - } - } - - // All seeding + binding completed (or recorded as - // non-fatal errors). Flip the project to `active` so - // request routing + waitForActive observers can see - // the project as ready. - const finishedAt = new Date().toISOString(); - await ql.update( - ENV, - { - status: 'active', - provisioned_at: finishedAt, - updated_at: finishedAt, - }, - { where: { id: environmentId } } as any, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const failedAt = new Date().toISOString(); - await ql.update( - ENV, - { - status: 'failed', - metadata: JSON.stringify({ - ...baseMetadata, - provisioningError: { message, failedAt }, - }), - updated_at: failedAt, - }, - { where: { id: environmentId } } as any, - ); - } - }; - // On serverless platforms (Vercel/AWS Lambda/Netlify) the - // function instance freezes the moment we send the response, - // so a fire-and-forget background task never gets to - // persist `database_url` — leaving every subsequent request - // to crash with "Project … missing database_url/database_driver". - // Auto-detect those environments and await the provisioning - // inline. Operators can also force this with - // `OS_PROVISION_SYNC=1` (or disable with `=0`). - const provisionSyncEnv = process.env.OS_PROVISION_SYNC; - const onServerless = !!( - process.env.VERCEL - || process.env.AWS_LAMBDA_FUNCTION_NAME - || process.env.NETLIFY - || process.env.CF_PAGES - ); - const syncProvisioning = provisionSyncEnv === undefined - ? onServerless - : provisionSyncEnv !== '0' && provisionSyncEnv !== 'false'; - if (syncProvisioning) { - await runProvisioning(); - } else { - void runProvisioning(); - } - - const project = cleanProjectRow(await findOne(ENV, { id: environmentId })); - const res = this.success({ project }); - res.status = syncProvisioning ? 201 : 202; - return { handled: true, response: res }; - } - - // ----- /cloud/environments/:id ----- - if (parts.length === 2 && parts[0] === 'projects') { - const id = decodeURIComponent(parts[1]); - - if (m === 'GET') { - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - const credRow = await findOne(CRED, { environment_id: id, status: 'active' }); - // Scope membership lookup to the calling user when possible so the - // returned `membership.role` reflects the caller, not some other - // arbitrary member of the project. - const callerUserId = await this.resolveCallerUserId(_context); - const membership = callerUserId - ? await findOne(MEM, { environment_id: id, user_id: callerUserId }) - : await findOne(MEM, { environment_id: id }); - // Omit the ciphertext from responses — metadata only. - const credMeta = credRow - ? { - id: credRow.id, - status: credRow.status, - authorization: credRow.authorization, - activatedAt: credRow.created_at, - expiresAt: credRow.expires_at, - } - : undefined; - // Expose a `database` block so Studio can show physical DB - // addressing directly (mirrors the legacy sys_environment_database shape). - const project = cleanProjectRow(envRow); - const database = project.database_url - ? { - driver: project.database_driver, - database_name: `env-${project.id}`, - database_url: project.database_url, - storage_limit_mb: project.storage_limit_mb, - provisioned_at: project.provisioned_at, - } - : undefined; - return { - handled: true, - response: this.success({ project, database, credential: credMeta, membership }), - }; - } - - if (m === 'PATCH') { - const patch: Record = {}; - if (body?.display_name !== undefined) patch.display_name = body.display_name; - if (body?.plan !== undefined) patch.plan = body.plan; - if (body?.status !== undefined) patch.status = body.status; - if (body?.is_default !== undefined) patch.is_default = body.is_default; - if (body?.visibility !== undefined) { - let v = String(body.visibility); - // Legacy: accept `unlisted` but persist as `private`. - if (v === 'unlisted') v = 'private'; - if (!['private', 'public'].includes(v)) { - return { handled: true, response: this.error(`Invalid visibility '${v}' (expected private | public)`, 400) }; - } - patch.visibility = v; - } - if (body?.metadata !== undefined) patch.metadata = JSON.stringify(body.metadata); - patch.updated_at = new Date().toISOString(); - await ql.update(ENV, patch, { where: { id } } as any); - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - return { handled: true, response: this.success({ project: cleanProjectRow(envRow) }) }; - } - - if (m === 'DELETE') { - const force = query?.force === '1' || query?.force === 'true' || body?.force === true; - const result = await this.deleteProjectCascade(id, { ql, findOne, getRealAdapter, force }); - if (!result.ok) { - return { handled: true, response: this.error(result.error ?? 'Delete failed', result.status ?? 500) }; - } - return { handled: true, response: this.success({ deleted: true, environmentId: id, warnings: result.warnings }) }; - } - } - - // ----- /cloud/organizations/:id (DELETE only — cascades projects) ----- - if (parts.length === 2 && parts[0] === 'organizations' && m === 'DELETE') { - const orgId = decodeURIComponent(parts[1]); - // Find every project owned by the organization and tear it down. - let projectRows: any[] = []; - try { - let rows = await ql.find(ENV, { where: { organization_id: orgId } } as any); - if (rows && (rows as any).value) rows = (rows as any).value; - projectRows = Array.isArray(rows) ? rows : []; - } catch { - projectRows = []; - } - const warnings: string[] = []; - let deletedProjects = 0; - for (const row of projectRows) { - const pid = row?.id; - if (!pid) continue; - try { - const r = await this.deleteProjectCascade(pid, { ql, findOne, getRealAdapter, force: true }); - if (r.ok) deletedProjects++; - if (r.warnings?.length) warnings.push(...r.warnings); - if (!r.ok && r.error) warnings.push(`Project ${pid}: ${r.error}`); - } catch (err) { - warnings.push( - `Failed to delete project ${pid}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Now drop the organization itself. Prefer better-auth's - // organization plugin (which also cascades members / - // invitations / teams). Fall back to a direct sys_organization - // delete if the plugin isn't loaded. - let orgDeleted = false; - try { - const authService: any = await this.getService(CoreServiceName.enum.auth); - const fn = authService?.api?.deleteOrganization; - if (typeof fn === 'function') { - await fn.call(authService.api, { - body: { organizationId: orgId }, - headers: _context?.request?.headers, - }); - orgDeleted = true; - } - } catch (err) { - warnings.push( - `auth.deleteOrganization failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (!orgDeleted) { - try { - await ql.delete('sys_organization', { where: { id: orgId } } as any); - orgDeleted = true; - } catch (err) { - warnings.push( - `Failed to delete sys_organization row: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - return { - handled: true, - response: this.success({ - deleted: orgDeleted, - organizationId: orgId, - deletedProjects, - warnings, - }), - }; - } - - // ----- /cloud/environments/:id/hostname ----- - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'hostname' && (m === 'POST' || m === 'PUT')) { - const id = decodeURIComponent(parts[1]); - const hostname = body?.hostname; - if (!hostname || typeof hostname !== 'string') { - return { handled: true, response: this.error('hostname is required', 400) }; - } - const normalized = hostname.trim().toLowerCase(); - if (!/^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$/.test(normalized)) { - return { handled: true, response: this.error('Invalid hostname format', 400) }; - } - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - // Enforce uniqueness — reject if another project already owns this hostname. - let existing: any; - try { - const rows = await ql.find(ENV, { where: { hostname: normalized } } as any); - const arr = Array.isArray(rows) ? rows : ((rows as any)?.value ?? []); - existing = arr.find((r: any) => r.id !== id); - } catch { /* table may be empty */ } - if (existing) { - return { - handled: true, - response: this.error( - `Hostname '${normalized}' is already in use by another project.`, - 409, - { code: 'HOSTNAME_TAKEN', hostname: normalized }, - ), - }; - } - const updatedAt = new Date().toISOString(); - await ql.update(ENV, { hostname: normalized, updated_at: updatedAt }, { where: { id } } as any); - // Invalidate the hostname cache entry so the routing layer picks up the new value. - if (this.envRegistry?.invalidate) { - try { await this.envRegistry.invalidate(id); } catch { /* best-effort */ } - } - const updated = cleanProjectRow(await findOne(ENV, { id })); - return { handled: true, response: this.success({ project: updated }) }; - } - - // ----- /cloud/environments/:id/retry ----- - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'retry' && m === 'POST') { - const id = decodeURIComponent(parts[1]); - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - if (envRow.status !== 'failed' && envRow.status !== 'provisioning') { - return { - handled: true, - response: this.error( - `Project '${id}' is '${envRow.status}'; only failed or provisioning projects can be retried.`, - 409, - ), - }; - } - - const driverName = envRow.database_driver; - const resolved = resolveDriver(driverName); - if (!resolved) { - return { - handled: true, - response: this.error( - `Driver '${driverName}' is no longer registered; retry aborted.`, - 503, - ), - }; - } - - // Parse metadata so we can clear provisioningError on success - // (or rewrite it on another failure). - let metadata: Record = {}; - if (envRow.metadata) { - if (typeof envRow.metadata === 'string') { - try { metadata = JSON.parse(envRow.metadata); } catch { metadata = {}; } - } else if (typeof envRow.metadata === 'object') { - metadata = { ...(envRow.metadata as Record) }; - } - } - delete (metadata as any).provisioningError; - - // Flip back to `provisioning` while we retry — Studio renders - // the spinner instead of the red error card. - const retryStartedAt = new Date().toISOString(); - await ql.update( - ENV, - { - status: 'provisioning', - metadata: JSON.stringify(metadata), - updated_at: retryStartedAt, - }, - { where: { id } } as any, - ); - - // Same dev-only knobs as POST /projects: if the caller - // originally asked to simulate a failure they must clear the - // flag in metadata before retry — otherwise retry fails again. - const simulateRetryFailure = Boolean((metadata as any).__simulateFailure); - const simulateRetryDelay = Number((metadata as any).__simulateDelayMs ?? 1500); - - const runRetry = async (): Promise => { - try { - if (simulateRetryDelay > 0) { - await new Promise((r) => setTimeout(r, simulateRetryDelay)); - } - if (simulateRetryFailure) { - throw new Error('Simulated provisioning failure (metadata.__simulateFailure=true)'); - } - let databaseUrl: string; - let retrySecret = `mock-token-${id}`; - try { - const adapter = await getRealAdapter(resolved.name); - if (adapter) { - const result = await adapter.createDatabase({ - environmentId: id, - databaseName: `p-${id.replace(/-/g, "").slice(0, 24)}`, - region: 'us-east-1', - storageLimitMb: envRow.storage_limit_mb ?? 1024, - }); - databaseUrl = result.databaseUrl; - if (result.plaintextSecret) retrySecret = result.plaintextSecret; - } else { - databaseUrl = buildDatabaseUrl(resolved.name, id); - } - } catch (adapterErr) { - throw adapterErr instanceof Error - ? adapterErr - : new Error(String(adapterErr)); - } - const nowIso = new Date().toISOString(); - await ql.update( - ENV, - { - status: 'active', - database_url: databaseUrl, - database_driver: resolved.name, - provisioned_at: nowIso, - updated_at: nowIso, - }, - { where: { id } } as any, - ); - const existingCred = await findOne(CRED, { environment_id: id, status: 'active' }); - if (!existingCred) { - await ql.insert(CRED, { - id: randomUUID(), - environment_id: id, - secret_ciphertext: retrySecret, - encryption_key_id: 'noop', - authorization: 'full_access', - status: 'active', - created_at: nowIso, - updated_at: nowIso, - }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const failedAt = new Date().toISOString(); - await ql.update( - ENV, - { - status: 'failed', - metadata: JSON.stringify({ - ...metadata, - provisioningError: { message, failedAt }, - }), - updated_at: failedAt, - }, - { where: { id } } as any, - ); - } - }; - void runRetry(); - - const envAfter = cleanProjectRow(await findOne(ENV, { id })); - const retryRes = this.success({ project: envAfter }); - retryRes.status = 202; - return { handled: true, response: retryRes }; - } - - // ----- /cloud/environments/:id/activate ----- - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'activate' && m === 'POST') { - const id = decodeURIComponent(parts[1]); - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - // TODO: persist active_environment_id on the session once session service is wired. - return { handled: true, response: this.success({ project: cleanProjectRow(envRow), sessionUpdated: false }) }; - } - - // ----- /cloud/environments/:id/credentials/rotate ----- - if (parts.length === 4 && parts[0] === 'projects' && parts[2] === 'credentials' && parts[3] === 'rotate' && m === 'POST') { - const id = decodeURIComponent(parts[1]); - const plaintext = body?.plaintext; - if (!plaintext || typeof plaintext !== 'string') { - return { handled: true, response: this.error('plaintext is required', 400) }; - } - const envRow = await findOne(ENV, { id }); - if (!envRow) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - - const nowIso = new Date().toISOString(); - // Revoke existing active credentials - let existing = await ql.find(CRED, { where: { environment_id: id, status: 'active' } } as any); - if (existing && (existing as any).value) existing = (existing as any).value; - for (const row of (Array.isArray(existing) ? existing : [])) { - await ql.update(CRED, { - status: 'revoked', - revoked_at: nowIso, - updated_at: nowIso, - }, { where: { id: row.id } } as any); - } - - const credentialId = randomUUID(); - await ql.insert(CRED, { - id: credentialId, - environment_id: id, - secret_ciphertext: plaintext, - encryption_key_id: 'noop', - authorization: 'full_access', - status: 'active', - created_at: nowIso, - updated_at: nowIso, - }); - - const credential = await findOne(CRED, { id: credentialId }); - const credMeta = credential - ? { - id: credential.id, - status: credential.status, - authorization: credential.authorization, - activatedAt: credential.created_at, - } - : undefined; - return { handled: true, response: this.success({ credential: credMeta }) }; - } - - // ----- /cloud/environments/:id/members ----- - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'members' && m === 'GET') { - const id = decodeURIComponent(parts[1]); - let rows = await ql.find(MEM, { where: { environment_id: id } } as any); - if (rows && (rows as any).value) rows = (rows as any).value; - const members = Array.isArray(rows) ? rows : []; - // Enrich with user display info (best-effort). - const userIds = Array.from(new Set(members.map((mem: any) => mem.user_id).filter(Boolean))); - const userMap = new Map(); - for (const uid of userIds) { - let row: any = null; - for (const tableName of ['sys_user', 'user']) { - try { - const u = await ql.findOne(tableName as any, { where: { id: uid } } as any); - row = (u as any)?.value ?? u; - if (row) break; - } catch { /* try next table */ } - } - if (row) userMap.set(String(uid), { - id: row.id, - name: row.name ?? row.display_name, - email: row.email, - image: row.image ?? row.avatar_url, - }); - } - const enriched = members.map((mem: any) => ({ - ...mem, - user: userMap.get(String(mem.user_id)) ?? undefined, - })); - return { handled: true, response: this.success({ members: enriched }) }; - } - - // ----- POST /cloud/environments/:id/members (invite) --------------- - // body: { email | user_id, role? = 'member' } - // Only owner / admin may invite. Idempotent: re-inviting an - // existing member returns 200 with the existing row instead of - // creating a duplicate. - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'members' && m === 'POST') { - const id = decodeURIComponent(parts[1]); - const project = await findOne(ENV, { id }); - if (!project) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - - const callerId = await this.resolveCallerUserId(_context); - if (!callerId) return { handled: true, response: this.error('Authentication required', 401) }; - const callerMem = await findOne(MEM, { environment_id: id, user_id: callerId }); - if (!callerMem || !['owner', 'admin'].includes(String(callerMem.role))) { - return { handled: true, response: this.error('Forbidden — owner or admin required', 403) }; - } - - const email = typeof body?.email === 'string' ? String(body.email).trim().toLowerCase() : null; - let inviteUserId = typeof body?.user_id === 'string' ? String(body.user_id).trim() : null; - let role = String(body?.role ?? 'member').trim().toLowerCase(); - if (!['owner', 'admin', 'member', 'viewer'].includes(role)) { - return { handled: true, response: this.error(`Invalid role '${role}' (expected owner | admin | member | viewer)`, 400) }; - } - if (!email && !inviteUserId) { - return { handled: true, response: this.error('email or user_id is required', 400) }; - } - // Resolve email → user_id (best-effort across schema variants). - if (!inviteUserId && email) { - let row: any = null; - for (const tableName of ['sys_user', 'user']) { - try { - const u = await ql.findOne(tableName as any, { where: { email } } as any); - row = (u as any)?.value ?? u; - if (row) break; - } catch { /* try next */ } - } - if (!row?.id) { - return { handled: true, response: this.error(`No user found with email '${email}'`, 404) }; - } - inviteUserId = String(row.id); - } - - const existing = await findOne(MEM, { environment_id: id, user_id: inviteUserId }); - if (existing) { - return { handled: true, response: this.success({ member: existing, alreadyMember: true }) }; - } - - try { - const memberId = randomUUID(); - await ql.insert(MEM, { - id: memberId, - environment_id: id, - user_id: inviteUserId, - role, - invited_by: callerId, - organization_id: (project as any).organization_id ?? null, - } as any); - const created = await findOne(MEM, { id: memberId }); - return { handled: true, response: this.success({ member: created, alreadyMember: false }) }; - } catch (e: any) { - return { handled: true, response: this.error(e?.message ?? 'Failed to add member', 500) }; - } - } - - // ----- PATCH /cloud/environments/:id/members/:memberId (update role) ---- - // body: { role } - if (parts.length === 4 && parts[0] === 'projects' && parts[2] === 'members' && m === 'PATCH') { - const id = decodeURIComponent(parts[1]); - const memberId = decodeURIComponent(parts[3]); - const project = await findOne(ENV, { id }); - if (!project) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - - const callerId = await this.resolveCallerUserId(_context); - if (!callerId) return { handled: true, response: this.error('Authentication required', 401) }; - const callerMem = await findOne(MEM, { environment_id: id, user_id: callerId }); - if (!callerMem || !['owner', 'admin'].includes(String(callerMem.role))) { - return { handled: true, response: this.error('Forbidden — owner or admin required', 403) }; - } - - const target = await findOne(MEM, { id: memberId, environment_id: id }); - if (!target) return { handled: true, response: this.error(`Member '${memberId}' not found`, 404) }; - - const newRole = String(body?.role ?? '').trim().toLowerCase(); - if (!['owner', 'admin', 'member', 'viewer'].includes(newRole)) { - return { handled: true, response: this.error(`Invalid role '${newRole}'`, 400) }; - } - - // Demoting the last owner is forbidden. - if (target.role === 'owner' && newRole !== 'owner') { - let owners = await ql.find(MEM, { where: { environment_id: id, role: 'owner' } } as any); - if (owners && (owners as any).value) owners = (owners as any).value; - const ownerCount = Array.isArray(owners) ? owners.length : 0; - if (ownerCount <= 1) { - return { handled: true, response: this.error('Cannot demote the last owner', 409) }; - } - } - - try { - await ql.update(MEM, { role: newRole, updated_at: new Date().toISOString() } as any, { where: { id: memberId } } as any); - const updated = await findOne(MEM, { id: memberId }); - return { handled: true, response: this.success({ member: updated }) }; - } catch (e: any) { - return { handled: true, response: this.error(e?.message ?? 'Failed to update role', 500) }; - } - } - - // ----- DELETE /cloud/environments/:id/members/:memberId ---------------- - // Owner / admin may remove anyone. Anyone may remove themselves - // unless they are the last owner. - if (parts.length === 4 && parts[0] === 'projects' && parts[2] === 'members' && m === 'DELETE') { - const id = decodeURIComponent(parts[1]); - const memberId = decodeURIComponent(parts[3]); - const project = await findOne(ENV, { id }); - if (!project) return { handled: true, response: this.error(`Project '${id}' not found`, 404) }; - - const callerId = await this.resolveCallerUserId(_context); - if (!callerId) return { handled: true, response: this.error('Authentication required', 401) }; - - const target = await findOne(MEM, { id: memberId, environment_id: id }); - if (!target) return { handled: true, response: this.error(`Member '${memberId}' not found`, 404) }; - - const callerMem = await findOne(MEM, { environment_id: id, user_id: callerId }); - const isSelf = String(target.user_id) === String(callerId); - const isPrivileged = callerMem && ['owner', 'admin'].includes(String(callerMem.role)); - if (!isSelf && !isPrivileged) { - return { handled: true, response: this.error('Forbidden — owner or admin required', 403) }; - } - - if (target.role === 'owner') { - let owners = await ql.find(MEM, { where: { environment_id: id, role: 'owner' } } as any); - if (owners && (owners as any).value) owners = (owners as any).value; - const ownerCount = Array.isArray(owners) ? owners.length : 0; - if (ownerCount <= 1) { - return { handled: true, response: this.error('Cannot remove the last owner', 409) }; - } - } - - try { - await ql.delete(MEM, { where: { id: memberId } } as any); - return { handled: true, response: this.success({ removed: true, memberId }) }; - } catch (e: any) { - return { handled: true, response: this.error(e?.message ?? 'Failed to remove member', 500) }; - } - } - - // ----- /cloud/environments/:envId/packages ----- - // GET /cloud/environments/:envId/packages - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'packages' && m === 'GET') { - const envId = decodeURIComponent(parts[1]); - let rows = await ql.find(PKG_INSTALL, { where: { environment_id: envId } } as any); - if (rows && (rows as any).value) rows = (rows as any).value; - const installs = Array.isArray(rows) ? rows : []; - - // Denormalize: translate package_id (UUID) → manifest_id (string) - // and package_version_id (UUID) → version string. Studio joins - // these against the in-memory registry which uses manifest ids. - const packages = await Promise.all( - installs.map(async (r: any) => { - let manifestId: string | null = null; - let versionStr: string | null = null; - try { - if (r.package_id) { - const pkg = await ql.findOne(PKG, { where: { id: r.package_id } } as any) as any; - manifestId = pkg?.manifest_id ?? null; - } - if (r.package_version_id) { - const ver = await ql.findOne(PKG_VERSION, { where: { id: r.package_version_id } } as any) as any; - versionStr = ver?.version ?? null; - } - } catch { - // best-effort enrichment - } - return { - ...r, - // Surface user-facing identifiers expected by client SDK - packageId: manifestId, - package_id: manifestId ?? r.package_id, - version: versionStr ?? r.version ?? null, - }; - }), - ); - return { handled: true, response: this.success({ packages, total: packages.length }) }; - } - - // POST /cloud/environments/:envId/packages - if (parts.length === 3 && parts[0] === 'projects' && parts[2] === 'packages' && m === 'POST') { - const envId = decodeURIComponent(parts[1]); - const { packageId, version, settings, enableOnInstall } = body ?? {}; - if (!packageId) return { handled: true, response: this.error('packageId is required', 400) }; - - // Resolve manifest from the host kernel's package registry - const qlSvc = await this.getObjectQLService(); - const pkgRegistry = (qlSvc as any)?.registry; - const allPkgs = pkgRegistry?.getAllPackages?.() ?? []; - const manifestEntry = allPkgs.find((p: any) => (p?.manifest?.id ?? p?.id) === packageId); - const manifest = manifestEntry?.manifest ?? manifestEntry; - if (!manifest) { - return { handled: true, response: this.error(`Package '${packageId}' is not registered on this server`, 404) }; - } - - // Prevent installing cloud/system-scope packages per-project - const CLOUD_SCOPES = new Set(['cloud', 'system', 'platform']); - if (CLOUD_SCOPES.has(manifest?.scope)) { - return { handled: true, response: this.error(`Package '${packageId}' has scope=${manifest.scope} and cannot be installed per-project`, 403) }; - } - - // Look up project to get organization_id (owner) and resolve session user - const projectRow = await findOne(ENV, { id: envId }) as any; - if (!projectRow) { - return { handled: true, response: this.error(`Project '${envId}' not found`, 404) }; - } - const ownerOrgId = projectRow.organization_id ?? 'system'; - - let userId = 'system'; - try { - const authService: any = await this.getService(CoreServiceName.enum.auth); - const sessionData = await authService?.api?.getSession?.({ - headers: _context?.request?.headers, - }); - userId = sessionData?.user?.id ?? sessionData?.session?.userId ?? 'system'; - } catch { - // Fall through with 'system' - } - - const resolvedVersion = version ?? manifest?.version ?? '1.0.0'; - - // Reject duplicate installs for the same package in the same project - const dup = await ql.findOne(PKG_INSTALL, { - where: { environment_id: envId, package_id: packageId }, - } as any) as any; - if (dup?.id) { - return { handled: true, response: this.error(`Package '${packageId}' is already installed in this project`, 409) }; - } - - // Upsert sys_package + sys_package_version, then create the install record - const sysPackageId = await ensureSysPackage(packageId, ownerOrgId, userId, manifest); - const sysPackageVersionId = await ensureSysPackageVersion(sysPackageId, resolvedVersion, userId, manifest); - - const nowIso = new Date().toISOString(); - const recordId = randomUUID(); - await ql.insert(PKG_INSTALL, { - id: recordId, - environment_id: envId, - package_id: sysPackageId, - package_version_id: sysPackageVersionId, - status: 'installed', - enabled: enableOnInstall !== false, - installed_at: nowIso, - installed_by: userId, - updated_at: nowIso, - settings: settings ? JSON.stringify(settings) : null, - }); - const record = await ql.findOne(PKG_INSTALL, { where: { id: recordId } } as any); - // Invalidate the project kernel cache so the freshly-installed - // package gets loaded on next request. - try { await this.kernelManager?.evict(envId); } catch { /* best effort */ } - return { handled: true, response: this.success({ package: record }) }; - } - - // ----- /cloud/environments/:envId/packages/:pkgId ----- - // GET /cloud/environments/:envId/packages/:pkgId - if (parts.length === 4 && parts[0] === 'projects' && parts[2] === 'packages' && m === 'GET') { - const envId = decodeURIComponent(parts[1]); - const pkgId = decodeURIComponent(parts[3]); - const record = await ql.findOne(PKG_INSTALL, { where: { environment_id: envId, package_id: pkgId } } as any); - if (!record) return { handled: true, response: this.error(`Package '${pkgId}' is not installed in this project`, 404) }; - return { handled: true, response: this.success({ package: record }) }; - } - - // DELETE /cloud/environments/:envId/packages/:pkgId - if (parts.length === 4 && parts[0] === 'projects' && parts[2] === 'packages' && m === 'DELETE') { - const envId = decodeURIComponent(parts[1]); - const pkgId = decodeURIComponent(parts[3]); - const record = await findInstallByManifestId(envId, pkgId) as any; - if (!record) return { handled: true, response: this.error(`Package '${pkgId}' is not installed in this project`, 404) }; - // Re-derive scope from the in-memory registry manifest, since - // sys_package_installation no longer carries `scope` directly. - const allPkgs0 = this.kernel.packages?.getAll?.() ?? []; - const m0 = allPkgs0.find((p: any) => (p.manifest?.id ?? p.id) === pkgId)?.manifest; - if (m0?.scope && ['cloud', 'system', 'platform'].includes(m0.scope)) { - return { handled: true, response: this.error(`Package '${pkgId}' with scope=${m0.scope} cannot be uninstalled`, 403) }; - } - await ql.delete(PKG_INSTALL, { where: { id: record.id } } as any); - try { await this.kernelManager?.evict(envId); } catch { /* best effort */ } - return { handled: true, response: this.success({ id: record.id, success: true }) }; - } - - // PATCH /cloud/environments/:envId/packages/:pkgId/enable - if (parts.length === 5 && parts[0] === 'projects' && parts[2] === 'packages' && parts[4] === 'enable' && m === 'PATCH') { - const envId = decodeURIComponent(parts[1]); - const pkgId = decodeURIComponent(parts[3]); - const record = await findInstallByManifestId(envId, pkgId) as any; - if (!record) return { handled: true, response: this.error(`Package '${pkgId}' is not installed in this project`, 404) }; - const nowIso = new Date().toISOString(); - await ql.update(PKG_INSTALL, { enabled: true, status: 'installed', updated_at: nowIso }, { where: { id: record.id } } as any); - const updated = await ql.findOne(PKG_INSTALL, { where: { id: record.id } } as any); - try { await this.kernelManager?.evict(envId); } catch { /* best effort */ } - return { handled: true, response: this.success({ package: updated }) }; - } - - // PATCH /cloud/environments/:envId/packages/:pkgId/disable - if (parts.length === 5 && parts[0] === 'projects' && parts[2] === 'packages' && parts[4] === 'disable' && m === 'PATCH') { - const envId = decodeURIComponent(parts[1]); - const pkgId = decodeURIComponent(parts[3]); - const record = await findInstallByManifestId(envId, pkgId) as any; - if (!record) return { handled: true, response: this.error(`Package '${pkgId}' is not installed in this project`, 404) }; - const allPkgs1 = this.kernel.packages?.getAll?.() ?? []; - const m1 = allPkgs1.find((p: any) => (p.manifest?.id ?? p.id) === pkgId)?.manifest; - if (m1?.scope && ['cloud', 'system', 'platform'].includes(m1.scope)) { - return { handled: true, response: this.error(`Package '${pkgId}' with scope=${m1.scope} cannot be disabled`, 403) }; - } - const nowIso = new Date().toISOString(); - await ql.update(PKG_INSTALL, { enabled: false, status: 'disabled', updated_at: nowIso }, { where: { id: record.id } } as any); - const updated = await ql.findOne(PKG_INSTALL, { where: { id: record.id } } as any); - try { await this.kernelManager?.evict(envId); } catch { /* best effort */ } - return { handled: true, response: this.success({ package: updated }) }; - } - - // POST /cloud/environments/:envId/packages/:pkgId/upgrade - if (parts.length === 5 && parts[0] === 'projects' && parts[2] === 'packages' && parts[4] === 'upgrade' && m === 'POST') { - const envId = decodeURIComponent(parts[1]); - const pkgId = decodeURIComponent(parts[3]); - const record = await findInstallByManifestId(envId, pkgId) as any; - if (!record) return { handled: true, response: this.error(`Package '${pkgId}' is not installed in this project`, 404) }; - const { targetVersion } = body ?? {}; - const allPkgs2 = this.kernel.packages?.getAll?.() ?? []; - const manifest2 = allPkgs2.find((p: any) => (p.manifest?.id ?? p.id) === pkgId)?.manifest; - const currentVer = await ql.findOne(PKG_VERSION, { where: { id: record.package_version_id } } as any) as any; - const newVersion = targetVersion ?? manifest2?.version ?? currentVer?.version ?? '1.0.0'; - if (newVersion === currentVer?.version) { - return { handled: true, response: this.success({ package: record, message: 'Already at target version' }) }; - } - // Resolve user for `created_by` audit columns - let userId = 'system'; - try { - const authService: any = await this.getService(CoreServiceName.enum.auth); - const sessionData = await authService?.api?.getSession?.({ - headers: _context?.request?.headers, - }); - userId = sessionData?.user?.id ?? 'system'; - } catch { /* fall through */ } - const newVersionId = await ensureSysPackageVersion(record.package_id, newVersion, userId, manifest2); - const nowIso = new Date().toISOString(); - await ql.update(PKG_INSTALL, { - package_version_id: newVersionId, - status: 'installed', - updated_at: nowIso, - }, { where: { id: record.id } } as any); - const updated = await ql.findOne(PKG_INSTALL, { where: { id: record.id } } as any); - try { await this.kernelManager?.evict(envId); } catch { /* best effort */ } - return { handled: true, response: this.success({ package: updated }) }; - } - - } catch (e: any) { - return { handled: true, response: this.error(e.message, e.statusCode || 500) }; - } - - return { handled: false }; - } - - /** - * Cascade-delete a project: cred / member / package_installation rows, - * then the physical database via the provisioning adapter, then the - * `sys_environment` row itself. Used by both `DELETE /cloud/environments/:id` - * and the org-cascade in `DELETE /cloud/organizations/:id`. - * - * Idempotent and best-effort: missing rows / unreachable adapters - * become warnings rather than hard failures, so a half-provisioned - * project can still be cleaned out. - */ - private async deleteProjectCascade( - environmentId: string, - deps: { - ql: any; - findOne: (obj: string, where: Record) => Promise; - getRealAdapter: (driver: string) => Promise<{ - deleteDatabase?(params: { environmentId: string; databaseName: string; databaseUrl?: string }): Promise; - } | undefined>; - force?: boolean; - }, - ): Promise<{ ok: boolean; status?: number; error?: string; warnings: string[] }> { - const { ql, findOne, getRealAdapter, force } = deps; - const ENV = 'sys_environment'; - const warnings: string[] = []; - - const row = await findOne(ENV, { id: environmentId }); - if (!row) { - return { ok: false, status: 404, error: `Project '${environmentId}' not found`, warnings }; - } - if (row.is_system === true || row.is_system === 1) { - return { ok: false, status: 409, error: `Project '${environmentId}' is a system project and cannot be deleted`, warnings }; - } - if ((row.is_default === true || row.is_default === 1) && !force) { - return { - ok: false, - status: 409, - error: `Project '${environmentId}' is the default project for its organization. Pass ?force=1 to delete it.`, - warnings, - }; - } - - // Cascade-delete dependent rows. - const cascade: Array<{ object: string; field: string }> = [ - { object: 'sys_environment_credential', field: 'environment_id' }, - { object: 'sys_environment_member', field: 'environment_id' }, - { object: 'sys_package_installation', field: 'environment_id' }, - ]; - for (const { object, field } of cascade) { - try { - let rows = await ql.find(object, { where: { [field]: environmentId } } as any); - if (rows && rows.value) rows = rows.value; - if (Array.isArray(rows)) { - for (const r of rows) { - if (r?.id != null) { - try { - await ql.delete(object, { where: { id: r.id } } as any); - } catch (innerErr) { - warnings.push( - `Failed to delete ${object} ${r.id}: ${innerErr instanceof Error ? innerErr.message : String(innerErr)}`, - ); - } - } - } - } - } catch (err) { - warnings.push( - `Failed to enumerate ${object} for project ${environmentId}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Tear down the physical database (best-effort). - const driver = (row.database_driver as string | undefined) ?? 'memory'; - const databaseUrl = row.database_url as string | undefined; - const databaseName = `p-${String(environmentId).replace(/-/g, '').slice(0, 24)}`; - try { - const adapter = await getRealAdapter(driver); - if (adapter?.deleteDatabase) { - await adapter.deleteDatabase({ environmentId, databaseName, databaseUrl }); - } else { - warnings.push(`No adapter for driver '${driver}'; physical DB for project ${environmentId} not released.`); - } - } catch (err) { - warnings.push( - `Failed to delete physical database for project ${environmentId}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Drop the sys_environment row itself. - try { - await ql.delete(ENV, { where: { id: environmentId } } as any); - } catch (err) { - return { - ok: false, - status: 500, - error: `Failed to delete sys_environment row: ${err instanceof Error ? err.message : String(err)}`, - warnings, - }; - } - - // Invalidate routing caches so subsequent requests don't resolve to a dead env. - if (this.envRegistry?.invalidate) { - try { await this.envRegistry.invalidate(environmentId); } catch { /* best-effort */ } - } - - return { ok: true, warnings }; - } - /** * Handles Storage requests * path: sub-path after /storage/ @@ -3879,13 +2187,9 @@ export class HttpDispatcher { // Strip the `/environments/:environmentId` prefix so the protocol dispatchers // below (meta, data, ui, automation, …) see the same shape whether // the caller used host-based routing, `X-Environment-Id`, or a scoped URL. - // `/cloud/environments/:id` is explicitly excluded — those are control - // plane CRUD endpoints and must reach handleCloud() unchanged. - if (!cleanPath.startsWith('/cloud/')) { - const scopedMatch = cleanPath.match(/^\/projects\/[^/]+(\/.*)?$/); - if (scopedMatch) { - cleanPath = scopedMatch[1] ?? ''; - } + const scopedMatch = cleanPath.match(/^\/projects\/[^/]+(\/.*)?$/); + if (scopedMatch) { + cleanPath = scopedMatch[1] ?? ''; } // 0. Discovery Endpoint (GET /discovery or GET /) @@ -3958,10 +2262,6 @@ export class HttpDispatcher { return this.handlePackages(cleanPath.substring(9), method, body, query, context); } - if (cleanPath.startsWith('/cloud')) { - return this.handleCloud(cleanPath.substring(6), method, body, query, context); - } - if (cleanPath.startsWith('/i18n')) { return this.handleI18n(cleanPath.substring(5), method, query, context); }