diff --git a/packages/runtime/src/cloud/artifact-api-client.ts b/packages/runtime/src/cloud/artifact-api-client.ts deleted file mode 100644 index 5301feeee0..0000000000 --- a/packages/runtime/src/cloud/artifact-api-client.ts +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Artifact API client. - * - * HTTP client that talks to the ObjectStack control plane (e.g. - * `apps/cloud`) to resolve hostnames to projects and to download a - * project's compiled artifact. - * - * The control plane is expected to expose two endpoints: - * - * GET {controlPlaneUrl}/api/v1/cloud/resolve-hostname?host={hostname} - * → { environmentId: string, organizationId?: string, runtime?: EnvironmentRuntimeConfig } - * - * GET {controlPlaneUrl}/api/v1/cloud/environments/:environmentId/artifact - * → EnvironmentArtifactResponse (EnvironmentArtifact + optional `runtime` block) - * - * Both endpoints accept an optional `Authorization: Bearer `. - * - * Responses are cached in-memory with a TTL so each kernel-manager - * miss does not produce an extra HTTP round trip. Concurrent callers - * for the same key share a single in-flight promise (singleflight). - */ - -import type { EnvironmentArtifact } from '@objectstack/spec/cloud'; - -/** - * Per-project runtime config injected by the control plane alongside - * the artifact. Carries the physical database URL the runtime should - * connect to (this is *not* part of the developer-authored compiled - * artifact — the control plane mints it when serving the API). - */ -export interface EnvironmentRuntimeConfig { - organizationId?: string; - hostname?: string; - /** Driver type — e.g. `sqlite`, `postgres`, `turso`, `memory`. */ - databaseDriver: string; - /** Driver-specific connection URL. */ - databaseUrl: string; - /** Optional auth token (e.g. for libSQL/Turso). */ - databaseAuthToken?: string; - /** - * Project-level metadata captured by the control plane at create time - * (e.g. `ownerSeed`, `orgSeed`). Forwarded to the runtime so cold-boot - * seed replay can mirror the cloud org + owner into the project DB - * before the user's first SSO callback arrives. - */ - metadata?: Record; -} - -/** - * Hostname resolution response. - */ -export interface ResolvedHostname { - environmentId: string; - organizationId?: string; - /** Optional runtime config — when present, callers can skip the artifact fetch's runtime block. */ - runtime?: EnvironmentRuntimeConfig; -} - -/** - * Artifact response wrapping the spec's `EnvironmentArtifact` envelope plus - * an optional `runtime` block carrying the project's database - * connection details. - */ -export interface EnvironmentArtifactResponse extends EnvironmentArtifact { - runtime?: EnvironmentRuntimeConfig; -} - -export interface ArtifactApiClientConfig { - /** Control-plane base URL (no trailing slash). */ - controlPlaneUrl: string; - /** Optional bearer token. */ - apiKey?: string; - /** Cache TTL in ms. Default: 5 min. */ - cacheTtlMs?: number; - /** Timeout for control-plane HTTP calls in ms. Default: 10s. */ - requestTimeoutMs?: number; - /** Optional fetch override (testing). */ - fetch?: typeof fetch; - /** Optional logger. */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; -} - -interface CacheEntry { - value: T; - expiresAt: number; -} - -export class ArtifactApiClient { - private readonly base: string; - private readonly apiKey?: string; - private readonly cacheTtlMs: number; - private readonly requestTimeoutMs: number; - private readonly fetchImpl: typeof fetch; - private readonly logger: NonNullable; - - private readonly hostnameCache = new Map>(); - private readonly artifactCache = new Map>(); - private readonly pendingHostname = new Map>(); - private readonly pendingArtifact = new Map>(); - - constructor(config: ArtifactApiClientConfig) { - if (!config.controlPlaneUrl) { - throw new Error('[ArtifactApiClient] controlPlaneUrl is required'); - } - this.base = config.controlPlaneUrl.replace(/\/+$/, ''); - this.apiKey = config.apiKey; - this.cacheTtlMs = config.cacheTtlMs ?? 5 * 60 * 1000; - this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000; - this.fetchImpl = config.fetch ?? globalThis.fetch; - this.logger = config.logger ?? console; - if (typeof this.fetchImpl !== 'function') { - throw new Error('[ArtifactApiClient] global fetch is not available — provide config.fetch'); - } - } - - /** - * Resolve a hostname to its project. Returns `null` on 404 or - * malformed responses. Errors (network / 5xx) are thrown so - * upstream callers can retry. - */ - async resolveHostname(host: string): Promise { - const cached = this.hostnameCache.get(host); - if (cached && cached.expiresAt > Date.now()) return cached.value; - - const inflight = this.pendingHostname.get(host); - if (inflight) return inflight; - - const promise = (async () => { - try { - const url = `${this.base}/api/v1/cloud/resolve-hostname?host=${encodeURIComponent(host)}`; - const res = await this.request(url); - if (res === null) return null; - const body = res.success === false ? null : (res.data ?? res); - if (!body || typeof body.environmentId !== 'string' || !body.environmentId) return null; - const value: ResolvedHostname = { - environmentId: body.environmentId, - organizationId: body.organizationId, - runtime: body.runtime, - }; - this.hostnameCache.set(host, { value, expiresAt: Date.now() + this.cacheTtlMs }); - return value; - } finally { - this.pendingHostname.delete(host); - } - })(); - this.pendingHostname.set(host, promise); - return promise; - } - - /** - * Fetch the compiled artifact for a project. - * - * When `opts.commit` is set, requests that specific revision via the - * existing `?commit=` query param. Different commits are cached - * independently (the cache key includes the commit id) so the preview - * runtime can hold multiple versions in memory simultaneously. - */ - async fetchArtifact(environmentId: string, opts?: { commit?: string }): Promise { - const commit = opts?.commit?.trim() || ''; - const cacheKey = commit ? `${environmentId}@${commit}` : environmentId; - const cached = this.artifactCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.value; - - const inflight = this.pendingArtifact.get(cacheKey); - if (inflight) return inflight; - - const promise = (async () => { - try { - const qs = commit ? `?commit=${encodeURIComponent(commit)}` : ''; - const url = `${this.base}/api/v1/cloud/environments/${encodeURIComponent(environmentId)}/artifact${qs}`; - const res = await this.request(url); - if (res === null) return null; - const body = res.success === false ? null : (res.data ?? res); - if (!body || typeof body !== 'object') return null; - if (!body.metadata) { - this.logger.warn?.('[ArtifactApiClient] artifact response missing `metadata`', { environmentId, commit }); - return null; - } - const value = body as EnvironmentArtifactResponse; - this.artifactCache.set(cacheKey, { value, expiresAt: Date.now() + this.cacheTtlMs }); - return value; - } finally { - this.pendingArtifact.delete(cacheKey); - } - })(); - this.pendingArtifact.set(cacheKey, promise); - return promise; - } - - /** - * Resolve an 8-hex project short id (first 8 hex chars of the UUID, - * dashes stripped) to the full environmentId. Used by the preview - * runtime, which encodes project ids in subdomains. - * - * Returns `null` on 404 or ambiguity (the control plane returns 409 - * if the prefix matches more than one project). - */ - async lookupProjectByShortId(shortId: string): Promise<{ environmentId: string; organizationId?: string } | null> { - const short = String(shortId ?? '').trim().toLowerCase(); - if (!/^[0-9a-f]{8,}$/.test(short)) return null; - const url = `${this.base}/api/v1/cloud/environments-by-short-id/${encodeURIComponent(short)}`; - const res = await this.request(url); - if (res === null) return null; - const body = res.success === false ? null : (res.data ?? res); - if (!body || typeof body.environmentId !== 'string' || !body.environmentId) return null; - return { environmentId: body.environmentId, organizationId: body.organizationId }; - } - - /** - * Fetch the head commit of a branch. Returns the commit id (and the - * matching revision row's `published_at` for cache-validity checks). - * Reuses the existing `GET /cloud/environments/:id/branches` endpoint. - */ - async fetchBranchHead( - environmentId: string, - branchName: string, - ): Promise<{ commitId: string; publishedAt?: string | null } | null> { - const url = `${this.base}/api/v1/cloud/environments/${encodeURIComponent(environmentId)}/branches`; - const res = await this.request(url); - if (res === null) return null; - const body = res.success === false ? null : (res.data ?? res); - const branches = Array.isArray(body?.branches) ? body.branches : []; - const target = String(branchName ?? '').trim().toLowerCase(); - const found = branches.find((b: any) => String(b?.branch ?? '').toLowerCase() === target); - if (!found?.headCommitId) return null; - return { commitId: String(found.headCommitId), publishedAt: found.headPublishedAt ?? null }; - } - - /** - * Cheap freshness probe — returns the env's `last_published_at` - * (and best-effort current commit) without rebuilding the artifact. - * Used by `KernelManager` on cache hits to detect when a per-env - * kernel has been invalidated by an upstream change (marketplace - * install/uninstall, artifact publish) so it can be rebuilt - * without waiting for the 15-minute LRU TTL to expire. - * - * Returns `null` on definitive 404 / unknown env. Errors propagate - * (caller decides whether to treat unreachable cloud as fresh or - * stale — typically fresh, so a brief outage doesn't churn every - * cached kernel). - */ - async getFreshness(environmentId: string): Promise<{ - environmentId: string; - lastPublishedAt: string | null; - commitId: string | null; - } | null> { - const url = `${this.base}/api/v1/cloud/environments/${encodeURIComponent(environmentId)}/freshness`; - const res = await this.request(url); - if (res === null) return null; - const body = res.success === false ? null : (res.data ?? res); - if (!body || typeof body !== 'object') return null; - const envId = typeof body.environmentId === 'string' ? body.environmentId : environmentId; - const lastPublishedAt = typeof body.lastPublishedAt === 'string' ? body.lastPublishedAt : null; - const commitId = typeof body.commitId === 'string' ? body.commitId : null; - return { environmentId: envId, lastPublishedAt, commitId }; - } - - /** Drop cached entries for a project (and any matching hostname). */ - invalidate(environmentId: string): void { - // Cache keys are `${environmentId}` for HEAD or `${environmentId}@${commit}` - // for pinned reads (preview runtime). Drop both shapes. - this.artifactCache.delete(environmentId); - const prefix = `${environmentId}@`; - for (const key of Array.from(this.artifactCache.keys())) { - if (key.startsWith(prefix)) this.artifactCache.delete(key); - } - for (const [host, entry] of this.hostnameCache) { - if (entry.value.environmentId === environmentId) this.hostnameCache.delete(host); - } - } - - /** Drop everything. Used on shutdown / hot-reload. */ - clear(): void { - this.hostnameCache.clear(); - this.artifactCache.clear(); - } - - private async request(url: string): Promise { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timer = controller ? setTimeout(() => controller.abort(), this.requestTimeoutMs) : null; - try { - const res = await this.fetchImpl(url, { - method: 'GET', - headers: this.buildHeaders(), - signal: controller?.signal, - }); - if (res.status === 404) return null; - if (!res.ok) { - throw new Error(`[ArtifactApiClient] ${url} → HTTP ${res.status}`); - } - return await res.json(); - } finally { - if (timer) clearTimeout(timer); - } - } - - private buildHeaders(): Record { - const headers: Record = { - 'accept': 'application/json', - 'user-agent': 'objectos-runtime', - }; - if (this.apiKey) headers['authorization'] = `Bearer ${this.apiKey}`; - return headers; - } -} diff --git a/packages/runtime/src/cloud/artifact-environment-registry.ts b/packages/runtime/src/cloud/artifact-environment-registry.ts deleted file mode 100644 index c592e5ab87..0000000000 --- a/packages/runtime/src/cloud/artifact-environment-registry.ts +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * EnvironmentDriverRegistry implementation that talks to the control plane - * over HTTP via {@link ArtifactApiClient}. - * - * Mirrors {@link DefaultEnvironmentDriverRegistry} from `environment-registry.ts` - * but does **not** read from a local control-plane database. Hostname → - * environmentId resolution and per-project runtime config (database URL / - * driver) come from the control plane API. - * - * The cached `project` payload exposed by `peekById()` is shaped to look - * like a `sys_environment` row so callers downstream (notably - * `ArtifactKernelFactory`) can read `id`, `organization_id`, - * `database_url` and `database_driver` without branching. - */ - -import type * as Contracts from '@objectstack/spec/contracts'; -import { resolve as resolvePathNode } from 'node:path'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; -import type { ArtifactApiClient, EnvironmentRuntimeConfig } from './artifact-api-client.js'; - -type IDataDriver = Contracts.IDataDriver; - -interface CacheEntry { - environmentId: string; - driver: IDataDriver; - project: any; - expiresAt: number; -} - -export interface ArtifactEnvironmentRegistryConfig { - client: ArtifactApiClient; - /** Cache TTL for resolved drivers in ms. Default: 5 min. */ - cacheTtlMs?: number; - /** Optional logger. */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; -} - -export class ArtifactEnvironmentRegistry implements EnvironmentDriverRegistry { - private readonly client: ArtifactApiClient; - private readonly cacheTTL: number; - private readonly logger: NonNullable; - - private readonly hostnameCache = new Map(); - private readonly idCache = new Map(); - private readonly pending = new Map>(); - - constructor(config: ArtifactEnvironmentRegistryConfig) { - this.client = config.client; - this.cacheTTL = config.cacheTtlMs ?? 5 * 60 * 1000; - this.logger = config.logger ?? console; - } - - async resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null> { - const cached = this.hostnameCache.get(host); - if (cached && cached.expiresAt > Date.now()) { - return { environmentId: cached.environmentId, driver: cached.driver }; - } - const key = `host:${host}`; - const inflight = this.pending.get(key); - if (inflight) { - const result = await inflight; - return result ? { environmentId: result.environmentId, driver: result.driver } : null; - } - const promise = (async (): Promise => { - try { - const resolved = await this.client.resolveHostname(host); - if (!resolved) return null; - const entry = await this.buildCacheEntry(resolved.environmentId, resolved.runtime, resolved.organizationId, host); - if (!entry) return null; - this.hostnameCache.set(host, entry); - this.idCache.set(entry.environmentId, entry); - return entry; - } catch (err: any) { - this.logger.error?.('[ArtifactEnvironmentRegistry] resolveByHostname failed', { - host, - error: err?.message ?? err, - }); - return null; - } finally { - this.pending.delete(key); - } - })(); - this.pending.set(key, promise); - const entry = await promise; - return entry ? { environmentId: entry.environmentId, driver: entry.driver } : null; - } - - async resolveById(environmentId: string): Promise { - const cached = this.idCache.get(environmentId); - if (cached && cached.expiresAt > Date.now()) return cached.driver; - - const key = `id:${environmentId}`; - const inflight = this.pending.get(key); - if (inflight) { - const result = await inflight; - return result?.driver ?? null; - } - const promise = (async (): Promise => { - try { - const entry = await this.buildCacheEntry(environmentId, undefined, undefined, undefined); - if (!entry) return null; - this.idCache.set(environmentId, entry); - if (entry.project?.hostname) this.hostnameCache.set(entry.project.hostname, entry); - return entry; - } catch (err: any) { - this.logger.error?.('[ArtifactEnvironmentRegistry] resolveById failed', { - environmentId, - error: err?.message ?? err, - }); - return null; - } finally { - this.pending.delete(key); - } - })(); - this.pending.set(key, promise); - const entry = await promise; - return entry?.driver ?? null; - } - - peekById(environmentId: string): { environmentId: string; driver: IDataDriver; project: any } | null { - const cached = this.idCache.get(environmentId); - if (cached && cached.expiresAt > Date.now()) { - return { environmentId: cached.environmentId, driver: cached.driver, project: cached.project }; - } - return null; - } - - invalidate(environmentId: string): void { - this.idCache.delete(environmentId); - for (const [host, entry] of this.hostnameCache) { - if (entry.environmentId === environmentId) this.hostnameCache.delete(host); - } - this.client.invalidate(environmentId); - } - - private async buildCacheEntry( - environmentId: string, - runtimeFromHostname: EnvironmentRuntimeConfig | undefined, - orgIdFromHostname: string | undefined, - hostname: string | undefined, - ): Promise { - let runtime = runtimeFromHostname; - let organizationId = orgIdFromHostname; - let host = hostname; - let artifactProjectId = environmentId; - - if (!runtime || !organizationId) { - const artifact = await this.client.fetchArtifact(environmentId); - if (!artifact) { - this.logger.warn?.('[ArtifactEnvironmentRegistry] artifact not found', { environmentId }); - return null; - } - artifactProjectId = artifact.environmentId ?? environmentId; - if (!runtime) runtime = artifact.runtime ?? extractRuntimeFromMetadata(artifact.metadata); - if (!organizationId) organizationId = artifact.runtime?.organizationId; - if (!host) host = artifact.runtime?.hostname; - } - - if (!runtime || !runtime.databaseUrl || !runtime.databaseDriver) { - this.logger.warn?.('[ArtifactEnvironmentRegistry] no runtime config for project', { environmentId }); - return null; - } - - const driver = await createDriver(runtime.databaseDriver, runtime.databaseUrl, runtime.databaseAuthToken ?? ''); - - const projectRow = { - id: artifactProjectId, - organization_id: organizationId, - hostname: host, - database_url: runtime.databaseUrl, - database_driver: runtime.databaseDriver, - metadata: runtime.metadata, - }; - - return { - environmentId: artifactProjectId, - driver, - project: projectRow, - expiresAt: Date.now() + this.cacheTTL, - }; - } -} - -/** - * Best-effort fallback: if the control plane did not return an explicit - * `runtime` block, look for a default datasource in the compiled artifact - * and reuse its connection config. Useful for self-published artifacts - * where the developer encoded the connection inline (e.g. memory:// for - * demos). - */ -function extractRuntimeFromMetadata(metadata: any): EnvironmentRuntimeConfig | undefined { - const datasources = metadata?.datasources; - if (!Array.isArray(datasources) || datasources.length === 0) return undefined; - const mapping: any[] | undefined = metadata?.datasourceMapping; - let preferredName: string | undefined; - if (mapping) { - const def = mapping.find((m: any) => m?.default === true); - if (def?.datasource) preferredName = def.datasource; - } - const ds = preferredName - ? datasources.find((d: any) => d?.name === preferredName) - : datasources[0]; - if (!ds || typeof ds !== 'object') return undefined; - const config = (ds.config ?? {}) as Record; - const url = config.url ?? config.connectionString ?? config.connection ?? config.filename; - const driver = ds.driver; - if (typeof driver !== 'string' || typeof url !== 'string') return undefined; - return { - databaseDriver: driver, - databaseUrl: url, - databaseAuthToken: typeof config.authToken === 'string' ? config.authToken : undefined, - }; -} - -async function createDriver(driverType: string, databaseUrl: string, authToken: string): Promise { - switch (driverType) { - case 'libsql': - case 'turso': { - // The libsql/turso driver was extracted out of the framework - // monorepo into `cloud/packages/driver-turso` (May 2026). - // Package name is unchanged, so `await import(...)` resolves - // it from the host app's node_modules (apps/objectos pins - // `@objectstack/driver-turso: workspace:*` from the cloud - // workspace, which surfaces in the Docker image's - // node_modules layout). Self-host installs that need Turso - // must `npm install @objectstack/driver-turso` from the cloud - // package (or use the published version) before booting. - // pnpm symlinks `@objectstack/runtime` into the host app's - // node_modules but Node's ESM resolver follows the file's - // realpath, which lives under framework/packages/runtime — from - // there `@objectstack/driver-turso` (which lives in cloud/) is - // invisible. We resolve explicitly from the host process cwd - // (apps/objectos at runtime), which can see the cloud package - // via its workspace:* dependency. - let TursoDriver: any; - try { - ({ TursoDriver } = await import('@objectstack/driver-turso' as any)); - } catch (primaryErr: any) { - try { - const { createRequire } = await import('node:module'); - const path = await import('node:path'); - const url = await import('node:url'); - const hostRequire = createRequire(path.join(process.cwd(), 'noop.js')); - const resolved = hostRequire.resolve('@objectstack/driver-turso'); - ({ TursoDriver } = await import(url.pathToFileURL(resolved).href)); - } catch (fallbackErr: any) { - throw new Error( - `[ArtifactEnvironmentRegistry] libsql/turso driver requested but @objectstack/driver-turso is not resolvable. ` + - `Install it from the cloud monorepo (cloud/packages/driver-turso) or via npm. ` + - `(primary: ${primaryErr?.message ?? primaryErr}; fallback: ${fallbackErr?.message ?? fallbackErr})`, - ); - } - } - return new TursoDriver({ url: databaseUrl, authToken }) as unknown as IDataDriver; - } - case 'memory': { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); - // Resolve memory persistence files under the process cwd's - // `.objectstack/data/projects/.json` — keeps file-only dev - // self-contained without depending on the cloud package's - // serverless data-dir resolver. - const filePath = dbName - ? resolvePathNode(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) - : undefined; - return new InMemoryDriver({ - persistence: filePath ? { type: 'file', path: filePath } : 'file', - }) as unknown as IDataDriver; - } - case 'sqlite': - case 'sql': { - const filePath = databaseUrl.replace(/^file:/, '').replace(/^sql:\/\//, ''); - const { SqlDriver } = await import('@objectstack/driver-sql'); - return new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: filePath }, - useNullAsDefault: true, - }) as unknown as IDataDriver; - } - case 'postgres': - case 'postgresql': - case 'pg': { - const { SqlDriver } = await import('@objectstack/driver-sql'); - return new SqlDriver({ - client: 'pg', - connection: databaseUrl, - pool: { min: 0, max: 5 }, - }) as unknown as IDataDriver; - } - case 'mongodb': - case 'mongo': { - const { MongoDBDriver } = await import('@objectstack/driver-mongodb'); - return new MongoDBDriver({ url: databaseUrl }) as unknown as IDataDriver; - } - default: - throw new Error(`[ArtifactEnvironmentRegistry] Unsupported driver type: ${driverType}`); - } -} diff --git a/packages/runtime/src/cloud/artifact-kernel-factory.ts b/packages/runtime/src/cloud/artifact-kernel-factory.ts deleted file mode 100644 index c19fc6b181..0000000000 --- a/packages/runtime/src/cloud/artifact-kernel-factory.ts +++ /dev/null @@ -1,610 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * EnvironmentKernelFactory backed by the control plane's Artifact API. - * - * Differs from {@link DefaultEnvironmentKernelFactory} in two ways: - * - * 1. There is no local control-plane database to query — project rows - * come from the {@link ArtifactEnvironmentRegistry} cache populated - * via HTTP. - * 2. There is no `ControlPlaneProxyDriver` mounted on the per-project - * kernel. The runtime is intentionally isolated from the control - * plane: each project kernel only knows about its own data driver. - * - * The kernel is bootstrapped with: - * • DriverPlugin(driver) — project-scoped data driver, also aliased - * as the `'cloud'` datasource so AuthPlugin's - * identity manifest resolves locally. - * • ObjectQLPlugin - * • MetadataPlugin (registers `sys_metadata` + `sys_metadata_history` on - * the project DB — required by ADR-0005: customization - * overlays such as user-created views/dashboards are - * persisted by ObjectStackProtocolImplementation on the - * per-project engine, so the table must exist there). - * • AuthPlugin — per-project, derives an HKDF secret from - * `OS_AUTH_SECRET` + environmentId. Each project owns its - * own `sys_user/sys_session/...` tables in its own - * Turso DB. Cookies are scoped to the project's - * hostname (no `.`-wide cross-project leak). - * • AppPlugin(artifact.metadata) — compiled developer code - */ - -import { createHmac } from 'node:crypto'; -import { ObjectKernel } from '@objectstack/core'; -import { readEnvWithDeprecation } from '@objectstack/types'; -import type * as Contracts from '@objectstack/spec/contracts'; -import { DriverPlugin } from '../driver-plugin.js'; -import { AppPlugin } from '../app-plugin.js'; -import type { EnvironmentKernelFactory } from './kernel-manager.js'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; -import type { ArtifactApiClient } from './artifact-api-client.js'; -import { loadCapabilities } from './capability-loader.js'; -import { - PLATFORM_SSO_PROVIDER_ID, - derivePlatformSsoClientId, - derivePlatformSsoClientSecret, -} from './platform-sso.js'; - -type IDataDriver = Contracts.IDataDriver; - -export interface ArtifactKernelFactoryConfig { - client: ArtifactApiClient; - envRegistry: EnvironmentDriverRegistry; - /** Optional logger. */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - /** Optional kernel constructor config. */ - kernelConfig?: ConstructorParameters[0]; - /** - * Base secret used to derive per-project AuthPlugin secrets via - * HKDF-style HMAC-SHA256(baseSecret, environmentId). Falls back to - * `process.env.OS_AUTH_SECRET` / `AUTH_SECRET` at construction time. - */ - authBaseSecret?: string; - /** - * Capability tokens force-mounted on every per-environment kernel, merged - * (and de-duped by the loader) with the artifact's own `requires`. Lets a - * host make a capability ubiquitous across tenants — e.g. `['ai','aiStudio']`. - */ - defaultRequires?: string[]; -} - -/** - * Derive a deterministic per-project auth secret. HMAC-SHA256 of the - * environmentId keyed by the base secret yields a 64-char hex string that is: - * - stable across container cold-starts (no DB lookup needed) - * - independent per project (forging a token on project A does not - * compromise project B) - * - rotatable by changing the base secret (will invalidate all sessions) - */ -function deriveProjectAuthSecret(baseSecret: string, environmentId: string): string { - return createHmac('sha256', baseSecret).update(`project:${environmentId}`).digest('hex'); -} - -export class ArtifactKernelFactory implements EnvironmentKernelFactory { - private readonly client: ArtifactApiClient; - private readonly envRegistry: EnvironmentDriverRegistry; - private readonly logger: NonNullable; - private readonly kernelConfig?: ArtifactKernelFactoryConfig['kernelConfig']; - private readonly authBaseSecret: string; - private readonly defaultRequires: string[]; - - constructor(config: ArtifactKernelFactoryConfig) { - this.client = config.client; - this.envRegistry = config.envRegistry; - this.logger = config.logger ?? console; - this.kernelConfig = config.kernelConfig; - this.defaultRequires = config.defaultRequires ?? []; - this.authBaseSecret = ( - config.authBaseSecret - ?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET']) - ?? '' - ).trim(); - } - - async create(environmentId: string): Promise { - let cached = this.envRegistry.peekById(environmentId); - if (!cached) { - const driver = await this.envRegistry.resolveById(environmentId); - if (!driver) { - throw new Error(`[ArtifactKernelFactory] Could not resolve driver for project '${environmentId}'`); - } - cached = this.envRegistry.peekById(environmentId); - if (!cached) { - throw new Error(`[ArtifactKernelFactory] envRegistry returned a driver but no cached entry for '${environmentId}'`); - } - } - - const driver: IDataDriver = cached.driver; - const project = cached.project as { id: string; organization_id?: string; hostname?: string }; - - const artifact = await this.client.fetchArtifact(environmentId); - if (!artifact) { - throw new Error(`[ArtifactKernelFactory] Artifact not available for project '${environmentId}'`); - } - - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { MetadataPlugin } = await import('@objectstack/metadata'); - - const kernel = new ObjectKernel(this.kernelConfig); - - // Register the project driver as both the unnamed default AND under - // the `'cloud'` alias. AuthPlugin's manifest header historically - // declares `defaultDatasource: 'cloud'`; aliasing here keeps that - // path working without forcing every project's identity table - // through a control-plane proxy. - await kernel.use(new DriverPlugin(driver, { datasourceName: 'cloud' } as any)); - // Enable schema sync per-project so sys_user / sys_session / etc. - // tables get created on the project's own DB. The host worker sets - // `OS_SKIP_SCHEMA_SYNC=1` for the control-plane DB; that env var - // must NOT bleed into project kernels because their auth tables - // need provisioning. KernelManager caches kernels so this runs - // at most once per cold-start per project. - await kernel.use(new ObjectQLPlugin({ environmentId: environmentId, skipSchemaSync: false })); - await kernel.use(new MetadataPlugin({ - watch: false, - environmentId: environmentId, - organizationId: project.organization_id, - // ADR-0005: customization overlays (user-created views, dashboards, - // edited objects, ...) are persisted by - // ObjectStackProtocolImplementation.saveMetaItem on whichever - // engine the protocol is attached to. For per-project kernels that - // means the project's own DB, so the sys_metadata + history tables - // MUST be provisioned here. The previous `false` setting caused - // "no such table: sys_metadata" errors on any PUT /api/v1/meta/* - // call (e.g. Studio "Create View") against a project deployment. - registerSystemObjects: true, - })); - - // Per-project AuthPlugin — only when an OS_AUTH_SECRET base is - // configured. Without it we cannot derive a secret deterministically - // and refuse to start auth (better silent-fail than insecure default). - if (this.authBaseSecret) { - try { - const { AuthPlugin } = await import('@objectstack/plugin-auth'); - const projectSecret = deriveProjectAuthSecret(this.authBaseSecret, environmentId); - const baseUrl = project.hostname - ? (project.hostname.startsWith('http') - ? project.hostname - : (/(\.|^)localhost(:\d+)?$/i.test(project.hostname) - ? (() => { - const runtimePort = (process.env.OS_RUNTIME_PORT ?? '').trim(); - const hasPort = /:\d+$/.test(project.hostname); - const hostWithPort = hasPort || !runtimePort - ? project.hostname - : `${project.hostname}:${runtimePort}`; - return `http://${hostWithPort}`; - })() - : `https://${project.hostname}`)) - : undefined; - - // Build the list of trusted origins for CSRF. - // - Production: just the project's https baseUrl + any - // platform-wide origins from OS_TRUSTED_ORIGINS (so - // hostname renames don't require a kernel evict — the - // parent worker already trusts `https://*.`). - // - Dev (*.localhost): also trust http variants on any port so - // the local objectos dev server (PORT=4100 or any user-chosen - // port) can complete sign-in from the browser. baseUrl alone - // is `https://*.localhost` (no port, https) which the - // browser's Origin (`http://*.localhost:4100`) does NOT - // match — leading to better-auth "Invalid origin" 403. - const trustedOriginsList: string[] = []; - if (baseUrl) trustedOriginsList.push(baseUrl); - // Inherit platform trusted-origin wildcards from the host - // worker. Without this, renaming an environment leaves the - // cached per-project kernel rejecting callbackURL= - // with INVALID_CALLBACK_URL until the next cold-start. - const platformOrigins = (process.env.OS_TRUSTED_ORIGINS ?? '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - for (const o of platformOrigins) { - if (!trustedOriginsList.includes(o)) trustedOriginsList.push(o); - } - // Convenience: when OS_ROOT_DOMAIN is set, trust the entire - // platform subdomain space. Matches the host worker's CORS - // posture so SSO survives any future tenant-domain rename. - const rootDomain = (process.env.OS_ROOT_DOMAIN ?? '').trim().replace(/^https?:\/\//, ''); - if (rootDomain) { - const wildcard = `https://*.${rootDomain}`; - if (!trustedOriginsList.includes(wildcard)) trustedOriginsList.push(wildcard); - } - if (project.hostname) { - const bareHost = project.hostname.replace(/^https?:\/\//, ''); - if (bareHost.endsWith('.localhost') || bareHost === 'localhost') { - trustedOriginsList.push(`http://${bareHost}`); - trustedOriginsList.push(`http://${bareHost}:*`); - trustedOriginsList.push(`https://${bareHost}:*`); - } - } - - // Platform SSO ("Airtable-style unified login"): when the - // cloud control-plane is reachable AND the master secret is - // shared between the two containers, wire better-auth's - // genericOAuth plugin so a builder who already signed in - // on `cloud.` is JIT-provisioned as a `sys_user` on - // every per-project deployment without re-registering. - // - // Opt-out: set OS_PLATFORM_SSO=false to fall back to the - // legacy "every project owns its own login" mode. - const platformSsoEnabled = String( - process.env.OS_PLATFORM_SSO ?? 'true', - ).toLowerCase() !== 'false'; - const cloudBaseUrl = (process.env.OS_CLOUD_URL ?? '').trim().replace(/\/+$/, ''); - const oidcProviders = platformSsoEnabled - && cloudBaseUrl - && /^https?:\/\//.test(cloudBaseUrl) - ? [{ - providerId: PLATFORM_SSO_PROVIDER_ID, - name: 'ObjectStack', - discoveryUrl: `${cloudBaseUrl}/.well-known/openid-configuration`, - clientId: derivePlatformSsoClientId(environmentId), - clientSecret: derivePlatformSsoClientSecret(this.authBaseSecret, environmentId), - scopes: ['openid', 'email', 'profile'], - }] - : undefined; - - await kernel.use(new AuthPlugin({ - secret: projectSecret, - baseUrl, - // Project kernel has no http-server (host owns it). The - // dispatcher's handleAuth path resolves `auth` via - // getService and invokes the handler directly — route - // registration is unnecessary and would warn. - registerRoutes: false, - // Identity tables live in the project's own DB — keep - // sys_user/sys_session local to this kernel. - manifestDatasource: 'default', - // Cookie scope: default to the project's own host. We - // intentionally do NOT pass crossSubDomainCookies here - // so cookies stay isolated per project subdomain. - trustedOrigins: trustedOriginsList.length ? trustedOriginsList : undefined, - ...(oidcProviders ? { oidcProviders } : {}), - } as any)); - if (oidcProviders) { - this.logger.info?.('[ArtifactKernelFactory] platform SSO wired', { - environmentId, - cloudBaseUrl, - }); - } - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] AuthPlugin not registered', { - environmentId, - error: err?.message, - }); - } - } else { - this.logger.warn?.('[ArtifactKernelFactory] OS_AUTH_SECRET not set — per-project AuthPlugin skipped (auth endpoints will return 404)', { environmentId }); - } - - // Per-project SecurityPlugin — provides RBAC + tenant_isolation RLS. - // Multi-tenant deployments additionally register OrgScopingPlugin, - // which provides organization_id auto-stamping, per-org seed - // replay, and default-org bootstrap. SecurityPlugin probes the - // `org-scoping` service at start time and strips the wildcard - // `tenant_isolation` RLS when the scoping plugin is absent — - // so OrgScopingPlugin MUST be registered before SecurityPlugin. - try { - const multiTenant = String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !== 'false'; - if (multiTenant) { - try { - const { OrgScopingPlugin } = await import('@objectstack/plugin-org-scoping'); - await kernel.use(new OrgScopingPlugin() as any); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] OrgScopingPlugin not registered (multi-tenant disabled)', { - environmentId, - error: err?.message, - }); - } - } - const { SecurityPlugin } = await import('@objectstack/plugin-security'); - await kernel.use(new SecurityPlugin() as any); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] SecurityPlugin not registered', { - environmentId, - error: err?.message, - }); - } - - const projectName = project.hostname ?? environmentId; - // Cloud Artifact API envelope shape (see - // `service-cloud/src/routes/cloud.ts`): - // { schemaVersion, environmentId, commitId, checksum, - // metadata: { objects, views, apps, ... }, // category arrays only - // functions, manifest, builtAt, runtime } - // `manifest` is a TOP-LEVEL SIBLING of `metadata`, not nested - // inside it. AppPlugin reads `bundle.manifest.id` for the plugin - // name, so we must surface the sibling manifest onto the bundle - // we hand it — otherwise it falls back to `'unnamed-app'` and a - // package install (e.g. CRM Starter with declarative hooks) - // crashes the env kernel at start, rolling back all plugins and - // 500'ing every API. - const artifactAny = artifact as any; - const topLevelManifest = (artifactAny?.manifest && typeof artifactAny.manifest === 'object') - ? artifactAny.manifest - : null; - const topLevelFunctions = Array.isArray(artifactAny?.functions) ? artifactAny.functions : []; - const bundle: any = { - ...(artifact.metadata ?? {}), - ...(topLevelManifest ? { manifest: topLevelManifest } : {}), - functions: topLevelFunctions, - }; - const sys = bundle.manifest ?? bundle; - const packageId = sys?.packageId ?? sys?.package_id ?? bundle?.packageId; - - // Per-project i18n: register I18nServicePlugin BEFORE AppPlugin so - // AppPlugin.loadTranslations() finds an i18n service to populate. - // Without this, the artifact's `translations` array is silently - // dropped and the `/api/v1/i18n/*` endpoints return empty payloads. - const i18nCfg = (bundle?.i18n ?? sys?.i18n ?? {}) as Record; - const trArr = Array.isArray(bundle?.translations) ? bundle.translations - : Array.isArray(sys?.translations) ? sys.translations : []; - // Always register — even with no inline translations the service - // can serve labels/locales loaded by hosted apps. Cheap to register. - try { - const { I18nServicePlugin } = await import('@objectstack/service-i18n'); - await kernel.use(new I18nServicePlugin({ - defaultLocale: i18nCfg.defaultLocale, - fallbackLocale: i18nCfg.fallbackLocale ?? i18nCfg.defaultLocale ?? 'en', - // Routes are dispatched by HttpDispatcher.handleI18n via - // kernel.getService('i18n'); the host worker owns the - // HTTP server. Skip self-registration to avoid warnings. - registerRoutes: false, - } as any)); - console.warn( - `[ArtifactKernelFactory] I18nServicePlugin registered (project=${environmentId}, translations=${trArr.length}, defaultLocale=${i18nCfg.defaultLocale ?? 'en'})`, - ); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] I18nServicePlugin not registered', { - environmentId, - error: err?.message, - }); - } - - // Tier-driven capability loading: install service plugins listed - // in the artifact's `requires` array (e.g. ['ai','automation', - // 'analytics']). Must be registered BEFORE AppPlugin so that - // AppPlugin's start phase can hand off flows/agents/cubes to - // services that are already initialised. - const requiresRaw = - (Array.isArray(bundle?.requires) ? bundle.requires : null) ?? - (Array.isArray(sys?.requires) ? sys.requires : null) ?? - []; - // Merge host-forced defaults (e.g. cloud's ['ai','aiStudio']) with the - // artifact's own requires. loadCapabilities de-dupes via a Set, so - // overlap is safe. - const requires: string[] = [ - ...(requiresRaw as unknown[]), - ...this.defaultRequires, - ].filter((x): x is string => typeof x === 'string' && x.length > 0); - - if (requires.length > 0) { - const installed = await loadCapabilities({ - kernel, - requires, - bundle: { ...(bundle ?? {}), ...(sys ?? {}) } as Record, - logger: this.logger, - environmentId, - }); - this.logger.info?.('[ArtifactKernelFactory] capabilities loaded', { - environmentId, - requires, - installed, - }); - } - - await kernel.use(new AppPlugin(bundle, { - environmentId, - organizationId: project.organization_id ?? '', - projectName, - packageId, - source: packageId ? 'package' : 'user', - } as any)); - - await kernel.bootstrap(); - - // Pre-seed the project owner. The cloud control-plane stashed the - // creator's identity into `sys_environment.metadata.ownerSeed` at - // project-create time; replay it AFTER `kernel.bootstrap()` so - // ObjectQL/Security plugins have fully initialised and - // `kernel.getService('objectql')` resolves. Pre-bootstrap, plugins - // are only registered — services aren't wired yet. - // - // Order matters: - // 1. Seed the OWNING cloud org into `sys_organization` so the - // project's primary workspace matches the cloud team that - // owns the project at the platform level. - // 2. Seed the owner's `sys_user` row. - // 3. Seed a `sys_member(owner)` row binding the user to the - // cloud org so their first sign-in resolves an - // activeOrganizationId without requiring an extra - // "create your first organization" step. - // - // All three are idempotent — safe across cold-boots. - try { - const projMeta: any = typeof (project as any)?.metadata === 'string' - ? JSON.parse((project as any).metadata) - : ((project as any)?.metadata ?? {}); - const ownerSeed = projMeta?.ownerSeed; - const orgSeed = projMeta?.orgSeed; - - if (orgSeed?.id && orgSeed?.name) { - try { - const { seedProjectOrganization } = await import('./environment-org-seed.js'); - await seedProjectOrganization(kernel, orgSeed, this.logger); - } catch (e: any) { - this.logger.warn?.('[ArtifactKernelFactory] orgSeed threw', { - environmentId, - error: e?.message, - }); - } - } - - if (ownerSeed?.userId && ownerSeed?.email) { - try { - const { seedProjectOwner } = await import('./environment-owner-seed.js'); - await seedProjectOwner(kernel, ownerSeed, this.logger); - } catch (e: any) { - this.logger.warn?.('[ArtifactKernelFactory] ownerSeed threw', { - environmentId, - error: e?.message, - }); - } - - if (orgSeed?.id) { - try { - const { seedProjectMember } = await import('./environment-org-seed.js'); - await seedProjectMember( - kernel, - { userId: ownerSeed.userId, organizationId: orgSeed.id, role: 'owner' }, - this.logger, - ); - } catch (e: any) { - this.logger.warn?.('[ArtifactKernelFactory] memberSeed threw', { - environmentId, - error: e?.message, - }); - } - } - } - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] owner/org seed skipped', { - environmentId, - error: err?.message, - }); - } - - // Post-bootstrap seed replay. The SecurityPlugin's `sys_organization` - // insert middleware only fires when a brand-new org row is inserted, - // so packages installed AFTER the env's primary org already exists - // would never get their `data` arrays applied. Run the seed-replayer - // once per kernel cold-start so newly-installed marketplace packages - // (e.g. CRM with "Include sample data" ticked) hydrate the primary - // org on the next request after install. - // - // SeedLoader uses upsert semantics, so re-running across cold-starts - // is idempotent — at worst we pay one batch upsert per kernel boot. - try { - const datasetsNow: any[] | undefined = (() => { - try { return (kernel as any).getService?.('seed-datasets'); } catch { return undefined; } - })(); - const replayer: any = (() => { - try { return (kernel as any).getService?.('seed-replayer'); } catch { return undefined; } - })(); - - if (Array.isArray(datasetsNow) && datasetsNow.length > 0 && typeof replayer === 'function') { - // Resolve the env's primary organization. Prefer the explicit - // orgSeed metadata (set when env is created via the data API); - // fall back to scanning sys_organization for the first row - // (env created via the lifecycle endpoint that doesn't stash - // orgSeed, or any env that has been used at least once). - const projMetaRaw: any = (project as any)?.metadata; - const projMeta: any = typeof projMetaRaw === 'string' ? (() => { - try { return JSON.parse(projMetaRaw); } catch { return {}; } - })() : (projMetaRaw ?? {}); - let primaryOrgId: string | undefined = projMeta?.orgSeed?.id; - - if (!primaryOrgId) { - try { - const ql: any = (kernel as any).getService?.('objectql'); - if (ql?.find) { - const rows = await ql.find('sys_organization', { limit: 5, orderBy: [{ field: 'created_at', direction: 'asc' }] } as any); - const list = Array.isArray(rows) ? rows : (rows?.value ?? rows?.records ?? []); - if (Array.isArray(list) && list.length > 0 && list[0]?.id) { - primaryOrgId = String(list[0].id); - } - } - } catch { /* org table may not exist yet on a brand-new env */ } - } - - if (primaryOrgId) { - try { - const summary = await replayer(primaryOrgId); - const inserted = summary?.inserted ?? 0; - const updated = summary?.updated ?? 0; - const errs = summary?.errors?.length ?? 0; - if (inserted > 0 || updated > 0 || errs > 0) { - this.logger.info?.('[ArtifactKernelFactory] post-bootstrap seed replay', { - environmentId, - organizationId: primaryOrgId, - datasets: datasetsNow.length, - inserted, - updated, - errors: errs, - }); - } - } catch (e: any) { - this.logger.warn?.('[ArtifactKernelFactory] post-bootstrap seed replay failed', { - environmentId, - organizationId: primaryOrgId, - error: e?.message, - }); - } - } - } - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] post-bootstrap seed step threw', { - environmentId, - error: err?.message, - }); - } - - // Belt-and-braces: load translation bundles directly into the i18n - // service after bootstrap. AppPlugin.loadTranslations should do this - // during its `start` phase, but several conditions (missing objectql - // service, runtime.onEnable throwing, bundle keys mismatch) can cause - // it to bail before reaching the i18n step. Loading here guarantees - // the bundles attached to the artifact metadata are always served via - // `/api/v1/i18n/*`, regardless of AppPlugin's runtime path. - let i18nSvc: any = null; - try { - i18nSvc = (kernel as any).getService?.('i18n'); - } catch { - // getService throws when service isn't registered — leave null - i18nSvc = null; - } - try { - if (i18nSvc && typeof i18nSvc.loadTranslations === 'function') { - if (i18nCfg.defaultLocale && typeof i18nSvc.setDefaultLocale === 'function') { - i18nSvc.setDefaultLocale(i18nCfg.defaultLocale); - } - let loaded = 0; - for (const tbundle of trArr) { - if (!tbundle || typeof tbundle !== 'object') continue; - for (const [locale, data] of Object.entries(tbundle)) { - if (data && typeof data === 'object') { - try { - i18nSvc.loadTranslations(locale, data as Record); - loaded++; - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] i18n loadTranslations failed', { - environmentId, locale, error: err?.message, - }); - } - } - } - } - if (loaded > 0) { - this.logger.info?.('[ArtifactKernelFactory] i18n direct-load complete', { - environmentId, locales: loaded, bundles: trArr.length, - }); - } - } - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] i18n direct-load failed', { - environmentId, - error: err?.message, - }); - } - - this.logger.info?.('[ArtifactKernelFactory] kernel ready', { - environmentId, - commitId: artifact.commitId, - checksum: artifact.checksum, - authEnabled: Boolean(this.authBaseSecret), - }); - - return kernel; - } -} diff --git a/packages/runtime/src/cloud/auth-proxy-plugin.ts b/packages/runtime/src/cloud/auth-proxy-plugin.ts deleted file mode 100644 index 887d92ce8c..0000000000 --- a/packages/runtime/src/cloud/auth-proxy-plugin.ts +++ /dev/null @@ -1,513 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * AuthProxyPlugin - * - * Mounts a single `/api/v1/auth/*` wildcard route on the host's Hono server - * that forwards every request to the per-project `AuthManager` registered - * by `ArtifactKernelFactory`. - * - * Why a dedicated plugin: AuthPlugin (better-auth) registers its routes by - * grabbing the host's `http-server` service from its own `PluginContext`. - * In objectos runtime mode AuthPlugin lives on a per-project kernel — it - * has no access to the host's HTTP server, so its `kernel:ready` route - * registration is a no-op. The dispatcher plugin's wildcard registration - * via `IHttpServer.post('/auth/*', …)` proved unreliable in practice, - * so this plugin uses Hono's raw app directly (same path AuthPlugin took - * historically) which is rock solid. - * - * Routing: - * 1. Resolve the project from the request hostname via `env-registry`. - * 2. Acquire the project's kernel via `kernel-manager`. - * 3. Look up the `auth` service on that kernel — this is the better-auth - * handler injected by `ArtifactKernelFactory`. - * 4. Build a Web `Request` using the project's canonical baseUrl and - * hand it to the better-auth handler. - * 5. Stream the response back through Hono. - */ - -import type { Plugin, PluginContext } from '@objectstack/core'; -import { createHmac, randomUUID } from 'node:crypto'; -import { runSetInitialPassword } from '@objectstack/plugin-auth'; -import type { KernelManager } from './kernel-manager.js'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; - -const AUTH_PREFIX = '/api/v1/auth'; - -/** - * HMAC-SHA256 + base64 + percent-encode the resulting `value.signature` - * payload — replicates `serializeSignedCookie` from `better-call` so the - * env's better-auth `getSignedCookie` validator accepts the cookie we - * write here (without pulling better-call into runtime's deps). - */ -function signSessionCookieValue(rawToken: string, secret: string): string { - const signature = createHmac('sha256', secret).update(rawToken).digest('base64'); - return encodeURIComponent(`${rawToken}.${signature}`); -} - -/** - * Serialize a Set-Cookie header value matching better-auth's session-cookie - * attributes. Attributes come from `authCookies.sessionToken.attributes`. - */ -function buildSetCookieHeader( - name: string, - encodedValue: string, - attrs: Record | undefined, - maxAgeSec: number, -): string { - const parts: string[] = [`${name}=${encodedValue}`]; - const a = attrs ?? {}; - if (a.path) parts.push(`Path=${a.path}`); else parts.push('Path=/'); - if (Number.isFinite(maxAgeSec) && maxAgeSec > 0) parts.push(`Max-Age=${Math.floor(maxAgeSec)}`); - if (a.domain) parts.push(`Domain=${a.domain}`); - if (a.sameSite) { - const ss = String(a.sameSite); - parts.push(`SameSite=${ss.charAt(0).toUpperCase() + ss.slice(1)}`); - } else { - parts.push('SameSite=Lax'); - } - if (a.secure) parts.push('Secure'); - if (a.httpOnly !== false) parts.push('HttpOnly'); - if (a.partitioned) parts.push('Partitioned'); - return parts.join('; '); -} - -// AuthCapableManager interface removed — unused (pickHandler uses duck typing on `any`). - -function pickHandler(svc: any): ((req: Request) => Promise) | undefined { - if (!svc) return undefined; - // AuthManager exposes handleRequest(req) — preferred entry point. - if (typeof svc.handleRequest === 'function') return svc.handleRequest.bind(svc); - if (typeof svc.handler === 'function') return svc.handler.bind(svc); - if (svc.api && typeof svc.api.handler === 'function') return svc.api.handler.bind(svc.api); - // AuthManager keeps the better-auth instance under `auth`. - if (svc.auth && typeof svc.auth.handler === 'function') return svc.auth.handler.bind(svc.auth); - return undefined; -} - -async function resolveAuthHandler(svc: any): Promise<((req: Request) => Promise) | undefined> { - const direct = pickHandler(svc); - if (direct) return direct; - if (typeof svc?.getApi === 'function') { - try { - const api = await svc.getApi(); - return pickHandler(api) ?? pickHandler({ api }); - } catch { - return undefined; - } - } - return undefined; -} - -export class AuthProxyPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.auth-proxy'; - readonly version = '1.0.0'; - - init = async (_ctx: PluginContext): Promise => { - // No services registered — pure HTTP wiring during start(). - }; - - start = async (ctx: PluginContext): Promise => { - // Mount routes on kernel:ready so HonoServerPlugin has finished - // registering the http-server service. Doing this in start() can - // race with HonoServerPlugin.init/start ordering. - ctx.hook('kernel:ready', async () => { - let httpServer: any; - try { - httpServer = ctx.getService('http-server'); - } catch { - ctx.logger?.warn?.('[AuthProxyPlugin] http-server not available — auth routes not mounted'); - return; - } - if (!httpServer || typeof httpServer.getRawApp !== 'function') { - ctx.logger?.warn?.('[AuthProxyPlugin] http-server missing getRawApp() — auth routes not mounted'); - return; - } - - const rawApp = httpServer.getRawApp(); - const kernelManager = ctx.getService('kernel-manager'); - const envRegistry = ctx.getService('env-registry'); - - const handler = async (c: any) => { - try { - const url = new URL(c.req.url); - const host = url.hostname; - let environmentId: string | undefined; - try { - const env = await envRegistry.resolveByHostname(host); - environmentId = env?.environmentId; - } catch { - // ignore - } - if (!environmentId) { - return c.json({ error: 'project_not_found', host }, 404); - } - - const projectKernel = await kernelManager.getOrCreate(environmentId); - let authSvc: any; - try { - authSvc = await (projectKernel as any).getServiceAsync?.('auth'); - } catch { authSvc = undefined; } - if (!authSvc) { - try { authSvc = (projectKernel as any).getService?.('auth'); } catch { /* ignore */ } - } - - // Custom non-better-auth endpoints. better-auth has no - // /config or /bootstrap-status route, so without these - // short-circuits the request would fall through to the - // better-auth handler and 404. The Account SPA needs - // /config to render the "Continue with ObjectStack" - // platform SSO button via SocialSignInButtons. - const subPath = url.pathname.startsWith(AUTH_PREFIX + '/') - ? url.pathname.substring(AUTH_PREFIX.length + 1) - : ''; - if (c.req.method === 'GET' && (subPath === 'config' || subPath === 'bootstrap-status')) { - if (subPath === 'config') { - try { - const config = typeof authSvc?.getPublicConfig === 'function' - ? authSvc.getPublicConfig() - : null; - if (config) { - return c.json({ success: true, data: config }); - } - return c.json({ success: false, error: { code: 'auth_config_unavailable', message: 'AuthManager has no getPublicConfig()' } }, 503); - } catch (e: any) { - return c.json({ success: false, error: { code: 'auth_config_error', message: String(e?.message ?? e) } }, 500); - } - } - // bootstrap-status - try { - // Report the real local owner state. Earlier the - // proxy short-circuited to `hasOwner: true` as - // soon as the `objectstack-cloud` SSO provider - // was wired — the idea being "identity lives in - // cloud, JIT-create on first SSO callback". That - // forced every project to depend on cloud being - // reachable for first-time setup AND made the - // local /setup wizard unreachable. With SSO now - // demoted to an *optional* federation button, - // bootstrap status MUST reflect the project's - // own `sys_user` table so /setup can run when - // there is genuinely no local owner yet. - const dataEngine = typeof authSvc?.getDataEngine === 'function' - ? authSvc.getDataEngine() - : null; - if (!dataEngine || typeof dataEngine.count !== 'function') { - return c.json({ hasOwner: true }); - } - const count = await dataEngine.count('sys_user', {}); - return c.json({ hasOwner: (count ?? 0) > 0 }); - } catch { - return c.json({ hasOwner: true }); - } - } - - // ── sso-handoff-issue ───────────────────────────── - // POST /api/v1/auth/sso-handoff-issue - // - // Called server-to-server by cloud's `sso_as_owner` - // action. Cloud control plane (createCloudStack) has - // no kernel-manager, so it cannot write into the env's - // better-auth verification table itself — this endpoint - // does it locally where the env kernel lives. - // - // Auth: `Authorization: Bearer ` must - // match `process.env.OS_CLOUD_API_KEY` on the env - // runtime. The same secret is shared between cloud and - // every env runtime (cloud uses it to verify env → - // cloud calls; we reuse it for the reverse direction). - // - // Body: { email, name?, by?, envId? } — payload that - // sso-exchange will JSON.parse from the verification - // value. Returns { token, expiresAt, ttlSec }. - if (c.req.method === 'POST' && subPath === 'sso-handoff-issue') { - try { - const expected = (process.env.OS_CLOUD_API_KEY ?? '').trim(); - if (!expected) { - return c.json({ error: 'sso_handoff_disabled', reason: 'OS_CLOUD_API_KEY unset on env runtime' }, 503); - } - const authz = c.req.header('authorization') ?? ''; - const provided = authz.toLowerCase().startsWith('bearer ') - ? authz.slice(7).trim() - : ''; - if (!provided || provided !== expected) { - return c.json({ error: 'unauthorized' }, 401); - } - - if (typeof authSvc?.getAuthContext !== 'function') { - return c.json({ error: 'auth_service_unavailable' }, 503); - } - const handoffAuthCtx: any = await authSvc.getAuthContext(); - const internal = handoffAuthCtx?.internalAdapter; - if (!internal?.createVerificationValue) { - return c.json({ error: 'verification_api_unavailable' }, 503); - } - - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } - const email = String(body?.email ?? '').toLowerCase().trim(); - if (!email) return c.json({ error: 'email_required' }, 400); - const name = body?.name == null ? null : String(body.name); - const by = body?.by == null ? 'service' : String(body.by); - const envIdInBody = body?.envId == null ? null : String(body.envId); - - const handoff = randomUUID().replace(/-/g, '') - + randomUUID().replace(/-/g, ''); - const ttlSec = 60; - const expiresAt = new Date(Date.now() + ttlSec * 1000); - await internal.createVerificationValue({ - identifier: `sso-handoff:${handoff}`, - // `sys_verification.value` carries a UNIQUE index (it is the - // OTP/token column for email-verification flows). The handoff - // puts its unique token in `identifier`, so the value payload - // must ALSO embed the token (`t`) — otherwise two handoffs for - // the same owner+env produce identical value JSON and the second - // one fails with `UNIQUE constraint failed: sys_verification.value` - // (i.e. opening the same environment twice 500s). - value: JSON.stringify({ email, name, by, envId: envIdInBody ?? environmentId, t: handoff }), - expiresAt, - }); - return c.json({ - token: handoff, - expiresAt: expiresAt.toISOString(), - ttlSec, - }); - } catch (err: any) { - ctx.logger?.error?.('[AuthProxyPlugin] sso-handoff-issue failed', err instanceof Error ? err : new Error(String(err))); - return c.json({ error: 'sso_handoff_issue_failed', message: String(err?.message ?? err) }, 500); - } - } - - // ── sso-exchange ─────────────────────────────────── - // GET /api/v1/auth/sso-exchange?token=&next=/ - // - // Consumes a single-use handoff token previously written - // by the cloud `sso_as_owner` action (via the - // sso-handoff-issue endpoint above), finds-or-creates - // the user in the env by email, mints a fresh better-auth - // session, sets the signed session cookie and 302s to - // `next`. If the user has no credential account yet, we - // redirect to the standalone /_console/set-password page - // so they can configure a disaster-recovery local password. - if (c.req.method === 'GET' && subPath === 'sso-exchange') { - try { - const token = (url.searchParams.get('token') ?? '').trim(); - const nextRaw = url.searchParams.get('next') ?? '/'; - const next = nextRaw.startsWith('/') ? nextRaw : '/'; - if (!token) return c.text('missing token', 400); - - if (typeof authSvc?.getAuthContext !== 'function') { - return c.text('auth service unavailable', 503); - } - const authCtx: any = await authSvc.getAuthContext(); - const internal = authCtx?.internalAdapter; - if (!internal?.consumeVerificationValue) { - return c.text('verification API unavailable', 503); - } - - const consumed = await internal.consumeVerificationValue(`sso-handoff:${token}`); - if (!consumed) return c.text('invalid or expired token', 401); - const expiresAt = consumed?.expiresAt ? new Date(consumed.expiresAt).getTime() : 0; - if (!expiresAt || expiresAt < Date.now()) return c.text('expired token', 401); - - let payload: { email?: string; name?: string | null } = {}; - try { payload = JSON.parse(String(consumed.value)); } catch { payload = { email: String(consumed.value) }; } - const email = String(payload.email ?? '').toLowerCase().trim(); - if (!email) return c.text('handoff missing email', 400); - - const found = await internal.findUserByEmail(email, { includeAccounts: true }); - let userId: string | undefined = found?.user?.id; - let hasCredentialAccount = (found?.accounts ?? []).some((a: any) => a.providerId === 'credential' && a.password); - - if (!userId) { - const created = await internal.createUser({ - email, - name: payload.name ?? email, - emailVerified: true, - }); - userId = created?.id; - hasCredentialAccount = false; - } - if (!userId) return c.text('failed to provision user', 500); - - const session = await internal.createSession(userId, false); - const rawToken: string | undefined = session?.token; - const sessionExpiresAt = session?.expiresAt ? new Date(session.expiresAt) : new Date(Date.now() + 7 * 24 * 3600 * 1000); - if (!rawToken) return c.text('failed to mint session', 500); - - const secret: string = authCtx?.secret ?? ''; - if (!secret) return c.text('auth secret unavailable', 503); - const cookieName: string = authCtx?.authCookies?.sessionToken?.name ?? 'better-auth.session_token'; - const cookieAttrs = authCtx?.authCookies?.sessionToken?.attributes ?? {}; - const encoded = signSessionCookieValue(rawToken, secret); - const maxAgeSec = Math.max(60, Math.floor((sessionExpiresAt.getTime() - Date.now()) / 1000)); - const setCookie = buildSetCookieHeader(cookieName, encoded, cookieAttrs, maxAgeSec); - - const finalNext = hasCredentialAccount - ? next - : `/_console/set-password?next=${encodeURIComponent(next)}`; - const headers = new Headers(); - headers.set('Set-Cookie', setCookie); - headers.set('Location', finalNext); - headers.set('Cache-Control', 'no-store'); - return new Response(null, { status: 302, headers }); - } catch (err: any) { - ctx.logger?.error?.('[AuthProxyPlugin] sso-exchange failed', err instanceof Error ? err : new Error(String(err))); - return c.text(`sso-exchange failed: ${err?.message ?? String(err)}`, 500); - } - } - - // ── set-initial-password ──────────────────────────── - // POST /api/v1/auth/set-initial-password { newPassword } - // - // The "Set local password" affordance the sso-exchange - // recovery redirect points at. The full AuthPlugin registers - // this route, but AuthPlugin is skipped on a per-environment - // runtime, so without this the request falls through to - // better-auth (no such route) and 404s. Mirrors AuthPlugin's - // handler against THIS environment's auth context. (#1544) - if (c.req.method === 'POST' && subPath === 'set-initial-password') { - try { - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } - const newPassword: unknown = body?.newPassword; - if (typeof newPassword !== 'string' || newPassword.length === 0) { - return c.json({ success: false, error: { code: 'invalid_request', message: 'newPassword is required' } }, 400); - } - if (typeof authSvc?.getAuthContext !== 'function') { - return c.json({ success: false, error: { code: 'unavailable', message: 'Auth context unavailable' } }, 503); - } - // Resolve the caller's session on this environment. - let userId: string | undefined; - try { - const api = typeof authSvc.getApi === 'function' ? await authSvc.getApi() : null; - const session = await api?.getSession?.({ headers: c.req.raw.headers }); - userId = session?.user?.id ? String(session.user.id) : undefined; - } catch { /* fall through to 401 */ } - if (!userId) { - return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401); - } - const setPwCtx: any = await authSvc.getAuthContext(); - if (!setPwCtx?.internalAdapter || !setPwCtx?.password) { - return c.json({ success: false, error: { code: 'unavailable', message: 'Auth context unavailable' } }, 503); - } - const minLen = setPwCtx.password?.config?.minPasswordLength ?? 8; - const maxLen = setPwCtx.password?.config?.maxPasswordLength ?? 128; - if (newPassword.length < minLen) { - return c.json({ success: false, error: { code: 'password_too_short', message: `Password must be at least ${minLen} characters` } }, 400); - } - if (newPassword.length > maxLen) { - return c.json({ success: false, error: { code: 'password_too_long', message: `Password must be at most ${maxLen} characters` } }, 400); - } - const accounts = await setPwCtx.internalAdapter.findAccounts(userId); - const existingCredential = accounts?.find?.((a: any) => a.providerId === 'credential' && a.password); - if (existingCredential) { - return c.json({ success: false, error: { code: 'credential_account_exists', message: 'A local password is already set for this account. Use change-password instead.' } }, 409); - } - const passwordHash = await setPwCtx.password.hash(newPassword); - await setPwCtx.internalAdapter.createAccount({ - userId, - providerId: 'credential', - accountId: userId, - password: passwordHash, - }); - return c.json({ success: true }); - } catch (err: any) { - ctx.logger?.error?.('[AuthProxyPlugin] set-initial-password failed', err instanceof Error ? err : new Error(String(err))); - return c.json({ success: false, error: { code: 'set_password_failed', message: String(err?.message ?? err) } }, 500); - } - } - - // ── set-initial-password ────────────────────────── - // POST /api/v1/auth/set-initial-password - // - // better-auth's `setPassword` is a server-only API (no - // HTTP route), and the full AuthPlugin — which exposes it - // as a custom route — is SKIPPED on a per-environment - // runtime. So without this short-circuit the request falls - // through to better-auth and 404s, dead-ending the - // `sso-exchange` → "Set local password" recovery flow - // (see #1544). Reuse the exact same wrapper the AuthPlugin - // uses so the two paths can never drift again. - if (c.req.method === 'POST' && subPath === 'set-initial-password') { - try { - if (typeof authSvc?.getApi !== 'function') { - return c.json({ success: false, error: { code: 'unavailable', message: 'Auth API unavailable' } }, 503); - } - const authApi = await authSvc.getApi(); - const { status, body } = await runSetInitialPassword(authApi, c.req.raw); - return c.json(body, status); - } catch (err: any) { - ctx.logger?.error?.('[AuthProxyPlugin] set-initial-password failed', err instanceof Error ? err : new Error(String(err))); - return c.json({ success: false, error: { code: 'internal', message: err?.message ?? String(err) } }, 500); - } - } - - const fn = await resolveAuthHandler(authSvc); - if (!fn) { - return c.json({ error: 'auth_service_unavailable', environmentId }, 503); - } - - // Forward the original Web Request directly — better-auth - // accepts a standard `Request` and returns a `Response`. - const resp = await fn(c.req.raw); - - // ── Cookie-leak cleanup ───────────────────────────────── - // Cloud previously set OS_COOKIE_DOMAIN=.objectos.ai, - // which made cloud's better-auth session cookies leak - // into every *.objectos.ai project subdomain and - // collide with each project's own session_token. - // The setting has been removed from cloud, but existing - // browsers still carry the wide-scoped cookies. Append - // delete instructions (Max-Age=0 + matching Domain) on - // every per-project auth response so the leaked cookies - // get drained on the next auth round-trip. Safe to keep - // long-term: it only deletes cookies on a parent domain - // that project containers never legitimately set. - const rootDomain = process.env.OS_ROOT_DOMAIN || ''; - if (rootDomain) { - const leakyDomain = rootDomain.startsWith('.') ? rootDomain : `.${rootDomain}`; - const leakyNames = [ - '__Secure-better-auth.session_token', - 'better-auth.session_token', - '__Secure-better-auth.state', - 'better-auth.state', - '__Secure-better-auth.csrf_token', - 'better-auth.csrf_token', - ]; - try { - for (const n of leakyNames) { - const isSecure = n.startsWith('__Secure-'); - const attrs = `Max-Age=0; Path=/; Domain=${leakyDomain}; SameSite=Lax${isSecure ? '; Secure' : ''}`; - (resp as any).headers?.append?.('Set-Cookie', `${n}=; ${attrs}`); - } - } catch { /* best-effort cleanup */ } - } - - return resp; - } catch (err: any) { - (ctx.logger?.error as any)?.('[AuthProxyPlugin] auth dispatch failed', { - error: err?.message, - stack: err?.stack, - }); - return c.json({ - error: 'auth_dispatch_failed', - message: err?.message ?? String(err), - }, 500); - } - }; - - // Mount on every method via Hono's `all`. AuthPlugin previously - // registered with `rawApp.all('/api/v1/auth/*', handler)` — same - // shape here. - if (typeof rawApp.all === 'function') { - rawApp.all(`${AUTH_PREFIX}/*`, handler); - } else { - for (const m of ['get', 'post', 'put', 'delete', 'patch', 'options'] as const) { - try { rawApp[m]?.(`${AUTH_PREFIX}/*`, handler); } catch { /* best effort */ } - } - } - ctx.logger?.info?.(`[AuthProxyPlugin] auth proxy mounted at ${AUTH_PREFIX}/*`); - }); - }; -} diff --git a/packages/runtime/src/cloud/capability-loader.ts b/packages/runtime/src/cloud/capability-loader.ts deleted file mode 100644 index cf8852901d..0000000000 --- a/packages/runtime/src/cloud/capability-loader.ts +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Capability loader — `bundle.requires` driven dispatch. - * - * Mirrors the CAPABILITY_PROVIDERS table in - * `@objectstack/cli/src/commands/serve.ts` so that per-project kernels - * built by {@link ArtifactKernelFactory} pick up the same service plugins - * a developer would get when running `objectstack serve` locally. - * - * Design goals: - * - Single source of truth: artifact's `requires` array. No hardcoded - * plugin list per host. - * - Lazy: each provider is dynamically imported only when requested, - * keeping cold-start small for artifacts that don't need that - * capability. - * - Silent on missing deps: a host that doesn't ship the optional - * package (e.g. service-queue) just logs a warn and continues. - */ - -import type { ObjectKernel } from '@objectstack/core'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { join } from 'node:path'; - -/** - * Import an optional capability package, resolving it from the HOST APP's - * context first. - * - * The host app (a tenant runtime like apps/objectos, or an enterprise - * objectos-ee install) is what declares optional plugin packages — including - * private ones such as `@objectstack/service-ai-studio` that are NOT part of - * the framework's own dependency graph. A bare `import(pkg)` from this file - * resolves relative to the framework package's location, which cannot see a - * package linked into the *app's* node_modules (the failure surfaces as - * "Cannot find package … imported from …/framework/…"). Anchoring a - * `createRequire` at the app's cwd resolves it from the app's node_modules. - * Falls back to a bare import for framework-owned packages. - */ -async function importFromHost(pkg: string): Promise { - try { - const req = createRequire(join(process.cwd(), 'package.json')); - const resolved = req.resolve(pkg); - return await import(pathToFileURL(resolved).href); - } catch { - return import(/* webpackIgnore: true */ pkg); - } -} - -export interface CapabilitySpec { - /** npm package name to import. */ - pkg: string; - /** Named export — class constructor for the main plugin. */ - export: string; - /** - * Optional bundle key that, when present, is forwarded as constructor - * argument (e.g. analytics needs `analyticsCubes`). - */ - configKey?: string; - /** Auxiliary plugins loaded alongside the main one. */ - extras?: Array<{ pkg: string; export: string }>; -} - -/** - * Registry of `requires` token → plugin provider. - * - * Keep keys in sync with the user-facing tokens accepted by - * `defineStack({ requires: [...] })` and the CLI's CAPABILITY_PROVIDERS. - * - * Tier-gated capabilities (`auth`, `ui`, `i18n`) are intentionally NOT - * listed here — they are wired explicitly by the kernel factory because - * they need bespoke configuration (per-project HKDF secret, UI dist - * paths, etc). - */ -export const CAPABILITY_PROVIDERS: Record = { - automation: { - // Self-contained: AutomationServicePlugin seeds all built-in node - // executors itself (ADR-0018), so no companion node-pack plugins. - pkg: '@objectstack/service-automation', - export: 'AutomationServicePlugin', - }, - ai: { - pkg: '@objectstack/service-ai', - export: 'AIServicePlugin', - }, - // AI Studio — AI-driven metadata authoring ("online development"). This is - // a commercial capability that ships in the private @objectstack/service-ai-studio - // package (not part of the open-source framework). The dynamic import below - // silently skips when the package isn't installed, so the open-source build - // is unaffected; cloud and enterprise installs that ship the package light it - // up. Pair with `ai` in `requires` (it attaches via the `ai:ready` hook). - aiStudio: { - pkg: '@objectstack/service-ai-studio', - export: 'AIStudioPlugin', - }, - analytics: { - pkg: '@objectstack/service-analytics', - export: 'AnalyticsServicePlugin', - configKey: 'analyticsCubes', - }, - audit: { - pkg: '@objectstack/plugin-audit', - export: 'AuditPlugin', - }, - cache: { - pkg: '@objectstack/service-cache', - export: 'CacheServicePlugin', - }, - storage: { - pkg: '@objectstack/service-storage', - export: 'StorageServicePlugin', - }, - queue: { - pkg: '@objectstack/service-queue', - export: 'QueueServicePlugin', - }, - job: { - pkg: '@objectstack/service-job', - export: 'JobServicePlugin', - }, - messaging: { - // Backs the `notify` flow node (ADR-0012): delivers to a user's - // channels (inbox by default → `sys_inbox_message` rows). - pkg: '@objectstack/service-messaging', - export: 'MessagingServicePlugin', - }, - triggers: { - // Concrete flow triggers — record-change (ObjectQL hooks) + schedule - // (cron/interval via the job service; pair `triggers` with `job`). - pkg: '@objectstack/plugin-trigger-record-change', - export: 'RecordChangeTriggerPlugin', - extras: [{ pkg: '@objectstack/plugin-trigger-schedule', export: 'ScheduleTriggerPlugin' }], - }, - realtime: { - pkg: '@objectstack/service-realtime', - export: 'RealtimeServicePlugin', - }, - feed: { - pkg: '@objectstack/service-feed', - export: 'FeedServicePlugin', - }, - settings: { - pkg: '@objectstack/service-settings', - export: 'SettingsServicePlugin', - }, -}; - -type Logger = { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - -export interface LoadCapabilitiesOptions { - kernel: ObjectKernel; - /** Tokens from `bundle.requires` (e.g. `['ai','automation','analytics']`). */ - requires: readonly string[]; - /** Compiled artifact metadata. Used to pull `configKey`-driven args. */ - bundle: Record; - /** Optional logger. */ - logger?: Logger; - /** environmentId for log breadcrumbs. */ - environmentId: string; -} - -/** - * Walk `requires` and install each known capability on the kernel. - * - * Returns the list of plugin exports actually installed for diagnostics. - */ -export async function loadCapabilities(opts: LoadCapabilitiesOptions): Promise { - const { kernel, requires, bundle, environmentId } = opts; - const logger: Logger = opts.logger ?? console; - const installed: string[] = []; - - // De-dupe, then ensure dependent capabilities are present. ADR-0030: - // collaboration notifications (audit @mention/assignment) and the bell now - // deliver through the messaging pipeline, so `messaging` must load whenever - // `audit` does — otherwise those notifications silently no-op on cloud - // per-project kernels (which, unlike `objectstack serve`, have no - // always-on capability slate). Mirrors the CLI's foundational defaults. - const resolved = [...new Set(requires)]; - if (resolved.includes('audit') && !resolved.includes('messaging')) { - resolved.push('messaging'); - } - - for (const cap of resolved) { - const spec = CAPABILITY_PROVIDERS[cap]; - if (!spec) { - // Tier-gated capability (auth/ui/i18n) — wired elsewhere. - continue; - } - - try { - const mod: any = await importFromHost(spec.pkg); - const Ctor = mod[spec.export]; - if (!Ctor) { - logger.warn?.( - `[CapabilityLoader] '${cap}': package '${spec.pkg}' did not export '${spec.export}'`, - { environmentId }, - ); - continue; - } - - let arg: unknown; - if (spec.configKey) { - const v = (bundle as Record)[spec.configKey]; - if (spec.configKey === 'analyticsCubes') { - arg = { cubes: Array.isArray(v) ? v : [] }; - } else if (v !== undefined) { - arg = v; - } - } - - await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor()); - installed.push(spec.export); - - if (spec.extras) { - for (const ex of spec.extras) { - try { - const exMod: any = await importFromHost(ex.pkg); - const ExCtor = exMod[ex.export]; - if (ExCtor) { - await kernel.use(new ExCtor()); - installed.push(ex.export); - } - } catch { - // Optional extra — silently skip. - } - } - } - - logger.info?.( - `[CapabilityLoader] '${cap}' installed (${spec.export}${spec.extras ? ' + ' + spec.extras.length + ' extras' : ''})`, - { environmentId }, - ); - } catch (err: any) { - const msg = err?.message ?? String(err); - if (msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) { - logger.warn?.( - `[CapabilityLoader] '${cap}' requested but '${spec.pkg}' not installed in host — skipped`, - { environmentId }, - ); - } else { - logger.error?.( - `[CapabilityLoader] '${cap}' load failed: ${msg}`, - { environmentId }, - ); - } - } - } - - return installed; -} diff --git a/packages/runtime/src/cloud/environment-org-seed.ts b/packages/runtime/src/cloud/environment-org-seed.ts deleted file mode 100644 index 4870985d03..0000000000 --- a/packages/runtime/src/cloud/environment-org-seed.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Pre-seed the OWNING cloud organization into a freshly-provisioned - * project DB. - * - * Why this exists - * --------------- - * Every project at the cloud control plane is owned by exactly one - * `sys_organization` (cloud-side). Mirroring that org into the project - * DB makes the project's primary org match the cloud team that owns - * it, so the owner's session resolves an `activeOrganizationId` on - * first sign-in instead of landing on the empty "create your first - * organization" prompt. - * - * Subsequent JIT-provisioned members of the same cloud org get - * attached to this same row (Phase 2 — claim-driven by the SSO - * callback). - * - * Idempotency - * ----------- - * Keyed by the cloud `organization_id`. Re-runs across cold-boots are - * safe no-ops; a row with that id either already exists or gets - * inserted on the first boot of the project worker. - */ - -import type { ObjectKernel } from '@objectstack/core'; - -export interface ProjectOrgSeed { - /** Cloud `sys_organization.id` of the project's owning org. Reused as the project-side `sys_organization.id` so cross-tier references stay consistent. */ - id: string; - /** Display name copied from the cloud org. */ - name: string; - /** URL slug copied from the cloud org. */ - slug?: string | null; - /** Optional logo URL. */ - logo?: string | null; -} - -const SYS_ORG = 'sys_organization'; - -/** - * Insert the project's owning organization into the project's - * `sys_organization` table. - * - * Returns: - * - `'inserted'` — the row was newly seeded - * - `'exists'` — a row with this id already existed (no-op) - * - `'skipped'` — payload missing required fields (no-op) - * - `'error'` — an unexpected failure; details logged via `logger.warn` - * (we never throw — org seed is best-effort) - */ -export async function seedProjectOrganization( - kernel: ObjectKernel, - seed: ProjectOrgSeed, - logger?: { info?: (msg: string, ctx?: any) => void; warn?: (msg: string, ctx?: any) => void }, -): Promise<'inserted' | 'exists' | 'skipped' | 'error'> { - if (!seed?.id || !seed?.name) return 'skipped'; - - try { - const ql: any = kernel.getService('objectql'); - if (!ql?.insert || !ql?.find) { - logger?.warn?.('[seedProjectOrganization] objectql service unavailable', { orgId: seed.id }); - return 'skipped'; - } - - try { - const existing = await ql.find(SYS_ORG, { where: { id: seed.id } } as any); - const rows = Array.isArray(existing) ? existing : (existing?.value ?? []); - if (Array.isArray(rows) && rows.length > 0) return 'exists'; - } catch { - // schema may not be fully synced on first cold-boot; fall - // through to insert — the DB layer will enforce uniqueness. - } - - const nowIso = new Date().toISOString(); - await ql.insert(SYS_ORG, { - id: seed.id, - name: seed.name, - slug: seed.slug ?? null, - logo: seed.logo ?? null, - metadata: null, - created_at: nowIso, - }); - - logger?.info?.('[seedProjectOrganization] org seeded', { - orgId: seed.id, - name: seed.name, - }); - return 'inserted'; - } catch (err: any) { - logger?.warn?.('[seedProjectOrganization] failed (non-fatal)', { - orgId: seed.id, - error: err?.message, - }); - return 'error'; - } -} - -/** - * Insert a `sys_member` row linking a user to an organization with a - * given role. Idempotent on (user_id, organization_id). - * - * Used in tandem with `seedProjectOrganization` to bind the project - * owner to the mirrored cloud org so the owner's first sign-in - * already resolves an `activeOrganizationId` instead of landing on - * the empty "create your first organization" prompt. - * - * Returns the same status enum as the org/owner seed helpers. - */ -export async function seedProjectMember( - kernel: ObjectKernel, - args: { - userId: string; - organizationId: string; - role?: 'owner' | 'admin' | 'member'; - }, - logger?: { info?: (msg: string, ctx?: any) => void; warn?: (msg: string, ctx?: any) => void }, -): Promise<'inserted' | 'exists' | 'skipped' | 'error'> { - const { userId, organizationId } = args; - const role = args.role ?? 'member'; - if (!userId || !organizationId) return 'skipped'; - - try { - const ql: any = kernel.getService('objectql'); - if (!ql?.insert || !ql?.find) { - logger?.warn?.('[seedProjectMember] objectql service unavailable', { userId, organizationId }); - return 'skipped'; - } - - try { - const existing = await ql.find('sys_member', { - where: { user_id: userId, organization_id: organizationId }, - } as any); - const rows = Array.isArray(existing) ? existing : (existing?.value ?? []); - if (Array.isArray(rows) && rows.length > 0) return 'exists'; - } catch { - // see comment in seedProjectOrganization - } - - const nowIso = new Date().toISOString(); - // sys_member's primary key is generated by the org plugin; we - // pre-generate a stable id of the form `mem_` to match - // the existing convention used by the cloud control plane. - const memId = `mem_${Math.random().toString(36).slice(2, 14)}`; - await ql.insert('sys_member', { - id: memId, - organization_id: organizationId, - user_id: userId, - role, - created_at: nowIso, - }); - - logger?.info?.('[seedProjectMember] member seeded', { - userId, - organizationId, - role, - }); - return 'inserted'; - } catch (err: any) { - logger?.warn?.('[seedProjectMember] failed (non-fatal)', { - userId, - organizationId, - error: err?.message, - }); - return 'error'; - } -} diff --git a/packages/runtime/src/cloud/environment-owner-seed.ts b/packages/runtime/src/cloud/environment-owner-seed.ts deleted file mode 100644 index f65ce275bc..0000000000 --- a/packages/runtime/src/cloud/environment-owner-seed.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Pre-seed the project owner into a freshly-provisioned project DB. - * - * Why this exists - * --------------- - * When a user creates a project on the cloud control plane, the project - * gets a brand-new isolated database. On first access, the SSO callback - * would otherwise treat the project owner as just another anonymous JIT - * user — no admin role, no membership, no recognition that *this is the - * person who owns this project*. The owner then has to manually promote - * themselves via the org membership UI, which is friction the user does - * not deserve to pay. - * - * Worse: better-auth's `accountLinking` safety check rejects implicit - * link of an unverified local row with an unverified OAuth identity - * (`error=account_not_linked`). Pre-seeding with `emailVerified: true` - * (cloud already verified them upstream) makes the link clean. - * - * Idempotency - * ----------- - * The seed is keyed by the cloud `userId`. If a `sys_user` row with that - * id already exists in the project DB, this is a no-op — safe to call - * on every cold-boot. SecurityPlugin's `sys_user` insert middleware - * (mounted by ArtifactKernelFactory) auto-creates the personal - * organization + `sys_member(owner)` binding as a side effect of the - * insert, so callers do not need to wire membership themselves. - */ - -import type { ObjectKernel } from '@objectstack/core'; - -export interface ProjectOwnerSeed { - /** Cloud `sys_user.id` of the project creator. Used as the project-side `sys_user.id` so SSO callbacks link by id, not by email-match. */ - userId: string; - /** Verified email at cloud — copied here so the link check passes without a project-side verification flow. */ - email: string; - /** Display name; nullable to tolerate users who never set one. */ - name?: string | null; - /** Avatar URL; nullable. */ - image?: string | null; -} - -const SYS_USER = 'sys_user'; - -/** - * Insert the project owner into the project's `sys_user` table. - * - * Returns: - * - `'inserted'` — the row was newly seeded - * - `'exists'` — a row with this id already existed (no-op) - * - `'skipped'` — payload missing required fields (no-op) - * - `'error'` — an unexpected failure; details logged via `logger.warn` - * (we never throw — owner seed is best-effort) - */ -export async function seedProjectOwner( - kernel: ObjectKernel, - seed: ProjectOwnerSeed, - logger?: { info?: (msg: string, ctx?: any) => void; warn?: (msg: string, ctx?: any) => void }, -): Promise<'inserted' | 'exists' | 'skipped' | 'error'> { - if (!seed?.userId || !seed?.email) return 'skipped'; - - try { - const ql: any = kernel.getService('objectql'); - if (!ql?.insert || !ql?.find) { - logger?.warn?.('[seedProjectOwner] objectql service unavailable', { userId: seed.userId }); - return 'skipped'; - } - - // Idempotency check: bail if the owner is already present. We key - // off `id` (not email) so re-runs are safe even if the user later - // changes their cloud email. - try { - const existing = await ql.find(SYS_USER, { where: { id: seed.userId } } as any); - const rows = Array.isArray(existing) ? existing : (existing?.value ?? []); - if (Array.isArray(rows) && rows.length > 0) return 'exists'; - } catch { - // `find` may legitimately fail on cold-start before the schema - // is fully synced. Fall through to the insert — uniqueness will - // also be enforced at the DB layer. - } - - const nowIso = new Date().toISOString(); - await ql.insert(SYS_USER, { - id: seed.userId, - email: seed.email, - name: seed.name ?? seed.email.split('@')[0] ?? 'Owner', - image: seed.image ?? null, - // Cloud already verified the upstream email. Marking it verified - // here is what unblocks better-auth's accountLinking check on - // the first SSO callback (alongside the trustedProviders config - // in plugin-auth/auth-manager.ts). - email_verified: true, - created_at: nowIso, - updated_at: nowIso, - }); - - logger?.info?.('[seedProjectOwner] owner seeded', { - userId: seed.userId, - email: seed.email, - }); - return 'inserted'; - } catch (err: any) { - // Common benign cases: race with another cold-boot, or unique - // constraint violation if two requests interleave. Log + swallow. - logger?.warn?.('[seedProjectOwner] failed (non-fatal)', { - userId: seed.userId, - error: err?.message, - }); - return 'error'; - } -} diff --git a/packages/runtime/src/cloud/environment-registry.ts b/packages/runtime/src/cloud/environment-registry.ts index b682096e1a..7183471224 100644 --- a/packages/runtime/src/cloud/environment-registry.ts +++ b/packages/runtime/src/cloud/environment-registry.ts @@ -16,9 +16,25 @@ */ import type * as Contracts from '@objectstack/spec/contracts'; +import type { ObjectKernel } from '@objectstack/core'; type IDataDriver = Contracts.IDataDriver; +/** + * Multi-tenant kernel router contract. + * + * The HTTP dispatcher uses this optional seam to resolve a per-environment + * kernel when serving a multi-tenant runtime. The framework only depends on + * the *interface* — the concrete LRU/TTL implementation (and everything else + * multi-tenant: hostname resolution, artifact fetching, per-env kernel + * construction) lives in the cloud distribution + * (`@objectstack/objectos-runtime`), not here. + */ +export interface KernelManager { + /** Resolve (building + caching on first use) the kernel for an environment. */ + getOrCreate(environmentId: string): Promise; +} + export interface EnvironmentDriverRegistry { /** Resolve a project by hostname. Returns `null` when unknown. */ resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null>; diff --git a/packages/runtime/src/cloud/file-artifact-api-client.ts b/packages/runtime/src/cloud/file-artifact-api-client.ts deleted file mode 100644 index eddeee5285..0000000000 --- a/packages/runtime/src/cloud/file-artifact-api-client.ts +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * File-backed ArtifactApiClient. - * - * A drop-in replacement for {@link ArtifactApiClient} that reads a - * single project's compiled artifact from a local JSON file instead of - * an HTTP control plane. Intended for: - * - * - `pnpm dev` workflows where standing up a full `apps/cloud` - * instance to host one artifact is overkill. - * - Smoke tests / CI that need a hermetic objectos boot. - * - Single-tenant self-hosted deployments that ship one artifact - * baked into the container image. - * - * Hostname resolution is the identity function: every host resolves - * to the same `environmentId`. The runtime config (database URL + - * driver) is synthesised from the artifact's default datasource, - * matching what the cloud API would mint for a project whose - * developer declared an inline datasource in `defineStack()`. - * - * Public API mirrors {@link ArtifactApiClient} so callers can swap - * implementations transparently. - */ - -import { readFile, stat } from 'node:fs/promises'; -import { resolve as resolvePath } from 'node:path'; -import type { - EnvironmentArtifactResponse, - EnvironmentRuntimeConfig, - ResolvedHostname, -} from './artifact-api-client.js'; - -export interface FileArtifactApiClientConfig { - /** - * Path to a compiled artifact JSON file (`dist/objectstack.json`). - * Resolved against `process.cwd()` when relative. Defaults to - * `/dist/objectstack.json`. - */ - artifactPath?: string; - /** - * Project id every hostname maps to. Defaults to - * `process.env.OS_ENVIRONMENT_ID` or `'proj_local'`. - */ - environmentId?: string; - /** - * Organization id surfaced alongside the project. Defaults to - * `process.env.OS_ORGANIZATION_ID` or `'org_local'`. - */ - organizationId?: string; - /** - * Override runtime config. When unset, the client tries to derive - * one from the artifact's `datasources` array; if that fails it - * falls back to a local-file SQLite DB at - * `/.objectstack/data/.db`. - */ - runtime?: EnvironmentRuntimeConfig; - /** - * Reload the artifact on every fetch instead of caching the first - * read. Useful when iterating on a project's metadata without - * restarting objectos. Defaults to `true` for dev ergonomics. - */ - watch?: boolean; - /** Optional logger. */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; -} - -export class FileArtifactApiClient { - private readonly artifactPath: string; - private readonly environmentId: string; - private readonly organizationId: string; - private readonly overrideRuntime?: EnvironmentRuntimeConfig; - private readonly watch: boolean; - private readonly logger: NonNullable; - - private cached?: { mtimeMs: number; response: EnvironmentArtifactResponse }; - - constructor(config: FileArtifactApiClientConfig = {}) { - const cwd = process.cwd(); - this.artifactPath = resolvePath( - cwd, - config.artifactPath - ?? process.env.OS_ARTIFACT_PATH - ?? 'dist/objectstack.json', - ); - this.environmentId = config.environmentId - ?? process.env.OS_ENVIRONMENT_ID - ?? 'proj_local'; - this.organizationId = config.organizationId - ?? process.env.OS_ORGANIZATION_ID - ?? 'org_local'; - this.overrideRuntime = config.runtime; - this.watch = config.watch ?? true; - this.logger = config.logger ?? console; - } - - async resolveHostname(_host: string): Promise { - // Single-project mode: every host maps to the one configured project. - const runtime = this.overrideRuntime ?? (await this.readRuntimeFromArtifact()); - return { - environmentId: this.environmentId, - organizationId: this.organizationId, - ...(runtime ? { runtime } : {}), - }; - } - - async fetchArtifact(_environmentId: string, _opts?: { commit?: string }): Promise { - return this.loadArtifact(); - } - - async lookupProjectByShortId(_shortId: string): Promise<{ environmentId: string; organizationId?: string } | null> { - return { environmentId: this.environmentId, organizationId: this.organizationId }; - } - - async fetchBranchHead( - _environmentId: string, - _branchName: string, - ): Promise<{ commitId: string; publishedAt?: string | null } | null> { - const artifact = await this.loadArtifact(); - return artifact - ? { commitId: artifact.commitId ?? 'local', publishedAt: null } - : null; - } - - invalidate(_environmentId: string): void { - this.cached = undefined; - } - - clear(): void { - this.cached = undefined; - } - - private async loadArtifact(): Promise { - try { - const stats = await stat(this.artifactPath); - const mtimeMs = stats.mtimeMs; - if (!this.watch && this.cached) return this.cached.response; - if (this.cached && this.cached.mtimeMs === mtimeMs) return this.cached.response; - - const raw = await readFile(this.artifactPath, 'utf8'); - const parsed = JSON.parse(raw); - // The compiled JSON may already be a `EnvironmentArtifact` envelope - // (with a `metadata` block) or a bare bundle. Wrap when needed. - const isEnvelope = parsed && typeof parsed === 'object' - && typeof parsed.metadata === 'object' - && parsed.metadata !== null; - const metadata = isEnvelope ? parsed.metadata : parsed; - const runtime = this.overrideRuntime - ?? (isEnvelope ? parsed.runtime : undefined) - ?? this.deriveRuntimeFromMetadata(metadata) - ?? this.defaultLocalSqliteRuntime(); - const response: EnvironmentArtifactResponse = { - schemaVersion: parsed.schemaVersion ?? '1', - environmentId: parsed.environmentId ?? this.environmentId, - commitId: parsed.commitId ?? 'local', - checksum: parsed.checksum ?? '', - publishedAt: parsed.publishedAt ?? new Date().toISOString(), - metadata, - functions: parsed.functions, - manifest: parsed.manifest, - runtime: { - organizationId: this.organizationId, - ...runtime, - }, - } as EnvironmentArtifactResponse; - this.cached = { mtimeMs, response }; - return response; - } catch (err: any) { - this.logger.error?.('[FileArtifactApiClient] failed to load artifact', { - artifactPath: this.artifactPath, - error: err?.message ?? err, - }); - return null; - } - } - - private async readRuntimeFromArtifact(): Promise { - const artifact = await this.loadArtifact(); - return artifact?.runtime; - } - - private deriveRuntimeFromMetadata(metadata: any): EnvironmentRuntimeConfig | undefined { - const datasources = metadata?.datasources; - if (!Array.isArray(datasources) || datasources.length === 0) return undefined; - const mapping: any[] | undefined = metadata?.datasourceMapping; - let preferredName: string | undefined; - if (mapping) { - const def = mapping.find((m: any) => m?.default === true); - if (def?.datasource) preferredName = def.datasource; - } - const ds = preferredName - ? datasources.find((d: any) => d?.name === preferredName) ?? datasources[0] - : datasources[0]; - if (!ds || typeof ds !== 'object') return undefined; - const config = (ds.config ?? {}) as Record; - const url = config.url ?? config.connectionString ?? config.connection ?? config.filename; - const driver = ds.driver; - if (typeof driver !== 'string' || typeof url !== 'string') return undefined; - return { - databaseDriver: driver, - databaseUrl: url, - databaseAuthToken: typeof config.authToken === 'string' ? config.authToken : undefined, - }; - } - - private defaultLocalSqliteRuntime(): EnvironmentRuntimeConfig { - const cwd = process.cwd(); - const dbPath = resolvePath(cwd, '.objectstack/data', `${this.environmentId}.db`); - return { - databaseDriver: 'sqlite', - databaseUrl: `file:${dbPath}`, - }; - } -} diff --git a/packages/runtime/src/cloud/kernel-manager.test.ts b/packages/runtime/src/cloud/kernel-manager.test.ts deleted file mode 100644 index 30040e95a9..0000000000 --- a/packages/runtime/src/cloud/kernel-manager.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { KernelManager, type EnvironmentKernelFactory } from './kernel-manager.js'; - -/** Build a stub "kernel" with a spy-able `shutdown()` — KernelManager only uses `.shutdown()`. */ -function makeStubKernel() { - return { shutdown: vi.fn().mockResolvedValue(undefined) } as any; -} - -/** - * Build a EnvironmentKernelFactory whose `create()` returns a fresh stub kernel per - * invocation. Optional `delayMs` lets us simulate concurrent in-flight builds. - */ -function makeFactory(opts: { delayMs?: number } = {}) { - const calls: string[] = []; - const kernels = new Map(); - const factory: EnvironmentKernelFactory = { - create: vi.fn(async (environmentId: string) => { - calls.push(environmentId); - if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs)); - const k = makeStubKernel(); - kernels.set(`${environmentId}#${calls.filter((c) => c === environmentId).length}`, k); - return k; - }), - }; - return { factory, calls, kernels }; -} - -describe('KernelManager', () => { - it('caches kernels and reuses them on repeat getOrCreate()', async () => { - const { factory } = makeFactory(); - const mgr = new KernelManager({ factory, ttlMs: 0 }); - - const a1 = await mgr.getOrCreate('p1'); - const a2 = await mgr.getOrCreate('p1'); - - expect(a1).toBe(a2); - expect(factory.create).toHaveBeenCalledTimes(1); - expect(mgr.size).toBe(1); - }); - - it('dedupes concurrent getOrCreate() for the same project (singleflight)', async () => { - const { factory } = makeFactory({ delayMs: 20 }); - const mgr = new KernelManager({ factory }); - - const [a, b, c] = await Promise.all([ - mgr.getOrCreate('p1'), - mgr.getOrCreate('p1'), - mgr.getOrCreate('p1'), - ]); - - expect(a).toBe(b); - expect(b).toBe(c); - expect(factory.create).toHaveBeenCalledTimes(1); - }); - - it('evicts the least-recently-used kernel when maxSize is exceeded', async () => { - const { factory } = makeFactory(); - const mgr = new KernelManager({ factory, maxSize: 2, ttlMs: 0 }); - - const k1 = await mgr.getOrCreate('p1'); - await new Promise((r) => setTimeout(r, 2)); - await mgr.getOrCreate('p2'); - await new Promise((r) => setTimeout(r, 2)); - // Touch p1 so p2 becomes LRU. - await mgr.getOrCreate('p1'); - await new Promise((r) => setTimeout(r, 2)); - await mgr.getOrCreate('p3'); // should evict p2 - - expect(mgr.keys().sort()).toEqual(['p1', 'p3']); - // p1 survived, its kernel is untouched. - expect(k1.shutdown).not.toHaveBeenCalled(); - }); - - it('evicts on TTL expiry and recreates on next access', async () => { - const { factory } = makeFactory(); - const mgr = new KernelManager({ factory, ttlMs: 10 }); - - const k1 = await mgr.getOrCreate('p1'); - await new Promise((r) => setTimeout(r, 20)); - const k2 = await mgr.getOrCreate('p1'); - - expect(k2).not.toBe(k1); - expect(k1.shutdown).toHaveBeenCalledTimes(1); - expect(factory.create).toHaveBeenCalledTimes(2); - }); - - it('evict() removes the entry and invokes kernel.shutdown()', async () => { - const { factory } = makeFactory(); - const mgr = new KernelManager({ factory }); - - const k1 = await mgr.getOrCreate('p1'); - await mgr.evict('p1'); - - expect(k1.shutdown).toHaveBeenCalledTimes(1); - expect(mgr.size).toBe(0); - // Evicting an absent entry is a no-op. - await expect(mgr.evict('nope')).resolves.toBeUndefined(); - }); - - it('evictAll() shuts down every cached kernel', async () => { - const { factory } = makeFactory(); - const mgr = new KernelManager({ factory }); - - const k1 = await mgr.getOrCreate('p1'); - const k2 = await mgr.getOrCreate('p2'); - await mgr.evictAll(); - - expect(k1.shutdown).toHaveBeenCalledTimes(1); - expect(k2.shutdown).toHaveBeenCalledTimes(1); - expect(mgr.size).toBe(0); - }); - - it('swallows kernel.shutdown() errors and logs them', async () => { - const factory: EnvironmentKernelFactory = { - create: vi.fn(async () => ({ - shutdown: vi.fn().mockRejectedValue(new Error('boom')), - }) as any), - }; - const errorSpy = vi.fn(); - const mgr = new KernelManager({ - factory, - logger: { error: errorSpy }, - }); - - await mgr.getOrCreate('p1'); - await expect(mgr.evict('p1')).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(mgr.size).toBe(0); - }); - - describe('freshnessProbe', () => { - it('skips probe within staleCheckIntervalMs after build', async () => { - const { factory } = makeFactory(); - const probe = vi.fn().mockResolvedValue(false); - const mgr = new KernelManager({ factory, freshnessProbe: probe, staleCheckIntervalMs: 60_000 }); - - const a = await mgr.getOrCreate('p1'); - const b = await mgr.getOrCreate('p1'); - - expect(a).toBe(b); - expect(probe).not.toHaveBeenCalled(); - expect(factory.create).toHaveBeenCalledTimes(1); - }); - - it('returns cached kernel when probe says fresh', async () => { - const { factory } = makeFactory(); - const probe = vi.fn().mockResolvedValue(false); - const mgr = new KernelManager({ factory, freshnessProbe: probe, staleCheckIntervalMs: 0 }); - - const a = await mgr.getOrCreate('p1'); - const b = await mgr.getOrCreate('p1'); - - expect(a).toBe(b); - expect(probe).toHaveBeenCalledTimes(1); - expect(factory.create).toHaveBeenCalledTimes(1); - }); - - it('evicts and rebuilds when probe reports stale', async () => { - const { factory } = makeFactory(); - const probe = vi.fn().mockResolvedValue(true); - const mgr = new KernelManager({ factory, freshnessProbe: probe, staleCheckIntervalMs: 0 }); - - const a = await mgr.getOrCreate('p1'); - const b = await mgr.getOrCreate('p1'); - - expect(a).not.toBe(b); - expect(factory.create).toHaveBeenCalledTimes(2); - expect((a as any).shutdown).toHaveBeenCalledTimes(1); - }); - - it('treats probe errors as fresh (logs warning)', async () => { - const { factory } = makeFactory(); - const probe = vi.fn().mockRejectedValue(new Error('upstream down')); - const warnSpy = vi.fn(); - const mgr = new KernelManager({ - factory, - freshnessProbe: probe, - staleCheckIntervalMs: 0, - logger: { warn: warnSpy }, - }); - - const a = await mgr.getOrCreate('p1'); - const b = await mgr.getOrCreate('p1'); - - expect(a).toBe(b); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(factory.create).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/runtime/src/cloud/kernel-manager.ts b/packages/runtime/src/cloud/kernel-manager.ts deleted file mode 100644 index 4620a5a06a..0000000000 --- a/packages/runtime/src/cloud/kernel-manager.ts +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectKernel } from '@objectstack/core'; - -/** - * Factory contract for instantiating a per-project {@link ObjectKernel}. - * - * Given a `environmentId`, the factory is expected to: - * 1. Read control-plane metadata (`sys_environment` + credentials + subscribed packages). - * 2. Construct a fresh `ObjectKernel` with project-scoped driver + plugins + Apps. - * 3. Return a **bootstrapped** kernel ready to serve requests. - */ -export interface EnvironmentKernelFactory { - create(environmentId: string): Promise; -} - -interface CachedEntry { - kernel: ObjectKernel; - createdAt: number; - lastAccess: number; - /** - * Wall-clock ms of the most recent freshness probe (see - * `freshnessProbe`). Throttles upstream probe rate to at most - * `staleCheckIntervalMs` per env. - */ - lastStaleCheckAt: number; -} - -export interface KernelManagerConfig { - factory: EnvironmentKernelFactory; - /** Maximum number of kernels to keep resident. Defaults to 32. */ - maxSize?: number; - /** - * Time-to-live (ms). Kernels idle longer than this are evicted on next - * access. `0` disables TTL expiry. Defaults to 15 minutes. - */ - ttlMs?: number; - /** - * Optional logger (duck-typed). Falls back to `console` when omitted. - */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - /** - * Optional upstream-change detector. When set, every cache hit older - * than `staleCheckIntervalMs` triggers this probe before returning the - * cached kernel. Returning `true` evicts the kernel and forces a - * rebuild, so changes to the control-plane state that don't reach - * this process via push (marketplace installs, artifact republish, - * etc.) become visible without waiting for the LRU TTL to expire. - * - * The probe should be cheap (single small GET). Errors thrown here - * are caught and treated as "still fresh" so a brief upstream - * outage doesn't churn every cached kernel — the worst case is - * stale-by-`ttlMs`, which is what we had before adding the probe. - * - * `builtAtMs` is the kernel's `createdAt` time so the probe can - * compare against an upstream "last changed at" timestamp. - */ - freshnessProbe?: (environmentId: string, builtAtMs: number) => Promise; - /** - * Minimum gap between successive freshness probes for the same env. - * Defaults to 10 seconds — enough to avoid hammering the control - * plane on tight render loops while still keeping the user's - * post-install refresh perceived as immediate. - */ - staleCheckIntervalMs?: number; -} - -/** - * LRU + TTL cache of per-project {@link ObjectKernel} instances. - * - * Implements ADR-0003 multi-kernel scheduling: each project gets an - * isolated kernel (App/plugin/metadata namespaces) that is lazily built - * on first request and evicted under memory / idle pressure. Concurrent - * `getOrCreate()` calls for the same environmentId share a single in-flight - * factory invocation (singleflight). - */ -export class KernelManager { - private readonly factory: EnvironmentKernelFactory; - private readonly maxSize: number; - private readonly ttlMs: number; - private readonly logger: NonNullable; - private readonly cache = new Map(); - private readonly pending = new Map>(); - private readonly freshnessProbe?: KernelManagerConfig['freshnessProbe']; - private readonly staleCheckIntervalMs: number; - - constructor(config: KernelManagerConfig) { - this.factory = config.factory; - this.maxSize = config.maxSize ?? 32; - this.ttlMs = config.ttlMs ?? 15 * 60 * 1000; - this.logger = config.logger ?? console; - this.freshnessProbe = config.freshnessProbe; - this.staleCheckIntervalMs = config.staleCheckIntervalMs ?? 10_000; - } - - /** Returns the currently cached environmentIds (ordered by insertion). */ - keys(): string[] { - return Array.from(this.cache.keys()); - } - - /** Cache size for diagnostics. */ - get size(): number { - return this.cache.size; - } - - /** - * Resolve or construct the kernel for `environmentId`. - * - * - Cache hit (fresh): bumps `lastAccess` and returns immediately. - * - Cache hit (TTL expired): evicts then falls through to factory. - * - Cache miss: dedupes concurrent callers through `pending`. - */ - async getOrCreate(environmentId: string): Promise { - const existing = this.cache.get(environmentId); - if (existing) { - if (this.ttlMs > 0 && Date.now() - existing.lastAccess > this.ttlMs) { - await this.evict(environmentId); - } else { - // Throttled upstream freshness check. Probe errors are swallowed - // so a brief control-plane outage doesn't churn the cache; the - // worst case is stale-by-ttlMs, our prior behaviour. - if (this.freshnessProbe) { - const now = Date.now(); - if (now - existing.lastStaleCheckAt >= this.staleCheckIntervalMs) { - existing.lastStaleCheckAt = now; - let stale = false; - try { - stale = await this.freshnessProbe(environmentId, existing.createdAt); - } catch (err) { - this.logger.warn?.('[KernelManager] freshness probe failed', { environmentId, err }); - } - if (stale) { - this.logger.info?.('[KernelManager] kernel evicted by freshness probe', { environmentId }); - await this.evict(environmentId); - // fall through to rebuild - } else { - existing.lastAccess = Date.now(); - return existing.kernel; - } - } else { - existing.lastAccess = Date.now(); - return existing.kernel; - } - } else { - existing.lastAccess = Date.now(); - return existing.kernel; - } - } - } - - const inflight = this.pending.get(environmentId); - if (inflight) return inflight; - - const promise = (async () => { - const kernel = await this.factory.create(environmentId); - const now = Date.now(); - this.cache.set(environmentId, { kernel, createdAt: now, lastAccess: now, lastStaleCheckAt: now }); - await this.enforceMaxSize(); - return kernel; - })(); - - this.pending.set(environmentId, promise); - try { - return await promise; - } finally { - this.pending.delete(environmentId); - } - } - - /** - * Evict the kernel for `environmentId` and invoke `kernel.shutdown()`. - * No-op when the entry is absent. - */ - async evict(environmentId: string): Promise { - const entry = this.cache.get(environmentId); - if (!entry) return; - this.cache.delete(environmentId); - try { - await entry.kernel.shutdown(); - } catch (err) { - this.logger.error?.('[KernelManager] shutdown failed', { environmentId, err }); - } - } - - /** Evict all resident kernels. Used on runtime shutdown. */ - async evictAll(): Promise { - const ids = Array.from(this.cache.keys()); - await Promise.all(ids.map((id) => this.evict(id))); - } - - private async enforceMaxSize(): Promise { - while (this.cache.size > this.maxSize) { - // Find least-recently-accessed entry. - let oldestKey: string | undefined; - let oldestAccess = Infinity; - for (const [key, entry] of this.cache) { - if (entry.lastAccess < oldestAccess) { - oldestAccess = entry.lastAccess; - oldestKey = key; - } - } - if (!oldestKey) return; - await this.evict(oldestKey); - } - } -} diff --git a/packages/runtime/src/cloud/objectos-stack.test.ts b/packages/runtime/src/cloud/objectos-stack.test.ts deleted file mode 100644 index 78c10756c3..0000000000 --- a/packages/runtime/src/cloud/objectos-stack.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tests for the `extraPlugins` host seam on createObjectOSStack (ADR §5.2). - * A host (e.g. ObjectStack Cloud) supplies product/policy plugins via the - * official seam instead of mutating the returned plugins array by hand. - */ - -import { describe, it, expect } from 'vitest'; -import { createObjectOSStack } from './objectos-stack.js'; -import type { Plugin } from '@objectstack/core'; - -function fakePlugin(name: string): Plugin { - return { - name, - version: '0.0.0', - async init() { /* no-op */ }, - async start() { /* no-op */ }, - } as Plugin; -} - -describe('createObjectOSStack — extraPlugins seam', () => { - it('appends host extraPlugins after the framework defaults', async () => { - const a = fakePlugin('com.host.alpha'); - const b = fakePlugin('com.host.beta'); - const stack = await createObjectOSStack({ - controlPlaneUrl: 'http://localhost:0', - extraPlugins: [a, b], - }); - const names = stack.plugins.map((p: any) => p.name); - // Both host plugins are present... - expect(names).toContain('com.host.alpha'); - expect(names).toContain('com.host.beta'); - // ...and appended LAST, after the framework marketplace proxy. - const proxyIdx = names.indexOf('com.objectstack.runtime.marketplace-proxy'); - expect(proxyIdx).toBeGreaterThanOrEqual(0); - expect(names.indexOf('com.host.alpha')).toBeGreaterThan(proxyIdx); - // ...preserving the given order. - expect(names.indexOf('com.host.beta')).toBeGreaterThan(names.indexOf('com.host.alpha')); - }); - - it('is a no-op when extraPlugins is omitted (default stack unchanged)', async () => { - const withSeam = await createObjectOSStack({ controlPlaneUrl: 'http://localhost:0', extraPlugins: [] }); - const without = await createObjectOSStack({ controlPlaneUrl: 'http://localhost:0' }); - expect(withSeam.plugins.length).toBe(without.plugins.length); - }); -}); diff --git a/packages/runtime/src/cloud/objectos-stack.ts b/packages/runtime/src/cloud/objectos-stack.ts deleted file mode 100644 index 13d503e8bc..0000000000 --- a/packages/runtime/src/cloud/objectos-stack.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * createObjectOSStack - * - * ObjectOS pure-runtime stack — no control-plane database, no auth / - * security / audit / tenant plugins. The host kernel registers: - * - * - A minimal engine triplet (ObjectQL + in-memory DriverPlugin + - * MetadataPlugin) so CLI auto-injected plugins (Setup, Studio, - * Dispatcher, REST) and the runtime can boot. The host kernel itself - * never reads or writes business data — every record query is routed - * to a per-project kernel built from a remote artifact. - * - The `env-registry` and `kernel-manager` services, so the runtime's - * HTTP dispatcher can resolve hostnames and dispatch every request - * to the matching project kernel. - * - * Invoked by `createRuntimeStack()` whenever `OS_CLOUD_URL` - * (or `config.controlPlaneUrl`) is set. The same plugin shape is returned - * as `createCloudStack()` so host configs can swap stacks transparently. - */ - -import { Plugin, PluginContext } from '@objectstack/core'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; -import { KernelManager } from './kernel-manager.js'; -import { ArtifactApiClient } from './artifact-api-client.js'; -import { ArtifactEnvironmentRegistry } from './artifact-environment-registry.js'; -import { ArtifactKernelFactory } from './artifact-kernel-factory.js'; -import { AuthProxyPlugin } from './auth-proxy-plugin.js'; -import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; -import { RuntimeConfigPlugin } from './runtime-config-plugin.js'; -import { FileArtifactApiClient, type FileArtifactApiClientConfig } from './file-artifact-api-client.js'; - -export interface ObjectOSStackConfig { - /** - * Control-plane base URL (HTTP) or a sentinel of `'file'` for the - * local file-backed dev mode. Required unless `client` is supplied. - * - * - `http(s)://…` — talk to a real ObjectStack Cloud control plane - * over HTTP and resolve hostnames via its `/cloud/*` API. - * - `'file'` — load a single project from a local - * `dist/objectstack.json` (or `fileConfig.artifactPath`). Every - * request, regardless of hostname, resolves to the same project. - * Intended for `pnpm dev` / smoke tests where standing up a - * separate control plane is overkill. - */ - controlPlaneUrl?: string; - /** Optional bearer token for the control-plane API. */ - controlPlaneApiKey?: string; - /** - * Override the artifact client entirely. When supplied, - * `controlPlaneUrl` is ignored — useful for tests or custom transports. - */ - client?: ArtifactApiClient | FileArtifactApiClient; - /** Config for the file-backed mode (used when `controlPlaneUrl === 'file'`). */ - fileConfig?: FileArtifactApiClientConfig; - /** KernelManager LRU size. Default: 32. */ - kernelCacheSize?: number; - /** KernelManager idle TTL (ms). Default: 15 min. */ - kernelTtlMs?: number; - /** EnvironmentDriverRegistry cache TTL (ms). Default: 5 min. */ - envCacheTtlMs?: number; - /** Artifact / hostname response cache TTL (ms). Default: 5 min. */ - artifactCacheTtlMs?: number; - /** API prefix (carried for parity with cloud-stack). Default: /api/v1. */ - apiPrefix?: string; - /** - * Host-supplied runtime plugins appended to the stack's default plugin - * list. This is the official seam for a host (e.g. the ObjectStack Cloud - * repo) to add **product/policy** plugins — marketplace install, cloud- - * account binding, set-initial-password — to the otherwise-neutral - * framework runtime, WITHOUT a framework release and without reaching into - * the returned array by hand. - * - * They are appended last, so they mount their routes after the framework - * plugins and can override/augment behaviour (e.g. supply a credentialled - * install path that the browse-only MarketplaceProxyPlugin deliberately - * does not). See docs/design/cloud-account-binding-marketplace-install.md - * (ADR §5.2 — "framework exposes seams; cloud supplies metadata + policy"). - */ - extraPlugins?: Plugin[]; - /** - * Capability tokens force-mounted on EVERY per-environment kernel, in - * addition to whatever the app artifact declares in `requires`. Merged and - * de-duped with `bundle.requires` before the capability loader runs. This - * is the host seam for a cloud operator to make a capability ubiquitous - * across all tenants without editing each app — e.g. `['ai','aiStudio']` - * so every cloud environment supports AI-driven online development. - */ - defaultRequires?: string[]; -} - -export interface ObjectOSStackResult { - plugins: any[]; - api: { enableProjectScoping: true; projectResolution: 'auto'; requireAuth: true }; -} - -/** - * Lazy-loaded host engine plugins. Mirrors the head of - * `createControlPlanePlugins()` — ObjectQL + InMemory Driver + Metadata. - * - * The host kernel in objectos is a pure routing shell. Per-tenant auth + - * business data live in per-project kernels (each backed by the project's - * own Turso/Postgres DB), so there is nothing to persist on the host. - * - * AuthPlugin is intentionally NOT injected on the host (CLI's - * `serve.ts` auto-injection guard skips it when `OS_CLOUD_URL` is set). - * Identity is owned by `ArtifactKernelFactory` per project so that: - * - users persist in the project's DB across container cold-starts - * - cookies are scoped to the project's hostname (no `.`-wide leak) - * - tokens are signed with a per-project HKDF-derived secret - */ -async function createHostEnginePlugins(): Promise { - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { DriverPlugin } = await import('../driver-plugin.js'); - const { MetadataPlugin } = await import('@objectstack/metadata'); - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - - const driver = new InMemoryDriver(); - const driverName = 'memory'; - - const oqlRef: { ql: any } = { ql: null }; - const objectql: Plugin = { - name: 'com.objectstack.engine.objectql', - version: '0.0.0', - async init(ctx: PluginContext) { - const plugin = new ObjectQLPlugin(); - (this as any)._inner = plugin; - if ((plugin as any).init) await (plugin as any).init(ctx); - // Capture the engine instance AFTER init() — ObjectQLPlugin - // creates its `ql` lazily inside init(), so reading `plugin.ql` - // before that returns undefined and breaks the - // datasource-mapping wiring below. - oqlRef.ql = (plugin as any).ql ?? plugin; - }, - async start(ctx: PluginContext) { - const plugin = (this as any)._inner; - // Forward start() so ObjectQLPlugin can discover `driver.*` - // services (registered by DriverPlugin.init) and wire them - // into the engine via `ql.registerDriver(...)`. Without this - // the engine has zero drivers at request time, causing - // `[ObjectQL] No driver available for object '...'` errors. - if (plugin?.start) await plugin.start(ctx); - }, - async destroy() { - const plugin = (this as any)._inner; - if (plugin?.destroy) await plugin.destroy(); - else if (plugin?.stop) await plugin.stop(); - }, - }; - - const datasourceMapping: Plugin = { - name: 'objectos-host-datasource-mapping', - version: '0.0.0', - dependencies: ['com.objectstack.engine.objectql'], - async init() { - const ql = oqlRef.ql; - if (ql?.setDatasourceMapping) { - ql.setDatasourceMapping([ - { default: true, datasource: `com.objectstack.driver.${driverName}` }, - ]); - } - }, - }; - - const driverPlugin = new DriverPlugin(driver as any, driverName); - - const metadata = new MetadataPlugin({ - watch: false, - // The host kernel is a routing shell. It doesn't own metadata — - // every per-project kernel registers its own. - registerSystemObjects: false, - }); - - return [objectql, datasourceMapping, driverPlugin as unknown as Plugin, metadata as unknown as Plugin]; -} - -/** - * Single host plugin that owns the artifact API client, the env registry, - * and the kernel manager. Registered as services on the host kernel so - * downstream plugins (the dispatcher, the REST API plugin) pick them up - * automatically. - */ -class ObjectOSEnvironmentPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.objectos-environment'; - readonly version = '1.0.0'; - - private readonly config: ObjectOSStackConfig; - private kernelManager?: KernelManager; - private client?: ArtifactApiClient; - - constructor(config: ObjectOSStackConfig) { - this.config = config; - } - - init = async (ctx: PluginContext): Promise => { - const client: ArtifactApiClient | FileArtifactApiClient = this.config.client - ?? (this.config.controlPlaneUrl === 'file' - ? new FileArtifactApiClient({ - ...(this.config.fileConfig ?? {}), - logger: ctx.logger as any, - }) - : new ArtifactApiClient({ - controlPlaneUrl: this.config.controlPlaneUrl!, - apiKey: this.config.controlPlaneApiKey, - cacheTtlMs: this.config.artifactCacheTtlMs, - logger: ctx.logger, - })); - this.client = client as ArtifactApiClient; - - const envRegistry: EnvironmentDriverRegistry = new ArtifactEnvironmentRegistry({ - client: client as ArtifactApiClient, - cacheTtlMs: this.config.envCacheTtlMs, - logger: ctx.logger, - }); - - const factory = new ArtifactKernelFactory({ - client: client as ArtifactApiClient, - envRegistry, - logger: ctx.logger, - defaultRequires: this.config.defaultRequires, - }); - - const kernelManager = new KernelManager({ - factory, - maxSize: this.config.kernelCacheSize, - ttlMs: this.config.kernelTtlMs, - logger: ctx.logger, - // Only the HTTP client exposes /freshness; file-mode (CLI dev) - // has no upstream to probe. - freshnessProbe: this.config.controlPlaneUrl === 'file' - ? undefined - : async (envId, builtAtMs) => { - const fresh = await (client as ArtifactApiClient).getFreshness(envId); - if (!fresh) return false; // unknown / unreachable → treat as fresh - const t = fresh.lastPublishedAt ? Date.parse(fresh.lastPublishedAt) : NaN; - if (!Number.isFinite(t)) return false; - if (t <= builtAtMs) return false; - // Upstream changed since this kernel was built. Drop - // the artifact cache too so the rebuild sees the new - // bundle (otherwise we'd happily rebuild from the - // same 5-minute-cached artifact JSON). - try { (client as ArtifactApiClient).invalidate(envId); } catch { /* best effort */ } - return true; - }, - }); - this.kernelManager = kernelManager; - - ctx.registerService('env-registry', envRegistry); - ctx.registerService('kernel-manager', kernelManager); - ctx.registerService('artifact-api-client', client); - - ctx.logger.info?.('ObjectOSEnvironmentPlugin: registered env-registry + kernel-manager', { - mode: this.config.controlPlaneUrl === 'file' ? 'file' : 'http', - controlPlaneUrl: this.config.controlPlaneUrl, - }); - }; - - destroy = async (): Promise => { - try { await this.kernelManager?.evictAll(); } catch { /* best effort */ } - try { this.client?.clear(); } catch { /* best effort */ } - }; -} - -export async function createObjectOSStack(config: ObjectOSStackConfig): Promise { - if (!config.controlPlaneUrl && !config.client) { - throw new Error('[createObjectOSStack] either controlPlaneUrl or client is required'); - } - const merged: ObjectOSStackConfig = { - ...config, - kernelCacheSize: Number(process.env.OS_KERNEL_CACHE_SIZE ?? config.kernelCacheSize ?? 32), - kernelTtlMs: Number(process.env.OS_KERNEL_TTL_MS ?? config.kernelTtlMs ?? 15 * 60 * 1000), - envCacheTtlMs: Number(process.env.OS_ENV_CACHE_TTL_MS ?? config.envCacheTtlMs ?? 5 * 60 * 1000), - artifactCacheTtlMs: Number(process.env.OS_ARTIFACT_CACHE_TTL_MS ?? config.artifactCacheTtlMs ?? 5 * 60 * 1000), - }; - - const enginePlugins = await createHostEnginePlugins(); - - return { - plugins: [ - ...enginePlugins, - new ObjectOSEnvironmentPlugin(merged), - new AuthProxyPlugin(), - new MarketplaceProxyPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl }), - new RuntimeConfigPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl, installLocal: false }), - // Host-supplied product/policy plugins (the official seam — see - // ObjectOSStackConfig.extraPlugins). Appended last so they mount - // after the framework defaults. - ...(config.extraPlugins ?? []), - ], - api: { - enableProjectScoping: true, - projectResolution: 'auto', - // ObjectOS is multi-tenant: anonymous /api/v1/data/* must never - // leak per-project data across organisations. AuthProxyPlugin - // verifies upstream tokens and populates ctx.userId; requireAuth - // turns missing userId into 401 at the REST layer before the - // request reaches the per-project kernel. - requireAuth: true, - }, - }; -} diff --git a/packages/runtime/src/cloud/platform-sso.test.ts b/packages/runtime/src/cloud/platform-sso.test.ts deleted file mode 100644 index a3e6abd368..0000000000 --- a/packages/runtime/src/cloud/platform-sso.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { - PLATFORM_SSO_PROVIDER_ID, - derivePlatformSsoClientId, - derivePlatformSsoClientSecret, - buildPlatformSsoRedirectUri, - seedPlatformSsoClient, - backfillPlatformSsoClients, -} from './platform-sso.js'; - -describe('platform-sso key derivation', () => { - it('client id is deterministic and project-scoped', () => { - expect(derivePlatformSsoClientId('abc')).toBe('project_abc'); - expect(derivePlatformSsoClientId('p_42')).toBe('project_p_42'); - }); - - it('client secret is deterministic for a given (baseSecret, environmentId)', () => { - const s1 = derivePlatformSsoClientSecret('master', 'abc'); - const s2 = derivePlatformSsoClientSecret('master', 'abc'); - expect(s1).toBe(s2); - expect(s1).toMatch(/^[a-f0-9]{64}$/); - }); - - it('client secret differs across projects', () => { - expect(derivePlatformSsoClientSecret('master', 'a')) - .not.toBe(derivePlatformSsoClientSecret('master', 'b')); - }); - - it('client secret rotates with the base secret', () => { - expect(derivePlatformSsoClientSecret('old', 'abc')) - .not.toBe(derivePlatformSsoClientSecret('new', 'abc')); - }); - - it('hashPlatformSsoClientSecret matches better-auth oauth-provider defaultHasher (SHA-256 base64url no padding)', async () => { - const { hashPlatformSsoClientSecret } = await import('./platform-sso.js'); - // Hand-computed: SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 - // base64url (no pad) of that = LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ - expect(hashPlatformSsoClientSecret('hello')) - .toBe('LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ'); - // Deterministic - expect(hashPlatformSsoClientSecret('x')).toBe(hashPlatformSsoClientSecret('x')); - // No '=' padding, no '+' or '/' - const h = hashPlatformSsoClientSecret('any-input'); - expect(h).not.toMatch(/[=+/]/); - }); - - it('redirect uri matches better-auth genericOAuth callback shape', () => { - expect(buildPlatformSsoRedirectUri('crm.example.com')) - .toBe(`https://crm.example.com/api/v1/auth/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`); - // Already-prefixed inputs are preserved. - expect(buildPlatformSsoRedirectUri('http://localhost:3000')) - .toBe(`http://localhost:3000/api/v1/auth/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`); - }); -}); - -function createMockQl(initialRows: Record = {}) { - const tables: Record = { ...initialRows }; - return { - tables, - async find(object: string, query: any) { - const list = tables[object] ?? []; - if (!query?.where) return list; - return list.filter((row) => - Object.entries(query.where).every(([k, v]) => row[k] === v), - ); - }, - async insert(object: string, data: any) { - tables[object] = [...(tables[object] ?? []), { ...data }]; - return data; - }, - async update(object: string, data: any, where: any) { - const list = tables[object] ?? []; - tables[object] = list.map((row) => { - const matches = Object.entries(where?.where ?? {}).every(([k, v]) => row[k] === v); - return matches ? { ...row, ...data } : row; - }); - return null; - }, - }; -} - -describe('seedPlatformSsoClient', () => { - it('creates a sys_oauth_application row on first call', async () => { - const ql = createMockQl({ sys_oauth_application: [] }); - await seedPlatformSsoClient({ - ql, - environmentId: 'proj1', - hostname: 'one.example.com', - baseSecret: 'master', - }); - expect(ql.tables.sys_oauth_application).toHaveLength(1); - const row = ql.tables.sys_oauth_application[0]; - expect(row.client_id).toBe('project_proj1'); - // client_secret is stored as SHA-256+base64url of the derived plaintext - // (better-auth oauth-provider defaultHasher format), NOT the raw hex. - expect(row.client_secret).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(JSON.parse(row.redirect_uris)).toEqual([ - `https://one.example.com/api/v1/auth/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`, - ]); - expect(row.skip_consent).toBe(true); - expect(JSON.parse(row.grant_types)).toContain('authorization_code'); - }); - - it('is idempotent — second call with same hostname is a no-op', async () => { - const ql = createMockQl({ sys_oauth_application: [] }); - await seedPlatformSsoClient({ ql, environmentId: 'p', hostname: 'h.example.com', baseSecret: 'm' }); - await seedPlatformSsoClient({ ql, environmentId: 'p', hostname: 'h.example.com', baseSecret: 'm' }); - expect(ql.tables.sys_oauth_application).toHaveLength(1); - }); - - it('merges a new redirect_uri into the existing row', async () => { - const ql = createMockQl({ sys_oauth_application: [] }); - await seedPlatformSsoClient({ ql, environmentId: 'p', hostname: 'first.example.com', baseSecret: 'm' }); - await seedPlatformSsoClient({ ql, environmentId: 'p', hostname: 'second.example.com', baseSecret: 'm' }); - const uris: string[] = JSON.parse(ql.tables.sys_oauth_application[0].redirect_uris); - expect(uris).toHaveLength(2); - expect(uris.some((u) => u.includes('first.example.com'))).toBe(true); - expect(uris.some((u) => u.includes('second.example.com'))).toBe(true); - }); - - it('repairs a malformed existing row (wrong client_secret, empty redirect_uris/grant_types, skip_consent=false)', async () => { - const ql = createMockQl({ - sys_oauth_application: [{ - id: 'oauthc_proj1', - client_id: 'project_proj1', - client_secret: 'WRONG_OLD_SECRET', - redirect_uris: '[]', - grant_types: '[]', - skip_consent: false, - type: 'web', - name: 'old name', - }], - }); - await seedPlatformSsoClient({ - ql, - environmentId: 'proj1', - hostname: 'one.example.com', - baseSecret: 'master', - }); - expect(ql.tables.sys_oauth_application).toHaveLength(1); - const row = ql.tables.sys_oauth_application[0]; - // client_secret rewritten to the derived+hashed value (43-char base64url) - expect(row.client_secret).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(row.client_secret).not.toBe('WRONG_OLD_SECRET'); - // redirect_uris merged in - expect(JSON.parse(row.redirect_uris)).toContain( - `https://one.example.com/api/v1/auth/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`, - ); - // grant_types repaired - expect(JSON.parse(row.grant_types)).toContain('authorization_code'); - // skip_consent flipped back on - expect(row.skip_consent).toBe(true); - // disabled set explicitly - expect(row.disabled).toBe(false); - // existing name preserved (not overwritten if already non-empty) - expect(row.name).toBe('old name'); - }); - - it('skips seed when baseSecret is empty', async () => { - const ql = createMockQl({ sys_oauth_application: [] }); - await seedPlatformSsoClient({ ql, environmentId: 'p', hostname: 'h.example.com', baseSecret: '' }); - expect(ql.tables.sys_oauth_application).toHaveLength(0); - }); -}); - -describe('backfillPlatformSsoClients', () => { - it('seeds every project that lacks an oauth client', async () => { - const ql = createMockQl({ - sys_environment: [ - { id: 'p1', hostname: 'one.example.com', status: 'active' }, - { id: 'p2', hostname: 'two.example.com', status: 'active' }, - ], - sys_oauth_application: [], - }); - const r = await backfillPlatformSsoClients({ ql, baseSecret: 'm' }); - expect(r.scanned).toBe(2); - expect(r.seeded).toBe(2); - expect(ql.tables.sys_oauth_application).toHaveLength(2); - }); - - it('skips projects that already have an oauth client', async () => { - const ql = createMockQl({ - sys_environment: [{ id: 'p1', hostname: 'one.example.com', status: 'active' }], - sys_oauth_application: [{ - id: 'oauthc_p1', - client_id: 'project_p1', - redirect_uris: JSON.stringify([ - `https://one.example.com/api/v1/auth/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`, - ]), - }], - }); - const r = await backfillPlatformSsoClients({ ql, baseSecret: 'm' }); - expect(r.scanned).toBe(1); - expect(r.seeded).toBe(0); - expect(ql.tables.sys_oauth_application).toHaveLength(1); - }); -}); diff --git a/packages/runtime/src/cloud/platform-sso.ts b/packages/runtime/src/cloud/platform-sso.ts deleted file mode 100644 index 1622e788a9..0000000000 --- a/packages/runtime/src/cloud/platform-sso.ts +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Platform SSO — shared between the cloud control-plane and the per-project - * runtime kernels created by {@link ArtifactKernelFactory}. - * - * The architecture is "Airtable-style unified login": a builder signs in - * once on `cloud.` and is JIT-provisioned as a `sys_user` on every - * per-project deployment (`.`) via the OAuth2 / OIDC - * authorization-code flow. - * - * - cloud = OIDC Identity Provider (better-auth's `oauth-provider` plugin - * — already wired in {@link AuthPlugin} when `oidcProvider:true`) - * - project = OIDC Relying Party (better-auth's `genericOAuth` plugin — - * opted in via the `oidcProviders` array on AuthPluginOptions) - * - * For the two sides to trust each other without runtime DB calls during - * the SSO handshake, we use deterministic, derived client credentials: - * - * - `client_id` = `'project_' + environmentId` - * - `client_secret` = HMAC-SHA256(baseSecret, 'oauth-client:' + environmentId) - * - `redirect_uri` = `https:///api/v1/auth/oauth2/callback/` - * - * Both cloud and project containers share the same `OS_AUTH_SECRET` (or - * `AUTH_SECRET`) — that's the only piece of state required for the - * project-side runtime to derive the right secret without a control-plane - * lookup. The cloud-side row in `sys_oauth_application` is a one-time - * write per project and is upserted in two places: - * - * 1. {@link seedPlatformSsoClient} — called from the project-provisioning - * flow in `http-dispatcher.ts` right after the `sys_environment` row is - * inserted, so brand-new projects can SSO from the very first request. - * 2. {@link backfillPlatformSsoClients} — registered as a boot-time - * plugin in `control-plane-preset.ts` to retro-fit any pre-existing - * projects that were created before this code shipped. - */ - -import { createHmac, createHash } from 'node:crypto'; - -/** - * Provider id used in better-auth's `genericOAuth` and as part of the - * callback URL: `/api/v1/auth/oauth2/callback/`. Keep stable — - * changing it invalidates every registered redirect_uri. - */ -export const PLATFORM_SSO_PROVIDER_ID = 'objectstack-cloud'; - -/** - * Derive the per-project OAuth client_id used in `sys_oauth_application` - * (cloud side) and {@link genericOAuth} config (project side). - */ -export function derivePlatformSsoClientId(environmentId: string): string { - return `project_${environmentId}`; -} - -/** - * Derive the per-project OAuth client_secret deterministically from the - * shared master secret. HMAC-SHA256(baseSecret, 'oauth-client:' + environmentId) - * yields a 64-char hex string that is: - * - stable across container cold-starts (no DB lookup needed) - * - independent per project (compromising one does not compromise others) - * - rotatable via OS_AUTH_SECRET rotation (invalidates all SSO clients) - * - * This is the **plaintext** value the RP must present at the token endpoint. - * The cloud-side `sys_oauth_application.client_secret` column instead stores - * {@link hashPlatformSsoClientSecret}(plaintext) — better-auth's oauth-provider - * defaults to `storeClientSecret: 'hashed'` (SHA-256 + base64url) when the JWT - * plugin is enabled, and looks up the row by hashing the presented secret. - */ -export function derivePlatformSsoClientSecret(baseSecret: string, environmentId: string): string { - return createHmac('sha256', baseSecret).update(`oauth-client:${environmentId}`).digest('hex'); -} - -/** - * Hash the plaintext client_secret the same way `@better-auth/oauth-provider`'s - * `defaultHasher` does it: SHA-256 → base64url (no padding). This MUST match - * exactly or the token endpoint returns `invalid_client / invalid client_secret` - * even though the row is present. - * - * Reference: `node_modules/@better-auth/oauth-provider/dist/utils-*.mjs` → - * `const defaultHasher = async (value) => base64Url.encode(SHA-256(value))` - */ -export function hashPlatformSsoClientSecret(plaintext: string): string { - return createHash('sha256').update(plaintext) - .digest('base64') - .replace(/=+$/, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); -} - -/** - * Build the redirect_uri better-auth's `genericOAuth` plugin will use - * when the project kernel mounts the provider with id - * {@link PLATFORM_SSO_PROVIDER_ID}. MUST be one of the URIs registered - * on the cloud-side oauth client or the authorization server will reject - * the callback with `invalid_request`. - */ -export function buildPlatformSsoRedirectUri(hostname: string, basePath: string = '/api/v1/auth'): string { - let host: string; - if (hostname.startsWith('http://') || hostname.startsWith('https://')) { - host = hostname; - } else if (/(\.|^)localhost(:\d+)?$/i.test(hostname)) { - // Local dev: localhost subdomains run on plain http with a custom - // port. When the caller passes only a hostname (no scheme/port), - // append the configured runtime port so the OAuth redirect_uri - // matches what the browser can actually reach. We read - // OS_RUNTIME_PORT (NOT PORT) because both the cloud control plane - // and the runtime container call this function — only the runtime - // port is meaningful for the callback. - const port = (process.env.OS_RUNTIME_PORT ?? '').trim(); - const hostWithPort = /:\d+$/.test(hostname) || !port ? hostname : `${hostname}:${port}`; - host = `http://${hostWithPort}`; - } else { - host = `https://${hostname}`; - } - const trimmed = host.replace(/\/+$/, ''); - const path = basePath.replace(/\/+$/, ''); - return `${trimmed}${path}/oauth2/callback/${PLATFORM_SSO_PROVIDER_ID}`; -} - -export interface SeedPlatformSsoClientOptions { - /** - * Cloud control-plane ObjectQL engine. Must expose `find(object, query)`, - * `insert(object, data)`, and `update(object, data, {where})`. Both the - * `apps/cloud` boot kernel (via `kernel.getService('objectql')`) and the - * dispatcher's local `ql` reference satisfy this shape. - */ - ql: { - find: (object: string, query: any, opts?: any) => Promise; - insert: (object: string, data: any, opts?: any) => Promise; - update: (object: string, data: any, where: any, opts?: any) => Promise; - }; - /** Project id (also used to derive client_id + client_secret). */ - environmentId: string; - /** - * Project hostname (e.g. `acme-crm.objectos.ai`). Optional — projects - * may be created before a hostname is assigned, in which case no - * redirect_uri is registered yet and the row is upserted with an - * empty `redirect_uris` array. Calling this function again once the - * hostname is known will merge the new URI in. - */ - hostname?: string | null; - /** Master secret shared between cloud and project containers. */ - baseSecret: string; - /** Optional logger for diagnostics. */ - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - /** When true, rethrow insert/update errors instead of swallowing them. - * Backfill uses this to surface real failures via the admin endpoint. */ - throwOnError?: boolean; -} - -/** - * Idempotently upsert a `sys_oauth_application` row for the given project. - * Re-running with the same `environmentId` is a no-op (the deterministic - * `client_id` is uniquely indexed and the secret derivation is stable). - * Re-running with a new `hostname` adds the new redirect_uri to the - * existing row's JSON array. - */ -export async function seedPlatformSsoClient(opts: SeedPlatformSsoClientOptions): Promise { - const { ql, environmentId, hostname, baseSecret, logger, throwOnError } = opts; - if (!baseSecret) { - logger?.warn?.('[platform-sso] OS_AUTH_SECRET not set — skipping client seed', { environmentId }); - return; - } - const clientId = derivePlatformSsoClientId(environmentId); - const clientSecretPlaintext = derivePlatformSsoClientSecret(baseSecret, environmentId); - const clientSecretStored = hashPlatformSsoClientSecret(clientSecretPlaintext); - const desiredRedirect = hostname ? buildPlatformSsoRedirectUri(hostname) : null; - - let existing: any = null; - try { - const rows = await ql.find('sys_oauth_application', { - where: { client_id: clientId }, - limit: 1, - }, { context: { isSystem: true } }); - const list = Array.isArray(rows) ? rows : Array.isArray((rows as any)?.records) ? (rows as any).records : []; - existing = list[0] ?? null; - } catch (err) { - // Table may not exist yet (control-plane not yet migrated). Treat - // as a no-op rather than crashing the project-create flow. - logger?.warn?.('[platform-sso] sys_oauth_application read failed — skipping seed', { - environmentId, - error: (err as Error)?.message, - }); - return; - } - - const nowIso = new Date().toISOString(); - if (!existing) { - const redirects = desiredRedirect ? [desiredRedirect] : []; - try { - await ql.insert('sys_oauth_application', { - id: `oauthc_${environmentId}`, - name: `Project ${environmentId}`, - client_id: clientId, - client_secret: clientSecretStored, - type: 'web', - redirect_uris: JSON.stringify(redirects), - grant_types: JSON.stringify(['authorization_code', 'refresh_token']), - response_types: JSON.stringify(['code']), - scopes: JSON.stringify(['openid', 'email', 'profile']), - token_endpoint_auth_method: 'client_secret_basic', - require_pkce: false, - skip_consent: true, - disabled: false, - subject_type: 'public', - created_at: nowIso, - updated_at: nowIso, - }, { context: { isSystem: true } }); - logger?.info?.('[platform-sso] sys_oauth_application row created', { environmentId, clientId }); - } catch (err) { - // Unique-index conflict implies a parallel writer raced us; treat - // as success. Other errors are logged but non-fatal so they - // don't poison the project-create response. - logger?.warn?.('[platform-sso] sys_oauth_application create failed', { - environmentId, - error: (err as Error)?.message, - }); - if (throwOnError) throw err; - } - return; - } - - // Row exists — repair it. We always overwrite the canonical fields - // (client_secret, grant_types, response_types, scopes, token_endpoint_auth_method, - // require_pkce, skip_consent, subject_type, type, disabled) because older code - // paths may have written rows with missing or wrong-shape values, and - // re-running the seed should converge to the known-good shape. - // For redirect_uris we MERGE — re-provisioning a project under an - // additional hostname should add the new URI without dropping the old one. - let currentRedirects: string[] = []; - try { - const raw = existing.redirect_uris; - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; - if (Array.isArray(parsed)) currentRedirects = parsed.filter((s): s is string => typeof s === 'string'); - } catch { /* malformed JSON — treat as empty */ } - const mergedRedirects = desiredRedirect && !currentRedirects.includes(desiredRedirect) - ? [...currentRedirects, desiredRedirect] - : currentRedirects; - - const repairPatch: Record = { - name: existing.name || `Project ${environmentId}`, - client_secret: clientSecretStored, - type: existing.type || 'web', - redirect_uris: JSON.stringify(mergedRedirects), - grant_types: JSON.stringify(['authorization_code', 'refresh_token']), - response_types: JSON.stringify(['code']), - scopes: JSON.stringify(['openid', 'email', 'profile']), - token_endpoint_auth_method: 'client_secret_basic', - require_pkce: false, - skip_consent: true, - disabled: false, - subject_type: 'public', - updated_at: nowIso, - }; - try { - await ql.update( - 'sys_oauth_application', - repairPatch, - { where: { id: existing.id } }, - { context: { isSystem: true } }, - ); - logger?.info?.('[platform-sso] sys_oauth_application repaired', { - environmentId, - clientId, - redirect_uris: mergedRedirects, - }); - } catch (err) { - logger?.warn?.('[platform-sso] sys_oauth_application repair failed', { - environmentId, - error: (err as Error)?.message, - }); - if (throwOnError) throw err; - } -} - -export interface BackfillPlatformSsoClientsOptions { - ql: SeedPlatformSsoClientOptions['ql']; - baseSecret: string; - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - /** Hard cap on rows scanned (default: 1000). */ - limit?: number; -} - -/** - * Scan `sys_environment` and ensure every active project has a corresponding - * `sys_oauth_application` row. Intended to run once at cloud boot — the - * happy path is dominated by the project-create hook - * ({@link seedPlatformSsoClient}); the backfill exists so projects - * created before this feature shipped also get SSO support without an - * out-of-band migration. - */ -export async function backfillPlatformSsoClients(opts: BackfillPlatformSsoClientsOptions): Promise<{ - scanned: number; - seeded: number; - alreadyExisted: number; - failures: Array<{ environmentId: string; error: string }>; -}> { - const { ql, baseSecret, logger, limit = 1000 } = opts; - if (!baseSecret) { - logger?.warn?.('[platform-sso] backfill skipped — OS_AUTH_SECRET not set'); - return { scanned: 0, seeded: 0, alreadyExisted: 0, failures: [] }; - } - let projects: any[] = []; - try { - const rows = await ql.find('sys_environment', { - limit, - fields: ['id', 'hostname', 'status'], - }, { context: { isSystem: true } }); - projects = Array.isArray(rows) ? rows : Array.isArray((rows as any)?.records) ? (rows as any).records : []; - } catch (err) { - logger?.warn?.('[platform-sso] backfill: sys_environment read failed', { - error: (err as Error)?.message, - }); - return { scanned: 0, seeded: 0, alreadyExisted: 0, failures: [{ environmentId: '', error: (err as Error)?.message ?? String(err) }] }; - } - let seeded = 0; - let alreadyExisted = 0; - const failures: Array<{ environmentId: string; error: string }> = []; - for (const p of projects) { - if (!p?.id) continue; - const before = await (async () => { - try { - const r = await ql.find('sys_oauth_application', { - where: { client_id: derivePlatformSsoClientId(p.id) }, - limit: 1, - }, { context: { isSystem: true } }); - const list = Array.isArray(r) ? r : Array.isArray((r as any)?.records) ? (r as any).records : []; - return list[0] ?? null; - } catch { return null; } - })(); - try { - await seedPlatformSsoClient({ ql, environmentId: p.id, hostname: p.hostname, baseSecret, logger, throwOnError: true }); - if (before) alreadyExisted++; - else { - // Verify the row is actually readable post-insert. - const after = await (async () => { - try { - const r = await ql.find('sys_oauth_application', { - where: { client_id: derivePlatformSsoClientId(p.id) }, - limit: 1, - }, { context: { isSystem: true } }); - const list = Array.isArray(r) ? r : Array.isArray((r as any)?.records) ? (r as any).records : []; - return list[0] ?? null; - } catch (err) { return { _readErr: (err as Error)?.message }; } - })(); - if (after && !(after as any)._readErr) seeded++; - else failures.push({ environmentId: p.id, error: `post-insert read returned ${after ? JSON.stringify(after) : 'null'}` }); - } - } catch (err: any) { - failures.push({ environmentId: p.id, error: err?.message ?? String(err) }); - } - } - logger?.info?.('[platform-sso] backfill complete', { scanned: projects.length, seeded, alreadyExisted, failures: failures.length }); - return { scanned: projects.length, seeded, alreadyExisted, failures }; -} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 3f5ce04ff8..5ff4b1b206 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -4,7 +4,7 @@ import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; import { pluralToSingular, PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { KernelManager } from './cloud/kernel-manager.js'; +import type { KernelManager } from './cloud/environment-registry.js'; import { setPackageDisabled } from './package-state-store.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 22f7060dd0..61016e50c7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -92,8 +92,14 @@ export type { LoadArtifactBundleOptions } from './load-artifact-bundle.js'; // artifact is fetched either from an HTTP control plane (apps/cloud or // the hosted ObjectStack Cloud) or from a local JSON file for single- // project dev workflows. See `cloud/objectos-stack.ts`. -export { createObjectOSStack } from './cloud/objectos-stack.js'; -export type { ObjectOSStackConfig, ObjectOSStackResult } from './cloud/objectos-stack.js'; +// Cloud connectivity for the single-environment local runtime (`os serve`): +// the runtime-config endpoint, marketplace browse/proxy, and cloud-URL +// resolution. The MULTI-TENANT runtime — createObjectOSStack, the kernel +// manager, artifact fetching, the auth proxy, per-environment kernel +// construction, platform SSO — moved to the cloud distribution +// (`@objectstack/objectos-runtime`). The framework keeps only the interface +// contracts a host runtime needs to accept an externally-supplied +// multi-tenant kernel router (see http-dispatcher's optional `kernelManager`). export { MarketplaceProxyPlugin } from './cloud/marketplace-proxy-plugin.js'; export type { MarketplaceProxyPluginConfig } from './cloud/marketplace-proxy-plugin.js'; export { MarketplaceInstallLocalPlugin } from './cloud/marketplace-install-local-plugin.js'; @@ -101,35 +107,7 @@ export type { MarketplaceInstallLocalPluginConfig } from './cloud/marketplace-in export { RuntimeConfigPlugin } from './cloud/runtime-config-plugin.js'; export type { RuntimeConfigPluginConfig } from './cloud/runtime-config-plugin.js'; export { DEFAULT_CLOUD_URL, resolveCloudUrl } from './cloud/cloud-url.js'; -export { ArtifactApiClient } from './cloud/artifact-api-client.js'; -export type { - ArtifactApiClientConfig, - EnvironmentArtifactResponse, - EnvironmentRuntimeConfig, - ResolvedHostname, -} from './cloud/artifact-api-client.js'; -export { FileArtifactApiClient } from './cloud/file-artifact-api-client.js'; -export type { FileArtifactApiClientConfig } from './cloud/file-artifact-api-client.js'; -export { ArtifactEnvironmentRegistry } from './cloud/artifact-environment-registry.js'; -export type { ArtifactEnvironmentRegistryConfig } from './cloud/artifact-environment-registry.js'; -export { ArtifactKernelFactory } from './cloud/artifact-kernel-factory.js'; -export type { ArtifactKernelFactoryConfig } from './cloud/artifact-kernel-factory.js'; -export { AuthProxyPlugin } from './cloud/auth-proxy-plugin.js'; -export { KernelManager } from './cloud/kernel-manager.js'; -export type { EnvironmentKernelFactory, KernelManagerConfig } from './cloud/kernel-manager.js'; -export type { EnvironmentDriverRegistry } from './cloud/environment-registry.js'; -export { - PLATFORM_SSO_PROVIDER_ID, - derivePlatformSsoClientId, - derivePlatformSsoClientSecret, - buildPlatformSsoRedirectUri, - seedPlatformSsoClient, - backfillPlatformSsoClients, -} from './cloud/platform-sso.js'; -export type { - SeedPlatformSsoClientOptions, - BackfillPlatformSsoClientsOptions, -} from './cloud/platform-sso.js'; +export type { EnvironmentDriverRegistry, KernelManager } from './cloud/environment-registry.js'; // Export Sandbox (script body runner) — engine choice is quickjs-emscripten. // See packages/runtime/src/sandbox/script-runner.ts for the decision rationale.