From a432eb3865cd26cf51f207594cb4b3e6116049db Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 17:48:14 +0800 Subject: [PATCH 1/4] chore: remove apps/cloud (split to objectstack-ai/cloud repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud control plane (cloud.objectos.app) is now built and deployed from the private split repo objectstack-ai/cloud as part of the public core / private cloud split (ADR planning ref: §1 of session plan.md). Changes: - Delete apps/cloud entirely (32 files, ~2.7K LOC removed). - Drop "dev:cloud" script from root package.json. - Slim .github/workflows/deploy.yml to objectos-only: - Workflow renamed to 'Deploy ObjectOS to Cloudflare Containers'. - Removed cloud job + plan-target input + deploy-cloud-* / deploy-all-* tag triggers. - Cloud deploy now lives in objectstack-ai/cloud's own workflow. What remains coupled (separate follow-up): - packages/services/service-cloud still re-exported by framework packages/runtime + packages/cli. Decoupling is tracked separately (todo id: framework-decouple-service-cloud). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy.yml | 170 +------- apps/cloud/.dockerignore | 49 --- apps/cloud/.env.cloudflare.example | 13 - apps/cloud/.env.cloudflare.secrets.example | 42 -- apps/cloud/.gitignore | 27 -- apps/cloud/CHANGELOG.md | 26 -- apps/cloud/Dockerfile | 137 ------- apps/cloud/README.md | 145 ------- apps/cloud/api/[[...route]].js | 16 - apps/cloud/cloudflare/worker.ts | 362 ------------------ apps/cloud/objectstack.config.ts | 38 -- apps/cloud/package.json | 63 --- apps/cloud/scripts/build-vercel.sh | 88 ----- apps/cloud/scripts/bundle-api.mjs | 76 ---- apps/cloud/scripts/deploy-cloudflare.sh | 183 --------- apps/cloud/scripts/migrate.ts | 168 -------- .../cloud/scripts/setup-cloudflare-secrets.sh | 104 ----- apps/cloud/server/index.ts | 349 ----------------- apps/cloud/server/templates/blank.ts | 13 - apps/cloud/server/templates/crm.ts | 20 - apps/cloud/server/templates/extract.ts | 43 --- apps/cloud/server/templates/registry.ts | 25 -- apps/cloud/server/templates/todo.ts | 16 - apps/cloud/server/templates/types.ts | 24 -- apps/cloud/test/production-flow.test.ts | 204 ---------- apps/cloud/tsconfig.cloudflare.json | 13 - apps/cloud/tsconfig.json | 11 - apps/cloud/tsup.config.ts | 20 - apps/cloud/types/service-tenant.d.ts | 1 - apps/cloud/types/template-bundles.d.ts | 8 - apps/cloud/vercel.json | 42 -- apps/cloud/wrangler.dev.toml | 39 -- apps/cloud/wrangler.toml | 75 ---- package.json | 1 - pnpm-lock.yaml | 118 ------ 35 files changed, 16 insertions(+), 2713 deletions(-) delete mode 100644 apps/cloud/.dockerignore delete mode 100644 apps/cloud/.env.cloudflare.example delete mode 100644 apps/cloud/.env.cloudflare.secrets.example delete mode 100644 apps/cloud/.gitignore delete mode 100644 apps/cloud/CHANGELOG.md delete mode 100644 apps/cloud/Dockerfile delete mode 100644 apps/cloud/README.md delete mode 100644 apps/cloud/api/[[...route]].js delete mode 100644 apps/cloud/cloudflare/worker.ts delete mode 100644 apps/cloud/objectstack.config.ts delete mode 100644 apps/cloud/package.json delete mode 100755 apps/cloud/scripts/build-vercel.sh delete mode 100644 apps/cloud/scripts/bundle-api.mjs delete mode 100755 apps/cloud/scripts/deploy-cloudflare.sh delete mode 100644 apps/cloud/scripts/migrate.ts delete mode 100755 apps/cloud/scripts/setup-cloudflare-secrets.sh delete mode 100644 apps/cloud/server/index.ts delete mode 100644 apps/cloud/server/templates/blank.ts delete mode 100644 apps/cloud/server/templates/crm.ts delete mode 100644 apps/cloud/server/templates/extract.ts delete mode 100644 apps/cloud/server/templates/registry.ts delete mode 100644 apps/cloud/server/templates/todo.ts delete mode 100644 apps/cloud/server/templates/types.ts delete mode 100644 apps/cloud/test/production-flow.test.ts delete mode 100644 apps/cloud/tsconfig.cloudflare.json delete mode 100644 apps/cloud/tsconfig.json delete mode 100644 apps/cloud/tsup.config.ts delete mode 100644 apps/cloud/types/service-tenant.d.ts delete mode 100644 apps/cloud/types/template-bundles.d.ts delete mode 100644 apps/cloud/vercel.json delete mode 100644 apps/cloud/wrangler.dev.toml delete mode 100644 apps/cloud/wrangler.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ca90baf47..bd234a747 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,26 +1,26 @@ -name: Deploy to Cloudflare Containers +name: Deploy ObjectOS to Cloudflare Containers # ───────────────────────────────────────────────────────────────────────── -# Build + push Docker images for cloud (cloud.objectos.app) and/or -# objectos (single-project runtime per env), then `wrangler deploy` the +# Build + push Docker image for the objectos runtime (single-project +# per-env containers fronting *.objectos.app), then `wrangler deploy` the # matching Worker so the new image is pinned. # +# NOTE: Cloud (cloud.objectos.app) deployment lives in the split private +# repo objectstack-ai/cloud and is NOT triggered from this workflow. +# # Triggers: -# 1. Manual: Actions → "Deploy to Cloudflare Containers" → Run -# (choose `target` = cloud | objectos | both, and optionally -# the commit SHA to deploy; defaults to the workflow ref). -# 2. Tag: push a tag matching `deploy-cloud-*`, `deploy-objectos-*`, -# or `deploy-all-*`. The tag suffix is informational; the -# image tag = the resolved commit SHA (short, 8 chars) so a -# redeploy of the same SHA is idempotent. +# 1. Manual: Actions → "Deploy ObjectOS to Cloudflare Containers" → Run +# (optionally provide commit SHA; defaults to workflow ref). +# 2. Tag: push a tag matching `deploy-objectos-*`. The tag suffix is +# informational; the image tag = the resolved commit SHA +# (short, 8 chars) so a redeploy of the same SHA is idempotent. # # Image naming: -# registry.cloudflare.com//objectstack-cloud: # registry.cloudflare.com//objectos: # -# The corresponding `apps//wrangler.toml` is rewritten in-place to -# pin `image = ":"` and committed back to `main` so the -# repo always tracks what is actually deployed. +# `apps/objectos/wrangler.toml` is rewritten in-place to pin +# `image = ":"` and committed back to `main` so the repo +# always tracks what is actually deployed. # # Required secrets (Repository → Settings → Secrets and variables → Actions): # CLOUDFLARE_API_TOKEN — token with Containers:Edit + Workers:Edit perms @@ -30,37 +30,22 @@ name: Deploy to Cloudflare Containers on: workflow_dispatch: inputs: - target: - description: 'Which app(s) to deploy' - required: true - default: 'both' - type: choice - options: - - cloud - - objectos - - both ref: description: 'Commit SHA or branch to deploy (default: workflow ref)' required: false default: '' push: tags: - - 'deploy-cloud-*' - 'deploy-objectos-*' - - 'deploy-all-*' concurrency: - # Serialize deploys per-target so two pushes don't race on `wrangler deploy`. - group: deploy-${{ github.event.inputs.target || github.ref_name }} + group: deploy-objectos-${{ github.ref_name }} cancel-in-progress: false jobs: - # ── Resolve which targets to build based on the trigger ── plan: runs-on: ubuntu-latest outputs: - deploy_cloud: ${{ steps.plan.outputs.deploy_cloud }} - deploy_objectos: ${{ steps.plan.outputs.deploy_objectos }} sha: ${{ steps.plan.outputs.sha }} sha8: ${{ steps.plan.outputs.sha8 }} steps: @@ -71,143 +56,20 @@ jobs: fetch-depth: 1 - id: plan - name: Plan targets + resolve SHA + name: Resolve SHA run: | set -euo pipefail SHA="$(git rev-parse HEAD)" SHA8="$(git rev-parse --short=8 HEAD)" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "sha8=$SHA8" >> "$GITHUB_OUTPUT" - - DEPLOY_CLOUD=false - DEPLOY_OBJECTOS=false - case "${{ github.event_name }}" in - workflow_dispatch) - T="${{ github.event.inputs.target }}" - [[ "$T" == "cloud" || "$T" == "both" ]] && DEPLOY_CLOUD=true - [[ "$T" == "objectos" || "$T" == "both" ]] && DEPLOY_OBJECTOS=true - ;; - push) - REF="${{ github.ref_name }}" - [[ "$REF" == deploy-cloud-* || "$REF" == deploy-all-* ]] && DEPLOY_CLOUD=true - [[ "$REF" == deploy-objectos-* || "$REF" == deploy-all-* ]] && DEPLOY_OBJECTOS=true - ;; - esac - - echo "deploy_cloud=$DEPLOY_CLOUD" >> "$GITHUB_OUTPUT" - echo "deploy_objectos=$DEPLOY_OBJECTOS" >> "$GITHUB_OUTPUT" echo "─────────────────────────────────" echo "Trigger: ${{ github.event_name }}" echo "SHA: $SHA" echo "Image tag: $SHA8" - echo "Cloud: $DEPLOY_CLOUD" - echo "ObjectOS: $DEPLOY_OBJECTOS" - - # ── Build + push cloud image; pin tag in wrangler.toml; wrangler deploy ── - cloud: - needs: plan - if: needs.plan.outputs.deploy_cloud == 'true' - runs-on: ubuntu-latest - permissions: - contents: write # to commit the wrangler.toml tag bump back to main - env: - ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - IMAGE_NAME: objectstack-cloud - SHA8: ${{ needs.plan.outputs.sha8 }} - steps: - - uses: actions/checkout@v5 - with: - ref: ${{ needs.plan.outputs.sha }} - # Need full history so we can push back the tag-bump commit. - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - name: Install workspace deps (for wrangler bundle) - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build cloud image (local) - uses: docker/build-push-action@v6 - with: - context: . - file: apps/cloud/Dockerfile - platforms: linux/amd64 - # Load to the local daemon — `wrangler containers push` reads from - # the local daemon and re-pushes with Cloudflare-issued temporary - # registry credentials (the registry rejects plain docker login). - load: true - tags: registry.cloudflare.com/${{ env.ACCOUNT_ID }}/${{ env.IMAGE_NAME }}:${{ env.SHA8 }} - # GitHub Actions cache — drops cloud rebuild from ~20m to ~3-6m - # after the first run. - cache-from: type=gha,scope=cloud - cache-to: type=gha,scope=cloud,mode=max - - - name: Push image via wrangler (handles CF registry auth) - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ env.ACCOUNT_ID }} - run: | - npx --yes wrangler@4 containers push \ - "registry.cloudflare.com/${ACCOUNT_ID}/${IMAGE_NAME}:${SHA8}" - - - name: Pin image tag in wrangler.toml - run: | - set -euo pipefail - NEW="registry.cloudflare.com/${ACCOUNT_ID}/${IMAGE_NAME}:${SHA8}" - sed -i -E "s#^image = .*${IMAGE_NAME}:.*#image = \"${NEW}\"#" apps/cloud/wrangler.toml - echo "Pinned to: $NEW" - grep '^image' apps/cloud/wrangler.toml - - - name: Wrangler deploy (cloud) - working-directory: apps/cloud - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ env.ACCOUNT_ID }} - run: npx --yes wrangler@4 deploy --config wrangler.toml - - - name: Commit pinned tag back to main - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') - run: | - set -euo pipefail - if git diff --quiet apps/cloud/wrangler.toml; then - echo "No tag change to commit." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B deploy/cloud-${SHA8} - git add apps/cloud/wrangler.toml - git commit -m "chore(deploy): pin cloud image tag to ${SHA8} - - Auto-bumped by .github/workflows/deploy.yml after successful Cloudflare - Containers deploy of registry.cloudflare.com/${ACCOUNT_ID}/${IMAGE_NAME}:${SHA8}. - - Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" - # Fast-forward push to main if possible; otherwise leave the branch - # for a human to PR. We never force-push main. - git fetch origin main - if git merge-base --is-ancestor origin/main HEAD; then - git push origin HEAD:main - else - git push origin HEAD - echo "::warning::Could not fast-forward main; pushed branch deploy/cloud-${SHA8} instead. Open a PR." - fi - # ── Build + push objectos image; pin tag; wrangler deploy ── objectos: needs: plan - if: needs.plan.outputs.deploy_objectos == 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/apps/cloud/.dockerignore b/apps/cloud/.dockerignore deleted file mode 100644 index 78f3578e4..000000000 --- a/apps/cloud/.dockerignore +++ /dev/null @@ -1,49 +0,0 @@ -# Paths here are evaluated relative to the build context (repo root). -# Keep image slim by excluding every workspace member that apps/cloud -# does not depend on + anything that gets regenerated during build. - -# ─── Node / pnpm caches ────────────────────────────────────────────── -**/node_modules -**/.pnpm-store -**/.pnpm - -# ─── Build output that Docker should not carry from the host ──────── -**/dist -**/.turbo -**/.next -**/.vercel -**/.cache -**/build - -# ─── Version control / editor ──────────────────────────────────────── -.git -.gitignore -.github -.vscode -.idea - -# ─── Local env / secrets ───────────────────────────────────────────── -**/.env -**/.env.* -!**/.env.example - -# ─── Tests / scratch data ──────────────────────────────────────────── -**/coverage -**/*.log -**/.DS_Store -**/tmp -**/.objectstack -**/data - -# ─── Workspace members not needed by apps/cloud at runtime ──────────── -apps/studio -apps/docs -apps/objectos -content/docs -skills -docs - -# ─── Misc ──────────────────────────────────────────────────────────── -README.md -CHANGELOG.md -LICENSE diff --git a/apps/cloud/.env.cloudflare.example b/apps/cloud/.env.cloudflare.example deleted file mode 100644 index 6b94dd398..000000000 --- a/apps/cloud/.env.cloudflare.example +++ /dev/null @@ -1,13 +0,0 @@ -# Cloudflare Containers deployment config — non-secret defaults. -# -# Copy to `.env.cloudflare` (gitignored) and fill in your account id. -# Secrets go in `.env.cloudflare.secrets` instead. - -# Required: Cloudflare account id (run `npx wrangler whoami` to find it) -CF_ACCOUNT_ID=2846eb40a60f4738e292b90dcd8cce10 - -# Optional overrides (defaults shown) -# CF_IMAGE_REGISTRY=registry.cloudflare.com/$CF_ACCOUNT_ID -# CF_IMAGE_NAME=objectstack-cloud -# CF_IMAGE_TAG=$(git rev-parse --short HEAD) -# CF_PLATFORM=linux/amd64 diff --git a/apps/cloud/.env.cloudflare.secrets.example b/apps/cloud/.env.cloudflare.secrets.example deleted file mode 100644 index 3f7ce6f42..000000000 --- a/apps/cloud/.env.cloudflare.secrets.example +++ /dev/null @@ -1,42 +0,0 @@ -# Cloudflare Worker secrets — copy to `.env.cloudflare.secrets` (gitignored) -# and fill in. `cf:secrets` will skip any unset key, so it is safe to share -# this file across both apps. - -# ── Required for both apps ───────────────────────────────────────────────── -# Control-plane database URL. Cloudflare Containers' local filesystem is -# **ephemeral**, so a remote database is mandatory in production. -# -# Supported schemes: -# • postgres://user:pass@host:5432/db (recommended for apps/cloud) -# • postgresql://user:pass@host/db -# • libsql://.turso.io (Turso / libSQL) -# • https://.turso.io (Turso HTTP) -# • file:/data/control.db (local Docker only — data lost on cold start) -# -# For apps/cloud, you almost certainly want Postgres in production. -# Tune connection pool size with OS_CONTROL_PG_POOL_MIN / OS_CONTROL_PG_POOL_MAX -# (defaults: 0 / 10). -# -# `OS_CONTROL_DATABASE_URL` takes priority over `OS_DATABASE_URL` when both are set. -OS_DATABASE_URL= -# Optional: dedicated URL for the *control plane* if it differs from OS_DATABASE_URL. -# OS_CONTROL_DATABASE_URL=postgres://user:pass@host:5432/objectstack_cloud -# Auth token for libSQL/Turso. Not used by Postgres (use the URL's password instead). -OS_DATABASE_AUTH_TOKEN= -# Cookie/session signing secret. Generate with: openssl rand -hex 32 -AUTH_SECRET= - -# ── Cloud-only (apps/cloud) ──────────────────────────────────────────────── -# Used by the project provisioning workflow to create per-project Turso DBs. -TURSO_API_TOKEN= -TURSO_ORG_NAME= - -# ── ObjectOS multi-project mode (apps/objectos) ──────────────────────────── -# Point at your apps/cloud Worker for multi-project mode. Leave unset for -# single-project local mode (you'll then need OS_DATABASE_URL above). -OS_CLOUD_URL= -OS_CLOUD_API_KEY= - -# ── Optional (both apps) ─────────────────────────────────────────────────── -# Set when binding a custom domain so cookies span subdomains. -# OS_COOKIE_DOMAIN=.your-domain.com diff --git a/apps/cloud/.gitignore b/apps/cloud/.gitignore deleted file mode 100644 index caf526db9..000000000 --- a/apps/cloud/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -# Build artifacts -dist/ -.turbo/ -public/ - -# Bundled API handler (generated during Vercel build) -api/_handler.js -api/_handler.js.map -api/node_modules/ - -# Node modules -node_modules/ - -# Environment files -.env -.env.local -.env.*.local - -# OS files -.DS_Store -Thumbs.db -.vercel -.env*.local - -# Cloudflare Containers deploy config (real values, not examples) -.env.cloudflare -.env.cloudflare.secrets diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md deleted file mode 100644 index 85279dcc9..000000000 --- a/apps/cloud/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# @objectstack/cloud - -## 4.0.5 - -### Patch Changes - -- Updated dependencies [15e0df6] - - @objectstack/spec@4.0.5 - - @objectstack/metadata@4.0.5 - - @objectstack/objectql@4.0.5 - - @objectstack/runtime@4.0.5 - - @objectstack/driver-memory@4.0.5 - - @objectstack/driver-sql@4.0.5 - - @objectstack/driver-turso@4.0.5 - - @objectstack/plugin-audit@4.0.5 - - @objectstack/plugin-auth@4.0.5 - - @objectstack/plugin-hono-server@4.0.5 - - @objectstack/plugin-security@4.0.5 - - @objectstack/hono@4.0.5 - - @objectstack/service-automation@4.0.5 - - @objectstack/service-analytics@4.0.5 - - @objectstack/service-feed@4.0.5 - - @objectstack/service-ai@4.0.5 - - @objectstack/service-cloud@4.0.5 - - @objectstack/service-package@4.0.5 - - @objectstack/service-tenant@4.0.5 diff --git a/apps/cloud/Dockerfile b/apps/cloud/Dockerfile deleted file mode 100644 index 1ceb15a42..000000000 --- a/apps/cloud/Dockerfile +++ /dev/null @@ -1,137 +0,0 @@ -# -# ObjectStack Cloud — production container image -# -------------------------------------------------- -# Cloud-only host: multi-project control plane + Studio template registry. -# See apps/objectos/Dockerfile for the architectural notes — this is the -# same 3-stage builder → pruner → runner pipeline, scoped to @objectstack/cloud. - -# ────────────────────────────────────────────────────────────────────────── -# Stage 1 — install workspace deps + build required packages -# ────────────────────────────────────────────────────────────────────────── -FROM node:22-slim AS builder - -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update \ - && apt-get install -y --no-install-recommends \ - python3 make g++ ca-certificates - -WORKDIR /repo - -# CI=true so pnpm skips interactive prompts (e.g. node_modules cleanup -# confirmation that fails with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). -# Route both corepack's pnpm-tarball download and pnpm's own package -# downloads through a fast public mirror — the default registry.npmjs.org -# is unreliable from many networks (CN). Override at build time with -# `--build-arg COREPACK_NPM_REGISTRY=…` if you have your own mirror. -ENV CI=true \ - COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \ - npm_config_registry=https://registry.npmmirror.com - -# ── Layer 0: bake pnpm into the image (one-shot network fetch) ────────────── -# Without this, every subsequent `pnpm …` invocation triggers corepack to -# re-fetch the pinned pnpm version on demand. If the mirror has a transient -# DNS or network blip, the build fails. Pinning to the version declared in -# package.json downloads it ONCE per Dockerfile change, caches the layer, -# and frees later steps from needing the corepack registry at all. -# Fall back to the official npm registry if the mirror is unreachable. -ARG PNPM_VERSION=10.31.0 -RUN corepack enable \ - && (corepack prepare pnpm@${PNPM_VERSION} --activate \ - || (echo "→ npmmirror unreachable, falling back to npmjs.org" \ - && COREPACK_NPM_REGISTRY=https://registry.npmjs.org \ - corepack prepare pnpm@${PNPM_VERSION} --activate)) - -# ── Layer 1: pre-fetch deps from lockfile only ────────────────────────────── -# `pnpm fetch` populates the virtual store from `pnpm-lock.yaml` alone (no -# package.json files needed), so this layer is reused across builds until -# the lockfile changes. The cache mount makes the global store survive -# across `docker buildx build` invocations. -ENV PNPM_HOME=/pnpm -ENV PATH=$PNPM_HOME:$PATH -COPY pnpm-lock.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm config set store-dir /pnpm/store \ - && pnpm fetch --prod=false - -# ── Layer 2: copy workspace manifests + sources, then install offline ─────── -# `--offline` forces resolution from the warm store; on a code-only change -# (lockfile unchanged) this finishes in seconds. -COPY package.json pnpm-workspace.yaml turbo.json tsconfig.json tsup.config.ts ./ -COPY packages ./packages -COPY examples ./examples -COPY apps/cloud ./apps/cloud -COPY apps/studio ./apps/studio -COPY apps/account ./apps/account -COPY apps/console ./apps/console - -RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ - pnpm install --frozen-lockfile --prefer-offline --prod=false -# Use turbo (not raw `pnpm --filter ... build`) so its content-addressed -# cache at /repo/.turbo can short-circuit unchanged packages on rebuilds. -# Cache mounts persist the cache across `docker build` invocations. -RUN --mount=type=cache,id=turbo-cloud,target=/repo/.turbo,sharing=locked \ - --mount=type=cache,id=node-cache-cloud,target=/repo/node_modules/.cache,sharing=locked \ - pnpm exec turbo run build --filter='@objectstack/cloud...' - -# ────────────────────────────────────────────────────────────────────────── -# Stage 2 — production prune (pnpm deploy) -# ────────────────────────────────────────────────────────────────────────── -FROM builder AS pruner -WORKDIR /repo -RUN pnpm --filter @objectstack/cloud deploy --prod --legacy /deploy \ - && find /deploy -type f \( -name "*.map" -o -name "*.test.*" -o -name "*.spec.*" -o -name "*.md" -o -name "*.markdown" \) -delete \ - && find /deploy -type d \( -name "__tests__" -o -name "test" -o -name "tests" -o -name "docs" -o -name "example" -o -name "examples" \) -prune -exec rm -rf {} + \ - && rm -rf /deploy/.turbo /deploy/.cache \ - # Surgical removal of dev-only deps that get pulled in by package peers - # but are NOT used by the Cloud runtime path (verified by smoke test). - && rm -rf \ - /deploy/node_modules/.pnpm/next@* \ - /deploy/node_modules/.pnpm/@next+* \ - /deploy/node_modules/.pnpm/playwright-core@* \ - /deploy/node_modules/.pnpm/@playwright+* \ - /deploy/node_modules/.pnpm/typescript@* \ - /deploy/node_modules/.pnpm/happy-dom@* \ - /deploy/node_modules/.pnpm/@rolldown+* \ - /deploy/node_modules/.pnpm/@img+sharp-libvips-* \ - /deploy/node_modules/.pnpm/@cloudflare+workers-types@* \ - /deploy/node_modules/.pnpm/@esbuild+* \ - /deploy/node_modules/.pnpm/lightningcss-* \ - /deploy/node_modules/.pnpm/caniuse-lite@* - -# ────────────────────────────────────────────────────────────────────────── -# Stage 3 — slim runtime image -# ────────────────────────────────────────────────────────────────────────── -FROM node:22-slim AS runner - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -ENV NODE_ENV=production \ - PORT=4000 \ - HOST=0.0.0.0 -# NOTE: Console IS mounted on the cloud control plane — it provides the -# Org/Project management UI and claims the root `/` redirect. -# Studio is disabled (no per-project tenant kernels live in this process) -# via OS_DISABLE_STUDIO=1 set at the Cloudflare Worker / runtime layer. - -COPY --from=pruner /deploy /app -COPY --from=builder /repo/apps/cloud/dist /app/dist - -EXPOSE 4000 -VOLUME ["/data"] - -# NB: do NOT bake `ENV OS_DATABASE_URL=…` here. service-cloud's -# resolveControlDriver() already handles the full fallback chain -# (OS_CONTROL_DATABASE_URL → OS_DATABASE_URL → TURSO_DATABASE_URL → -# file:/control.db) and will throw a clear error on serverless -# when none is configured. A baked image default would silently win over -# an unset Worker secret and quietly write to ephemeral container disk. - -HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD wget -qO- "http://127.0.0.1:${PORT}/api/v1/health" >/dev/null 2>&1 || exit 1 - -CMD ["node", "node_modules/@objectstack/cli/bin/run.js", "serve", "dist/objectstack.config.js", "--prebuilt"] diff --git a/apps/cloud/README.md b/apps/cloud/README.md deleted file mode 100644 index 468ed5dde..000000000 --- a/apps/cloud/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# @objectstack/cloud - -Cloud-mode host for ObjectStack — multi-project, control-plane connected, -deployed as a Vercel serverless function. - -This app is the cloud counterpart to [`@objectstack/objectos`](../objectos), -which hosts local single-project / standalone deployments. - -## Modes - -This config is **cloud-only**. Boot orchestration lives in -`@objectstack/service-cloud`; this package only supplies the -cloud-specific knobs: - -- **`templates`** — Studio's template registry (Blank / CRM / Todo). -- **`appBundles`** — filesystem-backed app bundle resolver. - -Set `OS_MODE=cloud` (default for this app) to boot the -multi-project plugin stack. - -## Local development - -```bash -# From repo root -pnpm install -pnpm --filter @objectstack/cloud dev -``` - -## Build - -```bash -pnpm --filter @objectstack/cloud build -``` - -Produces `dist/objectstack.config.js`, consumed by -`objectstack serve --prebuilt`. - -## Vercel deployment - -`vercel.json` and `scripts/build-vercel.sh` mirror the apps/objectos -deployment recipe — bundle `server/index.ts` with esbuild, copy Studio + -Account SPAs into `public/`, and ship `api/[[...route]].js` as the -catch-all serverless function. - -## Cloudflare Containers deployment - -`apps/cloud` runs the multi-project control plane as a long-lived Node.js -process (Hono + better-sqlite3 + `child_process`) and is **not** -Workers-compatible. It can, however, run on **Cloudflare Containers** -(GA 2025) using the bundled `Dockerfile`. - -Files: - -- `Dockerfile` — production image (Node 22, port 4000). -- `.dockerignore` — slims the workspace tree shipped to the builder. -- `wrangler.toml` — Worker + Container binding. -- `cloudflare/worker.ts` — fetch handler that proxies HTTP into the - `CloudContainer` Durable Object. -- `scripts/deploy-cloudflare.sh` — `build → push → deploy` pipeline. -- `scripts/setup-cloudflare-secrets.sh` — bulk `wrangler secret put` from - a local env file. - -### Quickstart (automated) - -```bash -# One-time setup -npx wrangler login -cp apps/cloud/.env.cloudflare.example apps/cloud/.env.cloudflare -cp apps/cloud/.env.cloudflare.secrets.example apps/cloud/.env.cloudflare.secrets -# Fill in CF_ACCOUNT_ID + secrets (see comments inside each file) - -# Push secrets (once, or any time they change) -pnpm --filter @objectstack/cloud cf:secrets - -# Build → push → deploy -pnpm --filter @objectstack/cloud cf:deploy - -# Live tail -pnpm --filter @objectstack/cloud cf:tail -``` - -Useful flags on `cf:deploy`: `--tag `, `--skip-build`, `--skip-push`, -`--dry-run`. - -### Manual (if you don't want the script) - -```bash -# Build from repo root (Dockerfile expects the full pnpm workspace) -docker buildx build --platform linux/amd64 \ - -f apps/cloud/Dockerfile \ - -t registry.cloudflare.com//objectstack-cloud:latest . - -wrangler containers push \ - registry.cloudflare.com//objectstack-cloud:latest - -# Secrets — the control plane MUST be on a remote database. The container -# filesystem is wiped on cold-start, so file:/... will lose all data. -# For production, Postgres is recommended: -# postgres://user:pass@host:5432/db -# libSQL/Turso also works: -# libsql://.turso.io + OS_DATABASE_AUTH_TOKEN -wrangler secret put OS_DATABASE_URL --config apps/cloud/wrangler.toml -wrangler secret put OS_DATABASE_AUTH_TOKEN --config apps/cloud/wrangler.toml # libSQL only -wrangler secret put AUTH_SECRET --config apps/cloud/wrangler.toml -wrangler secret put TURSO_API_TOKEN --config apps/cloud/wrangler.toml -wrangler secret put TURSO_ORG_NAME --config apps/cloud/wrangler.toml - -wrangler deploy --config apps/cloud/wrangler.toml -``` - -Required runtime env vars (set as Cloudflare secrets, **not** in -`wrangler.toml`): - -| Var | Purpose | -|---|---| -| `OS_DATABASE_URL` | Control-plane DB URL. Supports `postgres://…`, `postgresql://…`, `libsql://…`, `https://…` (Turso), or `file:/…` (local Docker only). | -| `OS_CONTROL_DATABASE_URL` | Optional override for the control DB when it differs from `OS_DATABASE_URL`. Takes priority when both are set. | -| `OS_DATABASE_AUTH_TOKEN` | Auth token for libSQL/Turso. Unused for Postgres (put the password in the URL). | -| `OS_CONTROL_PG_POOL_MIN` / `OS_CONTROL_PG_POOL_MAX` | Postgres pool sizing (default `0` / `10`). | -| `AUTH_SECRET` | Cookie/session signing secret. | -| `TURSO_API_TOKEN` / `TURSO_ORG_NAME` | Used by the provisioning workflow to create per-project Turso DBs. | - -### Postgres in production - -`apps/cloud` ships with the `pg` driver included. To run the control plane -on Postgres (Neon / Supabase / RDS / Cloudflare Hyperdrive / self-hosted): - -```bash -export OS_DATABASE_URL='postgres://user:pass@host:5432/objectstack_cloud' -# Optional fine-tuning: -export OS_CONTROL_PG_POOL_MAX=20 -``` - -Schema migrations are applied automatically on first boot by the -`@objectstack/driver-sql` engine using knex. The cloud control plane -expects a database it can DDL freely — point it at a dedicated database -(not one shared with unrelated tables). - -> **Hyperdrive note**: Cloudflare Hyperdrive accelerates Postgres -> connections from Workers, not Containers. For Containers, point -> `OS_DATABASE_URL` directly at your Postgres instance. - -Pair this with an `apps/objectos` deployment (see its README) and set -`OS_CLOUD_URL=https://..workers.dev` on the -runtime node so it talks to this control plane. diff --git a/apps/cloud/api/[[...route]].js b/apps/cloud/api/[[...route]].js deleted file mode 100644 index 1bbcc8746..000000000 --- a/apps/cloud/api/[[...route]].js +++ /dev/null @@ -1,16 +0,0 @@ -// Vercel Serverless Function — Catch-all API route. -// -// This file MUST be committed to the repository so Vercel can detect it -// as a serverless function during the pre-build phase. -// -// It delegates to the esbuild bundle (`_handler.js`) generated by -// `scripts/bundle-api.mjs` during the Vercel build step. A separate -// bundle file is used (rather than overwriting this file) so that: -// 1. Vercel always finds this committed entry point (no "File not found"). -// 2. Vercel does not TypeScript-compile a .ts stub that references -// source files absent at runtime (no ERR_MODULE_NOT_FOUND). -// -// @see ../server/index.ts — the actual server entrypoint -// @see ../scripts/bundle-api.mjs — the esbuild bundler - -export { default, config } from './_handler.js'; diff --git a/apps/cloud/cloudflare/worker.ts b/apps/cloud/cloudflare/worker.ts deleted file mode 100644 index 99263e43b..000000000 --- a/apps/cloud/cloudflare/worker.ts +++ /dev/null @@ -1,362 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Cloudflare Containers entrypoint for ObjectStack Cloud. - * - * The Worker fronts a Container-class Durable Object that runs the - * Node.js image built from `apps/cloud/Dockerfile`. All HTTP traffic is - * forwarded 1:1 to the container on port 4000. - * - * Deploy with: - * wrangler deploy --config apps/cloud/wrangler.toml - * - * See `apps/cloud/wrangler.toml` for the full deployment workflow - * (build + push image, then deploy Worker). - */ - -import { Container, getContainer } from '@cloudflare/containers'; - -export interface Env { - CLOUD: DurableObjectNamespace; - // ── Secrets / vars forwarded to the Container process ──────────────── - // Set via `wrangler secret put X` or `[vars]` in wrangler.toml. - // Anything declared here gets propagated into Container envVars in the - // constructor below (undefined values are dropped). Keep this list in - // sync with `FORWARDED_ENV_KEYS` further down. - - // — Database (tenant / control plane) — - OS_DATABASE_URL?: string; - OS_DATABASE_AUTH_TOKEN?: string; - OS_DATABASE_DRIVER?: string; - OS_CONTROL_DATABASE_URL?: string; - OS_CONTROL_DATABASE_AUTH_TOKEN?: string; - OS_CONTROL_PG_POOL_MIN?: string; - OS_CONTROL_PG_POOL_MAX?: string; - TURSO_DATABASE_URL?: string; - TURSO_AUTH_TOKEN?: string; - // Used by ProjectProvisioning's Turso adapter to create per-project DBs. - TURSO_API_TOKEN?: string; - TURSO_ORG_NAME?: string; - - // — Auth (better-auth) — - AUTH_SECRET?: string; - OS_AUTH_SECRET?: string; - AUTH_BASE_URL?: string; - OS_BASE_URL?: string; - OS_TRUSTED_ORIGINS?: string; - OS_COOKIE_DOMAIN?: string; - OS_ROOT_DOMAIN?: string; - GOOGLE_CLIENT_ID?: string; - GOOGLE_CLIENT_SECRET?: string; - GITHUB_CLIENT_ID?: string; - GITHUB_CLIENT_SECRET?: string; - - // — Cloud / multi-tenant — - OS_PROJECT_ID?: string; - OS_ORG_ID?: string; - OS_CLOUD_URL?: string; - OS_CLOUD_API_KEY?: string; - OS_MULTI_TENANT?: string; - - // — Artifact / project — - OS_PROJECT_ARTIFACT_ROOT?: string; - OS_ARTIFACT_PATH?: string; - OS_ARTIFACT_CACHE_TTL_MS?: string; - OS_ARTIFACT_FETCH_TIMEOUT_MS?: string; - OS_DATA_DIR?: string; - OS_PROVISION_SYNC?: string; - OS_EAGER_SCHEMAS?: string; - OS_SKIP_SCHEMA_SYNC?: string; - - // — Storage (S3/R2) — - OS_STORAGE_ADAPTER?: string; - OS_STORAGE_LOCAL_DIR?: string; - OS_S3_BUCKET?: string; - OS_S3_REGION?: string; - OS_S3_ENDPOINT?: string; - OS_S3_ACCESS_KEY_ID?: string; - OS_S3_SECRET_ACCESS_KEY?: string; - OS_S3_FORCE_PATH_STYLE?: string; - - // — Preview — - OS_PREVIEW_MODE?: string; - OS_PREVIEW_BASE_DOMAINS?: string; - - // — Performance / cache (override defaults below) — - OS_KERNEL_CACHE_SIZE?: string; - OS_KERNEL_TTL_MS?: string; - OS_ENV_CACHE_TTL_MS?: string; - - // — AI providers (when AI plugin is enabled) — - OPENAI_API_KEY?: string; - ANTHROPIC_API_KEY?: string; - GOOGLE_GENERATIVE_AI_API_KEY?: string; - AI_MODEL?: string; - AI_GATEWAY_MODEL?: string; - - // — MCP server — - MCP_SERVER_ENABLED?: string; - MCP_SERVER_NAME?: string; - MCP_SERVER_TRANSPORT?: string; - - // — Misc — - WEBHOOK_SECRET?: string; -} - -/** - * Whitelist of Worker env keys that get forwarded to the Container - * process via `process.env.X`. Keep alphabetically sorted within groups. - * - * Why a whitelist? Container envVars must be a finite map of string→string - * (no dynamic property access at startup time on the CF runtime), and we - * want to drop undefined values so Dockerfile ENV defaults still work - * when the secret/var isn't set. - */ -const FORWARDED_ENV_KEYS: readonly (keyof Env)[] = [ - // database - 'OS_DATABASE_URL', - 'OS_DATABASE_AUTH_TOKEN', - 'OS_DATABASE_DRIVER', - 'OS_CONTROL_DATABASE_URL', - 'OS_CONTROL_DATABASE_AUTH_TOKEN', - 'OS_CONTROL_PG_POOL_MIN', - 'OS_CONTROL_PG_POOL_MAX', - 'TURSO_DATABASE_URL', - 'TURSO_AUTH_TOKEN', - 'TURSO_API_TOKEN', - 'TURSO_ORG_NAME', - // auth - 'AUTH_SECRET', - 'OS_AUTH_SECRET', - 'AUTH_BASE_URL', - 'OS_BASE_URL', - 'OS_TRUSTED_ORIGINS', - 'OS_COOKIE_DOMAIN', - 'OS_ROOT_DOMAIN', - 'GOOGLE_CLIENT_ID', - 'GOOGLE_CLIENT_SECRET', - 'GITHUB_CLIENT_ID', - 'GITHUB_CLIENT_SECRET', - // cloud / multi-tenant - 'OS_PROJECT_ID', - 'OS_ORG_ID', - 'OS_CLOUD_URL', - 'OS_CLOUD_API_KEY', - 'OS_MULTI_TENANT', - // artifact / project - 'OS_PROJECT_ARTIFACT_ROOT', - 'OS_ARTIFACT_PATH', - 'OS_ARTIFACT_CACHE_TTL_MS', - 'OS_ARTIFACT_FETCH_TIMEOUT_MS', - 'OS_DATA_DIR', - 'OS_PROVISION_SYNC', - 'OS_EAGER_SCHEMAS', - 'OS_SKIP_SCHEMA_SYNC', - // storage - 'OS_STORAGE_ADAPTER', - 'OS_STORAGE_LOCAL_DIR', - 'OS_S3_BUCKET', - 'OS_S3_REGION', - 'OS_S3_ENDPOINT', - 'OS_S3_ACCESS_KEY_ID', - 'OS_S3_SECRET_ACCESS_KEY', - 'OS_S3_FORCE_PATH_STYLE', - // preview - 'OS_PREVIEW_MODE', - 'OS_PREVIEW_BASE_DOMAINS', - // performance - 'OS_KERNEL_CACHE_SIZE', - 'OS_KERNEL_TTL_MS', - 'OS_ENV_CACHE_TTL_MS', - // AI - 'OPENAI_API_KEY', - 'ANTHROPIC_API_KEY', - 'GOOGLE_GENERATIVE_AI_API_KEY', - 'AI_MODEL', - 'AI_GATEWAY_MODEL', - // MCP - 'MCP_SERVER_ENABLED', - 'MCP_SERVER_NAME', - 'MCP_SERVER_TRANSPORT', - // misc - 'WEBHOOK_SECRET', -]; - -/** - * Durable Object class that owns a single Cloud container instance. - * The control plane is long-lived and stateful, but all persistent state - * is offloaded to an external database (Turso / Neon / Postgres) — the - * container itself is replaceable. We pin to a single Durable Object id - * so all requests share one process. - */ -export class CloudContainer extends Container { - defaultPort = 4000; - sleepAfter = '30m'; - enableInternet = true; - requiredPorts = [4000]; - - /** - * Cold start budget for the Node app to bind port 4000. - * - * The default in `@cloudflare/containers` is 20s - * (`TIMEOUT_TO_GET_PORTS_MS`), which is not enough for a fresh - * boot that has to: - * 1. Open a Neon Postgres connection (cold start ~1–3s). - * 2. Run `CREATE TABLE IF NOT EXISTS` for every registered - * `sys_*` object — the SQL driver does NOT batch schema sync - * yet, so each table costs one network round-trip. - * 3. Hydrate sys_metadata, then sync any newly hydrated objects - * (Phase 3 in `ObjectQLPlugin.start`). - * 4. Finally start the Hono server which actually opens 4000. - * - * Cold first deploy against a fresh Neon DB easily exceeds 20s; - * 120s gives schema sync room to breathe without wedging warm - * traffic noticeably (subsequent requests go straight through). - */ - private readonly PORT_READY_TIMEOUT_MS = 120_000; - - /** - * Override the auto-start path so the container has the full cold - * start budget to open port 4000. Without this, `containerFetch` - * passes only `{ abort: request.signal }` and inherits the 20s - * default, killing the container mid-schema-sync on first boot. - * - * We also explicitly drop the inbound request's abort signal: the - * Cloudflare Worker request signal fires on subrequest abort - * (~30–45s), which would cancel the wait even if our timeout is - * 120s. Detaching the wait from the request lets the container - * finish booting for the *next* request even if this one's caller - * already hung up. - */ - override async startAndWaitForPorts( - portsOrArgs?: any, - cancellationOptions?: any, - startOptions?: any, - ): Promise { - // Two call shapes: (ports, cancellationOptions, startOptions) - // and ({ ports, cancellationOptions, startOptions }). Inject our - // default timeout and strip the inbound abort signal. - // - // Why both `portReadyTimeoutMS` and `instanceGetTimeoutMS`: - // - `instanceGetTimeoutMS` (default 8s) bounds the inner - // `startContainerIfNotRunning` loop that asks the CF - // control plane to provision an instance. On a cold first - // deploy that 8s is occasionally too tight. - // - `portReadyTimeoutMS` (default 20s) bounds the subsequent - // `waitForPort` loop after the instance is up. We need - // both because the second budget is computed as - // `portReadyTimeout - triesUsed` and any time spent - // getting the instance is deducted. - const TIMEOUT = this.PORT_READY_TIMEOUT_MS; - if ( - portsOrArgs !== null && - typeof portsOrArgs === 'object' && - !Array.isArray(portsOrArgs) && - ('ports' in portsOrArgs || 'cancellationOptions' in portsOrArgs || 'startOptions' in portsOrArgs) - ) { - const inner = { ...(portsOrArgs.cancellationOptions ?? {}) }; - delete inner.abort; - const merged = { - ...portsOrArgs, - cancellationOptions: { - portReadyTimeoutMS: TIMEOUT, - instanceGetTimeoutMS: TIMEOUT, - ...inner, - }, - }; - return super.startAndWaitForPorts(merged); - } - const inner = { ...(cancellationOptions ?? {}) }; - delete inner.abort; - const merged = { - portReadyTimeoutMS: TIMEOUT, - instanceGetTimeoutMS: TIMEOUT, - ...inner, - }; - return super.startAndWaitForPorts(portsOrArgs, merged, startOptions); - } - - // Default envVars baked into every Container. Worker-side secrets/vars - // forwarded via the constructor below OVERRIDE these (e.g. you can set - // AUTH_BASE_URL via Dashboard for a custom domain without redeploying). - envVars: Record = { - NODE_ENV: 'production', - OS_MODE: 'cloud', - PORT: '4000', - HOST: '0.0.0.0', - // Console IS mounted on the cloud control plane — it provides the - // Org/Project management UI and claims the root '/' redirect. - // Studio is disabled (no per-project tenant kernels in this process). - OS_DISABLE_STUDIO: '1', - OS_KERNEL_CACHE_SIZE: '50', - OS_KERNEL_TTL_MS: '1800000', - OS_ENV_CACHE_TTL_MS: '300000', - // Cold-start optimization: schema sync (one round-trip per - // sys_* table on a remote Postgres) routinely runs ~30–60s - // against a cold Neon DB, which exceeds Cloudflare Workers' - // inbound-request budget (~30s). The container can never - // finish booting on a fresh request because the platform - // tears down the in-flight DO invocation when the inbound - // request expires. Move DDL out-of-band: run - // `pnpm --filter @objectstack/cloud migrate` against the - // production DB before deploying the image, then let the - // container assume the schema is already there. - OS_SKIP_SCHEMA_SYNC: '1', - // Public URL the better-auth instance issues redirects from. MUST - // match the origin the browser hits, otherwise sign-up / OAuth - // callbacks fail with "Invalid origin". Override per environment - // via `wrangler secret put AUTH_BASE_URL` or [vars] in wrangler.toml. - AUTH_BASE_URL: 'https://cloud.objectos.app', - // Comma-separated extra origins to add to better-auth's trusted - // list (custom domains, preview hosts, …). AUTH_BASE_URL is - // already trusted automatically. - OS_TRUSTED_ORIGINS: 'https://*.objectstack.workers.dev,https://*.objectos.app', - }; - - constructor(state: DurableObjectState, env: Env) { - super(state, env); - // Forward Worker-level secrets / vars into the Container process. - // Without this, `wrangler secret put X` only reaches the Worker - // (V8 isolate), NOT the Node.js container. Empty / non-string - // values are dropped so Dockerfile ENV defaults still apply when - // the secret/var isn't configured. - for (const key of FORWARDED_ENV_KEYS) { - const value = env[key]; - if (typeof value === 'string' && value.length > 0) { - this.envVars[key as string] = value; - } - } - } -} - -export default { - async fetch(request: Request, env: Env): Promise { - const container = getContainer(env.CLOUD, 'singleton'); - // Admin: restart container on demand (used by deploy script to - // force a fresh image roll-over without waiting for sleepAfter - // eviction). Requires a shared secret to avoid public abuse. - const url = new URL(request.url); - if (url.pathname === '/_admin/restart-container') { - const provided = request.headers.get('x-admin-secret') ?? ''; - const expected = env.OS_CLOUD_API_KEY ?? ''; - if (!expected || provided !== expected) { - return new Response('forbidden', { status: 403 }); - } - try { - // destroy() = SIGKILL the container; next request cold-starts - // with whatever image tag is currently bound in wrangler.toml. - await container.destroy(); - return new Response(JSON.stringify({ ok: true, action: 'destroyed' }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } catch (err) { - return new Response( - JSON.stringify({ ok: false, error: String((err as Error)?.message ?? err) }), - { status: 500, headers: { 'content-type': 'application/json' } }, - ); - } - } - return container.fetch(request); - }, -}; diff --git a/apps/cloud/objectstack.config.ts b/apps/cloud/objectstack.config.ts deleted file mode 100644 index c5e018043..000000000 --- a/apps/cloud/objectstack.config.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ObjectStack Cloud — Host Configuration - * - * apps/cloud is the **control plane**: it owns the control-plane DB - * (organizations, projects, packages, billing), authentication - * (better-auth), the cloud_control metadata-driven App, and the - * artifact distribution API. It does NOT run per-project tenant - * kernels — apps/objectos is the runtime that pulls compiled - * artifacts from here and serves project data. - * - * Booted by `objectstack dev` / `objectstack serve` (see `package.json`) - * and by the Vercel / Cloudflare serverless entrypoints. - */ - -import { createCloudStack } from '@objectstack/service-cloud'; -import { templateRegistry } from './server/templates/registry.js'; - -const authSecret = process.env.AUTH_SECRET - ?? process.env.BETTER_AUTH_SECRET - ?? process.env.OS_AUTH_SECRET - ?? ''; -if (!authSecret) { - throw new Error('apps/cloud: AUTH_SECRET (or BETTER_AUTH_SECRET / OS_AUTH_SECRET) is required.'); -} - -const baseUrl = process.env.OS_BASE_URL - ?? process.env.BETTER_AUTH_URL - ?? `http://localhost:${process.env.PORT ?? '4000'}`; - -const config = await createCloudStack({ - authSecret, - baseUrl, - templates: templateRegistry, -}); - -export default config; diff --git a/apps/cloud/package.json b/apps/cloud/package.json deleted file mode 100644 index b6d73aa24..000000000 --- a/apps/cloud/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@objectstack/cloud", - "version": "4.0.5", - "license": "Apache-2.0", - "type": "module", - "private": true, - "scripts": { - "dev": "OS_DISABLE_STUDIO=1 PORT=${PORT:-4000} objectstack dev", - "build": "tsup", - "start": "OS_DISABLE_STUDIO=1 PORT=${PORT:-4000} objectstack serve dist/objectstack.config.js --prebuilt", - "doctor": "objectstack doctor", - "typecheck": "tsc --noEmit", - "test": "objectstack test", - "test:production-flow": "tsx test/production-flow.test.ts", - "migrate": "tsx scripts/migrate.ts", - "cf:build": "bash scripts/deploy-cloudflare.sh --skip-push --skip-deploy", - "cf:push": "bash scripts/deploy-cloudflare.sh --skip-build --skip-deploy", - "cf:deploy": "bash scripts/deploy-cloudflare.sh", - "cf:deploy:dry": "bash scripts/deploy-cloudflare.sh --dry-run", - "cf:secrets": "bash scripts/setup-cloudflare-secrets.sh", - "cf:tail": "wrangler tail --config wrangler.toml", - "clean": "rm -rf dist node_modules" - }, - "dependencies": { - "@hono/node-server": "^2.0.3", - "@libsql/client": "^0.17.3", - "@objectstack/account": "workspace:*", - "@objectstack/cli": "workspace:*", - "@objectstack/console": "workspace:*", - "@objectstack/driver-memory": "workspace:*", - "@objectstack/driver-sql": "workspace:*", - "@objectstack/driver-turso": "workspace:*", - "@objectstack/hono": "workspace:*", - "@objectstack/metadata": "workspace:*", - "@objectstack/objectql": "workspace:*", - "@objectstack/plugin-audit": "workspace:*", - "@objectstack/plugin-auth": "workspace:*", - "@objectstack/plugin-hono-server": "workspace:*", - "@objectstack/plugin-security": "workspace:*", - "@objectstack/runtime": "workspace:*", - "@objectstack/service-ai": "workspace:*", - "@objectstack/service-analytics": "workspace:*", - "@objectstack/service-automation": "workspace:*", - "@objectstack/service-cloud": "workspace:*", - "@objectstack/service-feed": "workspace:*", - "@objectstack/service-package": "workspace:*", - "@objectstack/service-tenant": "workspace:*", - "@objectstack/spec": "workspace:*", - "hono": "^4.12.21", - "pg": "^8.21.0" - }, - "devDependencies": { - "@cloudflare/containers": "^0.3.4", - "@cloudflare/workers-types": "^4.20260520.1", - "@types/pg": "^8.20.0", - "esbuild": "^0.28.0", - "ts-node": "^10.9.2", - "tsup": "^8.5.1", - "tsx": "^4.22.3", - "typescript": "^6.0.3", - "wrangler": "^4.93.0" - } -} \ No newline at end of file diff --git a/apps/cloud/scripts/build-vercel.sh b/apps/cloud/scripts/build-vercel.sh deleted file mode 100755 index a01e404e9..000000000 --- a/apps/cloud/scripts/build-vercel.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build script for Vercel deployment of @objectstack/cloud. -# -# Vercel's project root is `apps/cloud` (where vercel.json lives), so the -# final `public/` and `api/` directories MUST end up inside this directory -# — not inside any sibling app. Earlier this script was copy-pasted from -# apps/objectos and still cd'd into apps/objectos, which produced -# apps/objectos/public/ — Vercel then failed with: -# Error: No Output Directory named "public" found -# -# Pattern: -# - api/[[...route]].js committed in apps/cloud/api/ -# - esbuild bundles server/index.ts → api/_handler.js (self-contained) -# - Studio + Account SPAs built in their own packages, copied to -# apps/cloud/public/_studio/ and apps/cloud/public/_account/ -# - External native deps installed via npm into api/node_modules/ -# (pnpm symlinks confuse @vercel/node packaging) - -echo "[build-vercel] Starting cloud build..." - -# Resolve repo root regardless of where Vercel invokes the script from. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)" - -# 1. Build the workspace packages we need (cloud + the two SPAs). -cd "$REPO_ROOT" -pnpm turbo run build \ - --filter=@objectstack/cloud \ - --filter=@objectstack/studio \ - --filter=@objectstack/account - -# 1b. Compile objectstack.config.ts → dist/objectstack.json. -cd "$APP_DIR" -echo "[build-vercel] Compiling objectstack artifact..." -pnpm objectstack build -echo "[build-vercel] ✓ dist/objectstack.json generated" - -# 2. Bundle API serverless function (writes api/_handler.js). -node scripts/bundle-api.mjs - -# 2b. Ship the artifact alongside the bundled function. -echo "[build-vercel] Copying artifact into api/dist/..." -mkdir -p api/dist -cp dist/objectstack.json api/dist/objectstack.json -echo "[build-vercel] ✓ api/dist/objectstack.json ready" - -# 3. Assemble the static output directory. -echo "[build-vercel] Assembling public/ output directory..." -rm -rf public -mkdir -p public/_studio public/_account - -if [ -d "$REPO_ROOT/apps/studio/dist" ]; then - cp -r "$REPO_ROOT/apps/studio/dist/." public/_studio/ - echo "[build-vercel] ✓ Copied studio dist to public/_studio/" -else - echo "[build-vercel] ⚠ Studio dist not found (skipped)" -fi - -if [ -d "$REPO_ROOT/apps/account/dist" ]; then - cp -r "$REPO_ROOT/apps/account/dist/." public/_account/ - echo "[build-vercel] ✓ Copied account dist to public/_account/" -else - echo "[build-vercel] ⚠ Account dist not found (skipped)" -fi - -# 4. Install external native deps into api/node_modules/ (no symlinks). -echo "[build-vercel] Installing external dependencies for serverless function..." -cat > api/_package.json << 'DEPS' -{ - "private": true, - "dependencies": { - "@libsql/client": "0.14.0", - "pino": "10.3.1", - "pino-pretty": "13.1.3" - } -} -DEPS -cd api -mv _package.json package.json -npm install --production --no-package-lock --ignore-scripts --loglevel error -rm package.json -cd .. -echo "[build-vercel] ✓ External dependencies installed in api/node_modules/" - -echo "[build-vercel] Done. Static files in $APP_DIR/public, function in api/[[...route]].js → api/_handler.js" diff --git a/apps/cloud/scripts/bundle-api.mjs b/apps/cloud/scripts/bundle-api.mjs deleted file mode 100644 index 8c9a0f8a5..000000000 --- a/apps/cloud/scripts/bundle-api.mjs +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Pre-bundles the Vercel serverless API function. - * - * Vercel's @vercel/node builder resolves pnpm workspace packages via symlinks, - * which can cause esbuild to resolve to TypeScript source files rather than - * compiled dist output — producing ERR_MODULE_NOT_FOUND at runtime. - * - * This script bundles server/index.ts with ALL dependencies inlined (including - * npm packages), so the deployed function is self-contained. Only packages - * with native bindings are kept external. - * - * Run from the apps/cloud directory during the Vercel build step. - */ - -import { build } from 'esbuild'; - -// Packages that cannot be bundled (native bindings / optional drivers) -const EXTERNAL = [ - // Optional knex database drivers — never used at runtime, but knex requires() them - 'pg', - 'pg-native', - 'pg-query-stream', - 'mysql', - 'mysql2', - 'sqlite3', - 'oracledb', - 'tedious', - // macOS-only native file watcher - 'fsevents', - // LibSQL client — has native bindings, must remain external for Vercel - '@libsql/client', - // Logging libraries - use dynamic require, must be external - 'pino', - 'pino-pretty', -]; - -await build({ - entryPoints: ['server/index.ts'], - bundle: true, - platform: 'node', - format: 'esm', - target: 'es2022', - outfile: 'api/_handler.js', - sourcemap: true, - external: EXTERNAL, - // Silence warnings about optional/unused require() calls in knex drivers - logOverride: { 'require-resolve-not-external': 'silent' }, - // Vercel resolves ESM .js files correctly when "type": "module" is set. - // CJS format would conflict with the project's "type": "module" setting, - // causing Node.js to fail parsing require()/module.exports as ESM syntax. - // - // The createRequire banner provides a real `require` function in the ESM - // scope. esbuild's __require shim (generated for CJS→ESM conversion) - // checks `typeof require !== "undefined"` and uses it when available, - // which fixes "Dynamic require of is not supported" errors - // from CJS dependencies like knex/tarn that require() Node.js built-ins. - banner: { - js: [ - '// Bundled by esbuild — see scripts/bundle-api.mjs', - 'import { createRequire as __objectstack_createRequire } from "module";', - 'import { fileURLToPath as __objectstack_fileURLToPath } from "url";', - 'import { dirname as __objectstack_dirname } from "path";', - 'const require = __objectstack_createRequire(import.meta.url);', - // Some bundled CJS deps (e.g. `bindings`, used transitively by knex's - // better-sqlite3 dialect) reference `__filename`/`__dirname` directly. - // esbuild does NOT auto-shim these in ESM output, so without the - // assignment below the function crashes with `__filename is not defined` - // the first time the dialect is touched (e.g. when SeedLoader resolves - // a knex dialect during project bootstrap on Vercel). - 'const __filename = __objectstack_fileURLToPath(import.meta.url);', - 'const __dirname = __objectstack_dirname(__filename);', - ].join('\n'), - }, -}); - -console.log('[bundle-api] Bundled server/index.ts → api/_handler.js'); diff --git a/apps/cloud/scripts/deploy-cloudflare.sh b/apps/cloud/scripts/deploy-cloudflare.sh deleted file mode 100755 index 9bb321e5a..000000000 --- a/apps/cloud/scripts/deploy-cloudflare.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────────────────────── -# deploy-cloudflare.sh — build → push → deploy ObjectStack Cloud to -# Cloudflare Containers. -# -# Mirror of apps/objectos/scripts/deploy-cloudflare.sh — see that file's -# header for full usage. Only the app name / default image differ. -# ───────────────────────────────────────────────────────────────────────────── -set -euo pipefail - -APP_NAME="objectstack-cloud" -APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)" -WRANGLER_TOML="$APP_DIR/wrangler.toml" -DOCKERFILE="$APP_DIR/Dockerfile" -ENV_FILE="$APP_DIR/.env.cloudflare" - -if [[ -f "$ENV_FILE" ]]; then - echo "→ loading $ENV_FILE" - set -a; source "$ENV_FILE"; set +a -fi - -: "${CF_IMAGE_NAME:=$APP_NAME}" -: "${CF_IMAGE_TAG:=$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo latest)}" -: "${CF_PLATFORM:=linux/amd64}" - -SKIP_BUILD=0; SKIP_PUSH=0; SKIP_DEPLOY=0; SKIP_MIGRATE=0; DRY_RUN=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --) shift ;; - --skip-build) SKIP_BUILD=1; shift ;; - --skip-push) SKIP_PUSH=1; shift ;; - --skip-deploy) SKIP_DEPLOY=1; shift ;; - --skip-migrate) SKIP_MIGRATE=1; shift ;; - --dry-run) DRY_RUN=1; shift ;; - --tag) CF_IMAGE_TAG="$2"; shift 2 ;; - --tag=*) CF_IMAGE_TAG="${1#--tag=}"; shift ;; - -h|--help) grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; - esac -done - -# Accept both modern (CLOUDFLARE_*) and legacy (CF_*) env names. -# Wrangler v4 emits a deprecation warning for CF_ACCOUNT_ID, so promote -# whatever we have to CLOUDFLARE_ACCOUNT_ID and re-export. -: "${CLOUDFLARE_ACCOUNT_ID:=${CF_ACCOUNT_ID:-}}" -: "${CF_ACCOUNT_ID:=${CLOUDFLARE_ACCOUNT_ID:-}}" -: "${CLOUDFLARE_API_TOKEN:=${CF_API_TOKEN:-}}" -export CLOUDFLARE_ACCOUNT_ID CF_ACCOUNT_ID CLOUDFLARE_API_TOKEN - -if [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then - echo "✗ CLOUDFLARE_ACCOUNT_ID is required (set in $ENV_FILE or env)" >&2 - echo " Run: npx wrangler whoami" >&2 - exit 1 -fi - -# Preflight auth: wrangler emits a cryptic 'Unauthorized' when neither a -# token nor a `wrangler login` session exists. Catch it early. -if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then - if ! npx --yes wrangler whoami >/dev/null 2>&1; then - echo "✗ Not authenticated with Cloudflare." >&2 - echo " Add one of these to $ENV_FILE (or export to env):" >&2 - echo " CLOUDFLARE_API_TOKEN=" >&2 - echo " …or run \`npx wrangler login\` once on this machine." >&2 - echo " Token needs: Workers Scripts:Edit, Account → Cloudflare Images:Edit (containers)." >&2 - exit 1 - fi -fi - -: "${CF_IMAGE_REGISTRY:=registry.cloudflare.com/$CLOUDFLARE_ACCOUNT_ID}" -IMAGE="$CF_IMAGE_REGISTRY/$CF_IMAGE_NAME:$CF_IMAGE_TAG" - -echo "════════════════════════════════════════════════════════════════" -echo " App : $APP_NAME" -echo " Repo : $REPO_ROOT" -echo " Image : $IMAGE" -echo " Platform: $CF_PLATFORM" -echo " Wrangler: $WRANGLER_TOML" -echo "════════════════════════════════════════════════════════════════" - -run() { if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*"; else "$@"; fi; } - -if [[ $SKIP_BUILD -eq 0 ]]; then - echo "" - echo "▶ [1/4] docker buildx build" - command -v docker >/dev/null || { echo "✗ docker not installed" >&2; exit 1; } - run docker buildx build \ - --platform "$CF_PLATFORM" \ - -f "$DOCKERFILE" \ - -t "$IMAGE" \ - --provenance=false \ - --sbom=false \ - --load \ - "$REPO_ROOT" -else - echo "▶ [1/4] skipped (--skip-build)" -fi - -# ───────────────────────────────────────────────────────────────────── -# Migration step (out-of-band schema sync against the production DB). -# -# Runs the kernel locally with OS_MIGRATE_AND_EXIT=1 so the -# ObjectQLPlugin.start() schema sync runs ONCE against Neon/Turso -# from the deploy machine (which has no 30s wallclock budget). The -# container then ships with OS_SKIP_SCHEMA_SYNC=1 baked in (see -# cloudflare/worker.ts) and skips DDL on every cold boot. -# -# Without this, the container is killed mid-DDL on every cold start -# because Cloudflare Workers' inbound-request budget (~30s) is shorter -# than a fresh remote-DB schema sync (~30–60s for ~30 sys_* tables). -# -# Requires the prebuilt config (apps/cloud/dist/objectstack.config.js) -# — the build step above produces it inside the Docker image, so we -# trigger a host-side build if needed. -# ───────────────────────────────────────────────────────────────────── -if [[ $SKIP_MIGRATE -eq 0 && $SKIP_PUSH -eq 0 ]]; then - echo "" - echo "▶ [2/4] schema migration (OS_SKIP_SCHEMA_SYNC=0, OS_MIGRATE_AND_EXIT=1)" - if [[ ! -f "$APP_DIR/dist/objectstack.config.js" ]]; then - echo " · dist/ not found — building host-side first" - run pnpm --dir "$APP_DIR" build - fi - if [[ $DRY_RUN -eq 1 ]]; then - echo "[dry-run] pnpm --dir $APP_DIR migrate" - else - if ! pnpm --dir "$APP_DIR" migrate; then - echo "" >&2 - echo "✗ schema migration failed." >&2 - echo " Check OS_DATABASE_URL in $APP_DIR/.env.cloudflare.secrets and" >&2 - echo " re-run \`pnpm --dir $APP_DIR migrate\` with DEBUG=* for details." >&2 - echo " Pass --skip-migrate if you've already migrated separately." >&2 - exit 1 - fi - fi -else - echo "▶ [2/4] skipped ($([[ $SKIP_MIGRATE -eq 1 ]] && echo --skip-migrate || echo --skip-push))" -fi - -if [[ $SKIP_PUSH -eq 0 ]]; then - echo "" - echo "▶ [3/4] wrangler containers push" - if [[ $DRY_RUN -eq 1 ]]; then - echo "[dry-run] npx --yes wrangler containers push $IMAGE" - else - if ! npx --yes wrangler containers push "$IMAGE"; then - echo "" >&2 - echo "✗ wrangler containers push failed." >&2 - echo " Common causes:" >&2 - echo " • API token / OAuth session missing scope" >&2 - echo " Cloudflare Images:Edit (container registry push)" >&2 - echo " Workers Scripts:Edit (cf:deploy step)" >&2 - echo " • Account not enrolled in Cloudflare Containers beta" >&2 - echo " https://developers.cloudflare.com/containers/" >&2 - echo " • Token regenerated; refresh CLOUDFLARE_API_TOKEN in $ENV_FILE" >&2 - echo "" >&2 - exit 1 - fi - fi -else - echo "▶ [3/4] skipped (--skip-push)" -fi - -if [[ $SKIP_DEPLOY -eq 0 ]]; then - echo "" - echo "▶ [4/4] update wrangler.toml image → $IMAGE" - if [[ $DRY_RUN -eq 0 ]]; then - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' -E "s|^image = \".*\"|image = \"$IMAGE\"|" "$WRANGLER_TOML" - else - sed -i -E "s|^image = \".*\"|image = \"$IMAGE\"|" "$WRANGLER_TOML" - fi - fi - echo "" - echo "▶ wrangler deploy" - run npx --yes wrangler deploy --config "$WRANGLER_TOML" -else - echo "▶ [4/4] skipped (--skip-deploy)" -fi - -echo "" -echo "✓ done — $IMAGE" -echo " Tail logs : (cd $APP_DIR && npx wrangler tail)" -echo " Health : curl https://$APP_NAME..workers.dev/api/v1/health" diff --git a/apps/cloud/scripts/migrate.ts b/apps/cloud/scripts/migrate.ts deleted file mode 100644 index d73054b12..000000000 --- a/apps/cloud/scripts/migrate.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Out-of-band schema migration for ObjectStack Cloud. - * - * Why this exists - * ─────────────── - * The Cloudflare Containers runtime gives an inbound Worker request - * roughly 30s of wallclock before the platform tears the DO invocation - * down. A cold control-plane boot against a fresh Neon DB has to: - * - * 1. Open a Postgres connection (1–3s cold). - * 2. Run `CREATE TABLE IF NOT EXISTS` for every `sys_*` object — - * one round-trip per table because `driver-sql` does not yet - * implement `batchSchemaSync` (verified at - * packages/plugins/driver-sql/src/sql-driver.ts:108). - * 3. Hydrate `sys_metadata`, then DDL any custom tables that just - * came in (Phase 3 in `ObjectQLPlugin.start`). - * - * Steps 1+2 alone routinely take 30–60s, so the container is killed - * mid-DDL on every cold request and never reaches `listen(4000)`. The - * three timeouts we previously bumped (`startupTimeout`, - * `portReadyTimeoutMS`, `instanceGetTimeoutMS`) are necessary but - * insufficient — the platform's request-budget is the actual wall. - * - * Strategy - * ──────── - * Run schema sync ONCE from the deploy machine against the production - * DB, then ship the container with `OS_SKIP_SCHEMA_SYNC=1` so cold - * boots only do connection-open + sys_metadata hydration (~sub-second - * on warm Neon). - * - * How it works - * ──────────── - * This script just delegates to the existing `objectstack serve` - * machinery (which already knows how to load `dist/objectstack.config.js`, - * register every plugin, and bootstrap the kernel) but with two env - * overrides: - * - * • `OS_SKIP_SCHEMA_SYNC=0` — force `ObjectQLPlugin.start()` to - * actually run `syncRegisteredSchemas()` even if the operator's - * shell exports the production default. - * • `OS_MIGRATE_AND_EXIT=1` — `serve.ts` watches for this and - * `kernel.shutdown() + process.exit(0)` immediately after - * `runtime.start()` resolves successfully, instead of holding the - * port open. - * - * The `OS_DATABASE_URL` (and any other secrets) must be present in - * the script's env when it runs — we do NOT push secrets here, they - * come from `apps/cloud/.env.cloudflare.secrets` (loaded by the - * caller, e.g. `deploy-cloudflare.sh`) or from the operator's shell. - * - * Usage - * ───── - * pnpm --filter @objectstack/cloud build # produces dist/objectstack.config.js - * pnpm --filter @objectstack/cloud migrate # this script - * - * Or, automatically as part of `cf:deploy` (see deploy-cloudflare.sh). - */ - -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync, readFileSync } from 'node:fs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const APP_DIR = path.resolve(__dirname, '..'); -const CONFIG_PATH = path.join(APP_DIR, 'dist', 'objectstack.config.js'); -const SECRETS_FILE = path.join(APP_DIR, '.env.cloudflare.secrets'); - -// ── 1. Verify the prebuilt config exists ───────────────────────────── -if (!existsSync(CONFIG_PATH)) { - console.error(`✗ ${CONFIG_PATH} not found.`); - console.error(' Run `pnpm --filter @objectstack/cloud build` first.'); - process.exit(1); -} - -// ── 2. Load .env.cloudflare.secrets if present ─────────────────────── -// Mirrors the parser in setup-cloudflare-secrets.sh — values may -// contain `&`, `;`, `$`, etc., so we MUST NOT use `bash source`-style -// parsing. We strip ONE optional layer of surrounding `'` or `"`. -function loadEnvFile(file: string): Record { - if (!existsSync(file)) return {}; - const out: Record = {}; - const text = readFileSync(file, 'utf8'); - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) continue; - const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); - if (!m) continue; - const key = m[1]; - let value = m[2]; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - out[key] = value; - } - return out; -} - -const fileEnv = loadEnvFile(SECRETS_FILE); -// Don't clobber values the operator already set in their shell — the -// shell wins because that's where one-off overrides happen. -const mergedEnv: NodeJS.ProcessEnv = { ...fileEnv, ...process.env }; - -// ── 3. Sanity-check that we have a real DB URL ─────────────────────── -const dbUrl = mergedEnv.OS_DATABASE_URL || mergedEnv.OS_CONTROL_DATABASE_URL; -if (!dbUrl) { - console.error('✗ OS_DATABASE_URL is not set.'); - console.error(` Set it in ${SECRETS_FILE} or export it in your shell.`); - process.exit(1); -} -if (dbUrl.startsWith('file:') || dbUrl.includes(':memory:')) { - console.error(`✗ OS_DATABASE_URL points at a local file (${dbUrl}).`); - console.error(' Migrating local SQLite is pointless — the container ships'); - console.error(' with a fresh ephemeral filesystem. Point at production Neon/Turso.'); - process.exit(1); -} - -// ── 4. Force schema sync ON, exit-after-bootstrap ON ───────────────── -mergedEnv.OS_SKIP_SCHEMA_SYNC = '0'; -mergedEnv.OS_MIGRATE_AND_EXIT = '1'; -// Run on a non-conflicting port so we don't fight a `pnpm dev` instance. -mergedEnv.PORT = mergedEnv.MIGRATE_PORT ?? '4099'; -// Quiet down the runtime banner — the migration banner is what matters here. -mergedEnv.OS_DISABLE_CONSOLE = '1'; - -const redactedUrl = dbUrl.replace(/:\/\/[^@]+@/, '://***@'); -console.log('────────────────────────────────────────────────────────────'); -console.log(' ObjectStack Cloud — out-of-band schema migration'); -console.log('────────────────────────────────────────────────────────────'); -console.log(` Config : ${path.relative(process.cwd(), CONFIG_PATH)}`); -console.log(` Target : ${redactedUrl}`); -console.log(' Mode : OS_MIGRATE_AND_EXIT=1, OS_SKIP_SCHEMA_SYNC=0'); -console.log('────────────────────────────────────────────────────────────'); - -// ── 5. Delegate to `objectstack serve --prebuilt` ──────────────────── -// We use the local CLI binary from the workspace so this works inside -// `pnpm --filter @objectstack/cloud migrate` without a separate npx -// resolution. `--prebuilt` skips esbuild/bundle-require — the dist file -// is already pure ESM. -const cliBin = path.join(APP_DIR, 'node_modules', '.bin', 'objectstack'); -// `--no-server` skips the Hono HTTP server plugin entirely so we don't -// have to bind a port at all. Schema sync lives in -// `ObjectQLPlugin.start()`, which runs regardless. `--no-ui` skips the -// Studio static asset plugin (irrelevant for migration). -const args = ['serve', CONFIG_PATH, '--prebuilt', '--no-ui', '--no-server']; - -const child = spawn(cliBin, args, { - cwd: APP_DIR, - env: mergedEnv, - stdio: 'inherit', -}); - -child.on('exit', (code, signal) => { - if (signal) { - console.error(`✗ migrate killed by signal ${signal}`); - process.exit(1); - } - process.exit(code ?? 0); -}); -child.on('error', (err) => { - console.error(`✗ failed to spawn objectstack CLI: ${err.message}`); - process.exit(1); -}); diff --git a/apps/cloud/scripts/setup-cloudflare-secrets.sh b/apps/cloud/scripts/setup-cloudflare-secrets.sh deleted file mode 100755 index 906cbb87f..000000000 --- a/apps/cloud/scripts/setup-cloudflare-secrets.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -# ───────────────────────────────────────────────────────────────────────────── -# setup-cloudflare-secrets.sh — bulk-push secrets to a Cloudflare Worker. -# -# Reads values from `.env.cloudflare.secrets` (gitignored) and pipes each -# one to `wrangler secret put`. Safe to re-run — wrangler upserts secrets. -# -# pnpm --filter @objectstack/objectos cf:secrets -# pnpm --filter @objectstack/cloud cf:secrets -# -# .env.cloudflare.secrets format (one key=value per line, # for comments): -# -# OS_DATABASE_URL=libsql://my-control.turso.io -# OS_DATABASE_AUTH_TOKEN=eyJhbGciOi... -# AUTH_SECRET= -# # Cloud-only: -# TURSO_API_TOKEN=... -# TURSO_ORG_NAME=... -# # ObjectOS multi-project mode: -# OS_CLOUD_URL=https://objectstack-cloud..workers.dev -# OS_CLOUD_API_KEY=... -# -# Any unset variable is *skipped* (not cleared) so you can keep one shared -# file across both apps. -# ───────────────────────────────────────────────────────────────────────────── -set -euo pipefail - -APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WRANGLER_TOML="$APP_DIR/wrangler.toml" -SECRETS_FILE="${SECRETS_FILE:-$APP_DIR/.env.cloudflare.secrets}" - -# Per-app key allow-list. Secrets not in the list are ignored so we can -# share one .env.cloudflare.secrets file across apps without leaking -# unrelated keys to a Worker that has no use for them. -APP_BASENAME="$(basename "$APP_DIR")" -case "$APP_BASENAME" in - objectos) - KEYS=( OS_DATABASE_URL OS_DATABASE_AUTH_TOKEN AUTH_SECRET - OS_CLOUD_URL OS_CLOUD_API_KEY OS_COOKIE_DOMAIN OS_ROOT_DOMAIN ) ;; - cloud) - KEYS=( OS_DATABASE_URL OS_CONTROL_DATABASE_URL OS_DATABASE_AUTH_TOKEN AUTH_SECRET - OS_CONTROL_PG_POOL_MIN OS_CONTROL_PG_POOL_MAX - TURSO_API_TOKEN TURSO_ORG_NAME - OS_CLOUD_API_KEY OS_COOKIE_DOMAIN OS_ROOT_DOMAIN - OS_STORAGE_ADAPTER OS_STORAGE_LOCAL_DIR OS_STORAGE_KEY_PREFIX - OS_S3_BUCKET OS_S3_REGION OS_S3_ENDPOINT - OS_S3_ACCESS_KEY_ID OS_S3_SECRET_ACCESS_KEY OS_S3_FORCE_PATH_STYLE ) ;; - *) - echo "✗ unknown app dir: $APP_BASENAME" >&2; exit 1 ;; -esac - -if [[ ! -f "$SECRETS_FILE" ]]; then - echo "✗ no $SECRETS_FILE — copy .env.cloudflare.secrets.example and fill it in" >&2 - exit 1 -fi - -# Parse KEY=VALUE lines manually instead of `set -a; source`. -# Why: `source` runs the file as bash, so values containing shell -# metachars (& ; $ < > |) silently break — e.g. a Postgres URL with -# `?sslmode=require&channel_binding=require` would have everything -# after the `&` treated as a background command and the variable would -# be set to the truncated prefix (or empty). Manual parsing reads the -# raw bytes and supports optional surrounding single/double quotes. -# -# Note: macOS ships bash 3.2 which lacks associative arrays, so we -# extract one key at a time on demand rather than building a map. -read_secret() { - local target="$1" - local line key value - while IFS= read -r line || [[ -n "$line" ]]; do - [[ -z "${line//[[:space:]]/}" ]] && continue - [[ "$line" =~ ^[[:space:]]*# ]] && continue - line="${line#export }" - [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue - key="${BASH_REMATCH[1]}" - [[ "$key" != "$target" ]] && continue - value="${BASH_REMATCH[2]}" - if [[ "$value" =~ ^\"(.*)\"$ ]]; then value="${BASH_REMATCH[1]}" - elif [[ "$value" =~ ^\'(.*)\'$ ]]; then value="${BASH_REMATCH[1]}" - fi - value="${value%$'\r'}" - printf '%s' "$value" - return 0 - done < "$SECRETS_FILE" - return 0 -} - -echo "→ pushing secrets to Worker defined in $WRANGLER_TOML" -PUSHED=0; SKIPPED=0 -for key in "${KEYS[@]}"; do - value="$(read_secret "$key")" - if [[ -z "$value" ]]; then - echo " · $key (skipped — not set)" - SKIPPED=$((SKIPPED+1)) - continue - fi - echo " ✓ $key (${#value} chars)" - printf '%s' "$value" | npx --yes wrangler secret put "$key" \ - --config "$WRANGLER_TOML" >/dev/null - PUSHED=$((PUSHED+1)) -done - -echo "" -echo "✓ pushed=$PUSHED skipped=$SKIPPED" diff --git a/apps/cloud/server/index.ts b/apps/cloud/server/index.ts deleted file mode 100644 index 0b4157d5b..000000000 --- a/apps/cloud/server/index.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Vercel Serverless API Entrypoint - * - * Boots the ObjectStack kernel from the shared `objectstack.config.ts` - * and delegates all `/api/*` traffic to the Hono adapter. The same - * `ensureApp()` / `ensureBoot()` singletons are reused by the E2E test - * harness — local `pnpm dev` is served by the `objectstack dev` CLI and - * does not import this file. - */ - -import { createHonoApp } from '@objectstack/hono'; -import { createOriginMatcher, hasWildcardPattern, HonoHttpServer } from '@objectstack/plugin-hono-server'; -import { getRequestListener } from '@hono/node-server'; -import { ObjectKernel, createRestApiPlugin, createDispatcherPlugin, KernelManager } from '@objectstack/runtime'; -import type { EnvironmentDriverRegistry } from '@objectstack/runtime'; -import type { Hono } from 'hono'; -import stackConfig from '../objectstack.config.js'; - -// --------------------------------------------------------------------------- -// Runtime shape returned by ensureBoot() -// --------------------------------------------------------------------------- - -export interface BootResult { - kernel: ObjectKernel; - kernelManager?: KernelManager; - envRegistry?: EnvironmentDriverRegistry; -} - -// --------------------------------------------------------------------------- -// Singleton state — persists across warm Vercel invocations -// --------------------------------------------------------------------------- - -let _boot: BootResult | null = null; -let _app: Hono | null = null; - -/** Shared boot promise — prevents concurrent cold-start races. */ -let _bootPromise: Promise | null = null; - -async function bootKernel(): Promise { - const kernel = new ObjectKernel(); - - // 0. Register an `http.server` (IHttpServer) adapter BEFORE plugins so - // that any plugin's start() hook can resolve `ctx.getService('http.server')` - // and register routes on it. This is the official ObjectStack - // protocol for plugin-supplied HTTP routes (see IHttpServer in - // @objectstack/spec/contracts and HonoServerPlugin's reference - // implementation). The Vercel entrypoint cannot use HonoServerPlugin - // itself because we don't want plugin-hono-server to call listen() — - // Vercel hands us a request directly. Reusing the same adapter class - // keeps route-registration semantics identical between local - // (`objectstack dev`) and serverless deployments. - const httpServer = new HonoHttpServer(); - kernel.registerService('http.server', httpServer); - kernel.registerService('http-server', httpServer); // alias for backward compatibility - - // 1. Config plugins (control-plane preset + MultiProjectPlugin + Auth/Security/Audit). - // AuthPlugin registers the platform Setup App via its manifest - // (definition lives in @objectstack/platform-objects/apps), so no - // separate setup plugin is needed. - for (const plugin of stackConfig.plugins ?? []) { - await kernel.use(plugin as any); - } - - // 2. REST API + Dispatcher — consume the scoping config from stackConfig.api - const api = (stackConfig as any).api ?? {}; - try { - await kernel.use( - createRestApiPlugin({ api: { api } } as any), - ); - } catch { /* optional */ } - try { - await kernel.use( - createDispatcherPlugin({ scoping: api }), - ); - } catch { /* optional */ } - - await kernel.bootstrap(); - - const getOptionalService = async (name: string): Promise => { - try { return await (kernel as any).getServiceAsync(name) as T; } catch { return undefined; } - }; - const envRegistry = await getOptionalService('env-registry'); - const kernelManager = await getOptionalService('kernel-manager'); - - return { kernel, kernelManager, envRegistry }; -} - -async function ensureBoot(): Promise { - if (_boot) return _boot; - if (_bootPromise) return _bootPromise; - - _bootPromise = (async () => { - console.log('[ObjectStack] Booting kernel...'); - try { - const result = await bootKernel(); - _boot = result; - console.log('[ObjectStack] Kernel ready.'); - return result; - } catch (err) { - _bootPromise = null; - console.error('[ObjectStack] Kernel boot failed:', (err as any)?.message || err); - throw err; - } - })(); - - return _bootPromise; -} - -// --------------------------------------------------------------------------- -// Hono app factory -// --------------------------------------------------------------------------- - -async function ensureApp(): Promise { - if (_app) return _app; - - const { kernel } = await ensureBoot(); - - // Plugins have already registered their routes onto the IHttpServer - // (HonoHttpServer) we created in bootKernel(). Pull out its underlying - // Hono so those plugin routes are matched FIRST, then mount the - // dispatcher app underneath via `outer.route('/', inner)` — Hono uses - // registration-order priority, so the plugin routes win the match - // against the dispatcher's catch-all `/api/v1/*` handler. - const httpServer = kernel.getService('http.server'); - const outer = httpServer.getRawApp(); - - const inner = createHonoApp({ kernel, prefix: '/api/v1' }); - outer.route('/', inner); - - _app = outer; - return _app; -} - -export { ensureApp, ensureBoot }; - -// --------------------------------------------------------------------------- -// CORS headers — applied to responses that bypass the Hono app -// (bootstrap failures, preflight short-circuit). Mirrors the defaults of -// `createHonoApp()` so behaviour is identical whether or not the kernel -// has finished booting. See packages/adapters/hono/src/index.ts. -// -// Controlled by the same environment variables as the Hono adapter: -// CORS_ENABLED, CORS_ORIGIN, CORS_CREDENTIALS, CORS_MAX_AGE. -// --------------------------------------------------------------------------- - -const CORS_ALLOW_METHODS = 'GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS'; -const CORS_ALLOW_HEADERS = 'Content-Type,Authorization,X-Requested-With'; - -function corsEnabled(): boolean { - return process.env.CORS_ENABLED !== 'false'; -} - -function corsCredentials(): boolean { - return process.env.CORS_CREDENTIALS !== 'false'; -} - -function corsMaxAge(): number { - return process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400; -} - -function originMatches(pattern: string, origin: string): boolean { - if (pattern === origin) return true; - if (!pattern.includes('*')) return false; - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(`^${escaped}$`).test(origin); -} - -function resolveAllowOrigin(requestOrigin: string | null): string | null { - const credentials = corsCredentials(); - const envOrigin = process.env.CORS_ORIGIN?.trim(); - - if (!envOrigin) { - if (requestOrigin) return requestOrigin; - return credentials ? null : '*'; - } - - if (envOrigin === '*') { - if (credentials) return requestOrigin || null; - return '*'; - } - - if (hasWildcardPattern(envOrigin)) { - if (!requestOrigin) return null; - return createOriginMatcher(envOrigin)(requestOrigin); - } - - const allowed = envOrigin.includes(',') - ? envOrigin.split(',').map((s: string) => s.trim()).filter(Boolean) - : [envOrigin]; - - if (requestOrigin && allowed.some(pattern => originMatches(pattern, requestOrigin))) return requestOrigin; - if (allowed.length === 1 && !requestOrigin) return allowed[0]; - return null; -} - -function withCorsHeaders(response: Response, request: Request): Response { - if (!corsEnabled()) return response; - - const requestOrigin = request.headers.get('origin'); - const allowOrigin = resolveAllowOrigin(requestOrigin); - if (!allowOrigin) return response; - - const headers = new Headers(response.headers); - headers.set('Access-Control-Allow-Origin', allowOrigin); - if (corsCredentials()) { - headers.set('Access-Control-Allow-Credentials', 'true'); - } - const existingVary = headers.get('Vary'); - if (!existingVary) { - headers.set('Vary', 'Origin'); - } else if (!/(^|,\s*)Origin(\s*,|$)/i.test(existingVary)) { - headers.set('Vary', `${existingVary}, Origin`); - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -function buildPreflightResponse(request: Request): Response { - const requestOrigin = request.headers.get('origin'); - const allowOrigin = resolveAllowOrigin(requestOrigin); - - if (!allowOrigin) { - return new Response(null, { status: 204 }); - } - - const requestedHeaders = request.headers.get('access-control-request-headers'); - const headers = new Headers({ - 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS, - 'Access-Control-Allow-Headers': requestedHeaders || CORS_ALLOW_HEADERS, - 'Access-Control-Max-Age': String(corsMaxAge()), - Vary: 'Origin, Access-Control-Request-Headers', - }); - if (corsCredentials()) { - headers.set('Access-Control-Allow-Credentials', 'true'); - } - return new Response(null, { status: 204, headers }); -} - -// --------------------------------------------------------------------------- -// Body extraction — reads Vercel's pre-buffered request body. -// --------------------------------------------------------------------------- - -interface VercelIncomingMessage { - rawBody?: Buffer | string; - body?: unknown; - headers?: Record; -} - -interface VercelEnv { - incoming?: VercelIncomingMessage; -} - -function extractBody( - incoming: VercelIncomingMessage, - method: string, - contentType: string | undefined, -): any { - if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return null; - - if (incoming.rawBody != null) { - return incoming.rawBody; - } - - if (incoming.body != null) { - if (typeof incoming.body === 'string') return incoming.body; - if (contentType?.includes('application/json')) return JSON.stringify(incoming.body); - return String(incoming.body); - } - - return null; -} - -function resolvePublicUrl( - requestUrl: string, - incoming: VercelIncomingMessage | undefined, -): string { - if (!incoming) return requestUrl; - const fwdProto = incoming.headers?.['x-forwarded-proto']; - const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; - const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined; - if (proto === 'https' && requestUrl.startsWith('http:')) { - return requestUrl.replace(/^http:/, 'https:'); - } - return requestUrl; -} - -// --------------------------------------------------------------------------- -// Vercel Node.js serverless handler -// --------------------------------------------------------------------------- - -export default getRequestListener(async (request, env) => { - const method = request.method.toUpperCase(); - const incoming = (env as VercelEnv)?.incoming; - const url = resolvePublicUrl(request.url, incoming); - - if (method === 'OPTIONS') { - console.log(`[Vercel] OPTIONS ${url} (preflight short-circuit)`); - return buildPreflightResponse(request); - } - - let app: Hono; - try { - app = await ensureApp(); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('[Vercel] Handler error — bootstrap did not complete:', message); - const errorResponse = new Response( - JSON.stringify({ - success: false, - error: { - message: 'Service Unavailable — kernel bootstrap failed.', - code: 503, - }, - }), - { status: 503, headers: { 'content-type': 'application/json' } }, - ); - return withCorsHeaders(errorResponse, request); - } - - console.log(`[Vercel] ${method} ${url}`); - - if (method !== 'GET' && method !== 'HEAD' && incoming) { - const contentType = incoming.headers?.['content-type']; - const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType; - const body = extractBody(incoming, method, contentTypeStr); - if (body != null) { - const response = await app.fetch( - new Request(url, { method, headers: request.headers, body }), - ); - return withCorsHeaders(response, request); - } - } - - const response = await app.fetch( - new Request(url, { method, headers: request.headers }), - ); - return withCorsHeaders(response, request); -}); - -export const config = { - maxDuration: 60, -}; diff --git a/apps/cloud/server/templates/blank.ts b/apps/cloud/server/templates/blank.ts deleted file mode 100644 index e13ed40cd..000000000 --- a/apps/cloud/server/templates/blank.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { ProjectTemplate } from './types.js'; - -export const blankTemplate: ProjectTemplate = { - id: 'blank', - label: 'Blank', - description: 'Empty project — start from scratch.', - category: 'starter', - async load() { - return { manifest: { id: 'blank', namespace: 'blank' }, objects: [] } as any; - }, -}; diff --git a/apps/cloud/server/templates/crm.ts b/apps/cloud/server/templates/crm.ts deleted file mode 100644 index 03039a3a6..000000000 --- a/apps/cloud/server/templates/crm.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { ProjectTemplate } from './types.js'; - -export const crmTemplate: ProjectTemplate = { - id: 'crm', - label: 'CRM Starter', - description: 'Accounts, Contacts, Opportunities — full CRM example.', - category: 'business', - async load() { - // Lazy import — only resolved when a project is provisioned from this template. - // bundleRequire/esbuild inlines the module at bundle time but defers execution, - // so the CRM metadata (~8k lines) is not loaded into the control-plane kernel - // on server startup. The rootDir TS error below is a tsc-only constraint; - // esbuild (used by bundleRequire) handles cross-root imports correctly. - // @ts-ignore — outside tsconfig rootDir but safe under bundleRequire/esbuild - const mod = await import('../../../../examples/app-crm/objectstack.config.js'); - return mod.default ?? mod; - }, -}; diff --git a/apps/cloud/server/templates/extract.ts b/apps/cloud/server/templates/extract.ts deleted file mode 100644 index d9a96582d..000000000 --- a/apps/cloud/server/templates/extract.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Flatten an `ObjectStackDefinition` bundle into the `{type, name, data}` - * shape consumed by `MetadataPlugin.bulkRegister`. - * - * Object names are kept as the canonical short name. The registry stores - * objects by short name and disambiguates cross-package collisions via the - * package/namespace tag — adding `${ns}__` here would surface FQN names in - * URLs and queries (forbidden by the naming convention). - * - * Skipped on purpose: - * - apis / actions — handler refs require kernel code, not metadata only - * - translations — needs i18n plugin - * - sharingRules / roles — needs security plugin - * - onEnable hooks — code, not metadata - */ -export interface ExtractedItem { - type: string; - name: string; - data: unknown; -} - -export function extractMetadataItems(bundle: any): ExtractedItem[] { - const items: ExtractedItem[] = []; - - const pushAll = (type: string, arr?: any[]) => { - for (const item of arr ?? []) { - if (!item?.name) continue; - items.push({ type, name: item.name, data: item }); - } - }; - - pushAll('object', bundle?.objects); - pushAll('view', bundle?.views); - pushAll('dashboard', bundle?.dashboards); - pushAll('report', bundle?.reports); - pushAll('flow', bundle?.flows); - pushAll('agent', bundle?.agents); - pushAll('app', bundle?.apps); - - return items; -} diff --git a/apps/cloud/server/templates/registry.ts b/apps/cloud/server/templates/registry.ts deleted file mode 100644 index 4f6b66152..000000000 --- a/apps/cloud/server/templates/registry.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { blankTemplate } from './blank.js'; -import { crmTemplate } from './crm.js'; -import { todoTemplate } from './todo.js'; -import type { ProjectTemplate } from './types.js'; - -export const templateRegistry: Record = { - [blankTemplate.id]: blankTemplate, - [crmTemplate.id]: crmTemplate, - [todoTemplate.id]: todoTemplate, -}; - -export const DEFAULT_TEMPLATE_ID = 'blank'; - -export function listTemplates(): Array> { - return Object.values(templateRegistry).map(({ id, label, description, category }) => ({ - id, - label, - description, - category, - })); -} - -export type { ProjectTemplate } from './types.js'; diff --git a/apps/cloud/server/templates/todo.ts b/apps/cloud/server/templates/todo.ts deleted file mode 100644 index 504b226d1..000000000 --- a/apps/cloud/server/templates/todo.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { ProjectTemplate } from './types.js'; - -export const todoTemplate: ProjectTemplate = { - id: 'todo', - label: 'Todo List', - description: 'Lightweight task tracker — single-object example.', - category: 'starter', - async load() { - // Lazy import — see crm.ts for rationale. - // @ts-ignore — outside tsconfig rootDir but safe under bundleRequire/esbuild - const mod = await import('../../../../examples/app-todo/objectstack.config.js'); - return mod.default ?? mod; - }, -}; diff --git a/apps/cloud/server/templates/types.ts b/apps/cloud/server/templates/types.ts deleted file mode 100644 index 6eb51e0d9..000000000 --- a/apps/cloud/server/templates/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Project Template — descriptor used by the provisioning seeder. - * - * A template's `load()` returns an `ObjectStackDefinition`-shaped bundle - * (objects/views/dashboards/flows/agents/apps/data) which is then fanned - * out into `bulkRegister` calls against the freshly-provisioned project - * kernel. Loading is async + lazy so example bundles are evaluated only - * when the template is actually selected — a Zod drift in one example - * cannot crash control-plane bootstrap. - */ -export interface ProjectTemplate { - /** Stable id used by the API / Studio selector. */ - id: string; - /** Human-readable label shown in Studio. */ - label: string; - /** Short description for the picker. */ - description: string; - /** Optional category tag. */ - category?: string; - /** Lazy bundle loader. Must be cheap to call repeatedly. */ - load(): Promise; -} diff --git a/apps/cloud/test/production-flow.test.ts b/apps/cloud/test/production-flow.test.ts deleted file mode 100644 index 80da743e9..000000000 --- a/apps/cloud/test/production-flow.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * production-flow.test.ts - * - * End-to-end verification of the full production deployment shape: - * - * Browser → DNS (project.hostname) - * → apps/cloud (or apps/objectos as runtime node) - * 1. EnvironmentRegistry.resolveByHostname(host) - * → control-plane lookup of sys_project by hostname - * 2. Per-project kernel created (or fetched from cache) - * with the project's database driver + the bundle - * loaded by the chosen template ('crm' here) - * 3. Request dispatched to the project kernel; hooks - * (e.g. account_protection.beforeInsert) execute - * - * In production, apps/cloud and apps/objectos can run as a single - * unified binary (this test) or as two separate processes connected by - * `OS_CLOUD_URL`. Both topologies share the *exact same code paths* - * exercised here — the only difference is the transport between the - * EnvironmentRegistry and the control-plane SQL driver (in-process - * driver vs HTTP). This test validates the in-process flavour because - * it covers all the framework-side code; cross-process transport is a - * deployment concern. - */ - -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -const workdir = mkdtempSync(join(tmpdir(), 'objectstack-prod-flow-')); -const controlDb = join(workdir, 'control.db'); - -process.env.OS_MODE = 'cloud'; -process.env.OS_DATABASE_URL = `file:${controlDb}`; -process.env.AUTH_SECRET = 'production-flow-test-secret-must-be-at-least-32-chars-long'; -process.env.PORT = '0'; -process.env.OS_KERNEL_CACHE_SIZE = '8'; -delete process.env.OS_PROJECT_ARTIFACTS; -delete process.env.OS_ARTIFACT_PATH; - -const { ensureApp, ensureBoot } = await import('../server/index.js'); - -type Init = { - method?: string; - body?: unknown; - headers?: Record; - /** Sets the `Host` header — drives EnvironmentRegistry.resolveByHostname. */ - host?: string; -}; - -async function call(path: string, init: Init = {}): Promise<{ status: number; body: any }> { - const app = await ensureApp(); - const headers: Record = { - 'content-type': 'application/json', - ...(init.headers ?? {}), - }; - const reqInit: RequestInit = { method: init.method ?? 'GET', headers }; - if (init.body !== undefined) reqInit.body = JSON.stringify(init.body); - // The `Host` header is forbidden for fetch() Requests — it's always - // derived from the URL. To exercise hostname-based routing we have - // to put the project's hostname into the URL itself; that's what - // production does too (DNS resolves the vanity domain to the - // runtime node, which then receives `Host: vanity.example.com`). - const url = `http://${init.host ?? 'localhost'}${path}`; - const res = await app.fetch(new Request(url, reqInit)); - const text = await res.text(); - let body: any = text; - try { body = text ? JSON.parse(text) : null; } catch { /* leave as text */ } - return { status: res.status, body }; -} - -async function waitForActive(projectId: string, timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const { status, body } = await call(`/api/v1/cloud/projects/${projectId}`); - if (status === 200 && body?.data?.project?.status === 'active') return body.data.project; - if (status === 200 && body?.data?.project?.status === 'failed') { - throw new Error( - `Project ${projectId} failed to provision: ${JSON.stringify(body?.data?.project?.metadata)}`, - ); - } - await new Promise((r) => setTimeout(r, 100)); - } - throw new Error(`Project ${projectId} did not become active within ${timeoutMs}ms`); -} - -function assert(cond: any, msg: string) { - if (!cond) throw new Error(`Assertion failed: ${msg}`); -} - -const tests: Array<{ name: string; run: () => Promise }> = []; -function test(name: string, run: () => Promise) { tests.push({ name, run }); } - -const state = { orgId: '', projectId: '', hostname: '' }; - -test('boot apps/cloud and seed organization', async () => { - const boot = await ensureBoot(); - const ql = (boot.kernel as any).getService('objectql'); - if (!ql || typeof ql.insert !== 'function') { - throw new Error('control-plane objectql unavailable on cloud kernel'); - } - state.orgId = (globalThis as any).crypto.randomUUID(); - await ql.insert('sys_organization', { - id: state.orgId, - name: 'Production Flow Test Org', - slug: `prod-${state.orgId.slice(0, 8)}`, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); -}); - -test('GET /cloud/templates exposes the CRM template', async () => { - const { status, body } = await call('/api/v1/cloud/templates'); - assert(status === 200, `templates GET expected 200, got ${status}`); - const ids = (body?.data?.templates ?? []).map((t: any) => t.id); - assert(ids.includes('crm'), `expected 'crm' in templates, got ${JSON.stringify(ids)}`); -}); - -test('POST /cloud/projects with template_id=crm + hostname provisions an active project', async () => { - state.hostname = `crm-${Date.now().toString(36)}.test.localhost`; - const { status, body } = await call('/api/v1/cloud/projects', { - method: 'POST', - body: { - organization_id: state.orgId, - display_name: 'CRM Production Flow', - driver: 'sqlite', - hostname: state.hostname, - template_id: 'crm', - metadata: { __simulateDelayMs: 0 }, - }, - }); - if (status < 200 || status >= 300) { - throw new Error(`project create failed: ${status} ${JSON.stringify(body)}`); - } - state.projectId = body?.data?.project?.id ?? body?.data?.id; - assert(state.projectId, `no project id returned: ${JSON.stringify(body)}`); - const project = await waitForActive(state.projectId); - assert( - project?.hostname === state.hostname, - `persisted hostname mismatch: expected ${state.hostname}, got ${project?.hostname}`, - ); -}); - -test('POST /api/v1/data/account with bad website (Host: ) → 400 from CRM hook', async () => { - const { status, body } = await call('/api/v1/data/account', { - method: 'POST', - host: state.hostname, - body: { - name: 'Production Flow Co.', - website: 'bogus', - account_number: 'pf-001', - }, - }); - assert(status === 400, `expected 400 (hook), got ${status} body=${JSON.stringify(body)}`); - assert( - typeof body?.error === 'string' && /website must start with/i.test(body.error), - `expected CRM hook error, got ${JSON.stringify(body)}`, - ); -}); - -test('POST /api/v1/data/account with valid payload → 201 + uppercased account_number', async () => { - const { status, body } = await call('/api/v1/data/account', { - method: 'POST', - host: state.hostname, - body: { - name: 'Acme Hostname Inc.', - website: 'https://acme.example.com', - account_number: 'pf-002', - }, - }); - assert(status === 200 || status === 201, `expected 2xx, got ${status} body=${JSON.stringify(body)}`); - const record = body?.record ?? body?.data?.record ?? body?.data; - assert(record, `no record returned: ${JSON.stringify(body)}`); - assert( - record.account_number === 'PF-002', - `account_number not uppercased by hook (got ${JSON.stringify(record.account_number)})`, - ); -}); - -test('seeded CRM data is queryable through the hostname-routed kernel', async () => { - const { status, body } = await call('/api/v1/data/account?limit=200', { host: state.hostname }); - assert(status === 200, `query expected 200, got ${status}`); - const rows: any[] = body?.data ?? body?.records ?? []; - assert(Array.isArray(rows) && rows.length >= 1, `expected ≥1 account, got ${rows.length}`); - const acme = rows.find((r) => /Acme/i.test(r.name)); - assert(acme, `inserted account not found in list response`); -}); - -let exitCode = 0; -console.log(`[production-flow] workdir: ${workdir}`); -for (const t of tests) { - process.stdout.write(` • ${t.name} ... `); - try { - await t.run(); - console.log('OK'); - } catch (err) { - exitCode = 1; - console.log('FAIL'); - console.error((err as Error).message); - } -} -process.exit(exitCode); diff --git a/apps/cloud/tsconfig.cloudflare.json b/apps/cloud/tsconfig.cloudflare.json deleted file mode 100644 index de13c1614..000000000 --- a/apps/cloud/tsconfig.cloudflare.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "module": "esnext", - "moduleResolution": "bundler", - "types": ["@cloudflare/workers-types"], - "skipLibCheck": true, - "noEmit": true, - "strict": true, - "esModuleInterop": true - }, - "include": ["cloudflare/**/*.ts"] -} diff --git a/apps/cloud/tsconfig.json b/apps/cloud/tsconfig.json deleted file mode 100644 index d45b287b2..000000000 --- a/apps/cloud/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": ".", - "module": "NodeNext", - "skipLibCheck": true - }, - "include": ["*.ts", "lib/**/*.ts", "server/**/*.ts", "api/**/*.ts", "types/**/*.d.ts"], - "exclude": ["node_modules", "dist", "test"] -} diff --git a/apps/cloud/tsup.config.ts b/apps/cloud/tsup.config.ts deleted file mode 100644 index 81b6a8f72..000000000 --- a/apps/cloud/tsup.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineConfig } from 'tsup'; - -// Compile the host config to dist/ so production `start` can run via -// `objectstack serve dist/objectstack.config.js --prebuilt` and skip -// the esbuild/bundle-require runtime overhead used in dev. -export default defineConfig({ - entry: ['objectstack.config.ts'], - outDir: 'dist', - format: ['esm'], - target: 'node20', - splitting: false, - sourcemap: true, - clean: true, - dts: false, - // All workspace deps + native modules stay external — they resolve - // from node_modules at runtime just like in dev. - external: [/^@objectstack\//, /^@example\//, /^@hono\//, 'hono', '@libsql/client'], -}); diff --git a/apps/cloud/types/service-tenant.d.ts b/apps/cloud/types/service-tenant.d.ts deleted file mode 100644 index 23662dbec..000000000 --- a/apps/cloud/types/service-tenant.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@objectstack/service-tenant'; diff --git a/apps/cloud/types/template-bundles.d.ts b/apps/cloud/types/template-bundles.d.ts deleted file mode 100644 index e53100005..000000000 --- a/apps/cloud/types/template-bundles.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Ambient declaration for example-app template bundles statically imported -// by `server/templates/*.ts`. Those paths live outside this package's -// `include` scope so TS cannot discover their types; we accept `any` here -// and rely on the `StackBundle` shape returned by the seeder at runtime. -declare module '*/objectstack.config' { - const bundle: any; - export default bundle; -} diff --git a/apps/cloud/vercel.json b/apps/cloud/vercel.json deleted file mode 100644 index 27a4ea288..000000000 --- a/apps/cloud/vercel.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": null, - "installCommand": "cd ../.. && pnpm install", - "buildCommand": "bash scripts/build-vercel.sh", - "build": { - "env": { - "VITE_RUNTIME_MODE": "server", - "VITE_SERVER_URL": "" - } - }, - "functions": { - "api/**/*.js": { - "maxDuration": 60, - "includeFiles": "api/node_modules/**,api/dist/**" - } - }, - "headers": [ - { - "source": "/_console/assets/(.*)", - "headers": [ - { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } - ] - }, - { - "source": "/_account/assets/(.*)", - "headers": [ - { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } - ] - } - ], - "redirects": [ - { "source": "/", "destination": "/_console/", "permanent": false }, - { "source": "/_console", "destination": "/_console/", "permanent": false }, - { "source": "/_account", "destination": "/_account/", "permanent": false } - ], - "rewrites": [ - { "source": "/api/:path*", "destination": "/api/[[...route]]" }, - { "source": "/_console/:path*", "destination": "/_console/index.html" }, - { "source": "/_account/:path*", "destination": "/_account/index.html" } - ] -} diff --git a/apps/cloud/wrangler.dev.toml b/apps/cloud/wrangler.dev.toml deleted file mode 100644 index ff0511b83..000000000 --- a/apps/cloud/wrangler.dev.toml +++ /dev/null @@ -1,39 +0,0 @@ -# wrangler.dev.toml — local-development override of wrangler.toml. -# -# Differs from wrangler.toml in two ways: -# 1. `image` points at the local Dockerfile so `wrangler dev` builds the -# container against your working tree (instead of pulling the stale -# registry tag). -# 2. `image_build_context = "../.."` because the Dockerfile expects the -# pnpm workspace root as build context. -# -# Run from apps/cloud: -# -# pnpm exec wrangler dev --config wrangler.dev.toml -# -# Place secrets in apps/cloud/.dev.vars (gitignored). At minimum set: -# AUTH_SECRET, OS_DATABASE_URL (or accept the default empty for SQLite), -# AUTH_BASE_URL=http://localhost:8787, OS_TRUSTED_ORIGINS. - -name = "objectstack-cloud" -main = "cloudflare/worker.ts" -compatibility_date = "2025-04-01" -compatibility_flags = ["nodejs_compat"] - -[[containers]] -class_name = "CloudContainer" -image = "Dockerfile" -image_build_context = "../.." -max_instances = 1 -instance_type = "standard-1" - -[[durable_objects.bindings]] -name = "CLOUD" -class_name = "CloudContainer" - -[[migrations]] -tag = "v1" -new_sqlite_classes = ["CloudContainer"] - -[observability] -enabled = true diff --git a/apps/cloud/wrangler.toml b/apps/cloud/wrangler.toml deleted file mode 100644 index 38a3fcd0d..000000000 --- a/apps/cloud/wrangler.toml +++ /dev/null @@ -1,75 +0,0 @@ -# wrangler.toml — Cloudflare Containers deployment for ObjectStack Cloud. -# -# The cloud control plane runs as a long-lived Node.js process (Hono + -# better-sqlite3 + child_process), so it is incompatible with Workers V8 -# isolates. Cloudflare Containers (GA 2025) lets us deploy the production -# Docker image as a Durable Object-backed container, fronted by a tiny -# Worker that proxies HTTP to it. -# -# ────────────────────────── Deployment workflow ────────────────────────── -# -# 1. Build the image from the *repository root* (the Dockerfile expects -# the full pnpm workspace as its build context): -# -# docker build \ -# -f apps/cloud/Dockerfile \ -# -t registry.cloudflare.com//objectstack-cloud:latest . -# -# 2. Push to the Cloudflare container registry: -# -# wrangler containers push \ -# registry.cloudflare.com//objectstack-cloud:latest -# -# 3. Set required secrets (Turso URL + token, auth secret, …): -# -# wrangler secret put OS_DATABASE_URL --config apps/cloud/wrangler.toml -# wrangler secret put OS_DATABASE_AUTH_TOKEN --config apps/cloud/wrangler.toml -# wrangler secret put AUTH_SECRET --config apps/cloud/wrangler.toml -# wrangler secret put TURSO_API_TOKEN --config apps/cloud/wrangler.toml -# wrangler secret put TURSO_ORG_NAME --config apps/cloud/wrangler.toml -# -# 4. Deploy the Worker + Container binding: -# -# wrangler deploy --config apps/cloud/wrangler.toml -# -# ───────────────────────────────────────────────────────────────────────── - -name = "objectstack-cloud" -main = "cloudflare/worker.ts" -compatibility_date = "2025-04-01" -compatibility_flags = ["nodejs_compat"] - -# ── Container definition ──────────────────────────────────────────────── -# `image` points at the OCI tag pushed in step 2 above. Update -# to your Cloudflare account id. The image tag is the source of truth — -# rebuild + re-push to ship a new version, then run `wrangler deploy`. -[[containers]] -class_name = "CloudContainer" -image = "registry.cloudflare.com/2846eb40a60f4738e292b90dcd8cce10/objectstack-cloud:42c6a11d" -max_instances = 3 -instance_type = "standard-1" - -# Non-secret environment is set on the `CloudContainer` class (`envVars` -# field in cloudflare/worker.ts). Secrets are injected via -# `wrangler secret put` and surface as env vars inside the container at -# process spawn time. - -# ── Durable Object binding for the container class ────────────────────── -[[durable_objects.bindings]] -name = "CLOUD" -class_name = "CloudContainer" - -[[migrations]] -tag = "v1" -new_sqlite_classes = ["CloudContainer"] - -# ── Observability ─────────────────────────────────────────────────────── -[observability] -enabled = true - -# ── Worker-only vars (NOT injected into the Container process) ────────── -# Container env is set on the CloudContainer class in cloudflare/worker.ts -# (envVars field). Anything the Node.js runtime reads via process.env — -# AUTH_BASE_URL, OS_TRUSTED_ORIGINS, etc. — MUST live there, not here. -# This [vars] block is intentionally empty. -# [vars] \ No newline at end of file diff --git a/package.json b/package.json index b00514436..0913d6ee5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "build": "turbo run build --filter=!@objectstack/docs", "dev": "pnpm --filter @objectstack/objectos dev", - "dev:cloud": "OS_MODE=cloud pnpm --filter @objectstack/cloud dev", "start": "pnpm --filter @objectstack/objectos start", "dev:crm": "pnpm --filter @objectstack/example-crm build && OS_PROJECT_ID=proj_crm pnpm --filter @objectstack/example-crm dev", "studio:start": "pnpm --filter @objectstack/studio start", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc09d7a27..3defae2a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,115 +157,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - apps/cloud: - dependencies: - '@hono/node-server': - specifier: ^2.0.3 - version: 2.0.3(hono@4.12.21) - '@libsql/client': - specifier: ^0.17.3 - version: 0.17.3 - '@objectstack/account': - specifier: workspace:* - version: link:../account - '@objectstack/cli': - specifier: workspace:* - version: link:../../packages/cli - '@objectstack/console': - specifier: workspace:* - version: link:../console - '@objectstack/driver-memory': - specifier: workspace:* - version: link:../../packages/plugins/driver-memory - '@objectstack/driver-sql': - specifier: workspace:* - version: link:../../packages/plugins/driver-sql - '@objectstack/driver-turso': - specifier: workspace:* - version: link:../../packages/plugins/driver-turso - '@objectstack/hono': - specifier: workspace:* - version: link:../../packages/adapters/hono - '@objectstack/metadata': - specifier: workspace:* - version: link:../../packages/metadata - '@objectstack/objectql': - specifier: workspace:* - version: link:../../packages/objectql - '@objectstack/plugin-audit': - specifier: workspace:* - version: link:../../packages/plugins/plugin-audit - '@objectstack/plugin-auth': - specifier: workspace:* - version: link:../../packages/plugins/plugin-auth - '@objectstack/plugin-hono-server': - specifier: workspace:* - version: link:../../packages/plugins/plugin-hono-server - '@objectstack/plugin-security': - specifier: workspace:* - version: link:../../packages/plugins/plugin-security - '@objectstack/runtime': - specifier: workspace:* - version: link:../../packages/runtime - '@objectstack/service-ai': - specifier: workspace:* - version: link:../../packages/services/service-ai - '@objectstack/service-analytics': - specifier: workspace:* - version: link:../../packages/services/service-analytics - '@objectstack/service-automation': - specifier: workspace:* - version: link:../../packages/services/service-automation - '@objectstack/service-cloud': - specifier: workspace:* - version: link:../../packages/services/service-cloud - '@objectstack/service-feed': - specifier: workspace:* - version: link:../../packages/services/service-feed - '@objectstack/service-package': - specifier: workspace:* - version: link:../../packages/services/service-package - '@objectstack/service-tenant': - specifier: workspace:* - version: link:../../packages/services/service-tenant - '@objectstack/spec': - specifier: workspace:* - version: link:../../packages/spec - hono: - specifier: ^4.12.21 - version: 4.12.21 - pg: - specifier: ^8.21.0 - version: 8.21.0 - devDependencies: - '@cloudflare/containers': - specifier: ^0.3.4 - version: 0.3.4 - '@cloudflare/workers-types': - specifier: ^4.20260520.1 - version: 4.20260520.1 - '@types/pg': - specifier: ^8.20.0 - version: 8.20.0 - esbuild: - specifier: ^0.28.0 - version: 0.28.0 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.1)(typescript@6.0.3) - tsup: - specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) - tsx: - specifier: ^4.22.3 - version: 4.22.3 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - wrangler: - specifier: ^4.93.0 - version: 4.93.0(@cloudflare/workers-types@4.20260520.1) - apps/console: dependencies: '@object-ui/app-shell': @@ -5515,9 +5406,6 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -13664,12 +13552,6 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/pg@8.20.0': - dependencies: - '@types/node': 25.9.1 - pg-protocol: 1.14.0 - pg-types: 2.2.0 - '@types/prismjs@1.26.6': {} '@types/qs@6.15.1': {} From 2ecf6b2aead9dcb336da2c5bb59a917ce0892e7a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 17:59:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(runtime):=20align=20with=20sys=5Fproje?= =?UTF-8?q?ct=20=E2=86=92=20sys=5Fenvironment=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing test drift from ADR-0006 project→environment rename: production code reads sys_environment / sys_environment_member with environment_id columns, but four tests still expected the old names. This commit only renames in tests; no production code changes. Fixes 4 failing tests in: - packages/runtime/src/http-dispatcher.test.ts (RBAC membership lookup) - packages/runtime/src/cloud/platform-sso.test.ts (backfill scanning) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/runtime/src/cloud/platform-sso.test.ts | 4 ++-- packages/runtime/src/http-dispatcher.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/cloud/platform-sso.test.ts b/packages/runtime/src/cloud/platform-sso.test.ts index f874df601..3b4569ee8 100644 --- a/packages/runtime/src/cloud/platform-sso.test.ts +++ b/packages/runtime/src/cloud/platform-sso.test.ts @@ -168,7 +168,7 @@ describe('seedPlatformSsoClient', () => { describe('backfillPlatformSsoClients', () => { it('seeds every project that lacks an oauth client', async () => { const ql = createMockQl({ - sys_project: [ + sys_environment: [ { id: 'p1', hostname: 'one.example.com', status: 'active' }, { id: 'p2', hostname: 'two.example.com', status: 'active' }, ], @@ -182,7 +182,7 @@ describe('backfillPlatformSsoClients', () => { it('skips projects that already have an oauth client', async () => { const ql = createMockQl({ - sys_project: [{ id: 'p1', hostname: 'one.example.com', status: 'active' }], + sys_environment: [{ id: 'p1', hostname: 'one.example.com', status: 'active' }], sys_oauth_application: [{ id: 'oauthc_p1', client_id: 'project_p1', diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 47445524d..9dddc3fbd 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1414,7 +1414,7 @@ describe('HttpDispatcher', () => { const memberQL = { ...mockObjectQL, find: vi.fn().mockImplementation(async (name: string) => { - if (name === 'sys_project_member') return opts.memberRows ?? []; + if (name === 'sys_environment_member') return opts.memberRows ?? []; return []; }), }; @@ -1462,8 +1462,8 @@ describe('HttpDispatcher', () => { expect(result).not.toBeNull(); expect(result.status).toBe(403); expect(result.body.error.details.type).toBe('PROJECT_MEMBERSHIP_REQUIRED'); - expect(memberQL.find).toHaveBeenCalledWith('sys_project_member', expect.objectContaining({ - where: { project_id: 'proj-private', user_id: 'user-1' }, + expect(memberQL.find).toHaveBeenCalledWith('sys_environment_member', expect.objectContaining({ + where: { environment_id: 'proj-private', user_id: 'user-1' }, })); }); From e69da73e3e4625f4e33400aa1533ca8e867a548c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 18:00:43 +0800 Subject: [PATCH 3/4] refactor(cli): graceful failure when service-cloud is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud/runtime boot mode dispatch dynamically imports @objectstack/service-cloud. Previously a missing/broken install would fail with an opaque ERR_MODULE_NOT_FOUND stack trace. Wrap the import in try/catch and surface a clear, actionable error that tells the user to either install the package or switch to bootMode= 'standalone'. This is the only remaining hard dependency surface between the CLI and service-cloud — the other framework packages (runtime, rest, adapters/hono) already use structural / 'any'-typed interfaces. Decoupling note: physically moving service-cloud out of the framework workspace into the private cloud repo is a separate design decision (would break cli's monorepo build of cloud-aware boot modes). Tracked as a follow-up; not in scope here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/commands/serve.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 9a5a900d4..433d1d678 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -284,7 +284,20 @@ export default class Serve extends Command { const bootResult = await createStandaloneStack(config.standalone); config = { ...originalConfig, ...bootResult } as any; } else { - const { createBootStack } = await import('@objectstack/service-cloud'); + // Cloud / multi-project boot modes require @objectstack/service-cloud. + // When the package is unavailable (e.g. someone vendored only the + // public framework), fail with a clear, actionable error instead of + // an opaque module-not-found stack trace. + let createBootStack: any; + try { + ({ createBootStack } = await import('@objectstack/service-cloud')); + } catch (err) { + throw new Error( + `Boot mode '${resolvedMode}' requires @objectstack/service-cloud, which is not installed.\n` + + `Either install it (\`pnpm add @objectstack/service-cloud\`) or switch to bootMode='standalone'.\n` + + `Underlying error: ${(err as Error)?.message ?? String(err)}`, + ); + } const bootResult = await createBootStack({ mode: config.bootMode, runtime: config.runtime ?? config.project, From a170d9272e83b08677c95d4e06a50b7b832f224c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 21 May 2026 18:24:37 +0800 Subject: [PATCH 4/4] chore: remove packages/services/service-cloud (split to objectstack-ai/cloud repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control-plane service-cloud package now lives in the private cloud repo (objectstack-ai/cloud) alongside apps/cloud. Framework consumers already use only the structural `KernelManager` / `EnvironmentDriverRegistry` interfaces (any-typed) or dynamic `await import('@objectstack/service-cloud')` with try/catch fallbacks. No real imports remained — only doc comments. Changes: - packages/services/service-cloud/ deleted (~10k LOC, 56 files). - packages/cli/package.json: drop "@objectstack/service-cloud":"workspace:*" dependency. CLI's serve.ts already handles missing module gracefully via dynamic import + try/catch (commit e69da73e). - packages/cli/src/types/service-cloud.d.ts retained as ambient stub so TS still compiles the optional dynamic import path. - pnpm-lock.yaml refreshed (-89 lines). Verified: `pnpm turbo run test` → 108/108 tasks green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/package.json | 1 - packages/services/service-cloud/CHANGELOG.md | 22 - packages/services/service-cloud/package.json | 80 --- .../service-cloud/src/artifact-api-client.ts | 271 ------- .../src/artifact-environment-registry.ts | 262 ------- .../src/artifact-kernel-factory.ts | 298 -------- .../service-cloud/src/auth-proxy-plugin.ts | 190 ----- .../services/service-cloud/src/boot-env.ts | 130 ---- .../services/service-cloud/src/boot-stack.ts | 94 --- .../src/cloud-artifact-api-plugin.ts | 128 ---- .../src/cloud-artifact-helpers.ts | 282 -------- .../services/service-cloud/src/cloud-stack.ts | 183 ----- .../service-cloud/src/control-plane-preset.ts | 305 -------- .../src/control-plane-proxy-driver.ts | 227 ------ .../services/service-cloud/src/data-dir.ts | 103 --- .../src/default-project-plugins.ts | 201 ------ .../service-cloud/src/environment-registry.ts | 361 ---------- .../service-cloud/src/fs-bundle-resolver.ts | 110 --- packages/services/service-cloud/src/index.ts | 140 ---- .../service-cloud/src/kernel-manager.ts | 145 ---- .../service-cloud/src/local-identity.ts | 107 --- .../service-cloud/src/multi-project-plugin.ts | 598 ---------------- .../src/multi-project-plugins.ts | 92 --- .../service-cloud/src/objectos-stack.ts | 214 ------ .../src/preview/environment-registry.ts | 256 ------- .../service-cloud/src/preview/host-parser.ts | 134 ---- .../src/preview/kernel-factory.ts | 121 ---- .../src/preview/preview-stack.ts | 203 ------ .../src/project-kernel-factory.ts | 483 ------------- .../src/project-scope-manager.ts | 112 --- .../service-cloud/src/routes/branches.ts | 287 -------- .../service-cloud/src/routes/cloud.ts | 597 --------------- .../src/routes/package-install.ts | 315 -------- .../src/routes/package-publish.ts | 364 ---------- .../src/routes/project-lifecycle.ts | 677 ------------------ .../service-cloud/src/routes/public.ts | 198 ----- .../service-cloud/src/routes/storage.ts | 111 --- .../service-cloud/src/routes/types.ts | 92 --- .../service-cloud/src/runtime-stack.ts | 308 -------- .../src/shared-project-plugin.ts | 118 --- .../src/single-project-plugin.ts | 119 --- .../src/starter-seeder-plugin.ts | 102 --- .../services/service-cloud/src/storage-env.ts | 77 -- .../test/artifact-api-client.test.ts | 111 --- .../artifact-environment-registry.test.ts | 103 --- .../service-cloud/test/branches.test.ts | 363 ---------- .../test/cloud-artifact-api-plugin.test.ts | 303 -------- .../service-cloud/test/data-dir.test.ts | 89 --- .../test/default-project-plugins.test.ts | 93 --- .../test/fs-bundle-resolver.test.ts | 165 ----- .../test/preview-environment-registry.test.ts | 202 ------ .../test/preview-host-parser.test.ts | 139 ---- .../test/public-artifact-routes.test.ts | 232 ------ packages/services/service-cloud/tsconfig.json | 9 - .../services/service-cloud/tsup.config.ts | 31 - pnpm-lock.yaml | 89 --- 56 files changed, 11147 deletions(-) delete mode 100644 packages/services/service-cloud/CHANGELOG.md delete mode 100644 packages/services/service-cloud/package.json delete mode 100644 packages/services/service-cloud/src/artifact-api-client.ts delete mode 100644 packages/services/service-cloud/src/artifact-environment-registry.ts delete mode 100644 packages/services/service-cloud/src/artifact-kernel-factory.ts delete mode 100644 packages/services/service-cloud/src/auth-proxy-plugin.ts delete mode 100644 packages/services/service-cloud/src/boot-env.ts delete mode 100644 packages/services/service-cloud/src/boot-stack.ts delete mode 100644 packages/services/service-cloud/src/cloud-artifact-api-plugin.ts delete mode 100644 packages/services/service-cloud/src/cloud-artifact-helpers.ts delete mode 100644 packages/services/service-cloud/src/cloud-stack.ts delete mode 100644 packages/services/service-cloud/src/control-plane-preset.ts delete mode 100644 packages/services/service-cloud/src/control-plane-proxy-driver.ts delete mode 100644 packages/services/service-cloud/src/data-dir.ts delete mode 100644 packages/services/service-cloud/src/default-project-plugins.ts delete mode 100644 packages/services/service-cloud/src/environment-registry.ts delete mode 100644 packages/services/service-cloud/src/fs-bundle-resolver.ts delete mode 100644 packages/services/service-cloud/src/index.ts delete mode 100644 packages/services/service-cloud/src/kernel-manager.ts delete mode 100644 packages/services/service-cloud/src/local-identity.ts delete mode 100644 packages/services/service-cloud/src/multi-project-plugin.ts delete mode 100644 packages/services/service-cloud/src/multi-project-plugins.ts delete mode 100644 packages/services/service-cloud/src/objectos-stack.ts delete mode 100644 packages/services/service-cloud/src/preview/environment-registry.ts delete mode 100644 packages/services/service-cloud/src/preview/host-parser.ts delete mode 100644 packages/services/service-cloud/src/preview/kernel-factory.ts delete mode 100644 packages/services/service-cloud/src/preview/preview-stack.ts delete mode 100644 packages/services/service-cloud/src/project-kernel-factory.ts delete mode 100644 packages/services/service-cloud/src/project-scope-manager.ts delete mode 100644 packages/services/service-cloud/src/routes/branches.ts delete mode 100644 packages/services/service-cloud/src/routes/cloud.ts delete mode 100644 packages/services/service-cloud/src/routes/package-install.ts delete mode 100644 packages/services/service-cloud/src/routes/package-publish.ts delete mode 100644 packages/services/service-cloud/src/routes/project-lifecycle.ts delete mode 100644 packages/services/service-cloud/src/routes/public.ts delete mode 100644 packages/services/service-cloud/src/routes/storage.ts delete mode 100644 packages/services/service-cloud/src/routes/types.ts delete mode 100644 packages/services/service-cloud/src/runtime-stack.ts delete mode 100644 packages/services/service-cloud/src/shared-project-plugin.ts delete mode 100644 packages/services/service-cloud/src/single-project-plugin.ts delete mode 100644 packages/services/service-cloud/src/starter-seeder-plugin.ts delete mode 100644 packages/services/service-cloud/src/storage-env.ts delete mode 100644 packages/services/service-cloud/test/artifact-api-client.test.ts delete mode 100644 packages/services/service-cloud/test/artifact-environment-registry.test.ts delete mode 100644 packages/services/service-cloud/test/branches.test.ts delete mode 100644 packages/services/service-cloud/test/cloud-artifact-api-plugin.test.ts delete mode 100644 packages/services/service-cloud/test/data-dir.test.ts delete mode 100644 packages/services/service-cloud/test/default-project-plugins.test.ts delete mode 100644 packages/services/service-cloud/test/fs-bundle-resolver.test.ts delete mode 100644 packages/services/service-cloud/test/preview-environment-registry.test.ts delete mode 100644 packages/services/service-cloud/test/preview-host-parser.test.ts delete mode 100644 packages/services/service-cloud/test/public-artifact-routes.test.ts delete mode 100644 packages/services/service-cloud/tsconfig.json delete mode 100644 packages/services/service-cloud/tsup.config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 39a999f51..f5088e63f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,7 +62,6 @@ "@objectstack/service-analytics": "workspace:*", "@objectstack/service-automation": "workspace:*", "@objectstack/service-cache": "workspace:*", - "@objectstack/service-cloud": "workspace:*", "@objectstack/service-feed": "workspace:*", "@objectstack/service-job": "workspace:*", "@objectstack/service-package": "workspace:*", diff --git a/packages/services/service-cloud/CHANGELOG.md b/packages/services/service-cloud/CHANGELOG.md deleted file mode 100644 index 1cffdb29a..000000000 --- a/packages/services/service-cloud/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -# @objectstack/service-cloud - -## 4.0.5 - -### Patch Changes - -- 15e0df6: chore: unify all package versions to a single patch release -- Updated dependencies [15e0df6] - - @objectstack/spec@4.0.5 - - @objectstack/core@4.0.5 - - @objectstack/metadata@4.0.5 - - @objectstack/objectql@4.0.5 - - @objectstack/runtime@4.0.5 - - @objectstack/driver-memory@4.0.5 - - @objectstack/driver-sql@4.0.5 - - @objectstack/driver-turso@4.0.5 - - @objectstack/driver-mongodb@4.0.5 - - @objectstack/plugin-audit@4.0.5 - - @objectstack/plugin-auth@4.0.5 - - @objectstack/plugin-security@4.0.5 - - @objectstack/service-package@4.0.5 - - @objectstack/service-tenant@4.0.5 diff --git a/packages/services/service-cloud/package.json b/packages/services/service-cloud/package.json deleted file mode 100644 index ac330e061..000000000 --- a/packages/services/service-cloud/package.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "name": "@objectstack/service-cloud", - "version": "4.0.5", - "description": "ObjectStack Cloud Service - Multi-project orchestration, control-plane, and cloud deployment", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@objectstack/core": "workspace:^", - "@objectstack/driver-memory": "workspace:^", - "@objectstack/driver-mongodb": "workspace:^", - "@objectstack/driver-sql": "workspace:^", - "@objectstack/driver-turso": "workspace:^", - "@objectstack/metadata": "workspace:^", - "@objectstack/objectql": "workspace:^", - "@objectstack/plugin-audit": "workspace:^", - "@objectstack/plugin-auth": "workspace:^", - "@objectstack/plugin-email": "workspace:^", - "@objectstack/plugin-security": "workspace:^", - "@objectstack/runtime": "workspace:^", - "@objectstack/service-cache": "workspace:^", - "@objectstack/service-i18n": "workspace:^", - "@objectstack/service-job": "workspace:^", - "@objectstack/service-package": "workspace:^", - "@objectstack/service-queue": "workspace:^", - "@objectstack/service-settings": "workspace:^", - "@objectstack/service-storage": "workspace:*", - "@objectstack/service-tenant": "workspace:^", - "@objectstack/spec": "workspace:^", - "zod": "^4.4.3" - }, - "optionalDependencies": { - "pg": "^8.21.0" - }, - "devDependencies": { - "@types/node": "^22.19.19", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "vitest": "^4.1.7" - }, - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/objectstack-ai/framework.git", - "directory": "packages/services/service-cloud" - }, - "keywords": [ - "objectstack", - "cloud", - "multi-project", - "control-plane", - "multi-tenant" - ], - "author": "ObjectStack Team", - "license": "Apache-2.0", - "homepage": "https://objectstack.ai/docs", - "bugs": "https://github.com/objectstack-ai/framework/issues", - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/services/service-cloud/src/artifact-api-client.ts b/packages/services/service-cloud/src/artifact-api-client.ts deleted file mode 100644 index a25ab0caf..000000000 --- a/packages/services/service-cloud/src/artifact-api-client.ts +++ /dev/null @@ -1,271 +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} - * → { projectId: string, organizationId?: string, runtime?: ProjectRuntimeConfig } - * - * GET {controlPlaneUrl}/api/v1/cloud/projects/:projectId/artifact - * → ProjectArtifactResponse (ProjectArtifact + 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 { ProjectArtifact } 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 ProjectRuntimeConfig { - 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; -} - -/** - * Hostname resolution response. - */ -export interface ResolvedHostname { - projectId: string; - organizationId?: string; - /** Optional runtime config — when present, callers can skip the artifact fetch's runtime block. */ - runtime?: ProjectRuntimeConfig; -} - -/** - * Artifact response wrapping the spec's `ProjectArtifact` envelope plus - * an optional `runtime` block carrying the project's database - * connection details. - */ -export interface ProjectArtifactResponse extends ProjectArtifact { - runtime?: ProjectRuntimeConfig; -} - -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.projectId !== 'string' || !body.projectId) return null; - const value: ResolvedHostname = { - projectId: body.projectId, - 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(projectId: string, opts?: { commit?: string }): Promise { - const commit = opts?.commit?.trim() || ''; - const cacheKey = commit ? `${projectId}@${commit}` : projectId; - 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/projects/${encodeURIComponent(projectId)}/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`', { projectId, commit }); - return null; - } - const value = body as ProjectArtifactResponse; - 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 projectId. 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<{ projectId: 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/projects-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.projectId !== 'string' || !body.projectId) return null; - return { projectId: body.projectId, 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/projects/:id/branches` endpoint. - */ - async fetchBranchHead( - projectId: string, - branchName: string, - ): Promise<{ commitId: string; publishedAt?: string | null } | null> { - const url = `${this.base}/api/v1/cloud/projects/${encodeURIComponent(projectId)}/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 }; - } - - /** Drop cached entries for a project (and any matching hostname). */ - invalidate(projectId: string): void { - // Cache keys are `${projectId}` for HEAD or `${projectId}@${commit}` - // for pinned reads (preview runtime). Drop both shapes. - this.artifactCache.delete(projectId); - const prefix = `${projectId}@`; - 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.projectId === projectId) 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/services/service-cloud/src/artifact-environment-registry.ts b/packages/services/service-cloud/src/artifact-environment-registry.ts deleted file mode 100644 index b4c0fcbc1..000000000 --- a/packages/services/service-cloud/src/artifact-environment-registry.ts +++ /dev/null @@ -1,262 +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 → - * projectId 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_project` 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 type { EnvironmentDriverRegistry } from './environment-registry.js'; -import type { ArtifactApiClient, ProjectRuntimeConfig } from './artifact-api-client.js'; -import { resolveDefaultDataDir } from './data-dir.js'; - -type IDataDriver = Contracts.IDataDriver; - -interface CacheEntry { - projectId: 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<{ projectId: string; driver: IDataDriver } | null> { - const cached = this.hostnameCache.get(host); - if (cached && cached.expiresAt > Date.now()) { - return { projectId: cached.projectId, driver: cached.driver }; - } - const key = `host:${host}`; - const inflight = this.pending.get(key); - if (inflight) { - const result = await inflight; - return result ? { projectId: result.projectId, 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.projectId, resolved.runtime, resolved.organizationId, host); - if (!entry) return null; - this.hostnameCache.set(host, entry); - this.idCache.set(entry.projectId, 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 ? { projectId: entry.projectId, driver: entry.driver } : null; - } - - async resolveById(projectId: string): Promise { - const cached = this.idCache.get(projectId); - if (cached && cached.expiresAt > Date.now()) return cached.driver; - - const key = `id:${projectId}`; - 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(projectId, undefined, undefined, undefined); - if (!entry) return null; - this.idCache.set(projectId, entry); - if (entry.project?.hostname) this.hostnameCache.set(entry.project.hostname, entry); - return entry; - } catch (err: any) { - this.logger.error?.('[ArtifactEnvironmentRegistry] resolveById failed', { - projectId, - 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(projectId: string): { projectId: string; driver: IDataDriver; project: any } | null { - const cached = this.idCache.get(projectId); - if (cached && cached.expiresAt > Date.now()) { - return { projectId: cached.projectId, driver: cached.driver, project: cached.project }; - } - return null; - } - - invalidate(projectId: string): void { - this.idCache.delete(projectId); - for (const [host, entry] of this.hostnameCache) { - if (entry.projectId === projectId) this.hostnameCache.delete(host); - } - this.client.invalidate(projectId); - } - - private async buildCacheEntry( - projectId: string, - runtimeFromHostname: ProjectRuntimeConfig | undefined, - orgIdFromHostname: string | undefined, - hostname: string | undefined, - ): Promise { - let runtime = runtimeFromHostname; - let organizationId = orgIdFromHostname; - let host = hostname; - let artifactProjectId = projectId; - - if (!runtime || !organizationId) { - const artifact = await this.client.fetchArtifact(projectId); - if (!artifact) { - this.logger.warn?.('[ArtifactEnvironmentRegistry] artifact not found', { projectId }); - return null; - } - artifactProjectId = artifact.projectId ?? projectId; - 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', { projectId }); - 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, - }; - - return { - projectId: 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): ProjectRuntimeConfig | 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 'memory': { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const { resolve: resolvePath } = await import('node:path'); - const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); - const filePath = dbName - ? resolvePath(resolveDefaultDataDir(), '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 'libsql': - case 'turso': { - const { TursoDriver } = await import('@objectstack/driver-turso'); - return new TursoDriver({ url: databaseUrl, authToken }) 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/services/service-cloud/src/artifact-kernel-factory.ts b/packages/services/service-cloud/src/artifact-kernel-factory.ts deleted file mode 100644 index a10fb36c6..000000000 --- a/packages/services/service-cloud/src/artifact-kernel-factory.ts +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ProjectKernelFactory backed by the control plane's Artifact API. - * - * Differs from {@link DefaultProjectKernelFactory} 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 (no system-object registration) - * • AuthPlugin — per-project, derives an HKDF secret from - * `OS_AUTH_SECRET` + projectId. 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 type * as Contracts from '@objectstack/spec/contracts'; -import { DriverPlugin, AppPlugin } from '@objectstack/runtime'; -import type { ProjectKernelFactory } from './kernel-manager.js'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; -import type { ArtifactApiClient } from './artifact-api-client.js'; -import { mountDefaultProjectPlugins } from './default-project-plugins.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, projectId). Falls back to - * `process.env.OS_AUTH_SECRET` / `AUTH_SECRET` at construction time. - */ - authBaseSecret?: string; -} - -/** - * Derive a deterministic per-project auth secret. HMAC-SHA256 of the - * projectId 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, projectId: string): string { - return createHmac('sha256', baseSecret).update(`project:${projectId}`).digest('hex'); -} - -export class ArtifactKernelFactory implements ProjectKernelFactory { - private readonly client: ArtifactApiClient; - private readonly envRegistry: EnvironmentDriverRegistry; - private readonly logger: NonNullable; - private readonly kernelConfig?: ArtifactKernelFactoryConfig['kernelConfig']; - private readonly authBaseSecret: string; - - constructor(config: ArtifactKernelFactoryConfig) { - this.client = config.client; - this.envRegistry = config.envRegistry; - this.logger = config.logger ?? console; - this.kernelConfig = config.kernelConfig; - this.authBaseSecret = ( - config.authBaseSecret - ?? process.env.OS_AUTH_SECRET - ?? process.env.AUTH_SECRET - ?? '' - ).trim(); - } - - async create(projectId: string): Promise { - let cached = this.envRegistry.peekById(projectId); - if (!cached) { - const driver = await this.envRegistry.resolveById(projectId); - if (!driver) { - throw new Error(`[ArtifactKernelFactory] Could not resolve driver for project '${projectId}'`); - } - cached = this.envRegistry.peekById(projectId); - if (!cached) { - throw new Error(`[ArtifactKernelFactory] envRegistry returned a driver but no cached entry for '${projectId}'`); - } - } - - const driver: IDataDriver = cached.driver; - const project = cached.project as { id: string; organization_id?: string; hostname?: string }; - - const artifact = await this.client.fetchArtifact(projectId); - if (!artifact) { - throw new Error(`[ArtifactKernelFactory] Artifact not available for project '${projectId}'`); - } - - 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({ projectId: projectId, skipSchemaSync: false })); - await kernel.use(new MetadataPlugin({ - watch: false, - projectId: projectId, - organizationId: project.organization_id, - registerSystemObjects: false, - })); - - // 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, projectId); - const baseUrl = project.hostname - ? (project.hostname.startsWith('http') ? project.hostname : `https://${project.hostname}`) - : 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: baseUrl ? [baseUrl] : undefined, - } as any)); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] AuthPlugin not registered', { - projectId, - error: err?.message, - }); - } - } else { - this.logger.warn?.('[ArtifactKernelFactory] OS_AUTH_SECRET not set — per-project AuthPlugin skipped (auth endpoints will return 404)', { projectId }); - } - - // Per-project SecurityPlugin — provides RBAC + tenant_isolation RLS - // AND, crucially, the `sys_user` insert middleware that auto-creates - // a personal organization for new self-service signups (without it, - // a freshly registered user lands on a UI showing "No data" because - // they have zero `sys_member` rows and the default RLS denies all). - // The CLI's `objectstack serve` does this for `pnpm dev`; we have - // to mirror that behaviour for cloud-deployed per-project kernels. - try { - const { SecurityPlugin } = await import('@objectstack/plugin-security'); - const multiTenant = String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false'; - await kernel.use(new SecurityPlugin({ multiTenant }) as any); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] SecurityPlugin not registered', { - projectId, - error: err?.message, - }); - } - - // Mount the default per-project plugin slate (queue, job, cache, - // settings, email, storage). Mirrors `ALWAYS_CAPS` in - // `packages/cli/src/commands/serve.ts` so hosted tenants get the - // same foundational services a single-tenant `objectstack dev` - // stack gets. - await mountDefaultProjectPlugins(kernel, { - projectId, - logger: this.logger, - }); - - const projectName = project.hostname ?? projectId; - const bundle = artifact.metadata as any; - 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=${projectId}, translations=${trArr.length}, defaultLocale=${i18nCfg.defaultLocale ?? 'en'})`, - ); - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] I18nServicePlugin not registered', { - projectId, - error: err?.message, - }); - } - - await kernel.use(new AppPlugin(bundle, { - projectId, - organizationId: project.organization_id ?? '', - projectName, - packageId, - source: packageId ? 'package' : 'user', - } as any)); - - await kernel.bootstrap(); - - // 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', { - projectId, locale, error: err?.message, - }); - } - } - } - } - if (loaded > 0) { - this.logger.info?.('[ArtifactKernelFactory] i18n direct-load complete', { - projectId, locales: loaded, bundles: trArr.length, - }); - } - } - } catch (err: any) { - this.logger.warn?.('[ArtifactKernelFactory] i18n direct-load failed', { - projectId, - error: err?.message, - }); - } - - this.logger.info?.('[ArtifactKernelFactory] kernel ready', { - projectId, - commitId: artifact.commitId, - checksum: artifact.checksum, - authEnabled: Boolean(this.authBaseSecret), - }); - - return kernel; - } -} diff --git a/packages/services/service-cloud/src/auth-proxy-plugin.ts b/packages/services/service-cloud/src/auth-proxy-plugin.ts deleted file mode 100644 index 4abdb2afe..000000000 --- a/packages/services/service-cloud/src/auth-proxy-plugin.ts +++ /dev/null @@ -1,190 +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 type { KernelManager } from './kernel-manager.js'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; - -const AUTH_PREFIX = '/api/v1/auth'; - -interface AuthCapableManager { - handler?: (req: Request) => Promise; - getApi?: () => Promise<{ handler: (req: Request) => Promise }>; - api?: { handler: (req: Request) => Promise }; -} - -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 projectId: string | undefined; - try { - const env = await envRegistry.resolveByHostname(host); - projectId = env?.projectId; - } catch { - // ignore - } - if (!projectId) { - return c.json({ error: 'project_not_found', host }, 404); - } - - const projectKernel = await kernelManager.getOrCreate(projectId); - 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 { - 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 }); - } - } - - const fn = await resolveAuthHandler(authSvc); - if (!fn) { - return c.json({ error: 'auth_service_unavailable', projectId }, 503); - } - - // Forward the original Web Request directly — better-auth - // accepts a standard `Request` and returns a `Response`. - return await fn(c.req.raw); - } catch (err: any) { - ctx.logger?.error?.('[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/services/service-cloud/src/boot-env.ts b/packages/services/service-cloud/src/boot-env.ts deleted file mode 100644 index 640852e2d..000000000 --- a/packages/services/service-cloud/src/boot-env.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Boot environment resolution. - * - * Single source of truth for reading `process.env` related to deployment - * mode selection. All other modules in this package consume the resolved - * values via the helpers exported here — no other module reads - * `process.env` directly. - * - * Each helper accepts an optional `env` override so callers (and tests) - * can pass an explicit environment without mutating the process. - */ - -import { z } from 'zod'; - -export const BootEnvSchema = z.object({ - OS_MODE: z.string().optional(), - OS_MULTI_PROJECT: z.string().optional(), - AUTH_SECRET: z.string().optional(), - AUTH_BASE_URL: z.string().optional(), - OS_BASE_URL: z.string().optional(), - NEXT_PUBLIC_BASE_URL: z.string().optional(), - VERCEL_PROJECT_PRODUCTION_URL: z.string().optional(), - VERCEL_URL: z.string().optional(), - PORT: z.coerce.number().optional(), - OS_PROJECT_ID: z.string().optional(), - OS_ARTIFACT_PATH: z.string().optional(), - OS_DATABASE_URL: z.string().optional(), - OS_DATABASE_AUTH_TOKEN: z.string().optional(), - OS_DATABASE_DRIVER: z.string().optional(), - TURSO_DATABASE_URL: z.string().optional(), - TURSO_AUTH_TOKEN: z.string().optional(), -}); - -export type BootEnv = z.infer; -export type BootMode = 'runtime' | 'cloud' | 'standalone'; - -const DEV_AUTH_SECRET_FALLBACK = - 'dev-secret-please-change-in-production-min-32-chars'; - -function envFlag(value: string | undefined): boolean { - return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); -} - -function pickEnv(env?: Record): Record { - return env ?? (process.env as Record); -} - -/** - * Resolve the deployment mode from environment. - * - * Recognised values: - * - `standalone` → `'standalone'` (default) - * - `runtime`, `project`, `local`, `single-project` → `'runtime'` - * - `cloud`, `multi-project` → `'cloud'` - * - * Falls back to `'standalone'` when unset; logs a warning and falls back - * to `'standalone'` when the value is unrecognised. The legacy - * `OS_MULTI_PROJECT=true` flag is still honoured (with a - * deprecation warning) when `OS_MODE` is unset. - * - * The `project` value continues to be accepted as a deprecated alias for - * `runtime` — that mode was renamed in the v4.x series to better reflect - * its role (a runtime node connected to ObjectStack Cloud) and to leave - * "project" available as a domain term. - */ -export function resolveMode(env?: Record): BootMode { - const e = pickEnv(env); - const raw = e.OS_MODE?.trim().toLowerCase(); - if (raw === 'cloud' || raw === 'multi-project') return 'cloud'; - if (raw === 'standalone') return 'standalone'; - if (raw === 'runtime') return 'runtime'; - if (raw === 'project' || raw === 'local' || raw === 'single-project') { - // eslint-disable-next-line no-console - console.warn( - `[objectstack] OS_MODE=${raw} is a deprecated alias for "runtime"; please update your config.`, - ); - return 'runtime'; - } - if (raw && raw.length > 0) { - // eslint-disable-next-line no-console - console.warn(`[objectstack] Unknown OS_MODE=${raw}; falling back to "standalone".`); - } - if (envFlag(e.OS_MULTI_PROJECT)) { - // eslint-disable-next-line no-console - console.warn( - '[objectstack] OS_MULTI_PROJECT is deprecated. Use `OS_MODE=cloud` instead.', - ); - return 'cloud'; - } - return 'standalone'; -} - -/** - * Auth secret used by `plugin-auth` (better-auth). Returns the dev - * fallback when unset — explicit warning is the caller's responsibility. - */ -export function resolveAuthSecret(env?: Record): string { - const e = pickEnv(env); - return e.AUTH_SECRET ?? DEV_AUTH_SECRET_FALLBACK; -} - -/** - * Public origin used by better-auth callbacks. - * - * Resolution order (highest priority first): - * 1. `AUTH_BASE_URL` — explicit, framework-wide (matches what - * better-auth itself reads via `BETTER_AUTH_URL`). - * 2. `OS_BASE_URL` — ObjectStack-specific alias. - * 3. `NEXT_PUBLIC_BASE_URL` — Next.js apps that publish their public URL. - * 4. `VERCEL_PROJECT_PRODUCTION_URL` — Vercel stable prod URL. - * 5. `VERCEL_URL` — Vercel per-deploy URL. - * 6. `http://localhost:` — last-resort dev fallback. - * - * The CLI's `serve` command builds its trustedOrigins allow-list from - * the same env vars (see packages/cli/src/commands/serve.ts) — keep - * the precedence here in sync. - */ -export function resolveBaseUrl(env?: Record): string { - const e = pickEnv(env); - return ( - e.AUTH_BASE_URL - ?? e.OS_BASE_URL - ?? e.NEXT_PUBLIC_BASE_URL - ?? (e.VERCEL_PROJECT_PRODUCTION_URL ? `https://${e.VERCEL_PROJECT_PRODUCTION_URL}` : undefined) - ?? (e.VERCEL_URL ? `https://${e.VERCEL_URL}` : undefined) - ?? `http://localhost:${e.PORT ?? 3000}` - ); -} diff --git a/packages/services/service-cloud/src/boot-stack.ts b/packages/services/service-cloud/src/boot-stack.ts deleted file mode 100644 index 0d473dc9b..000000000 --- a/packages/services/service-cloud/src/boot-stack.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Top-level boot-mode orchestrator. - * - * Dispatches to the appropriate stack factory based on the resolved - * `OS_MODE` (or an explicit `mode` override). The same config - * object is accepted by every host (apps/objectos, CLI's `serve`, - * embedding frameworks) — branches not relevant to the resolved mode - * are ignored. - */ - -import { z } from 'zod'; -import { resolveMode, resolveAuthSecret, resolveBaseUrl } from './boot-env.js'; -import type { BootMode } from './boot-env.js'; -import { createRuntimeStack, RuntimeStackConfigSchema } from './runtime-stack.js'; -import { createCloudStack } from './cloud-stack.js'; -import type { CloudStackConfig } from './cloud-stack.js'; -import type { ProjectTemplate } from './multi-project-plugin.js'; -import type { AppBundleResolver } from './project-kernel-factory.js'; - -const CloudStackConfigSchema = z.object({ - authSecret: z.string().optional(), - baseUrl: z.string().optional(), - controlDriverUrl: z.string().optional(), - controlDriverAuthToken: z.string().optional(), - appBundles: z.custom().optional(), - templates: z.record(z.string(), z.custom()).optional(), - kernelCacheSize: z.number().optional(), - kernelTtlMs: z.number().optional(), - envCacheTtlMs: z.number().optional(), - apiPrefix: z.string().optional(), -}); - -export const BootStackConfigSchema = z.object({ - /** Explicit mode override. When unset, resolves from env. */ - mode: z.enum(['runtime', 'cloud', 'project']).optional(), - /** Runtime-mode options (used when mode resolves to `runtime`). */ - runtime: RuntimeStackConfigSchema.optional(), - /** - * @deprecated Use `runtime`. Kept for back-compat with hosts that - * already pass `project: { … }` to `createBootStack()`. - */ - project: RuntimeStackConfigSchema.optional(), - /** Cloud-mode options (used when mode resolves to `cloud`). */ - cloud: CloudStackConfigSchema.optional(), -}); - -export type BootStackConfig = z.input; - -export interface BootStackResult { - plugins: any[]; - api: { enableProjectScoping: boolean; projectResolution: 'auto' | 'none' }; -} - -/** - * Build the host plugin list for the resolved boot mode. - * - * Selection precedence: - * 1. `config.mode` (explicit override) - * 2. `OS_MODE` environment variable - * 3. Default: `'standalone'` - * - * Note: `'project'` is accepted as a deprecated alias for `'runtime'` - * (renamed v4.x to better describe the mode's role: a runtime node - * connected to ObjectStack Cloud). - */ -export async function createBootStack(config?: BootStackConfig): Promise { - const cfg = BootStackConfigSchema.parse(config ?? {}); - const explicitMode: BootMode | undefined = cfg.mode - ? (cfg.mode === 'project' ? 'runtime' : cfg.mode as BootMode) - : undefined; - const mode: BootMode = explicitMode ?? resolveMode(); - - if (mode === 'cloud') { - const cloudCfg = cfg.cloud ?? {}; - const merged: CloudStackConfig = { - authSecret: cloudCfg.authSecret ?? resolveAuthSecret(), - baseUrl: cloudCfg.baseUrl ?? resolveBaseUrl(), - controlDriverUrl: cloudCfg.controlDriverUrl, - controlDriverAuthToken: cloudCfg.controlDriverAuthToken, - appBundles: cloudCfg.appBundles, - templates: cloudCfg.templates, - kernelCacheSize: cloudCfg.kernelCacheSize, - kernelTtlMs: cloudCfg.kernelTtlMs, - envCacheTtlMs: cloudCfg.envCacheTtlMs, - apiPrefix: cloudCfg.apiPrefix, - }; - return createCloudStack(merged); - } - - // runtime (also reached via deprecated `mode: 'project'`) - return createRuntimeStack(cfg.runtime ?? cfg.project); -} diff --git a/packages/services/service-cloud/src/cloud-artifact-api-plugin.ts b/packages/services/service-cloud/src/cloud-artifact-api-plugin.ts deleted file mode 100644 index a4a5c0f7f..000000000 --- a/packages/services/service-cloud/src/cloud-artifact-api-plugin.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Cloud-side Artifact API plugin (P0 + P1). - * - * Thin assembler: resolves storage backend + driver, then delegates route - * registration to the `routes/cloud.ts` and `routes/public.ts` modules. - * - * P0 — Pluggable storage via {@link IStorageService} (fallback to local FS). - * P1 — Version history via `sys_environment_revision`, commit-aware GET, rollback. - * - * Routes registered: - * GET /cloud/resolve-hostname?host=... - * GET /cloud/projects/:id/artifact[?commit=...] - * POST /cloud/projects/:id/metadata - * GET /cloud/projects/:id/revisions - * POST /cloud/projects/:id/revisions/:commit/activate - * POST /cloud/projects/:id/revisions/prune - * POST /cloud/packages — register/upsert a package (ADR-0006 v4 Phase B) - * POST /cloud/packages/:id/versions — publish a new package version (ADR-0006 v4 Phase B) - * GET /pub/v1/projects/:id/manifest.json - * GET /pub/v1/projects/:id/artifact[?commit=&redirect=] - * GET /pub/v1/projects/:id/revisions - */ - -import type { IHttpServer, IDataDriver, IStorageService } from '@objectstack/spec/contracts'; -import { resolveStorage } from './routes/storage.js'; -import { registerCloudRoutes } from './routes/cloud.js'; -import { registerPublicRoutes } from './routes/public.js'; -import { registerBranchRoutes } from './routes/branches.js'; -import { registerProjectLifecycleRoutes } from './routes/project-lifecycle.js'; -import { registerPackageInstallRoutes } from './routes/package-install.js'; -import { registerPackagePublishRoutes } from './routes/package-publish.js'; -import type { ProjectTemplate } from './multi-project-plugin.js'; -import type { RouteDeps } from './routes/types.js'; - -type AnyContext = any; - -export interface CloudArtifactApiPluginOptions { - /** Promise resolving to the control-plane driver. */ - controlDriverPromise: Promise<{ driver: IDataDriver; driverName: string; databaseUrl: string }>; - /** API prefix (default `/api/v1`). */ - apiPrefix?: string; - /** Filesystem root for relative `artifact_path` values (default `process.cwd()`). */ - artifactRoot?: string; - /** Bearer token required on requests. */ - apiKey?: string; - /** Pluggable storage backend. When omitted, tries kernel's `file-storage` service; falls back to local FS. */ - storage?: { - service?: 'file-storage' | IStorageService; - keyPrefix?: string; - }; - /** - * Template registry — used by the package-install route to lazy-snapshot - * a starter template into `sys_package_version.manifest_json` on first - * install. When omitted, install of un-snapshotted starter packages - * will return 409. - */ - templates?: Record; -} - -export function createCloudArtifactApiPlugin(options: CloudArtifactApiPluginOptions): any { - const prefix = options.apiPrefix ?? '/api/v1'; - const artifactRoot = options.artifactRoot ?? process.env.OS_PROJECT_ARTIFACT_ROOT ?? process.cwd(); - const requiredKey = options.apiKey ?? process.env.OS_CLOUD_API_KEY; - const keyPrefix = options.storage?.keyPrefix ?? 'artifacts'; - - return { - name: 'com.objectstack.cloud.artifact-api', - version: '2.0.0', - init: async (_ctx: AnyContext) => {}, - start: async (ctx: AnyContext) => { - let server: IHttpServer | undefined; - try { server = ctx.getService('http.server') as IHttpServer | undefined; } catch { return; } - if (!server) return; - - const { storage, adapterName: storageAdapterName } = resolveStorage(ctx, options, artifactRoot); - - // Best-effort better-auth session resolver. Cached on first use. - let cachedAuthSvc: any | null | undefined; - const headersFromReq = (req: any): any => { - const raw = req?.headers; - if (!raw) return new Headers(); - if (typeof raw.get === 'function') return raw; - const h = new Headers(); - for (const [k, v] of Object.entries(raw as Record)) { - if (v == null) continue; - h.set(k, Array.isArray(v) ? v.join(', ') : String(v)); - } - return h; - }; - const getSessionData = async (req: any): Promise => { - if (cachedAuthSvc === undefined) { - try { cachedAuthSvc = ctx.getService?.('auth') ?? null; } - catch { cachedAuthSvc = null; } - } - if (!cachedAuthSvc) return null; - try { - const apiObj = cachedAuthSvc.auth?.api ?? cachedAuthSvc.api; - if (!apiObj?.getSession) return null; - return await apiObj.getSession.call(apiObj, { headers: headersFromReq(req) }); - } catch { return null; } - }; - const getCallerUserId = async (req: any) => (await getSessionData(req))?.user?.id; - const getCallerActiveOrgId = async (req: any) => (await getSessionData(req))?.session?.activeOrganizationId; - - const deps: RouteDeps = { - prefix, - artifactRoot, - keyPrefix, - storage, - storageAdapterName, - requiredKey, - controlDriverPromise: options.controlDriverPromise, - getCallerUserId, - getCallerActiveOrgId, - }; - - registerCloudRoutes(server, deps); - registerPublicRoutes(server, deps); - registerBranchRoutes(server, deps); - registerProjectLifecycleRoutes(server, { ...deps, templates: options.templates }); - registerPackageInstallRoutes(server, { ...deps, templates: options.templates }); - registerPackagePublishRoutes(server, { ...deps, templates: options.templates }); - }, - stop: async (_ctx: AnyContext) => {}, - }; -} diff --git a/packages/services/service-cloud/src/cloud-artifact-helpers.ts b/packages/services/service-cloud/src/cloud-artifact-helpers.ts deleted file mode 100644 index e61489ba8..000000000 --- a/packages/services/service-cloud/src/cloud-artifact-helpers.ts +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Shared helpers for cloud-artifact-api-plugin. - */ - -import { createHash } from 'node:crypto'; -import type { IDataDriver } from '@objectstack/spec/contracts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SysProjectRow { - id: string; - organization_id?: string; - hostname?: string; - database_driver?: string; - database_url?: string; - database_auth_token?: string; - metadata?: Record | string; - is_system?: boolean | number; - visibility?: 'private' | 'unlisted' | 'public'; -} - -export interface SysCredentialRow { - id: string; - environment_id: string; - database_driver?: string; - database_url?: string; - database_auth_token?: string; - /** The encrypted (or, with NoopSecretEncryptor, plaintext) DB secret. */ - secret_ciphertext?: string; -} - -export interface SysEnvironmentRevisionRow { - id: string; - environment_id: string; - commit_id: string; - checksum?: string; - storage_key: string; - storage_adapter?: string; - size_bytes?: number; - built_at?: string; - built_with?: string; - published_by?: string; - published_at?: string; - note?: string; - is_current: boolean; -} - -// --------------------------------------------------------------------------- -// Utility functions -// --------------------------------------------------------------------------- - -export function ok(data: T) { return { success: true, data }; } -export function fail(message: string, _status = 400) { return { success: false, error: message }; } - -export function parseMetadata(raw: any): Record { - if (!raw) return {}; - if (typeof raw === 'string') { - try { return JSON.parse(raw) ?? {}; } catch { return {}; } - } - if (typeof raw === 'object') return raw as Record; - return {}; -} - -export function extractArtifactPaths(metadata: Record): string[] { - const out: string[] = []; - const single = metadata.artifact_path; - if (typeof single === 'string') out.push(single); - const list = metadata.artifact_paths; - if (Array.isArray(list)) { - for (const p of list) if (typeof p === 'string') out.push(p); - } - return out; -} - -export function sha256Hex(input: string): string { - return createHash('sha256').update(input).digest('hex'); -} - -/** - * Known per-category metadata keys recognised by ObjectOS at boot. - */ -export const KNOWN_METADATA_CATEGORIES = new Set([ - 'objects', 'fields', 'views', 'apps', 'pages', 'dashboards', 'reports', - 'flows', 'workflows', 'triggers', 'agents', 'tools', 'skills', - 'permissions', 'permissionSets', 'roles', 'profiles', 'translations', - 'datasources', 'datasets', 'actions', 'apis', 'i18n', 'sharingRules', - 'ragPipelines', 'data', -]); - -/** - * Merge metadata blocks from multiple artifact bundles into a single envelope. - */ -export function mergeArtifactMetadata(bundles: any[]): Record { - const merged: Record = {}; - - const ingest = (source: Record) => { - for (const [key, value] of Object.entries(source)) { - if (!Array.isArray(value)) continue; - if (!KNOWN_METADATA_CATEGORIES.has(key) && key !== 'manifest') { - if (typeof key !== 'string') continue; - } - const bucket = merged[key] ?? (merged[key] = []); - bucket.push(...value); - } - }; - - for (const b of bundles) { - if (!b || typeof b !== 'object') continue; - const nested = (b as any).metadata; - if (nested && typeof nested === 'object' && !Array.isArray(nested)) { - ingest(nested); - } - ingest(b as Record); - } - return merged; -} - -// --------------------------------------------------------------------------- -// Database helpers -// --------------------------------------------------------------------------- - -export async function resolveProjectByHost(driver: IDataDriver, host: string): Promise { - if (!host) return null; - const direct = await (driver.findOne as any)('sys_environment', { where: { hostname: host } }); - if (direct) return direct as SysProjectRow; - const wildcard = await (driver.findOne as any)('sys_environment', { where: { hostname: '*' } }); - if (wildcard) return wildcard as SysProjectRow; - return null; -} - -export async function readProjectCredentials(driver: IDataDriver, projectId: string): Promise { - try { - const row = await (driver.findOne as any)('sys_environment_credential', { - where: { environment_id: projectId }, - }); - return (row ?? null) as SysCredentialRow | null; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Publish helper — shared by POST /cloud/projects/:id/metadata and the -// MultiProjectPlugin template seeder. Uploads the artifact bundle to the -// configured storage adapter and inserts/refreshes a sys_environment_revision -// row so the next GET /cloud/projects/:id/artifact resolves it. -// --------------------------------------------------------------------------- - -export interface PublishProjectRevisionParams { - /** Control-plane data driver (sys_environment / sys_environment_revision). */ - driver: IDataDriver; - /** Storage adapter (R2, S3, local FS) — must implement upload/exists. */ - storage: { upload: (key: string, data: Buffer) => Promise; exists: (key: string) => Promise }; - /** Storage adapter name persisted on the revision row. */ - storageAdapter: string; - /** Storage key prefix (defaults to `artifacts`). */ - keyPrefix?: string; - /** Project row (id + organization_id are required). */ - project: { id: string; organization_id?: string | null }; - /** Artifact body — already-shaped JSON object (will be JSON-serialised). */ - bundle: any; - /** Optional commit id override; defaults to the first 16 hex chars of the body hash. */ - commitId?: string; - /** Branch name for branch-head book-keeping; defaults to `main`. */ - branch?: string; - /** Optional note attached to the revision. */ - note?: string; -} - -export interface PublishProjectRevisionResult { - commitId: string; - revisionId: string; - storageKey: string; - checksum: string; - created: boolean; -} - -export async function publishProjectRevision( - params: PublishProjectRevisionParams, -): Promise { - const { driver, storage, storageAdapter, project, bundle, note } = params; - const keyPrefix = params.keyPrefix ?? 'artifacts'; - const branch = (params.branch ?? 'main').trim() || 'main'; - - const bodyStr = JSON.stringify(bundle ?? {}); - const bodyBuf = Buffer.from(bodyStr, 'utf-8'); - const fullHash = sha256Hex(bodyStr); - const commitId = params.commitId ?? fullHash.slice(0, 16); - const checksum = (bundle as any)?.checksum && typeof (bundle as any).checksum === 'string' - ? (bundle as any).checksum - : fullHash; - const orgId = project.organization_id ?? null; - const storageKey = orgId - ? `${keyPrefix}/orgs/${orgId}/projects/${project.id}/${commitId}.json` - : `${keyPrefix}/${project.id}/${commitId}.json`; - - if (!(await storage.exists(storageKey))) { - await storage.upload(storageKey, bodyBuf); - } - - let created = false; - let revisionId: string; - const existing = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: project.id, commit_id: commitId }, - }); - if (existing) { - revisionId = existing.id; - if (!existing.is_current) { - try { - const oldCurrent = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: project.id, is_current: true }, - }); - if (oldCurrent && oldCurrent.id !== existing.id) { - await (driver.update as any)('sys_environment_revision', oldCurrent.id, { is_current: false }); - } - } catch { /* table may not exist yet */ } - await (driver.update as any)('sys_environment_revision', existing.id, { is_current: true }); - } - } else { - try { - const oldCurrent = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: project.id, is_current: true }, - }); - if (oldCurrent) { - await (driver.update as any)('sys_environment_revision', oldCurrent.id, { is_current: false }); - } - } catch { /* ok */ } - const { randomUUID } = await import('node:crypto'); - revisionId = randomUUID(); - await (driver.create as any)('sys_environment_revision', { - id: revisionId, - project_id: project.id, - commit_id: commitId, - checksum, - storage_key: storageKey, - storage_adapter: storageAdapter, - size_bytes: bodyBuf.byteLength, - built_at: (bundle as any)?.builtAt ?? new Date().toISOString(), - built_with: (bundle as any)?.builtWith ? JSON.stringify((bundle as any).builtWith) : null, - published_at: new Date().toISOString(), - note: note ?? null, - is_current: true, - branch, - is_branch_head: true, - }); - created = true; - } - - return { commitId, revisionId, storageKey, checksum, created }; -} - -export function buildRuntimeBlock(project: SysProjectRow, cred: SysCredentialRow | null) { - const driver = (cred?.database_driver ?? project.database_driver ?? '').trim(); - const url = (cred?.database_url ?? project.database_url ?? '').trim(); - if (!driver || !url) return undefined; - const out: Record = { - organizationId: project.organization_id, - hostname: project.hostname, - databaseDriver: driver, - databaseUrl: url, - }; - const token = cred?.database_auth_token - ?? cred?.secret_ciphertext - ?? project.database_auth_token; - if (token) out.databaseAuthToken = token; - // Forward project metadata (ownerSeed, orgSeed, …) so the per-project - // runtime can replay cold-boot seeds. Metadata is stored either as a - // JSON string or already-parsed object depending on the driver. - const rawMeta: any = (project as any).metadata; - if (rawMeta != null) { - try { - out.metadata = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta; - } catch { - // Malformed metadata — skip rather than poisoning the runtime block. - } - } - return out; -} diff --git a/packages/services/service-cloud/src/cloud-stack.ts b/packages/services/service-cloud/src/cloud-stack.ts deleted file mode 100644 index 741a8f937..000000000 --- a/packages/services/service-cloud/src/cloud-stack.ts +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * createCloudStack - * - * The single public API for cloud (multi-project) mode. Builds the ordered - * plugin list and API config that `objectstack.config.ts` needs when - * `OS_MODE=cloud`. - * - * Usage: - * import { createCloudStack } from '@objectstack/service-cloud'; - * export default await createCloudStack({ authSecret, baseUrl }); - */ - -import { resolve as resolvePath } from 'node:path'; -import type * as Contracts from '@objectstack/spec/contracts'; -import type { ProjectTemplate } from './multi-project-plugin.js'; -import { createControlPlanePlugins } from './control-plane-preset.js'; -import { createTemplatesRoutePlugin } from './multi-project-plugins.js'; -import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js'; -import { createStarterSeederPlugin } from './starter-seeder-plugin.js'; -import { resolveDefaultDataDir } from './data-dir.js'; -import { resolveStoragePluginFromEnv, resolveStorageFromEnv } from './storage-env.js'; - -type IDataDriver = Contracts.IDataDriver; - -/** - * Cloud (control-plane) stack configuration. - * - * apps/cloud is intentionally narrow: it owns the control-plane DB - * (organizations, projects, packages, billing), authentication - * (better-auth), the cloud_control metadata-driven App, and the - * artifact distribution API consumed by apps/objectos. - * - * It deliberately does NOT load per-project tenant kernels or - * compiled app bundles — that responsibility lives in apps/objectos - * (`createObjectOSStack`), which pulls artifacts from this stack - * over HTTP and boots per-project kernels on demand. - */ -export interface CloudStackConfig { - authSecret: string; - baseUrl: string; - /** Control-plane DB URL. Defaults to file:.objectstack/data/control.db */ - controlDriverUrl?: string; - /** Auth token for libSQL/Turso control-plane driver. */ - controlDriverAuthToken?: string; - /** - * Template registry. Only the metadata (id/label/description/category) - * is exposed via `GET /cloud/templates`; the actual seed bundles are - * applied at runtime by the consumer of this control plane. - */ - templates?: Record; - /** API prefix. Default: /api/v1. */ - apiPrefix?: string; -} - -async function buildControlDriver(url: string, authToken?: string): Promise<{ - driver: IDataDriver; - driverName: 'sqlite' | 'turso' | 'postgres'; - databaseUrl: string; -}> { - // Postgres / CockroachDB / any pg-wire database. - // Accept both `postgres://` and `postgresql://` schemes; `pg://` is also recognised - // for parity with the per-tenant artifact registry. - if (/^(postgres(ql)?|pg):\/\//i.test(url)) { - const { SqlDriver } = await import('@objectstack/driver-sql'); - // `pg` is a peer/optional dep of knex; bring it in here so a clear error - // surfaces at boot if the host forgot to install it. - try { - await import('pg'); - } catch (err) { - throw new Error( - `[service-cloud] Control-plane URL "${url}" requires the "pg" driver. ` - + `Add \`pg\` to your application dependencies. Original: ${(err as Error).message}`, - ); - } - const poolMin = Number.parseInt(process.env.OS_CONTROL_PG_POOL_MIN ?? '0', 10); - const poolMax = Number.parseInt(process.env.OS_CONTROL_PG_POOL_MAX ?? '10', 10); - const driver = new SqlDriver({ - client: 'pg', - connection: url, - pool: { - min: Number.isFinite(poolMin) ? poolMin : 0, - max: Number.isFinite(poolMax) ? poolMax : 10, - }, - }); - return { driver: driver as unknown as IDataDriver, driverName: 'postgres', databaseUrl: url }; - } - - if (/^(libsql|https?):\/\//i.test(url)) { - const { TursoDriver } = await import('@objectstack/driver-turso'); - const driver = new TursoDriver({ url, authToken }); - return { driver: driver as unknown as IDataDriver, driverName: 'turso', databaseUrl: url }; - } - - const filename = url.replace(/^file:(\/\/)?/, ''); - const { SqlDriver } = await import('@objectstack/driver-sql'); - const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true }); - return { driver: driver as unknown as IDataDriver, driverName: 'sqlite', databaseUrl: `file:${filename}` }; -} - -export async function createCloudStack(config: CloudStackConfig): Promise<{ - plugins: any[]; - api: { enableProjectScoping: true; projectResolution: 'auto' }; -}> { - const { - authSecret, - baseUrl, - // NOTE: no eager default here. The file-backed fallback is computed - // lazily below so that serverless deployments which configure - // TURSO_DATABASE_URL / OS_CONTROL_DATABASE_URL never trip the - // resolveDefaultDataDir() throw-on-serverless guard. - controlDriverUrl, - controlDriverAuthToken, - templates = {}, - apiPrefix, - } = config; - - // Resolve the control-plane DB URL. - // Priority: - // 1. OS_CONTROL_DATABASE_URL (explicit, dedicated to the control plane) - // 2. controlDriverUrl (explicit param from the calling stack) - // 3. OS_DATABASE_URL (legacy alias — only used here when no - // higher-priority source is set; reserved - // going forward for the project's data DB) - // 4. TURSO_DATABASE_URL (legacy alias — recommended on Vercel) - // 5. file:/control.db on writable filesystems. - // On serverless (Vercel / Lambda / Netlify) without any of the above, - // resolveDefaultDataDir() throws with a message pointing at Turso — - // we never silently fall back to ephemeral /tmp SQLite. - const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim(); - const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim(); - const resolvedControlUrl = explicitControlUrl - || controlDriverUrl - || legacyControlUrl - || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`; - const controlDriverPromise = buildControlDriver( - resolvedControlUrl, - process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken, - ); - - // Storage service — used by the artifact API to persist published - // project bundles. Wires from env: OS_STORAGE_ADAPTER=s3 + - // OS_S3_BUCKET/OS_S3_REGION/... Falls back to a local-FS adapter - // (rooted at OS_STORAGE_LOCAL_DIR or /storage). On - // serverless without S3 env vars the cloud-artifact plugin will warn - // — set OS_STORAGE_ADAPTER=s3 in production. - const storageEnv = await resolveStorageFromEnv(); - - // List templates for the static /cloud/templates route. We expose - // only the metadata (id/label/description/category); the actual seed - // bundles are applied at runtime by whoever consumes this control - // plane (apps/objectos), not here. - const templateList = Object.values(templates).map(({ id, label, description, category }) => ({ - id, label, description, category, - })); - - const plugins = [ - ...createControlPlanePlugins({ - controlDriverPromise, - authSecret, - baseUrl, - }), - ...(storageEnv.plugin ? [storageEnv.plugin] : []), - createTemplatesRoutePlugin(templateList, { apiPrefix }), - createStarterSeederPlugin({ templates, controlDriverPromise }), - createCloudArtifactApiPlugin({ controlDriverPromise, apiPrefix, templates }), - ]; - - return { - plugins, - api: { - // Project scoping stays enabled so the reserved virtual id - // `/api/v1/projects/platform/...` continues to resolve to the - // control-plane protocol. Real per-project IDs (proj_xxx) have - // no kernel here and will fall through to the same control- - // plane protocol — apps/objectos is the runtime that owns - // per-project data. - enableProjectScoping: true, - projectResolution: 'auto', - }, - }; -} diff --git a/packages/services/service-cloud/src/control-plane-preset.ts b/packages/services/service-cloud/src/control-plane-preset.ts deleted file mode 100644 index f8bbd16d6..000000000 --- a/packages/services/service-cloud/src/control-plane-preset.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Control-Plane Plugin Preset - * - * All heavy plugin packages (better-auth, security, audit, metadata …) are - * loaded via dynamic import() inside init() so that bundleRequire/esbuild - * does NOT inline them at parse time. This keeps startup RSS below 200 MB. - * - * Each entry is a lazy-proxy plugin: init() dynamically imports the real - * package, constructs it, and delegates all subsequent lifecycle hooks - * (start, stop) to the real instance stored on `_impl`. - */ - -import type * as Contracts from '@objectstack/spec/contracts'; - -export interface ControlPlanePresetConfig { - /** Promise resolving to the control-plane driver. Accepted as a Promise so - * the caller can defer the heavy DB library import until plugin init time. */ - controlDriverPromise: Promise<{ - driver: Contracts.IDataDriver; - driverName: string; - databaseUrl: string; - }>; - authSecret: string; - baseUrl: string; - registerSystemObjects?: boolean; - authPlugins?: Record; -} - -/** - * Create a lazy-proxy plugin that defers its import to init(). - * - * `startupTimeout` is set on the **wrapper** (not the inner plugin) so the - * kernel honours it during Phase 2 start. The kernel reads - * `plugin.startupTimeout` from the registered plugin object, but the inner - * implementation is not constructed until init() — by which time the - * registration is already locked in. Without forwarding the budget on the - * wrapper, heavy plugins like `ObjectQLPlugin` (which does N×CREATE TABLE - * round-trips against a remote DB) inherit the kernel's default 30s and - * time out on cold Neon/Turso boots. - */ -function lazyPlugin(name: string, factory: (ctx: any) => Promise, opts?: { startupTimeout?: number }): any { - let impl: any = null; - const wrapper: any = { - name, - async init(ctx: any) { - impl = await factory(ctx); - if (impl?.init) await impl.init(ctx); - }, - async start(ctx: any) { - if (impl?.start) await impl.start(ctx); - }, - async stop(ctx: any) { - if (impl?.stop) await impl.stop(ctx); - }, - }; - if (typeof opts?.startupTimeout === 'number' && opts.startupTimeout > 0) { - wrapper.startupTimeout = opts.startupTimeout; - } - return wrapper; -} - -/** - * Build the ordered plugin list that powers the control plane. - * - * Ordering: - * 1. ObjectQL — schema registry - * 2. Datasource mapping — wires single driver to ObjectQL - * 3. Driver — control-plane database - * 4. PackageService, Tenant, SystemProject — sys_* objects - * 5. Auth, Security, Audit — authentication + RBAC - * 6. Metadata — file-system schema snapshots - */ -export function createControlPlanePlugins(cfg: ControlPlanePresetConfig): any[] { - // Shared ref so ObjectQL proxy can expose its instance to the datasource-mapping plugin. - const oqlRef: { ql: any } = { ql: null }; - // Driver info resolved lazily; both datasource-mapping and Driver proxy read from here. - const driverRef: { driverName: string; driver: any; databaseUrl: string } = { - driverName: '', driver: null, databaseUrl: '', - }; - - return [ - // ── 1. ObjectQL ──────────────────────────────────────────────────────── - // Migration mode (`OS_MIGRATE_AND_EXIT=1`) gets a 10-minute startup - // budget because schema sync from a developer laptop to a remote DB - // can be much slower than from a colocated container. Without this, - // operators in latency-disadvantaged regions (e.g. Asia → Neon US East - // at ~300ms RTT × 30 tables × 2 phases) hit the 120s kernel ceiling - // before all DDL completes. Production cold-boot still uses 120s. - lazyPlugin('com.objectstack.engine.objectql', async () => { - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const plugin = new ObjectQLPlugin(); - oqlRef.ql = (plugin as any).ql ?? plugin; - return plugin; - }, { - startupTimeout: - process.env.OS_MIGRATE_AND_EXIT === '1' ? 600_000 : 120_000, - }), - - // ── 2. Datasource mapping (no heavy deps) ───────────────────────────── - // Runs after Driver (step 3) because kernel calls init() in registration order. - // We defer the actual mapping until after driverRef is populated. - { - name: 'control-plane-datasource-mapping', - async init() { - // Resolve driver info if not yet done (may have been done by step 3 already). - if (!driverRef.driverName) { - const resolved = await cfg.controlDriverPromise; - Object.assign(driverRef, resolved); - } - const ql = oqlRef.ql; - if (ql?.setDatasourceMapping) { - ql.setDatasourceMapping([ - { default: true, datasource: `com.objectstack.driver.${driverRef.driverName}` }, - ]); - } - }, - }, - - // ── 3. Driver ────────────────────────────────────────────────────────── - { - name: 'com.objectstack.driver', - version: '0.0.0', - async init(ctx: any) { - const resolved = await cfg.controlDriverPromise; - Object.assign(driverRef, resolved); - console.log(`[Bootstrap] Control DB: ${driverRef.databaseUrl} (${driverRef.driverName})`); - const { DriverPlugin } = await import('@objectstack/runtime'); - const plugin = new DriverPlugin(driverRef.driver, driverRef.driverName); - // Patch the name so kernel registers it under the correct driver id - (this as any)._driverPlugin = plugin; - if (plugin.init) await plugin.init(ctx); - }, - async start(ctx: any) { - if ((this as any)._driverPlugin?.start) await (this as any)._driverPlugin.start(ctx); - }, - async stop(ctx: any) { - if ((this as any)._driverPlugin?.stop) await (this as any)._driverPlugin.stop(ctx); - }, - }, - - // ── 4a. PackageService ──────────────────────────────────────────────── - lazyPlugin('com.objectstack.service.package', async () => { - const { PackageServicePlugin } = await import('@objectstack/service-package'); - return new PackageServicePlugin(); - }), - - // ── 4b. Tenant ──────────────────────────────────────────────────────── - lazyPlugin('com.objectstack.service.tenant', async () => { - const { createTenantPlugin } = await import('@objectstack/service-tenant'); - return createTenantPlugin({ - registerSystemObjects: cfg.registerSystemObjects ?? true, - }); - }), - - // ── 4c. SystemProject ───────────────────────────────────────────────── - lazyPlugin('com.objectstack.system-project', async () => { - const { createSystemProjectPlugin } = await import('@objectstack/runtime'); - return createSystemProjectPlugin(); - }), - - // ── 5a. Auth (heavy: better-auth + all plugins) ─────────────────────── - lazyPlugin('com.objectstack.auth', async () => { - const { AuthPlugin } = await import('@objectstack/plugin-auth'); - const socialProviders: Record = {}; - if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) - socialProviders.google = { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET }; - if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) - socialProviders.github = { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET }; - if (process.env.MICROSOFT_CLIENT_ID && process.env.MICROSOFT_CLIENT_SECRET) - socialProviders.microsoft = { clientId: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET }; - - // ── Trusted origins (CSRF allow-list for better-auth) ──────────── - // better-auth rejects any request whose Origin header is not in this - // list with `ERROR: Invalid origin`. Build it from: - // 1. explicit `OS_TRUSTED_ORIGINS` (comma-separated) - // 2. the configured baseUrl's origin (so first-party redirects work) - // 3. preview-mode wildcards (`--.`) - // 4. `http://localhost:*` in dev - // Keep this in sync with the dev-mode logic in - // `packages/cli/src/commands/serve.ts` (auto-AuthPlugin block). - const trustedOrigins: string[] = []; - const explicitTrusted = process.env.OS_TRUSTED_ORIGINS?.trim(); - if (explicitTrusted) { - for (const o of explicitTrusted.split(',').map(s => s.trim()).filter(Boolean)) { - if (!trustedOrigins.includes(o)) trustedOrigins.push(o); - } - } - try { - const u = new URL(cfg.baseUrl); - const baseOrigin = `${u.protocol}//${u.host}`; - if (!trustedOrigins.includes(baseOrigin)) trustedOrigins.push(baseOrigin); - } catch { /* ignore malformed baseUrl */ } - const previewMode = (process.env.OS_PREVIEW_MODE ?? '').trim().toLowerCase(); - const isPreviewMode = previewMode === '1' || previewMode === 'true' || previewMode === 'yes'; - if (isPreviewMode) { - const baseDomains = (process.env.OS_PREVIEW_BASE_DOMAINS - ?? 'preview.objectstack.ai,localhost') - .split(',').map(s => s.trim()).filter(Boolean); - for (const dom of baseDomains) { - const isLoopback = dom === 'localhost' || dom.endsWith('.localhost'); - const scheme = isLoopback ? 'http' : 'https'; - const portSuffix = isLoopback ? ':*' : ''; - const wildcard = `${scheme}://*.${dom}${portSuffix}`; - if (!trustedOrigins.includes(wildcard)) trustedOrigins.push(wildcard); - } - } - const isDev = process.env.NODE_ENV !== 'production'; - if (isDev && !trustedOrigins.includes('http://localhost:*')) { - trustedOrigins.push('http://localhost:*'); - } - // Per-project subdomains: when OS_ROOT_DOMAIN is set (multi-project - // hosting under `*.`), every project hostname must be trusted - // by better-auth or sign-up/sign-in / cookie operations are blocked - // with "Invalid origin". The wildcard mirrors the OS_COOKIE_DOMAIN - // semantics — they are always set together. - const rootDomain = (process.env.OS_ROOT_DOMAIN ?? process.env.ROOT_DOMAIN)?.trim(); - if (rootDomain) { - const wildcard = `https://*.${rootDomain}`; - if (!trustedOrigins.includes(wildcard)) trustedOrigins.push(wildcard); - } - - return new AuthPlugin({ - secret: cfg.authSecret, - baseUrl: cfg.baseUrl, - plugins: (cfg.authPlugins ?? { organization: true, oidcProvider: true, deviceAuthorization: true }) as any, - socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, - trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined, - advanced: process.env.OS_COOKIE_DOMAIN - ? ({ - crossSubDomainCookies: { - enabled: true, - domain: process.env.OS_COOKIE_DOMAIN, - }, - useSecureCookies: process.env.NODE_ENV === 'production', - } as any) - : undefined, - }); - }), - - // ── 5b. Security ────────────────────────────────────────────────────── - lazyPlugin('com.objectstack.security', async () => { - const { SecurityPlugin } = await import('@objectstack/plugin-security'); - return new SecurityPlugin(); - }), - - // ── 5c. Audit ───────────────────────────────────────────────────────── - lazyPlugin('com.objectstack.audit', async () => { - const { AuditPlugin } = await import('@objectstack/plugin-audit'); - return new AuditPlugin(); - }), - - // ── 6. Metadata ─────────────────────────────────────────────────────── - lazyPlugin('com.objectstack.metadata', async () => { - const { MetadataPlugin } = await import('@objectstack/metadata'); - return new MetadataPlugin({ watch: false }); - }), - - // ── 7. Platform SSO backfill ───────────────────────────────────────── - // Ensure every pre-existing `sys_project` has a matching - // `sys_oauth_application` row so per-project deployments can - // authenticate against this cloud as a unified IdP. Brand-new - // projects get seeded inline by the dispatcher's POST /cloud/projects - // handler; this backfill exists to retro-fit anything created before - // the feature shipped. Runs once per boot, after the auth plugin (so - // `sys_oauth_application` is registered) and after ObjectQL is ready. - lazyPlugin('com.objectstack.platform-sso-backfill', async () => ({ - name: 'com.objectstack.platform-sso-backfill', - version: '1.0.0', - async start(ctx: any) { - // Backfill iterates all sys_project rows (up to 1000) doing 2-3 - // DB calls each. On Neon-over-Workers that easily exceeds the - // 30s plugin-start timeout and rolls back the whole boot, taking - // the cloud container down with it. The backfill is non-critical - // (project-create hook seeds the happy path inline); run it in - // the background so a slow scan never blocks startup. - const runBackfill = async () => { - try { - const baseSecret = (process.env.OS_AUTH_SECRET ?? process.env.AUTH_SECRET ?? cfg.authSecret ?? '').trim(); - if (!baseSecret) { - ctx.logger?.warn?.('[platform-sso-backfill] OS_AUTH_SECRET missing — skipping'); - return; - } - const ql = ctx.getService?.('objectql'); - if (!ql) { - ctx.logger?.warn?.('[platform-sso-backfill] objectql service not available — skipping'); - return; - } - const { backfillPlatformSsoClients } = await import('@objectstack/runtime'); - const result = await backfillPlatformSsoClients({ ql, baseSecret, logger: ctx.logger }); - ctx.logger?.info?.('[platform-sso-backfill] done', result); - } catch (err) { - ctx.logger?.warn?.('[platform-sso-backfill] failed (non-fatal)', { - error: (err as Error)?.message, - }); - } - }; - // Fire-and-forget. Attach a noop catch so an unhandled rejection - // never tips the process over. - void runBackfill().catch(() => {}); - }, - })), - ]; -} diff --git a/packages/services/service-cloud/src/control-plane-proxy-driver.ts b/packages/services/service-cloud/src/control-plane-proxy-driver.ts deleted file mode 100644 index 6a43d7566..000000000 --- a/packages/services/service-cloud/src/control-plane-proxy-driver.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { IDataDriver } from '@objectstack/spec/contracts'; -import type { QueryAST } from '@objectstack/spec/data'; -import type { DriverOptions } from '@objectstack/spec/data'; - -/** - * CloudProxyDriver (formerly ControlPlaneProxyDriver) - * - * An IDataDriver proxy that delegates all operations to the cloud - * (control-plane) driver. All read queries automatically receive an - * `organization_id` filter so that project kernels can safely expose - * `scope: 'system'` objects (user, org, role …) without leaking data across - * organizations. - * - * Write operations (create / update / delete …) are forwarded directly — - * system objects are globally writable from any project context. - * - * Registration: `DefaultProjectKernelFactory` registers this driver under the - * well-known datasource name `'cloud'` so that all objects whose package has - * `scope: 'system'` or `defaultDatasource: 'cloud'` resolve to it - * automatically. - */ -export class ControlPlaneProxyDriver implements IDataDriver { - readonly name = 'cloud'; - readonly version = '1.0.0'; - - readonly supports: any; - - constructor( - private readonly controlPlaneDriver: IDataDriver, - private readonly organizationId: string, - ) { - if (!organizationId) { - throw new Error('[CloudProxyDriver] organizationId is required — refusing to mount cloud datasource without org scope'); - } - // Inherit capability flags from the underlying driver so query - // planners (aggregation, streaming, …) make accurate decisions. - this.supports = (controlPlaneDriver as any).supports ?? { - transactions: false, - bulkOperations: true, - streaming: false, - aggregations: false, - vectorSearch: false, - fullTextSearch: false, - jsonFields: false, - relations: false, - }; - } - - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - async connect(): Promise {} - - async disconnect(): Promise {} - - async checkHealth(): Promise { - return this.controlPlaneDriver.checkHealth(); - } - - // ------------------------------------------------------------------------- - // Raw execution - // ------------------------------------------------------------------------- - - async execute(command: unknown, parameters?: unknown[], options?: DriverOptions): Promise { - return this.controlPlaneDriver.execute(command, parameters, options); - } - - // ------------------------------------------------------------------------- - // Read operations — inject org filter (only for org-scoped tables) - // ------------------------------------------------------------------------- - - find(object: string, query: QueryAST, options?: DriverOptions) { - return this.controlPlaneDriver.find(object, this.#injectOrg(object, query), options); - } - - findStream(object: string, query: QueryAST, options?: DriverOptions) { - return this.controlPlaneDriver.findStream(object, this.#injectOrg(object, query), options); - } - - findOne(object: string, query: QueryAST, options?: DriverOptions) { - return this.controlPlaneDriver.findOne(object, this.#injectOrg(object, query), options); - } - - count(object: string, query?: QueryAST, options?: DriverOptions) { - const q: QueryAST = query - ? this.#injectOrg(object, query) - : (this.#isOrgScoped(object) ? ({ where: this.#orgFilter() } as any) : ({} as any)); - return this.controlPlaneDriver.count(object, q, options); - } - - // ------------------------------------------------------------------------- - // Write operations — forward directly to control plane - // ------------------------------------------------------------------------- - - create(object: string, data: Record, options?: DriverOptions) { - return this.controlPlaneDriver.create(object, data, options); - } - - update(object: string, id: string | number, data: Record, options?: DriverOptions) { - return this.controlPlaneDriver.update(object, id, data, options); - } - - upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions) { - return this.controlPlaneDriver.upsert(object, data, conflictKeys, options); - } - - delete(object: string, id: string | number, options?: DriverOptions) { - return this.controlPlaneDriver.delete(object, id, options); - } - - bulkCreate(object: string, dataArray: Record[], options?: DriverOptions) { - return this.controlPlaneDriver.bulkCreate(object, dataArray, options); - } - - bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions) { - return this.controlPlaneDriver.bulkUpdate(object, updates, options); - } - - async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { - return this.controlPlaneDriver.bulkDelete(object, ids, options); - } - - updateMany?(object: string, query: QueryAST, data: Record, options?: DriverOptions): Promise { - return this.controlPlaneDriver.updateMany!(object, query, data, options); - } - - deleteMany?(object: string, query: QueryAST, options?: DriverOptions): Promise { - return this.controlPlaneDriver.deleteMany!(object, query, options); - } - - // ------------------------------------------------------------------------- - // Schema operations — delegate - // ------------------------------------------------------------------------- - - describeSchema?(...args: any[]) { - return (this.controlPlaneDriver as any).describeSchema?.(...args); - } - - listObjects?(...args: any[]) { - return (this.controlPlaneDriver as any).listObjects?.(...args); - } - - // ------------------------------------------------------------------------- - // Transactions — not supported - // ------------------------------------------------------------------------- - - beginTransaction(): Promise { - return this.controlPlaneDriver.beginTransaction(); - } - - commit(transaction: unknown): Promise { - return this.controlPlaneDriver.commit(transaction); - } - - rollback(transaction: unknown): Promise { - return this.controlPlaneDriver.rollback(transaction); - } - - commitTransaction(): Promise { - return Promise.reject(new Error('[ControlPlaneProxyDriver] Transactions not supported')); - } - - rollbackTransaction(): Promise { - return Promise.reject(new Error('[ControlPlaneProxyDriver] Transactions not supported')); - } - - syncSchema(object: string, schema: unknown, options?: DriverOptions): Promise { - return this.controlPlaneDriver.syncSchema(object, schema, options); - } - - syncSchemasBatch?(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise { - return this.controlPlaneDriver.syncSchemasBatch?.(schemas, options) ?? Promise.resolve(); - } - - dropTable(object: string, options?: DriverOptions): Promise { - return this.controlPlaneDriver.dropTable(object, options); - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - #orgFilter() { - return { organization_id: this.organizationId }; - } - - /** - * Org-scoped tables — these have an `organization_id` column and require - * tenant filtering. Tables like sys_user / sys_session / sys_account are - * global identity tables and must NOT receive an org filter (a user can - * belong to many orgs). - */ - #isOrgScoped(object: string): boolean { - return ORG_SCOPED_OBJECTS.has(object); - } - - #injectOrg(object: string, query: QueryAST): QueryAST { - if (!this.#isOrgScoped(object)) return query; - const orgFilter = this.#orgFilter(); - if (!query.where) { - return { ...query, where: orgFilter }; - } - return { ...query, where: { ...(query.where as Record), ...orgFilter } }; - } -} - -const ORG_SCOPED_OBJECTS = new Set([ - 'sys_organization', - 'sys_member', - 'sys_invitation', - 'sys_team', - 'sys_team_member', - 'sys_role', - 'sys_permission_set', - 'sys_api_key', - 'sys_audit_log', - 'sys_metadata', - 'sys_metadata_history', - 'sys_environment', - 'sys_environment_credential', - 'sys_environment_member', - 'sys_project_package', - 'sys_app', -]); diff --git a/packages/services/service-cloud/src/data-dir.ts b/packages/services/service-cloud/src/data-dir.ts deleted file mode 100644 index 193985ab6..000000000 --- a/packages/services/service-cloud/src/data-dir.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Default data-directory + serverless-platform detection. - * - * Single source of truth for the on-disk location of the control-plane - * SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver - * persistence JSON files in **non-serverless** deployments. - * - * On serverless platforms with a read-only application bundle (Vercel, - * AWS Lambda, Netlify Functions, Cloudflare Workers Node compat) the - * file-backed default is unsupported — `/var/task` is read-only and - * `/tmp` is per-instance, ephemeral, and not shared between concurrent - * cold starts. Persisting business data there silently corrupts - * deployments. The recommended (and only sensible) default for these - * platforms is **Turso / libSQL** — set `TURSO_DATABASE_URL` (or - * `OS_CONTROL_DATABASE_URL=libsql://…`) and the cloud-stack driver - * factory will pick it up automatically. - * - * Resolution order for {@link resolveDefaultDataDir}: - * - * 1. `OS_DATA_DIR` environment variable (explicit override — wins - * always, even on serverless; intended for self-managed mounts - * such as a network volume or EFS share). - * 2. `/.objectstack/data` on a writable filesystem (the default - * for `objectstack dev`, `objectstack serve`, Docker, bare metal, …). - * 3. **THROWS** on a detected serverless read-only filesystem. The - * error message tells the user exactly which env var to set. - * - * Centralising this logic prevents both - * (a) the "ENOENT: mkdir '/var/task/.objectstack'" cold-start crash, and - * (b) the worse failure mode where an ephemeral `/tmp` SQLite "works" - * for a single cold start and silently loses data on the next one. - */ - -import { resolve as resolvePath } from 'node:path'; - -/** - * Returns `true` when the current process is running on a serverless - * platform whose application bundle is a read-only filesystem and whose - * `/tmp` is per-instance / ephemeral. The set of detected platforms - * intentionally matches the ones where ObjectStack is regularly deployed - * today; new platforms can be added via the `OS_READONLY_FS=1` escape - * hatch. - */ -export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean { - if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) { - return true; - } - // Vercel sets VERCEL=1 in all build & runtime environments. - if (env.VERCEL === '1') return true; - // AWS Lambda & Lambda@Edge. - if (env.AWS_LAMBDA_FUNCTION_NAME) return true; - // Netlify Functions. - if (env.NETLIFY === 'true' || env.NETLIFY_DEV) return true; - return false; -} - -/** - * Build the standard "configure a persistent database" error message - * shown when a file-backed default is requested on serverless. - * @internal - */ -export function buildServerlessPersistenceError(role: 'control' | 'project' = 'control'): Error { - const urlVar = role === 'control' ? 'TURSO_DATABASE_URL (or OS_CONTROL_DATABASE_URL)' : 'OS_DATABASE_URL'; - const tokenVar = role === 'control' ? 'TURSO_AUTH_TOKEN (or OS_CONTROL_DATABASE_AUTH_TOKEN)' : 'OS_DATABASE_AUTH_TOKEN'; - return new Error( - `[objectstack/service-cloud] Detected a serverless read-only filesystem ` + - `(Vercel / AWS Lambda / Netlify) but no persistent database is configured ` + - `for the ${role === 'control' ? 'control plane' : 'project data plane'}. ` + - `Set ${urlVar} to a libsql:// URL (recommended on Vercel — Turso is the ` + - `default ObjectStack pairing for serverless) and ${tokenVar} to the ` + - `matching auth token. ` + - `For self-hosted Postgres / MySQL, set the same variable to a ` + - `postgres:// or mysql:// URL instead. ` + - `If you have a writable persistent mount (EFS, network volume, …), ` + - `set OS_DATA_DIR to its path to opt out of this check. ` + - `File-backed SQLite is rejected on these platforms because /tmp is ` + - `per-instance and ephemeral, which silently corrupts data across ` + - `concurrent invocations.`, - ); -} - -/** - * Resolve the canonical default data directory for SQLite / file-backed - * driver persistence. See module docstring for precedence rules. - * - * Throws on serverless platforms unless `OS_DATA_DIR` is set — see - * {@link buildServerlessPersistenceError} for the rationale. - * - * @param env - Optional process-env override, primarily for tests. - * @returns Absolute filesystem path. Never returns a trailing slash. - */ -export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): string { - const explicit = env.OS_DATA_DIR?.trim(); - if (explicit) return resolvePath(explicit); - - if (isServerlessReadOnlyFs(env)) { - throw buildServerlessPersistenceError('control'); - } - - return resolvePath(process.cwd(), '.objectstack/data'); -} diff --git a/packages/services/service-cloud/src/default-project-plugins.ts b/packages/services/service-cloud/src/default-project-plugins.ts deleted file mode 100644 index dbdec6e5c..000000000 --- a/packages/services/service-cloud/src/default-project-plugins.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Default per-project plugin slate for hosted ObjectStack runtimes. - * - * Mirrors `ALWAYS_CAPS` in `packages/cli/src/commands/serve.ts` so that - * a tenant booted on a per-project kernel (objectos / cloud runtime) - * gets the same foundational services a single-tenant `objectstack dev` - * stack gets: - * - * QueueServicePlugin → JobServicePlugin → CacheServicePlugin - * → SettingsServicePlugin → EmailServicePlugin → StorageServicePlugin - * - * Why this lives in service-cloud (not in the CLI): - * - The CLI builds ONE kernel per process; this helper is for kernels - * built on-demand from artifacts, where the lifecycle is different - * (LRU cached, per-tenant). - * - Per-project kernels do NOT inherit anything from the host: the - * host stays a stateless routing shell on purpose. So defaults must - * be re-mounted per kernel. - * - * Ordering matters: `email` subscribes to settings change events on its - * `kernel:ready` hook, so `settings` MUST mount first. `queue` + `job` - * must come before `email` so durable mail-send subscribers can bind. - */ - -import type { ObjectKernel } from '@objectstack/core'; -import path from 'node:path'; - -export type Logger = { - info?: (...a: any[]) => void; - warn?: (...a: any[]) => void; - error?: (...a: any[]) => void; -}; - -export interface MountDefaultProjectPluginsOptions { - /** Project identifier (used for storage path isolation + log context). */ - projectId: string; - /** Optional logger. Defaults to `console`. */ - logger?: Logger; - /** - * Root directory for per-project local-disk storage when no shared - * S3/GCS backend is configured. Defaults to `/.objectstack/data`. - * The plugin will write under `/projects//uploads/`. - */ - dataRoot?: string; - /** - * When true (default), emit a single `console.warn` if the storage - * plugin falls back to the local-disk driver in non-development mode. - * Hosted runtimes should ALWAYS configure a shared object store - * (S3/GCS/Azure) — per-project local storage is bound to a single - * pod and will not survive eviction. - */ - warnOnLocalStorageInProd?: boolean; - /** - * Set to `false` to skip an individual capability (e.g. when the host - * has already mounted its own shared queue adapter). Defaults to - * mounting every cap. - */ - caps?: Partial>; -} - -export type DefaultProjectCap = - | 'queue' - | 'job' - | 'cache' - | 'settings' - | 'email' - | 'storage'; - -const ORDER: DefaultProjectCap[] = ['queue', 'job', 'cache', 'settings', 'email', 'storage']; - -/** - * Mount the default per-project plugin slate on `kernel`. Safe to call - * exactly once per kernel; the helper guards against double-mount of - * the same plugin name (each plugin's `name` is checked against the - * kernel's plugin list). - */ -export async function mountDefaultProjectPlugins( - kernel: ObjectKernel, - opts: MountDefaultProjectPluginsOptions, -): Promise { - const logger = opts.logger ?? console; - const isDev = process.env.NODE_ENV !== 'production'; - const warnProd = opts.warnOnLocalStorageInProd ?? true; - const caps = opts.caps ?? {}; - - for (const cap of ORDER) { - if (caps[cap] === false) continue; - try { - switch (cap) { - case 'queue': { - const { QueueServicePlugin } = await import('@objectstack/service-queue'); - await kernel.use(new QueueServicePlugin()); - break; - } - case 'job': { - const { JobServicePlugin } = await import('@objectstack/service-job'); - await kernel.use(new JobServicePlugin()); - break; - } - case 'cache': { - const { CacheServicePlugin } = await import('@objectstack/service-cache'); - await kernel.use(new CacheServicePlugin()); - break; - } - case 'settings': { - const { SettingsServicePlugin } = await import('@objectstack/service-settings'); - await kernel.use(new SettingsServicePlugin()); - break; - } - case 'email': { - const { EmailServicePlugin } = await import('@objectstack/plugin-email'); - // Inherit transport options from process env so per-pod ops - // can wire a shared SMTP / Resend account without redeploying - // every project. Per-tenant overrides come through the tenant's - // own `sys_setting` rows (mail namespace). - const provider = (process.env.OS_EMAIL_PROVIDER || 'log').toLowerCase(); - const apiKey = process.env.OS_EMAIL_API_KEY; - const fromEnv = process.env.OS_EMAIL_FROM; - let defaultFrom: any = undefined; - if (fromEnv) { - const m = fromEnv.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/); - if (m) { - const name = (m[1] ?? '').trim(); - const address = (m[2] ?? m[3] ?? '').trim(); - if (address) defaultFrom = name ? { name, address } : { address }; - } - } - await kernel.use( - new EmailServicePlugin({ - provider: provider === 'log' || apiKey ? provider : 'log', - ...(apiKey ? { apiKey } : {}), - ...(defaultFrom ? { defaultFrom } : {}), - } as any), - ); - break; - } - case 'storage': { - const { StorageServicePlugin } = await import('@objectstack/service-storage'); - const sharedAdapter = (process.env.OS_STORAGE_ADAPTER || '').toLowerCase(); - if (sharedAdapter === 's3') { - // Host-shared S3 with project prefix — operator owns the bucket; - // the existing `storage-env.ts` wiring handles credential - // resolution from OS_S3_* env vars. We re-instantiate here - // because each project kernel needs its own plugin instance - // (services aren't shared across kernels). - const { S3StorageAdapter } = await import('@objectstack/service-storage'); - const bucket = process.env.OS_S3_BUCKET; - const region = process.env.OS_S3_REGION; - if (bucket && region) { - const adapter = new S3StorageAdapter({ - bucket, - region, - accessKeyId: process.env.OS_S3_ACCESS_KEY_ID, - secretAccessKey: process.env.OS_S3_SECRET_ACCESS_KEY, - endpoint: process.env.OS_S3_ENDPOINT, - pathStylePrefix: `projects/${opts.projectId}`, - } as any); - await kernel.use(new StorageServicePlugin({ adapter: 's3', s3: adapter } as any)); - break; - } - logger.warn?.( - '[default-project-plugins] OS_STORAGE_ADAPTER=s3 but OS_S3_BUCKET/OS_S3_REGION missing — falling back to local driver', - { projectId: opts.projectId }, - ); - } - // Per-project local-disk fallback. Isolate uploads per - // project so files written by tenant A can't be served as - // tenant B by a path-traversal mishap. - const dataRoot = opts.dataRoot ?? path.join(process.cwd(), '.objectstack', 'data'); - const root = path.join(dataRoot, 'projects', opts.projectId, 'uploads'); - await kernel.use(new StorageServicePlugin({ driver: 'local', root } as any)); - if (!isDev && warnProd) { - // Emit only once per process even if many projects boot — - // logger.warn is fine because hosted runtimes aggregate. - logger.warn?.( - `[default-project-plugins] StorageServicePlugin using local driver for project='${opts.projectId}' (${root}) — switch to S3/GCS/Azure for production (set OS_STORAGE_ADAPTER=s3 + OS_S3_*).`, - ); - } - break; - } - } - } catch (err: any) { - // Each cap is independently optional. Log and continue so a - // missing peer dep can't take down a tenant boot. - const msg = err?.message ?? String(err); - if (msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) { - logger.warn?.( - `[default-project-plugins] capability '${cap}' skipped — package not installed`, - { projectId: opts.projectId }, - ); - } else { - logger.warn?.( - `[default-project-plugins] capability '${cap}' failed to mount: ${msg}`, - { projectId: opts.projectId, error: err?.stack }, - ); - } - } - } -} diff --git a/packages/services/service-cloud/src/environment-registry.ts b/packages/services/service-cloud/src/environment-registry.ts deleted file mode 100644 index 20d67fdd2..000000000 --- a/packages/services/service-cloud/src/environment-registry.ts +++ /dev/null @@ -1,361 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type * as Contracts from '@objectstack/spec/contracts'; -import { resolveDefaultDataDir } from './data-dir.js'; -type IDataDriver = Contracts.IDataDriver; - -/** - * Project-scoped driver registry with LRU caching. - * - * Resolves projects by hostname or ID, lazily instantiates data drivers, - * and caches them with TTL to avoid re-querying control plane on every request. - * - * Implements ADR-0004 project routing: request → hostname/header/session → - * sys_project → sys_project_credential → project-scoped IDataDriver. - * - * (Historically named "EnvironmentDriverRegistry" for ADR-0002 compatibility; - * semantics are the same — each project owns its physical database.) - */ -export interface EnvironmentDriverRegistry { - /** - * Resolve project by hostname (e.g. "acme-dev.objectstack.app"). - * Returns { projectId, driver } if found, null otherwise. - * Caches result with TTL. - */ - resolveByHostname(host: string): Promise<{ projectId: string; driver: IDataDriver } | null>; - - /** - * Resolve project by ID. - * Returns driver if found, null otherwise. - * Caches result with TTL. - */ - resolveById(projectId: string): Promise; - - /** - * Lookup cached project row + driver by ID without fetching from control plane. - * Returns the full cached row (driver + project metadata) when fresh, else null. - * Used by DefaultProjectKernelFactory to avoid duplicate control-plane queries. - */ - peekById(projectId: string): { projectId: string; driver: IDataDriver; project: any } | null; - - /** - * Invalidate cached driver for given project. - * Call this when project is updated (e.g. hostname change, credential rotation). - */ - invalidate(projectId: string): void; -} - -interface CacheEntry { - projectId: string; - driver: IDataDriver; - project: any; - expiresAt: number; -} - -/** - * Secret encryptor interface - must match service-tenant NoopSecretEncryptor - */ -export interface SecretEncryptor { - readonly keyId: string; - encrypt(plaintext: string): Promise | string; - decrypt(ciphertext: string): Promise | string; -} - -/** - * No-op encryptor used in development / tests. **Never** use in production. - */ -export class NoopSecretEncryptor implements SecretEncryptor { - readonly keyId = 'noop'; - encrypt(plaintext: string): string { - return plaintext; - } - decrypt(ciphertext: string): string { - return ciphertext; - } -} - -/** - * Default implementation of EnvironmentDriverRegistry with LRU caching. - * - * Queries `sys.project` + `sys.project_credential` on the control-plane driver. - */ -export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegistry { - private readonly controlPlaneDriver: IDataDriver; - private readonly encryptor: SecretEncryptor; - private readonly cacheTTL: number; - private readonly projectObjectName: string; - private readonly credentialObjectName: string; - private readonly hostnameCache = new Map(); - private readonly idCache = new Map(); - private readonly pendingResolves = new Map>(); - - constructor(config: { - controlPlaneDriver: IDataDriver; - encryptor?: SecretEncryptor; - cacheTTLMs?: number; - projectObjectName?: string; - credentialObjectName?: string; - }) { - this.controlPlaneDriver = config.controlPlaneDriver; - this.encryptor = config.encryptor ?? new NoopSecretEncryptor(); - this.cacheTTL = config.cacheTTLMs ?? 5 * 60 * 1000; - // Default to the namespaced physical names that ObjectQL-registered - // tenant objects end up with (`sys.project` → `sys_project`). Callers - // can override — e.g. a mocked driver in unit tests might use the short - // name directly. - // Default to the physical table names produced by ObjectQL / the SQL - // driver for the tenant plugin's `sys.*` namespace. The short name is - // `sys_project`; drivers store the physical table under that name. - // Callers can override for test drivers that use different naming. - this.projectObjectName = config.projectObjectName ?? 'sys_environment'; - this.credentialObjectName = config.credentialObjectName ?? 'sys_environment_credential'; - } - - async resolveByHostname(host: string): Promise<{ projectId: string; driver: IDataDriver } | null> { - const cached = this.hostnameCache.get(host); - if (cached && cached.expiresAt > Date.now()) { - return { projectId: cached.projectId, driver: cached.driver }; - } - - const cacheKey = `host:${host}`; - const pending = this.pendingResolves.get(cacheKey); - if (pending) { - const result = await pending; - return result ? { projectId: result.projectId, driver: result.driver } : null; - } - - const resolvePromise = this.fetchAndCacheByHostname(host); - this.pendingResolves.set(cacheKey, resolvePromise); - - try { - const entry = await resolvePromise; - return entry ? { projectId: entry.projectId, driver: entry.driver } : null; - } finally { - this.pendingResolves.delete(cacheKey); - } - } - - async resolveById(projectId: string): Promise { - const cached = this.idCache.get(projectId); - if (cached && cached.expiresAt > Date.now()) { - return cached.driver; - } - - const cacheKey = `id:${projectId}`; - const pending = this.pendingResolves.get(cacheKey); - if (pending) { - const result = await pending; - return result?.driver ?? null; - } - - const resolvePromise = this.fetchAndCacheById(projectId); - this.pendingResolves.set(cacheKey, resolvePromise); - - try { - const entry = await resolvePromise; - return entry?.driver ?? null; - } finally { - this.pendingResolves.delete(cacheKey); - } - } - - peekById(projectId: string): { projectId: string; driver: IDataDriver; project: any } | null { - const cached = this.idCache.get(projectId); - if (cached && cached.expiresAt > Date.now()) { - return { projectId: cached.projectId, driver: cached.driver, project: cached.project }; - } - return null; - } - - invalidate(projectId: string): void { - this.idCache.delete(projectId); - for (const [hostname, entry] of this.hostnameCache.entries()) { - if (entry.projectId === projectId) { - this.hostnameCache.delete(hostname); - } - } - } - - private async fetchAndCacheByHostname(host: string): Promise { - try { - const result = await this.controlPlaneDriver.find(this.projectObjectName, { - object: this.projectObjectName, - where: { hostname: host }, - limit: 1, - } as any); - - const rows = Array.isArray(result) ? result : (result as any)?.value ?? []; - const projectRow = rows[0]; - - if (!projectRow) { - return null; - } - - const entry = await this.buildCacheEntry(projectRow); - if (entry) { - this.hostnameCache.set(host, entry); - this.idCache.set(entry.projectId, entry); - } - - return entry; - } catch (error) { - console.error(`[EnvironmentRegistry] Failed to resolve hostname ${host}:`, error); - return null; - } - } - - private async fetchAndCacheById(projectId: string): Promise { - try { - const result = await this.controlPlaneDriver.find(this.projectObjectName, { - object: this.projectObjectName, - where: { id: projectId }, - limit: 1, - } as any); - - const rows = Array.isArray(result) ? result : (result as any)?.value ?? []; - const projectRow = rows[0]; - - if (!projectRow) { - return null; - } - - const entry = await this.buildCacheEntry(projectRow); - if (entry) { - this.idCache.set(projectId, entry); - if (projectRow.hostname) { - this.hostnameCache.set(projectRow.hostname, entry); - } - } - - return entry; - } catch (error) { - console.error(`[EnvironmentRegistry] Failed to resolve project ID ${projectId}:`, error); - return null; - } - } - - private async buildCacheEntry(projectRow: any): Promise { - const projectId = projectRow.id; - const databaseUrl = projectRow.database_url; - const databaseDriver = projectRow.database_driver; - - if (!databaseUrl || !databaseDriver) { - const status = projectRow.status; - if (status === 'provisioning' || status === 'pending') { - // Expected during async provisioning — database_url is set after the background job completes - console.debug(`[EnvironmentRegistry] Project ${projectId} is ${status}, database not ready yet`); - } else { - console.warn(`[EnvironmentRegistry] Project ${projectId} missing database_url or database_driver (status: ${status ?? 'unknown'})`); - } - return null; - } - - const credResult = await this.controlPlaneDriver.find(this.credentialObjectName, { - object: this.credentialObjectName, - where: { environment_id: projectId, status: 'active' }, - limit: 1, - } as any); - - const credRows = Array.isArray(credResult) ? credResult : (credResult as any)?.value ?? []; - const credRow = credRows[0]; - - const plaintextSecret = credRow - ? await Promise.resolve(this.encryptor.decrypt(credRow.secret_ciphertext)) - : ''; - - const driver = await this.createDriver(databaseDriver, databaseUrl, plaintextSecret); - - return { - projectId, - driver, - project: projectRow, - expiresAt: Date.now() + this.cacheTTL, - }; - } - - private async createDriver(driverType: string, databaseUrl: string, authToken: string): Promise { - switch (driverType) { - case 'memory': { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - // Derive a per-project JSON path from the `memory://` URL - // so each project owns its own persistence file instead of every - // memory-driver project sharing a single `memory-driver.json`. - // Mirrors DefaultProjectKernelFactory.createDriver so both paths - // (cache warm-up here + factory fallback) land on the same file. - const { resolve: resolvePath } = await import('node:path'); - const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); - const filePath = dbName - ? resolvePath(resolveDefaultDataDir(), '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 'libsql': - case 'turso': { - const { TursoDriver } = await import('@objectstack/driver-turso'); - return new TursoDriver({ - url: databaseUrl, - authToken, - }) 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(`[EnvironmentRegistry] Unsupported driver type: ${driverType}`); - } - } -} - -/** - * Create a default environment driver registry instance. - */ -export function createEnvironmentDriverRegistry( - controlPlaneDriver: IDataDriver, - options?: { - encryptor?: SecretEncryptor; - cacheTTLMs?: number; - projectObjectName?: string; - credentialObjectName?: string; - }, -): EnvironmentDriverRegistry { - return new DefaultEnvironmentDriverRegistry({ - controlPlaneDriver, - encryptor: options?.encryptor, - cacheTTLMs: options?.cacheTTLMs, - projectObjectName: options?.projectObjectName, - credentialObjectName: options?.credentialObjectName, - }); -} diff --git a/packages/services/service-cloud/src/fs-bundle-resolver.ts b/packages/services/service-cloud/src/fs-bundle-resolver.ts deleted file mode 100644 index ad4819427..000000000 --- a/packages/services/service-cloud/src/fs-bundle-resolver.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * File-System AppBundleResolver. - * - * Resolves the artifact bundles a project is bound to by reading the - * project's row from the control plane (`sys_project.metadata`) and - * loading any artifact files referenced there. - * - * Binding model — a project's `metadata` column may carry: - * - * { - * "artifact_path": "./examples/app-crm/dist/objectstack.json", - * "artifact_paths": [ - * "./pkg-a/dist/objectstack.json", - * "https://example.com/pkg-b/objectstack.json" - * ] - * } - * - * Local relative paths are resolved against `OS_PROJECT_ARTIFACT_ROOT` - * (defaults to `process.cwd()`); absolute paths are honored as-is; - * `http(s)://` URLs are fetched verbatim and never path-joined. - * - * For pure dev convenience, an env-var override is also supported: - * - * OS_PROJECT_ARTIFACTS=proj_crm:/abs/path/crm.json,proj_todo:/abs/path/todo.json - * - * Entries listed there override `metadata.artifact_path` for that - * project id, so a developer can rebind without rewriting the DB row. - * - * On read errors (missing file, malformed JSON) the resolver logs a - * warning and returns `[]` for that path. - */ - -import { resolve as resolvePath, isAbsolute } from 'node:path'; -import { loadArtifactBundle, isHttpUrl } from '@objectstack/runtime'; -import type { AppBundleResolver } from './project-kernel-factory.js'; - -const ENV_MAP_VAR = 'OS_PROJECT_ARTIFACTS'; -const ARTIFACT_ROOT_VAR = 'OS_PROJECT_ARTIFACT_ROOT'; - -function parseEnvMap(raw: string | undefined): Map { - const map = new Map(); - if (!raw) return map; - for (const segment of raw.split(',')) { - const trimmed = segment.trim(); - if (!trimmed) continue; - const idx = trimmed.indexOf(':'); - if (idx <= 0) continue; - const projectId = trimmed.slice(0, idx).trim(); - const path = trimmed.slice(idx + 1).trim(); - if (!projectId || !path) continue; - const list = map.get(projectId) ?? []; - list.push(path); - map.set(projectId, list); - } - return map; -} - -function extractMetadataPaths(metadata: any): string[] { - if (!metadata || typeof metadata !== 'object') return []; - const out: string[] = []; - if (typeof metadata.artifact_path === 'string') out.push(metadata.artifact_path); - if (Array.isArray(metadata.artifact_paths)) { - for (const p of metadata.artifact_paths) { - if (typeof p === 'string') out.push(p); - } - } - return out; -} - -export function createFsAppBundleResolver(): AppBundleResolver { - const envMap = parseEnvMap(process.env[ENV_MAP_VAR]); - const root = process.env[ARTIFACT_ROOT_VAR] ?? process.cwd(); - const cache = new Map(); - - async function loadOne(path: string): Promise { - const key = isHttpUrl(path) - ? path - : (isAbsolute(path) ? path : resolvePath(root, path)); - if (cache.has(key)) return cache.get(key); - const bundle = await loadArtifactBundle(key, { tag: '[FsAppBundleResolver]' }); - cache.set(key, bundle); - return bundle; - } - - return { - async resolve(project: any) { - const projectId = project?.id; - const overridePaths = projectId ? envMap.get(projectId) : undefined; - let meta: any = project?.metadata; - if (typeof meta === 'string') { - try { meta = JSON.parse(meta); } catch { meta = undefined; } - } - const metadataPaths = extractMetadataPaths(meta); - - const paths = overridePaths && overridePaths.length > 0 - ? overridePaths - : metadataPaths; - if (paths.length === 0) return []; - - const bundles: any[] = []; - for (const p of paths) { - const b = await loadOne(p); - if (b) bundles.push(b); - } - return bundles; - }, - }; -} diff --git a/packages/services/service-cloud/src/index.ts b/packages/services/service-cloud/src/index.ts deleted file mode 100644 index 2304f3b6e..000000000 --- a/packages/services/service-cloud/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -// ── Main entry point ────────────────────────────────────────────────────────── -export { createCloudStack } from './cloud-stack.js'; -export type { CloudStackConfig } from './cloud-stack.js'; -export { - mountDefaultProjectPlugins, -} from './default-project-plugins.js'; -export type { - MountDefaultProjectPluginsOptions, - DefaultProjectCap, -} from './default-project-plugins.js'; - -// ── Data-directory resolution ───────────────────────────────────────────────── -export { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js'; - -// ── Multi-project orchestration ─────────────────────────────────────────────── -export { MultiProjectPlugin } from './multi-project-plugin.js'; -export type { - MultiProjectPluginConfig, - ProjectTemplate, - TemplateSeeder, -} from './multi-project-plugin.js'; - -export { KernelManager } from './kernel-manager.js'; -export type { - ProjectKernelFactory, - KernelManagerConfig, -} from './kernel-manager.js'; - -export { DefaultProjectKernelFactory } from './project-kernel-factory.js'; -export type { - DefaultProjectKernelFactoryConfig, - BasePluginsFactory, - AppBundleResolver, - SysProjectRow, - SysProjectCredentialRow, - LocalProjectConfig, -} from './project-kernel-factory.js'; - -// ── Environment registry ────────────────────────────────────────────────────── -export { - DefaultEnvironmentDriverRegistry, - createEnvironmentDriverRegistry, - NoopSecretEncryptor, -} from './environment-registry.js'; -export type { - EnvironmentDriverRegistry, - SecretEncryptor, -} from './environment-registry.js'; - -// ── Proxy driver ────────────────────────────────────────────────────────────── -export { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js'; - -// ── Shared-kernel mode (ADR-0003 v2) ───────────────────────────────────────── -export { SharedProjectPlugin } from './shared-project-plugin.js'; -export type { SharedProjectPluginConfig } from './shared-project-plugin.js'; -export { ProjectScopeManager } from './project-scope-manager.js'; -export type { ProjectScopeManagerConfig } from './project-scope-manager.js'; - -// ── Control-plane preset ────────────────────────────────────────────────────── -export { createControlPlanePlugins } from './control-plane-preset.js'; -export type { ControlPlanePresetConfig } from './control-plane-preset.js'; - -// ── Studio auxiliary routes ─────────────────────────────────────────────────── -export { - createStudioRuntimeConfigPlugin, - createTemplatesRoutePlugin, -} from './multi-project-plugins.js'; - -// ── Cloud Artifact API (M3) ─────────────────────────────────────────────────── -export { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js'; - -// ── Boot-mode orchestration ─────────────────────────────────────────────────── -export { - resolveMode, - resolveAuthSecret, - resolveBaseUrl, - BootEnvSchema, -} from './boot-env.js'; -export type { BootMode, BootEnv } from './boot-env.js'; - -export { createRuntimeStack, RuntimeStackConfigSchema, DEFAULT_CLOUD_URL } from './runtime-stack.js'; -export type { RuntimeStackConfig, RuntimeStackResult } from './runtime-stack.js'; - -/** @deprecated Use `createRuntimeStack`. */ -export { createProjectStack, ProjectStackConfigSchema } from './runtime-stack.js'; -/** @deprecated Use `RuntimeStackConfig`/`RuntimeStackResult`. */ -export type { ProjectStackConfig, ProjectStackResult } from './runtime-stack.js'; - - -export { createBootStack, BootStackConfigSchema } from './boot-stack.js'; -export type { BootStackConfig, BootStackResult } from './boot-stack.js'; - -// ── Local identity seeding ──────────────────────────────────────────────────── -export { - ensureLocalIdentity, - LOCAL_ORG_ID, - LOCAL_PROJECT_ID, -} from './local-identity.js'; -export type { LocalIdentityOptions } from './local-identity.js'; - -// ── Single-project plugin ───────────────────────────────────────────────────── -export { - createSingleProjectPlugin, - DEFAULT_LOCAL_ORG_ID, - DEFAULT_LOCAL_PROJECT_ID, -} from './single-project-plugin.js'; -export type { SingleProjectPluginOptions } from './single-project-plugin.js'; - -// ── Filesystem app bundle resolver ──────────────────────────────────────────── -export { createFsAppBundleResolver } from './fs-bundle-resolver.js'; - -// ── ObjectOS Cloud Runtime (artifact API mode) ──────────────────────────────── -export { ArtifactApiClient } from './artifact-api-client.js'; -export type { - ArtifactApiClientConfig, - ProjectArtifactResponse, - ProjectRuntimeConfig, - ResolvedHostname, -} from './artifact-api-client.js'; - -export { ArtifactEnvironmentRegistry } from './artifact-environment-registry.js'; -export type { ArtifactEnvironmentRegistryConfig } from './artifact-environment-registry.js'; - -export { ArtifactKernelFactory } from './artifact-kernel-factory.js'; -export type { ArtifactKernelFactoryConfig } from './artifact-kernel-factory.js'; - -export { createObjectOSStack } from './objectos-stack.js'; -export type { ObjectOSStackConfig, ObjectOSStackResult } from './objectos-stack.js'; - -// ── Preview-mode stack (sandbox previews of pinned commits / branches) ─────── -export { createPreviewStack } from './preview/preview-stack.js'; -export type { PreviewStackConfig, PreviewStackResult } from './preview/preview-stack.js'; -export { parsePreviewHost, projectIdToShort } from './preview/host-parser.js'; -export type { PreviewHost, PreviewParseConfig } from './preview/host-parser.js'; -export { PreviewEnvironmentRegistry } from './preview/environment-registry.js'; -export type { PreviewEnvironmentRegistryConfig } from './preview/environment-registry.js'; -export { PreviewKernelFactory } from './preview/kernel-factory.js'; -export type { PreviewKernelFactoryConfig } from './preview/kernel-factory.js'; diff --git a/packages/services/service-cloud/src/kernel-manager.ts b/packages/services/service-cloud/src/kernel-manager.ts deleted file mode 100644 index cd709bb45..000000000 --- a/packages/services/service-cloud/src/kernel-manager.ts +++ /dev/null @@ -1,145 +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 `projectId`, the factory is expected to: - * 1. Read control-plane metadata (`sys_project` + 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 ProjectKernelFactory { - create(projectId: string): Promise; -} - -interface CachedEntry { - kernel: ObjectKernel; - createdAt: number; - lastAccess: number; -} - -export interface KernelManagerConfig { - factory: ProjectKernelFactory; - /** 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 }; -} - -/** - * 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 projectId share a single in-flight - * factory invocation (singleflight). - */ -export class KernelManager { - private readonly factory: ProjectKernelFactory; - private readonly maxSize: number; - private readonly ttlMs: number; - private readonly logger: NonNullable; - private readonly cache = new Map(); - private readonly pending = new Map>(); - - constructor(config: KernelManagerConfig) { - this.factory = config.factory; - this.maxSize = config.maxSize ?? 32; - this.ttlMs = config.ttlMs ?? 15 * 60 * 1000; - this.logger = config.logger ?? console; - } - - /** Returns the currently cached projectIds (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 `projectId`. - * - * - 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(projectId: string): Promise { - const existing = this.cache.get(projectId); - if (existing) { - if (this.ttlMs > 0 && Date.now() - existing.lastAccess > this.ttlMs) { - await this.evict(projectId); - } else { - existing.lastAccess = Date.now(); - return existing.kernel; - } - } - - const inflight = this.pending.get(projectId); - if (inflight) return inflight; - - const promise = (async () => { - const kernel = await this.factory.create(projectId); - const now = Date.now(); - this.cache.set(projectId, { kernel, createdAt: now, lastAccess: now }); - await this.enforceMaxSize(); - return kernel; - })(); - - this.pending.set(projectId, promise); - try { - return await promise; - } finally { - this.pending.delete(projectId); - } - } - - /** - * Evict the kernel for `projectId` and invoke `kernel.shutdown()`. - * No-op when the entry is absent. - */ - async evict(projectId: string): Promise { - const entry = this.cache.get(projectId); - if (!entry) return; - this.cache.delete(projectId); - try { - await entry.kernel.shutdown(); - } catch (err) { - this.logger.error?.('[KernelManager] shutdown failed', { projectId, 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/services/service-cloud/src/local-identity.ts b/packages/services/service-cloud/src/local-identity.ts deleted file mode 100644 index 1a1b0a3b7..000000000 --- a/packages/services/service-cloud/src/local-identity.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Idempotent control-plane seed for project mode. - * - * In project mode the runtime reuses the cloud (multi-project) plugin - * stack but backs it with two local SQLite files: - * - * - `control.db` — control plane (sys_organization, sys_project, …) - * - `proj_local.db` — the single project's business database - * - * For `KernelManager` (cloud factory) to resolve `proj_local`, the - * control plane must contain a real `sys_project` row whose - * `database_url` points at `proj_local.db`. This helper performs that - * seed idempotently on every boot, so the project-mode code path is - * identical to cloud after the first request — no synthetic project - * rows, no special branches in the dispatcher. - */ - -export const LOCAL_ORG_ID = 'org_local'; -export const LOCAL_PROJECT_ID = 'proj_local'; - -export interface LocalIdentityOptions { - /** ObjectQL service handle bound to the control-plane DB. */ - objectql: any; - /** Override the default `org_local` identifier. */ - orgId?: string; - /** Override the default `proj_local` identifier. */ - projectId?: string; - /** Display name for the seeded organization. */ - orgName?: string; - /** Project DB URL written to `sys_project.database_url`. */ - projectDatabaseUrl: string; - /** Project DB driver name (e.g. `sqlite`, `turso`). */ - projectDatabaseDriver: string; -} - -/** - * Insert the local org + project rows if they don't yet exist. Safe to - * call on every boot — uses `find` with an exact-id filter for the - * existence check. - */ -export async function ensureLocalIdentity(opts: LocalIdentityOptions): Promise { - const { - objectql, - orgId = LOCAL_ORG_ID, - projectId = LOCAL_PROJECT_ID, - orgName = 'Local', - projectDatabaseUrl, - projectDatabaseDriver, - } = opts; - - if (!objectql) return; - const now = new Date().toISOString(); - - // ── Organization ───────────────────────────────────────────────────── - const existingOrg = await safeFind(objectql, 'sys_organization', orgId); - if (!existingOrg?.length) { - await safeInsert(objectql, 'sys_organization', { - id: orgId, - name: orgName, - slug: orgId, - created_at: now, - updated_at: now, - }); - } - - // ── Project ────────────────────────────────────────────────────────── - const existingProject = await safeFind(objectql, 'sys_environment', projectId); - if (!existingProject?.length) { - await safeInsert(objectql, 'sys_environment', { - id: projectId, - organization_id: orgId, - display_name: orgName, - is_default: true, - is_system: false, - plan: 'free', - status: 'active', - created_by: 'system', - database_url: projectDatabaseUrl, - database_driver: projectDatabaseDriver, - created_at: now, - updated_at: now, - }); - } -} - -async function safeFind(objectql: any, name: string, id: string): Promise { - try { - const result = await objectql.find(name, { filters: [['id', '=', id]], top: 1 }); - const rows = (result && (result as any).value) ?? result; - return Array.isArray(rows) ? rows : []; - } catch (err: any) { - // eslint-disable-next-line no-console - console.warn(`[ensureLocalIdentity] find ${name} failed:`, err?.message ?? err); - return null; - } -} - -async function safeInsert(objectql: any, name: string, doc: Record): Promise { - try { - await objectql.insert(name, doc); - } catch (err: any) { - // eslint-disable-next-line no-console - console.warn(`[ensureLocalIdentity] insert ${name} failed:`, err?.message ?? err); - } -} diff --git a/packages/services/service-cloud/src/multi-project-plugin.ts b/packages/services/service-cloud/src/multi-project-plugin.ts deleted file mode 100644 index fb0d44208..000000000 --- a/packages/services/service-cloud/src/multi-project-plugin.ts +++ /dev/null @@ -1,598 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { Plugin, PluginContext } from '@objectstack/core'; -import { - SeedLoaderService, - collectBundleHooks, - collectBundleFunctions, - collectBundleActions, - actionBodyRunnerFactory, - QuickJSScriptRunner, -} from '@objectstack/runtime'; -import { SeedLoaderConfigSchema } from '@objectstack/spec/data'; -import { - DefaultEnvironmentDriverRegistry, - type EnvironmentDriverRegistry, - type SecretEncryptor, -} from './environment-registry.js'; -import { - DefaultProjectKernelFactory, - type BasePluginsFactory, - type AppBundleResolver, -} from './project-kernel-factory.js'; -import { KernelManager } from './kernel-manager.js'; -import type * as Contracts from '@objectstack/spec/contracts'; - -type IDataDriver = Contracts.IDataDriver; - -/** - * Lazy descriptor for a project template. - */ -export interface ProjectTemplate { - id: string; - label: string; - description: string; - category?: string; - load(): Promise; -} - -export interface TemplateSeeder { - listTemplates(): Array>; - seed(params: { projectId: string; templateId: string }): Promise; - seedBundle(params: { projectId: string; bundle: any }): Promise; -} - -export interface MultiProjectPluginConfig { - controlDriver: IDataDriver; - basePlugins: BasePluginsFactory; - appBundles?: AppBundleResolver; - templates?: Record; - encryptor?: SecretEncryptor; - cacheTTLMs?: number; - maxSize?: number; - ttlMs?: number; - /** Direct storage adapter for artifact publishing. Bypasses kernel.getService('file-storage'). */ - storage?: any; - /** Adapter name (e.g. 's3', 'local') — used when persisting sys_environment_revision rows. */ - storageAdapterName?: string; -} - -interface ExtractedItem { - type: string; - name: string; - data: unknown; -} - -function extractMetadataItems(bundle: any): ExtractedItem[] { - const items: ExtractedItem[] = []; - - const pushAll = (type: string, arr?: any[]) => { - for (const item of arr ?? []) { - if (!item?.name) continue; - items.push({ type, name: item.name, data: item }); - } - }; - - pushAll('object', bundle?.objects); - pushAll('view', bundle?.views); - pushAll('dashboard', bundle?.dashboards); - pushAll('report', bundle?.reports); - pushAll('flow', bundle?.flows); - pushAll('agent', bundle?.agents); - pushAll('app', bundle?.apps); - pushAll('action', bundle?.actions); - - return items; -} - -function namespaceDatasets(bundle: any): any[] { - const datasets = Array.isArray(bundle?.data) ? bundle.data : []; - return datasets; -} - -function createTemplateSeeder( - kernelManager: KernelManager, - templates: Record, - envRegistry: EnvironmentDriverRegistry, - publishCtx: { controlDriver: IDataDriver; getStorage: () => any | Promise | null; storageAdapter: string; keyPrefix: string }, -): TemplateSeeder { - const seedBundleForProject = async (projectId: string, bundle: any): Promise => { - const items = bundle ? extractMetadataItems(bundle) : []; - const dataSets = bundle ? namespaceDatasets(bundle) : []; - - if (items.length === 0 && dataSets.length === 0) return; - - const kernel = await kernelManager.getOrCreate(projectId); - - let metadata: any; - try { - metadata = await kernel.getServiceAsync('metadata'); - } catch (err: any) { - throw new Error( - `metadata service unavailable for project ${projectId}: ${err?.message ?? err}`, - ); - } - if (!metadata || typeof metadata.bulkRegister !== 'function') { - throw new Error( - `metadata.bulkRegister unavailable for project ${projectId} (got ${metadata ? typeof metadata : 'null'})`, - ); - } - - const engine: any = await kernel.getServiceAsync('objectql').catch(() => null); - if (!engine) { - throw new Error( - `objectql engine unavailable for project ${projectId} — metadata persistence would be in-memory only`, - ); - } - if (typeof metadata.setDataEngine === 'function') { - const cached = envRegistry.peekById(projectId); - const orgId = (cached?.project as any)?.organization_id as string | undefined; - try { metadata.setDataEngine(engine, orgId, projectId); } catch { /* already set */ } - } - - if (items.length > 0) { - const result: any = await metadata.bulkRegister(items, { continueOnError: true }); - const failed = result?.failed ?? 0; - if (failed > 0) { - const errs = (result?.errors ?? []) - .slice(0, 5) - .map((e: any) => `${e?.type}/${e?.name}: ${e?.error ?? 'unknown'}`) - .join('; '); - throw new Error( - `bulkRegister reported ${failed} failures for project ${projectId}: ${errs}`, - ); - } - } - - if (items.length > 0 && typeof engine?.registerApp === 'function') { - try { (engine as any).registerApp(bundle); } catch { /* best effort */ } - } - - // Wire declarative hooks + their handler functions onto the engine. - // `engine.registerApp(bundle)` only registers schema + hook *names*; - // the actual handler-binding step (which AppPlugin.onInstall does in - // standalone mode) must be replicated here so hooks fire when records - // are mutated through this project kernel. Without this, runtime - // bundles loaded via `metadata.artifact_path` (cloud mode) silently - // skip beforeInsert/afterInsert/etc. handlers. - if (typeof engine?.bindHooks === 'function') { - const hooks = collectBundleHooks(bundle); - const functions = collectBundleFunctions(bundle); - if (hooks.length > 0 || Object.keys(functions).length > 0) { - try { - engine.bindHooks(hooks, { engine, functions }); - } catch (err: any) { - // Non-fatal — schema is still registered; only handlers - // are missing. Log loudly so the operator can investigate. - // eslint-disable-next-line no-console - console.error( - `[MultiProjectPlugin] bindHooks failed for project ${projectId}:`, - err?.message ?? err, - ); - } - } - } - - // Wire declarative Action bodies. Symmetric with hooks above: - // `engine.registerApp(bundle)` only persists schema metadata — it - // does NOT install action handlers. Without this loop, any action - // shipped with a metadata `body` (extracted by the CLI from inline - // `execute:` arrow functions) would be unreachable through - // `POST /api/v1/projects/:projectId/actions/...`. - if (typeof engine?.registerAction === 'function') { - const actions = collectBundleActions(bundle); - if (actions.length > 0) { - const actionBodyRunner = actionBodyRunnerFactory(new QuickJSScriptRunner(), { - ql: engine, - appId: projectId, - }); - let registered = 0; - for (const action of actions) { - const handler = actionBodyRunner(action); - if (!handler) continue; - const objectKey = - typeof action.object === 'string' && action.object.length > 0 - ? action.object - : 'global'; - try { - engine.registerAction( - objectKey, - action.name, - handler, - `app:${projectId}`, - ); - registered++; - } catch (err: any) { - // eslint-disable-next-line no-console - console.warn( - `[MultiProjectPlugin] registerAction failed for ${objectKey}.${action.name} in project ${projectId}:`, - err?.message ?? err, - ); - } - } - if (registered > 0) { - // eslint-disable-next-line no-console - console.log( - `[MultiProjectPlugin] Bound ${registered} action body(s) for project ${projectId}`, - ); - } - } - } - - // Ensure physical tables exist for the bundle's objects. We bypass - // engine.syncSchemas() (which iterates *all* registered objects - // across the kernel — including platform objects whose drivers may - // not be wired) and instead drive `initObjects` per-driver for the - // bundle's own object set. This is the same code path that - // AppPlugin.onInstall takes in standalone mode. - const bundleObjectsRaw: any = (bundle as any)?.objects; - const bundleObjects: any[] = Array.isArray(bundleObjectsRaw) - ? bundleObjectsRaw - : (bundleObjectsRaw && typeof bundleObjectsRaw === 'object' ? Object.values(bundleObjectsRaw) : []); - if (bundleObjects.length > 0) { - const driverGroups = new Map(); - for (const obj of bundleObjects) { - let driver: any; - try { driver = (engine as any).getDriverForObject?.(obj.name) ?? (engine as any).defaultDriver; } - catch { driver = (engine as any).defaultDriver; } - if (!driver || typeof driver.initObjects !== 'function') continue; - if (!driverGroups.has(driver)) driverGroups.set(driver, []); - driverGroups.get(driver)!.push(obj); - } - for (const [driver, objs] of driverGroups) { - try { - await driver.initObjects(objs); - } catch (err: any) { - // Non-fatal — schema is registered but the physical - // table couldn't be created. Surface so the operator - // can investigate (mismatched datasource binding, - // permission errors, etc.). Subsequent inserts will - // fail with `no such table` from the SQL driver. - // eslint-disable-next-line no-console - console.error( - `[MultiProjectPlugin] initObjects failed for project ${projectId}:`, - err?.message ?? err, - ); - } - } - } - - if (dataSets.length > 0) { - const seedLoader = new SeedLoaderService(engine, metadata, console as any); - const config = SeedLoaderConfigSchema.parse({}); - await seedLoader.load({ datasets: dataSets, config }); - } - - const driverWithFlush = await (kernel as any).getServiceAsync?.('driver').catch?.(() => null) - ?? (kernel as any).services?.driver - ?? null; - const flushable = typeof driverWithFlush?.flush === 'function' - ? driverWithFlush - : null; - if (flushable) { - try { await flushable.flush(); } catch { /* best effort */ } - } - - // Publish the seeded bundle as a sys_environment_revision so the - // artifact API serves it to remote runtimes (objectos in cloud - // mode reads `GET /cloud/projects/:id/artifact`). Without this - // step, the seeded data only lives in this project's metadata - // DB on the cloud container's filesystem and is invisible to - // any other process. - let publishStatus: { stage: string; ok: boolean; error?: string; detail?: any } = { - stage: 'start', ok: false, - }; - try { - const storage = await Promise.resolve(publishCtx.getStorage()); - publishStatus.stage = 'getStorage'; - publishStatus.detail = { - hasStorage: !!storage, - storageType: storage ? (storage.constructor?.name ?? typeof storage) : 'null', - bundleHasObjects: Array.isArray(bundle?.objects) ? bundle.objects.length : 0, - bundleHasApps: Array.isArray(bundle?.apps) ? bundle.apps.length : 0, - bundleHasViews: Array.isArray(bundle?.views) ? bundle.views.length : 0, - lastServiceKeys: (publishCtx as any)._lastServiceKeys ?? null, - }; - if (!storage) { - publishStatus.error = 'no-file-storage-service'; - console.warn( - `[MultiProjectPlugin] No file-storage service registered; skipping artifact publish for project ${projectId}.`, - ); - } else { - publishStatus.stage = 'findProject'; - const projectRow = await (publishCtx.controlDriver as any).findOne( - 'sys_environment', - { where: { id: projectId } }, - ); - if (projectRow) { - publishStatus.stage = 'publishProjectRevision'; - const { publishProjectRevision } = await import('./cloud-artifact-helpers.js'); - await publishProjectRevision({ - driver: publishCtx.controlDriver, - storage, - storageAdapter: publishCtx.storageAdapter, - keyPrefix: publishCtx.keyPrefix, - project: { id: projectRow.id, organization_id: projectRow.organization_id }, - bundle, - note: 'template-seed', - }); - publishStatus.ok = true; - publishStatus.stage = 'done'; - console.log(`[MultiProjectPlugin] Published artifact bundle for project ${projectId}`); - } else { - publishStatus.error = 'project-not-found'; - } - } - } catch (err: any) { - publishStatus.error = err?.message ?? String(err); - publishStatus.detail = { ...(publishStatus.detail ?? {}), stack: err?.stack }; - console.error( - `[MultiProjectPlugin] Failed to publish seeded artifact for project ${projectId}:`, - err?.stack ?? err?.message ?? err, - ); - } - // Persist publish diagnostic into sys_project.metadata.publishStatus so - // operators can see why publish skipped without container logs. - try { - const cur = await (publishCtx.controlDriver as any).findOne('sys_environment', { where: { id: projectId } }); - const meta = cur && typeof cur.metadata === 'string' - ? JSON.parse(cur.metadata) - : (cur?.metadata ?? {}); - await (publishCtx.controlDriver as any).update( - 'sys_environment', - projectId, - { metadata: JSON.stringify({ ...meta, publishStatus, publishStatusAt: new Date().toISOString() }) }, - ); - } catch (diagErr: any) { - console.error('[MultiProjectPlugin] Failed to write publishStatus diagnostic:', diagErr?.message ?? diagErr); - } - }; - - return { - listTemplates() { - return Object.values(templates).map(({ id, label, description, category }) => ({ - id, - label, - description, - category, - })); - }, - - async seed({ projectId, templateId }) { - const writeDiag = async (stage: string, extra: any = {}) => { - try { - const cur = await (publishCtx.controlDriver as any).findOne('sys_environment', { where: { id: projectId } }); - const meta = cur && typeof cur.metadata === 'string' ? JSON.parse(cur.metadata) : (cur?.metadata ?? {}); - const seedDiag = { ...(meta.seedDiag ?? {}), [stage]: { at: new Date().toISOString(), ...extra } }; - await (publishCtx.controlDriver as any).update('sys_environment', projectId, { - metadata: JSON.stringify({ ...meta, seedDiag }), - }); - } catch (e: any) { console.error('[MultiProjectPlugin] writeDiag failed', stage, e?.message); } - }; - await writeDiag('seedCalled', { templateId, templates: Object.keys(templates) }); - const template = templates[templateId]; - if (!template) { - await writeDiag('seedNotFound', { templateId }); - throw new Error( - `Unknown template: '${templateId}'. Available: [${Object.keys(templates).join(', ')}]`, - ); - } - let bundle: any; - try { - bundle = await template.load(); - await writeDiag('templateLoaded', { - objects: Array.isArray(bundle?.objects) ? bundle.objects.length : 'n/a', - apps: Array.isArray(bundle?.apps) ? bundle.apps.length : 'n/a', - views: Array.isArray(bundle?.views) ? bundle.views.length : 'n/a', - data: Array.isArray(bundle?.data) ? bundle.data.length : 'n/a', - bundleKeys: bundle ? Object.keys(bundle) : null, - }); - } catch (e: any) { - await writeDiag('templateLoadError', { error: e?.message, stack: e?.stack?.split('\n').slice(0,5) }); - throw e; - } - try { - await seedBundleForProject(projectId, bundle); - await writeDiag('seedBundleDone'); - } catch (e: any) { - await writeDiag('seedBundleError', { error: e?.message, stack: e?.stack?.split('\n').slice(0,5) }); - throw e; - } - }, - - async seedBundle({ projectId, bundle }) { - await seedBundleForProject(projectId, bundle); - }, - }; -} - -/** - * Control-plane plugin that stands up the per-project orchestration layer. - * Registers `env-registry`, `kernel-manager` and `template-seeder` on the - * control kernel. - */ -export class MultiProjectPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.multi-project'; - readonly version = '1.0.0'; - - private readonly config: MultiProjectPluginConfig; - private kernelManager?: KernelManager; - - constructor(config: MultiProjectPluginConfig) { - this.config = config; - } - - init = async (ctx: PluginContext): Promise => { - const envRegistry: EnvironmentDriverRegistry = new DefaultEnvironmentDriverRegistry({ - controlPlaneDriver: this.config.controlDriver, - encryptor: this.config.encryptor, - cacheTTLMs: this.config.cacheTTLMs, - }); - - // Wrap the user-supplied resolver so bundles also come from packages - // installed into the project via the marketplace UI. The marketplace - // writes rows into `sys_package_installation`; for each row we look up - // the corresponding manifest in the host kernel's SchemaRegistry and - // reuse it as a project-scoped AppPlugin bundle. - const userResolver = this.config.appBundles; - const controlDriver = this.config.controlDriver; - const hostKernel: any = ctx.kernel; - - /** - * Pull installed-package bundles for `projectId` from the control - * plane. Returns one bundle per installed manifest_id whose package is - * present in the host kernel's package registry. - */ - const resolveInstalledBundles = async (projectId: string): Promise => { - try { - const findRows = async (table: string, where: any) => { - const r: any = await (controlDriver as any).find(table, { where, limit: 1000 }); - if (Array.isArray(r)) return r; - if (r && Array.isArray(r.value)) return r.value; - return []; - }; - const installs = await findRows('sys_package_installation', { - environment_id: projectId, - }); - if (installs.length === 0) return []; - - // Optionally consult the host kernel's in-memory package - // registry as a fallback when sys_package_version.manifest_json - // is missing (e.g. seeded data without snapshot). - const qlService: any = (() => { - try { return hostKernel?.getService?.('objectql'); } catch { return null; } - })(); - const pkgRegistry = qlService?.registry; - const allHostPackages: any[] = pkgRegistry?.getAllPackages?.() ?? []; - - const out: any[] = []; - for (const inst of installs) { - if (inst.enabled === false || inst.enabled === 0) continue; - if (!inst?.package_version_id && !inst?.package_id) continue; - - // 1) Try manifest_json snapshot from sys_package_version - let manifest: any = null; - let manifestId: string | undefined; - if (inst.package_version_id) { - const verRows = await findRows('sys_package_version', { id: inst.package_version_id }); - const verRow = verRows[0]; - if (verRow?.manifest_json) { - try { - manifest = typeof verRow.manifest_json === 'string' - ? JSON.parse(verRow.manifest_json) - : verRow.manifest_json; - } catch { /* invalid JSON — fall through */ } - } - } - - // Resolve manifest_id (for fallback lookup + bundle metadata) - if (inst.package_id) { - const pkgRows = await findRows('sys_package', { id: inst.package_id }); - manifestId = pkgRows[0]?.manifest_id; - } - - // 2) Fallback: in-memory host registry by manifest_id - if (!manifest && manifestId) { - const entry = allHostPackages.find( - (p: any) => (p?.manifest?.id ?? p?.id ?? p?.manifest?.name) === manifestId, - ); - manifest = entry?.manifest ?? entry; - } - - if (!manifest) { - console.warn( - `[MultiProjectPlugin] No manifest available for install (manifest_id='${manifestId}', version_id='${inst.package_version_id}')`, - ); - continue; - } - out.push({ manifest, packageId: manifestId ?? manifest?.id }); - } - return out; - } catch (err: any) { - console.error( - `[MultiProjectPlugin] Failed to resolve installed bundles for '${projectId}':`, - err?.stack ?? err?.message ?? err, - ); - return []; - } - }; - - const wrappedResolver: AppBundleResolver = { - async resolve(project) { - const baseBundles = userResolver ? await userResolver.resolve(project) : []; - const installedBundles = await resolveInstalledBundles(project.id); - - // Dedupe by manifest id — file-based bundles take precedence - // over installed bundles when both reference the same id. - const seen = new Set(); - const merged: any[] = []; - for (const b of [...baseBundles, ...installedBundles]) { - const sys = b?.manifest || b; - const key = sys?.id ?? sys?.name ?? Math.random().toString(); - if (seen.has(key)) continue; - seen.add(key); - merged.push(b); - } - return merged; - }, - }; - - const factory = new DefaultProjectKernelFactory({ - controlPlaneDriver: this.config.controlDriver, - basePlugins: this.config.basePlugins, - appBundles: wrappedResolver, - envRegistry, - encryptor: this.config.encryptor, - }); - - const kernelManager = new KernelManager({ - factory, - maxSize: this.config.maxSize, - ttlMs: this.config.ttlMs, - }); - this.kernelManager = kernelManager; - - const directStorage = this.config.storage ?? null; - const directStorageAdapter = this.config.storageAdapterName - ?? (process.env.OS_STORAGE_ADAPTER ?? 'local').toLowerCase(); - const seeder = createTemplateSeeder( - kernelManager, - this.config.templates ?? {}, - envRegistry, - { - controlDriver: this.config.controlDriver, - storageAdapter: directStorageAdapter, - keyPrefix: process.env.OS_STORAGE_KEY_PREFIX ?? 'artifacts', - getStorage: async () => { - // 1. Direct storage adapter (preferred — set by cloud-stack - // via resolveStorageFromEnv to bypass kernel.getService - // lookup which is unreliable through the proxy wrapper). - if (directStorage && typeof directStorage.upload === 'function') { - return directStorage; - } - // 2. Fallback to kernel service lookup (for any stack that - // forgot to pass `storage` in MultiProjectPluginConfig). - try { - const sync = (hostKernel as any).getService?.('file-storage'); - if (sync && typeof sync.upload === 'function') return sync; - } catch { /* ignore */ } - try { - const async = await (hostKernel as any).getServiceAsync?.('file-storage'); - if (async && typeof async.upload === 'function') return async; - } catch { /* ignore */ } - return null; - }, - }, - ); - - ctx.registerService('env-registry', envRegistry); - ctx.registerService('kernel-manager', kernelManager); - ctx.registerService('template-seeder', seeder); - - ctx.logger.info?.('MultiProjectPlugin: registered env-registry + kernel-manager + template-seeder'); - }; - - destroy = async (): Promise => { - try { await this.kernelManager?.evictAll(); } catch { /* best effort */ } - }; -} diff --git a/packages/services/service-cloud/src/multi-project-plugins.ts b/packages/services/service-cloud/src/multi-project-plugins.ts deleted file mode 100644 index 7334cb3be..000000000 --- a/packages/services/service-cloud/src/multi-project-plugins.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Multi-Project / Cloud auxiliary route plugins. - * - * Counterpart to `single-project-plugin.ts`. These plugins are registered - * only when the server runs in cloud mode (`OS_MODE=cloud`, - * e.g. the Vercel deployment behind play.objectstack.ai). They expose two - * Studio-facing endpoints: - * - * - `GET /api/v1/studio/runtime-config` → `{ singleProject: false }` - * so the SPA initRuntimeConfig() handshake doesn't 404 and falls - * through to the org/project picker. - * - * - `GET /api/v1/cloud/templates` → static list from the template - * registry. Bypasses the dispatcher's `template-seeder` service - * indirection, which silently returned `{templates:[],total:0}` on - * Vercel cold starts when MultiProjectPlugin's service registration - * races the request. - * - * Both plugins register their routes on `http.server` *before* - * DispatcherPlugin, so they win the route match. - */ - -import type { IHttpServer } from '@objectstack/spec/contracts'; - -type AnyContext = any; - -/** - * Returns `{ singleProject: false }` so the SPA's `initRuntimeConfig()` - * handshake succeeds in multi-project mode without 404 noise. - */ -export function createStudioRuntimeConfigPlugin(options: { apiPrefix?: string } = {}): any { - const prefix = options.apiPrefix ?? '/api/v1'; - return { - name: 'com.objectstack.studio.runtime-config', - version: '1.0.0', - init: async (_ctx: AnyContext) => {}, - start: async (ctx: AnyContext) => { - let server: IHttpServer | undefined; - try { - server = ctx.getService('http.server') as IHttpServer | undefined; - } catch { - return; - } - if (!server) return; - server.get(`${prefix}/studio/runtime-config`, async (_req: any, res: any) => { - res.json({ singleProject: false }); - }); - }, - stop: async (_ctx: AnyContext) => {}, - }; -} - -/** - * Direct `/cloud/templates` route. Serves a snapshot of the template - * registry captured at config-load time, so SPA always receives the - * full list even before per-project kernels finish provisioning. - */ -export function createTemplatesRoutePlugin( - templates: Array<{ id: string; label: string; description: string; category?: string }>, - options: { apiPrefix?: string } = {}, -): any { - const prefix = options.apiPrefix ?? '/api/v1'; - const payload = templates.map(({ id, label, description, category }) => ({ - id, - label, - description, - category, - })); - return { - name: 'com.objectstack.studio.templates-route', - version: '1.0.0', - init: async (_ctx: AnyContext) => {}, - start: async (ctx: AnyContext) => { - let server: IHttpServer | undefined; - try { - server = ctx.getService('http.server') as IHttpServer | undefined; - } catch { - return; - } - if (!server) return; - server.get(`${prefix}/cloud/templates`, async (_req: any, res: any) => { - res.json({ - success: true, - data: { templates: payload, total: payload.length }, - }); - }); - }, - stop: async (_ctx: AnyContext) => {}, - }; -} diff --git a/packages/services/service-cloud/src/objectos-stack.ts b/packages/services/service-cloud/src/objectos-stack.ts deleted file mode 100644 index 52a84a415..000000000 --- a/packages/services/service-cloud/src/objectos-stack.ts +++ /dev/null @@ -1,214 +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'; - -export interface ObjectOSStackConfig { - /** Control-plane base URL. Required. */ - controlPlaneUrl: string; - /** Optional bearer token for the control-plane API. */ - controlPlaneApiKey?: string; - /** 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; -} - -export interface ObjectOSStackResult { - plugins: any[]; - api: { enableProjectScoping: true; projectResolution: 'auto' }; -} - -/** - * 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('@objectstack/runtime'); - 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 stop(ctx: PluginContext) { - const plugin = (this as any)._inner; - if (plugin?.stop) await plugin.stop(ctx); - }, - }; - - 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 ObjectOSProjectPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.objectos-project'; - 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 => { - this.client = new ArtifactApiClient({ - controlPlaneUrl: this.config.controlPlaneUrl, - apiKey: this.config.controlPlaneApiKey, - cacheTtlMs: this.config.artifactCacheTtlMs, - logger: ctx.logger, - }); - - const envRegistry: EnvironmentDriverRegistry = new ArtifactEnvironmentRegistry({ - client: this.client, - cacheTtlMs: this.config.envCacheTtlMs, - logger: ctx.logger, - }); - - const factory = new ArtifactKernelFactory({ - client: this.client, - envRegistry, - logger: ctx.logger, - }); - - const kernelManager = new KernelManager({ - factory, - maxSize: this.config.kernelCacheSize, - ttlMs: this.config.kernelTtlMs, - logger: ctx.logger, - }); - this.kernelManager = kernelManager; - - ctx.registerService('env-registry', envRegistry); - ctx.registerService('kernel-manager', kernelManager); - ctx.registerService('artifact-api-client', this.client); - - ctx.logger.info?.('ObjectOSProjectPlugin: registered env-registry + kernel-manager', { - 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) { - throw new Error('[createObjectOSStack] controlPlaneUrl 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 ObjectOSProjectPlugin(merged), new AuthProxyPlugin()], - api: { - enableProjectScoping: true, - projectResolution: 'auto', - }, - }; -} diff --git a/packages/services/service-cloud/src/preview/environment-registry.ts b/packages/services/service-cloud/src/preview/environment-registry.ts deleted file mode 100644 index 33d25bb5b..000000000 --- a/packages/services/service-cloud/src/preview/environment-registry.ts +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Preview EnvironmentDriverRegistry. - * - * Resolves preview hostnames (`--.`) to a composite - * key `${projectId}:${commitId}` so each (project, commit) pair owns its - * own kernel slot in the {@link KernelManager}. Each composite key gets - * a *fresh* in-memory data driver — the preview runtime never touches a - * project's real database. - * - * For branch-tracking previews (`--...`) the registry - * resolves the slug's HEAD commit per request and triggers cache - * invalidation when the HEAD has advanced. The kernel for the OLD commit - * is then evicted so the next request gets a fresh one bound to the new - * artifact (and a fresh memory driver — schema may have changed). - * - * This registry implements the EnvironmentDriverRegistry surface - * (resolveByHostname / resolveById / peekById) so the existing - * dispatcher and KernelManager can use it unchanged. - */ - -import type * as Contracts from '@objectstack/spec/contracts'; -import type { EnvironmentDriverRegistry } from '../environment-registry.js'; -import type { ArtifactApiClient } from '../artifact-api-client.js'; -import type { KernelManager } from '../kernel-manager.js'; -import { parsePreviewHost, type PreviewParseConfig } from './host-parser.js'; - -type IDataDriver = Contracts.IDataDriver; - -interface CompositeProject { - id: string; // composite key: `${projectId}:${commitId}` - projectId: string; // real project UUID - commitId: string; // pinned 16-hex commit id - organization_id?: string; - /** Set when the entry was resolved via a branch slug. */ - branchName?: string; - /** HEAD commit observed at last resolve time (branch entries only). */ - branchHeadCommit?: string; -} - -interface CacheEntry { - project: CompositeProject; - driver: IDataDriver; - /** When did we last verify the branch head? Branch entries only. */ - headCheckedAt: number; -} - -export interface PreviewEnvironmentRegistryConfig { - client: ArtifactApiClient; - parseConfig?: PreviewParseConfig; - /** - * Factory for the per-(project,commit) memory driver. Defaulted to - * `new InMemoryDriver()` from `@objectstack/driver-memory`. Tests can - * inject a stub. - */ - driverFactory?: () => Promise; - /** - * KernelManager used to evict the previous kernel when a branch HEAD - * advances. Optional — when omitted the registry just updates its own - * cache and lets the kernel manager LRU sort itself out. - */ - kernelManager?: KernelManager; - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; -} - -let cachedDefaultDriverFactory: (() => Promise) | null = null; -async function defaultDriverFactory(): Promise { - if (!cachedDefaultDriverFactory) { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - cachedDefaultDriverFactory = async () => new InMemoryDriver() as unknown as IDataDriver; - } - return cachedDefaultDriverFactory(); -} - -export class PreviewEnvironmentRegistry implements EnvironmentDriverRegistry { - private readonly client: ArtifactApiClient; - private readonly parseConfig?: PreviewParseConfig; - private readonly driverFactory: () => Promise; - private kernelManager?: KernelManager; - private readonly logger: NonNullable; - - private readonly byHost = new Map(); - private readonly byCompositeId = new Map(); - private readonly pending = new Map>(); - - constructor(config: PreviewEnvironmentRegistryConfig) { - this.client = config.client; - this.parseConfig = config.parseConfig; - this.driverFactory = config.driverFactory ?? defaultDriverFactory; - this.kernelManager = config.kernelManager; - this.logger = config.logger ?? console; - } - - async resolveByHostname(host: string): Promise<{ projectId: string; driver: IDataDriver } | null> { - const parsed = parsePreviewHost(host, this.parseConfig); - if (!parsed) return null; - - // Fast path: cached entry that is still valid. - const cached = this.byHost.get(host); - if (cached) { - if (parsed.kind === 'commit') { - // Commits are immutable; cache forever (LRU evicts via the kernel manager). - return { projectId: cached.project.id, driver: cached.driver }; - } - // Branch-tracking: revalidate HEAD on every request (per the - // approved design). The check is one HTTP call to the - // control plane, cheap and predictable. - const fresh = await this.refreshBranchHead(cached, parsed.ref); - if (fresh) { - return { projectId: fresh.project.id, driver: fresh.driver }; - } - // HEAD became unresolvable (branch deleted?) — fall through to - // re-resolve which will likely 404 too. - } - - // Singleflight on host to avoid duplicate HEAD lookups under load. - const inflight = this.pending.get(host); - if (inflight) { - const r = await inflight; - return r ? { projectId: r.project.id, driver: r.driver } : null; - } - - const promise = this.buildEntry(host, parsed).finally(() => { - this.pending.delete(host); - }); - this.pending.set(host, promise); - const entry = await promise; - return entry ? { projectId: entry.project.id, driver: entry.driver } : null; - } - - async resolveById(compositeKey: string): Promise { - const e = this.byCompositeId.get(compositeKey); - return e ? e.driver : null; - } - - peekById(compositeKey: string): { projectId: string; driver: IDataDriver; project: any } | null { - const e = this.byCompositeId.get(compositeKey); - if (!e) return null; - return { - projectId: compositeKey, - driver: e.driver, - project: { ...e.project, hostname: undefined }, - }; - } - - /** Drop everything (used on shutdown). */ - clear(): void { - this.byHost.clear(); - this.byCompositeId.clear(); - } - - /** Wire the kernel manager after construction (for stale-eviction). */ - setKernelManager(km: KernelManager): void { - (this as { kernelManager?: KernelManager }).kernelManager = km; - } - - private async buildEntry(host: string, parsed: ReturnType): Promise { - if (!parsed) return null; - - // 1. Resolve full projectId from 8-hex prefix. - const lookup = await this.client.lookupProjectByShortId(parsed.pidShort).catch((err) => { - this.logger.error?.('[PreviewEnvironmentRegistry] short-id lookup failed', { - pidShort: parsed.pidShort, - error: err?.message ?? err, - }); - return null; - }); - if (!lookup) { - this.logger.warn?.('[PreviewEnvironmentRegistry] no project for short id', { host, pidShort: parsed.pidShort }); - return null; - } - const { projectId, organizationId } = lookup; - - // 2. Resolve commit id. - let commitId: string; - let branchName: string | undefined; - if (parsed.kind === 'commit') { - commitId = parsed.ref; - } else { - branchName = parsed.ref; - const head = await this.client.fetchBranchHead(projectId, branchName).catch((err) => { - this.logger.error?.('[PreviewEnvironmentRegistry] branch head lookup failed', { - projectId, branch: branchName, error: err?.message ?? err, - }); - return null; - }); - if (!head) { - this.logger.warn?.('[PreviewEnvironmentRegistry] no head for branch', { host, projectId, branch: branchName }); - return null; - } - commitId = head.commitId; - } - - const compositeId = `${projectId}:${commitId}`; - const driver = await this.driverFactory(); - const project: CompositeProject = { - id: compositeId, - projectId, - commitId, - organization_id: organizationId, - branchName, - branchHeadCommit: branchName ? commitId : undefined, - }; - - const entry: CacheEntry = { project, driver, headCheckedAt: Date.now() }; - this.byHost.set(host, entry); - this.byCompositeId.set(compositeId, entry); - this.logger.info?.('[PreviewEnvironmentRegistry] resolved preview host', { - host, projectId, commitId, branch: branchName, - }); - return entry; - } - - /** - * For branch hosts: re-check the HEAD; if it changed, evict the old - * kernel + composite entry and lazily re-resolve. Returns the - * (possibly new) cached entry, or `null` when re-resolution failed. - */ - private async refreshBranchHead(cached: CacheEntry, branchName: string): Promise { - const head = await this.client.fetchBranchHead(cached.project.projectId, branchName).catch((err) => { - this.logger.warn?.('[PreviewEnvironmentRegistry] branch head re-check failed; serving cached', - { projectId: cached.project.projectId, branch: branchName, error: err?.message ?? err }); - return null; - }); - if (!head) { - // Conservative: keep serving the cached version on transient - // control-plane errors. The next request will retry. - return cached; - } - if (head.commitId === cached.project.branchHeadCommit) { - cached.headCheckedAt = Date.now(); - return cached; - } - - // HEAD advanced — evict the stale kernel + cache entry. - this.logger.info?.('[PreviewEnvironmentRegistry] branch HEAD advanced — evicting stale kernel', { - projectId: cached.project.projectId, - branch: branchName, - oldCommit: cached.project.branchHeadCommit, - newCommit: head.commitId, - }); - const oldCompositeId = cached.project.id; - this.byCompositeId.delete(oldCompositeId); - for (const [host, e] of this.byHost) { - if (e === cached) this.byHost.delete(host); - } - if (this.kernelManager) { - try { await this.kernelManager.evict(oldCompositeId); } catch { /* best-effort */ } - } - // Also drop the artifact cache for the project so the next build - // sees fresh metadata. Drops both HEAD-shaped and `@commit` keys. - this.client.invalidate(cached.project.projectId); - return null; - } -} diff --git a/packages/services/service-cloud/src/preview/host-parser.ts b/packages/services/service-cloud/src/preview/host-parser.ts deleted file mode 100644 index 60425ac52..000000000 --- a/packages/services/service-cloud/src/preview/host-parser.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Preview host parser. - * - * Recognises hostnames of the form `--.` where: - * • `pidShort` is exactly 8 lowercase hex chars (= first 8 hex chars of - * a project UUID, dashes stripped). - * • `ref` is either: - * - exactly 16 lowercase hex chars → a commit-pinned preview - * (publish derives commitId = sha256(artifact).slice(0,16)), or - * - a branch slug (matches BRANCH_SLUG_RE elsewhere) → branch-tracking. - * • `` is one of the configured preview base domains, e.g. - * `preview.objectstack.ai` (prod) or `localhost[:port]` (dev, - * RFC 6761 — the browser resolves any *.localhost to 127.0.0.1). - * - * Examples - * -------- - * abc123def456--7f3e9a01.preview.objectstack.ai commit, pid prefix 7f3e9a01 - * main--7f3e9a01.preview.objectstack.ai branch 'main' - * feature/login--7f3e9a01.localhost:4100 branch 'feature/login' (dev) - * - * The parser does NOT resolve the short id to a full projectId, nor look - * up the branch head — that lives in PreviewEnvironmentRegistry. It is a - * pure string function so it can be unit-tested without a registry. - */ - -const COMMIT_HEX_RE = /^[0-9a-f]{16}$/; -const PID_SHORT_RE = /^[0-9a-f]{8}$/; -/** Same shape as BRANCH_SLUG_RE in routes/branches.ts. Duplicated here to - * avoid pulling that file into the parser's import graph. */ -const BRANCH_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,62}$/; - -export interface PreviewHost { - /** 'commit' = ref is a 16-hex commit id; 'branch' = ref is a slug. */ - kind: 'commit' | 'branch'; - /** First 8 hex chars of the project's UUID (no dashes). */ - pidShort: string; - /** Either a 16-hex commit id or a branch slug. */ - ref: string; -} - -export interface PreviewParseConfig { - /** - * Allowed base domains. Defaults to `['preview.objectstack.ai', - * 'localhost']` — `localhost` is matched with an optional `:port`. - * - * Each entry is matched case-insensitively as an exact hostname suffix - * (after the first `.` separator). Subdomains of subdomains are - * not allowed — hostnames must be exactly `--.`. - */ - baseDomains?: readonly string[]; -} - -const DEFAULT_BASE_DOMAINS = ['preview.objectstack.ai', 'localhost']; - -/** - * Normalises the host (lowercases, strips trailing dots) and returns a - * {@link PreviewHost} when the pattern matches; otherwise `null`. - * - * Stripping the port: parsing accepts a `host:port` and silently drops - * the port for matching, so dev hosts like `main--7f3e9a01.localhost:4100` - * work seamlessly. The full original string is never echoed. - */ -export function parsePreviewHost(host: string, config?: PreviewParseConfig): PreviewHost | null { - if (typeof host !== 'string' || host.length === 0) return null; - - // Drop port and any trailing dot, lowercase. - let h = host.toLowerCase().trim(); - const colon = h.lastIndexOf(':'); - if (colon > 0 && /^[0-9]+$/.test(h.slice(colon + 1))) h = h.slice(0, colon); - while (h.endsWith('.')) h = h.slice(0, -1); - if (!h) return null; - - const bases = (config?.baseDomains ?? DEFAULT_BASE_DOMAINS).map((b) => b.toLowerCase()); - - // Find which base the hostname ends with. - let head: string | null = null; - for (const base of bases) { - const suffix = `.${base}`; - if (h === base) { - // Bare base host; not a preview URL. - return null; - } - if (h.endsWith(suffix)) { - const candidate = h.slice(0, -suffix.length); - // Reject deeper subdomains: candidate must NOT contain another - // dot. We only support exactly one label before the base. - // (BRANCH_SLUG_RE actually allows '.' in slugs, but a slug - // can't end in a dot so this disambiguation is safe.) - // - // Note: branch slugs like 'feature.x' contain a dot but are - // followed by `--.`. The candidate string (before - // the base) is `feature.x--7f3e9a01` — which is fine. We - // allow dots within the candidate because both the slug and - // the `--` separator may legitimately contain them. - head = candidate; - break; - } - } - if (head === null) return null; - - // The candidate must contain `--` separating from . - // We use lastIndexOf so a ref that itself contains `--` (rare but - // technically allowed by the slug regex `[a-z0-9._/-]`) still works: - // the rightmost `--` always precedes the 8-hex pid. - const sep = head.lastIndexOf('--'); - if (sep < 1 || sep + 2 >= head.length) return null; - const ref = head.slice(0, sep); - const pidShort = head.slice(sep + 2); - - if (!PID_SHORT_RE.test(pidShort)) return null; - if (!ref) return null; - - if (COMMIT_HEX_RE.test(ref)) { - return { kind: 'commit', pidShort, ref }; - } - if (BRANCH_SLUG_RE.test(ref)) { - return { kind: 'branch', pidShort, ref }; - } - return null; -} - -/** - * Compute the 8-hex short id for a project UUID (or any string). - * Strips dashes and lowercases, then takes the first 8 hex chars. - * Returns `null` if the input doesn't yield 8 hex chars. - */ -export function projectIdToShort(projectId: string): string | null { - if (typeof projectId !== 'string') return null; - const compact = projectId.replace(/-/g, '').toLowerCase(); - const first8 = compact.slice(0, 8); - return PID_SHORT_RE.test(first8) ? first8 : null; -} diff --git a/packages/services/service-cloud/src/preview/kernel-factory.ts b/packages/services/service-cloud/src/preview/kernel-factory.ts deleted file mode 100644 index 319e12a8d..000000000 --- a/packages/services/service-cloud/src/preview/kernel-factory.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Preview ProjectKernelFactory. - * - * Builds a per-(project, commit) sandbox kernel for the preview runtime: - * - * • Receives a composite key `${projectId}:${commitId}` from - * {@link KernelManager.getOrCreate}. - * • Resolves the cached entry from {@link PreviewEnvironmentRegistry} - * (the registry has already minted a fresh in-memory driver). - * • Fetches the artifact at exactly `commitId` from the control plane. - * • Bootstraps an isolated `ObjectKernel` with DriverPlugin (memory) + - * ObjectQL + Metadata + AppPlugin — same shape as - * {@link ArtifactKernelFactory}, but always pinned to a commit and - * always backed by ephemeral storage. - * - * The kernel never reads from a project's real database. Sandbox data - * lives in-process and is lost when the kernel is evicted (LRU / TTL / - * branch-HEAD-advance). - */ - -import { ObjectKernel } from '@objectstack/core'; -import { DriverPlugin, AppPlugin } from '@objectstack/runtime'; -import type { ProjectKernelFactory } from '../kernel-manager.js'; -import type { ArtifactApiClient } from '../artifact-api-client.js'; -import type { PreviewEnvironmentRegistry } from './environment-registry.js'; - -export interface PreviewKernelFactoryConfig { - client: ArtifactApiClient; - envRegistry: PreviewEnvironmentRegistry; - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - kernelConfig?: ConstructorParameters[0]; -} - -export class PreviewKernelFactory implements ProjectKernelFactory { - private readonly client: ArtifactApiClient; - private readonly envRegistry: PreviewEnvironmentRegistry; - private readonly logger: NonNullable; - private readonly kernelConfig?: PreviewKernelFactoryConfig['kernelConfig']; - - constructor(config: PreviewKernelFactoryConfig) { - this.client = config.client; - this.envRegistry = config.envRegistry; - this.logger = config.logger ?? console; - this.kernelConfig = config.kernelConfig; - } - - async create(compositeKey: string): Promise { - const sep = compositeKey.lastIndexOf(':'); - if (sep <= 0 || sep === compositeKey.length - 1) { - throw new Error( - `[PreviewKernelFactory] expected composite key ':', got '${compositeKey}'`, - ); - } - const projectId = compositeKey.slice(0, sep); - const commitId = compositeKey.slice(sep + 1); - - const cached = this.envRegistry.peekById(compositeKey); - if (!cached) { - throw new Error( - `[PreviewKernelFactory] no env-registry entry for composite key '${compositeKey}'. ` + - `The registry must resolve the host before the kernel manager calls create().`, - ); - } - - const artifact = await this.client.fetchArtifact(projectId, { commit: commitId }); - if (!artifact) { - throw new Error( - `[PreviewKernelFactory] artifact not found for project '${projectId}' commit '${commitId}'`, - ); - } - - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { MetadataPlugin } = await import('@objectstack/metadata'); - - const kernel = new ObjectKernel(this.kernelConfig); - const project = cached.project as { - organization_id?: string; - branchName?: string; - commitId: string; - projectId: string; - }; - - await kernel.use(new DriverPlugin(cached.driver)); - await kernel.use(new ObjectQLPlugin({ projectId })); - await kernel.use(new MetadataPlugin({ - watch: false, - projectId, - organizationId: project.organization_id, - registerSystemObjects: false, - })); - - const bundle = artifact.metadata as any; - const sys = bundle?.manifest ?? bundle; - const packageId = sys?.packageId ?? sys?.package_id ?? bundle?.packageId; - - await kernel.use(new AppPlugin(bundle, { - projectId, - organizationId: project.organization_id ?? '', - // Surface the commit/branch in the project name so logs are - // unambiguous when multiple sandboxes are resident. - projectName: project.branchName - ? `${projectId}@${project.branchName}#${commitId.slice(0, 12)}` - : `${projectId}#${commitId.slice(0, 12)}`, - packageId, - source: packageId ? 'package' : 'user', - } as any)); - - await kernel.bootstrap(); - - this.logger.info?.('[PreviewKernelFactory] sandbox ready', { - projectId, - commitId, - branch: project.branchName, - checksum: artifact.checksum, - }); - - return kernel; - } -} diff --git a/packages/services/service-cloud/src/preview/preview-stack.ts b/packages/services/service-cloud/src/preview/preview-stack.ts deleted file mode 100644 index 755d16c12..000000000 --- a/packages/services/service-cloud/src/preview/preview-stack.ts +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * createPreviewStack - * - * Preview-mode runtime stack — a sibling of `createObjectOSStack` - * specialised for sandbox previews of pinned (project, commit) pairs. - * - * Differences vs. the regular ObjectOS stack: - * - * • EnvironmentDriverRegistry is `PreviewEnvironmentRegistry`, which - * parses `--.` hostnames, resolves the short id - * to a full project, optionally resolves a branch slug to its HEAD - * commit, and mints a fresh in-memory driver per (project, commit). - * • ProjectKernelFactory is `PreviewKernelFactory`, which fetches the - * artifact at the pinned commit and builds a kernel against the - * ephemeral memory driver. - * • Every other moving part (host engine plugins, KernelManager, - * ArtifactApiClient) is identical so the dispatcher and REST plugin - * work without modification. - * - * Wiring is intentionally trivial: the host kernel registers the - * familiar `env-registry` + `kernel-manager` + `artifact-api-client` - * services, the dispatcher does the rest. - */ - -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 { PreviewEnvironmentRegistry } from './environment-registry.js'; -import { PreviewKernelFactory } from './kernel-factory.js'; -import type { PreviewParseConfig } from './host-parser.js'; - -export interface PreviewStackConfig { - /** Control-plane base URL. Required. */ - controlPlaneUrl: string; - /** Optional bearer token for the control-plane API. */ - controlPlaneApiKey?: string; - /** Allowed preview base domains. Defaults to ['preview.objectstack.ai', 'localhost']. */ - baseDomains?: readonly string[]; - /** KernelManager LRU size. Default: 32. */ - kernelCacheSize?: number; - /** KernelManager idle TTL (ms). Default: 15 min. */ - kernelTtlMs?: number; - /** Artifact response cache TTL (ms). Default: 5 min. */ - artifactCacheTtlMs?: number; - /** API prefix (carried for parity with sibling stacks). Default: /api/v1. */ - apiPrefix?: string; -} - -export interface PreviewStackResult { - plugins: any[]; - api: { enableProjectScoping: true; projectResolution: 'auto' }; -} - -/** - * Lazy-loaded host engine plugins. Identical to the ObjectOS stack's - * `createHostEnginePlugins()` — the host kernel is a routing shell that - * never persists anything, so it gets a transient in-memory driver. - */ -async function createHostEnginePlugins(): Promise { - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const { DriverPlugin } = await import('@objectstack/runtime'); - const { MetadataPlugin } = await import('@objectstack/metadata'); - - 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); - oqlRef.ql = (plugin as any).ql ?? plugin; - }, - async start(ctx: PluginContext) { - const plugin = (this as any)._inner; - if (plugin?.start) await plugin.start(ctx); - }, - async stop(ctx: PluginContext) { - const plugin = (this as any)._inner; - if (plugin?.stop) await plugin.stop(ctx); - }, - }; - - const datasourceMapping: Plugin = { - name: 'preview-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, - registerSystemObjects: false, - }); - - return [objectql, datasourceMapping, driverPlugin as unknown as Plugin, metadata as unknown as Plugin]; -} - -class PreviewProjectPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.preview-project'; - readonly version = '1.0.0'; - - private readonly config: PreviewStackConfig; - private kernelManager?: KernelManager; - private envRegistry?: PreviewEnvironmentRegistry; - private client?: ArtifactApiClient; - - constructor(config: PreviewStackConfig) { - this.config = config; - } - - init = async (ctx: PluginContext): Promise => { - this.client = new ArtifactApiClient({ - controlPlaneUrl: this.config.controlPlaneUrl, - apiKey: this.config.controlPlaneApiKey, - cacheTtlMs: this.config.artifactCacheTtlMs, - logger: ctx.logger, - }); - - const parseConfig: PreviewParseConfig | undefined = this.config.baseDomains - ? { baseDomains: this.config.baseDomains } - : undefined; - - const envRegistry = new PreviewEnvironmentRegistry({ - client: this.client, - parseConfig, - logger: ctx.logger, - }); - this.envRegistry = envRegistry; - - const factory = new PreviewKernelFactory({ - client: this.client, - envRegistry, - logger: ctx.logger, - }); - - const kernelManager = new KernelManager({ - factory, - maxSize: this.config.kernelCacheSize, - ttlMs: this.config.kernelTtlMs, - logger: ctx.logger, - }); - envRegistry.setKernelManager(kernelManager); - this.kernelManager = kernelManager; - - ctx.registerService('env-registry', envRegistry as unknown as EnvironmentDriverRegistry); - ctx.registerService('kernel-manager', kernelManager); - ctx.registerService('artifact-api-client', this.client); - - ctx.logger.info?.('PreviewProjectPlugin: registered preview env-registry + kernel-manager', { - controlPlaneUrl: this.config.controlPlaneUrl, - baseDomains: this.config.baseDomains ?? ['preview.objectstack.ai', 'localhost'], - }); - }; - - destroy = async (): Promise => { - try { await this.kernelManager?.evictAll(); } catch { /* best effort */ } - try { this.envRegistry?.clear(); } catch { /* best effort */ } - try { this.client?.clear(); } catch { /* best effort */ } - }; -} - -export async function createPreviewStack(config: PreviewStackConfig): Promise { - if (!config.controlPlaneUrl) { - throw new Error('[createPreviewStack] controlPlaneUrl is required'); - } - const merged: PreviewStackConfig = { - ...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), - artifactCacheTtlMs: Number(process.env.OS_ARTIFACT_CACHE_TTL_MS ?? config.artifactCacheTtlMs ?? 5 * 60 * 1000), - baseDomains: config.baseDomains - ?? (process.env.OS_PREVIEW_BASE_DOMAINS - ? process.env.OS_PREVIEW_BASE_DOMAINS.split(',').map((s) => s.trim()).filter(Boolean) - : undefined), - }; - - const enginePlugins = await createHostEnginePlugins(); - - return { - plugins: [...enginePlugins, new PreviewProjectPlugin(merged)], - api: { - enableProjectScoping: true, - projectResolution: 'auto', - }, - }; -} diff --git a/packages/services/service-cloud/src/project-kernel-factory.ts b/packages/services/service-cloud/src/project-kernel-factory.ts deleted file mode 100644 index 502fb8c20..000000000 --- a/packages/services/service-cloud/src/project-kernel-factory.ts +++ /dev/null @@ -1,483 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectKernel, Plugin } from '@objectstack/core'; -import type * as Contracts from '@objectstack/spec/contracts'; -import { DriverPlugin, AppPlugin, hookBodyRunnerFactory, QuickJSScriptRunner } from '@objectstack/runtime'; -import { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js'; -import type { ProjectKernelFactory } from './kernel-manager.js'; -import type { EnvironmentDriverRegistry, SecretEncryptor } from './environment-registry.js'; -import { NoopSecretEncryptor } from './environment-registry.js'; -import { resolveDefaultDataDir } from './data-dir.js'; -import { mountDefaultProjectPlugins } from './default-project-plugins.js'; - -type IDataDriver = Contracts.IDataDriver; - -/** - * Row shape fetched from control plane `sys_project`. - * Only the fields required to stand up a data-plane kernel are enumerated — - * additional columns are ignored. - */ -export interface SysProjectRow { - id: string; - organization_id?: string; - database_url?: string | null; - database_driver?: string | null; - hostname?: string | null; - metadata?: string | null; - [k: string]: any; -} - -/** - * Row shape fetched from `sys_project_credential` (`status = 'active'`). - */ -export interface SysEnvironmentCredentialRow { - id: string; - environment_id: string; - secret_ciphertext: string; - encryption_key_id?: string; - database_driver?: string; - database_url?: string; - database_auth_token?: string; - [k: string]: any; -} -/** @deprecated Use `SysEnvironmentCredentialRow`. Kept for backward compat. */ -export type SysProjectCredentialRow = SysEnvironmentCredentialRow; - -/** - * Resolves the list of App bundles a project is subscribed to. - */ -export interface AppBundleResolver { - resolve(project: SysProjectRow): Promise; -} - -/** - * Factory that builds, for every project, the **base** set of plugins that - * sit below the project's apps. - * - * Each invocation must return fresh plugin instances — plugins may not be - * shared across kernels. - */ -export interface BasePluginsFactory { - (args: { projectId: string; project: SysProjectRow; driver: IDataDriver }): Promise | Plugin[]; -} - -/** - * Static project config for local / offline mode. - */ -export interface LocalProjectConfig { - projectId: string; - organizationId?: string; - databaseUrl: string; - databaseDriver: string; -} - -export interface DefaultProjectKernelFactoryConfig { - controlPlaneDriver?: IDataDriver; - basePlugins: BasePluginsFactory; - appBundles?: AppBundleResolver; - encryptor?: SecretEncryptor; - envRegistry?: EnvironmentDriverRegistry; - logger?: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void }; - kernelConfig?: ConstructorParameters[0]; - localProject?: LocalProjectConfig; - /** - * Ops escape hatch — replace or augment the default per-project - * plugin slate (queue, job, cache, settings, email, storage) without - * forking the factory. Common cases: - * - inject a shared Redis-backed `QueueServicePlugin` so retries - * survive kernel eviction - * - skip storage when the host worker mounts a shared S3 instance - * out-of-band (return `{ caps: { storage: false } }`) - * - pass `extraPlugins` to mount per-tenant plugins (audit, - * analytics, automation) that aren't part of the default slate - */ - basePluginsExtra?: (ctx: { - projectId: string; - kernel: ObjectKernel; - }) => Promise<{ - caps?: Partial>; - extraPlugins?: Plugin[]; - } | undefined> | { caps?: any; extraPlugins?: Plugin[] } | undefined; -} - -export class DefaultProjectKernelFactory implements ProjectKernelFactory { - private readonly controlPlaneDriver?: IDataDriver; - private readonly basePlugins: BasePluginsFactory; - private readonly appBundles?: AppBundleResolver; - private readonly encryptor: SecretEncryptor; - private readonly envRegistry?: EnvironmentDriverRegistry; - private readonly logger: NonNullable; - private readonly kernelConfig?: DefaultProjectKernelFactoryConfig['kernelConfig']; - private readonly localProject?: LocalProjectConfig; - private readonly basePluginsExtra?: DefaultProjectKernelFactoryConfig['basePluginsExtra']; - - constructor(config: DefaultProjectKernelFactoryConfig) { - this.controlPlaneDriver = config.controlPlaneDriver; - this.basePlugins = config.basePlugins; - this.appBundles = config.appBundles; - this.encryptor = config.encryptor ?? new NoopSecretEncryptor(); - this.envRegistry = config.envRegistry; - this.logger = config.logger ?? console; - this.kernelConfig = config.kernelConfig; - this.localProject = config.localProject; - this.basePluginsExtra = config.basePluginsExtra; - } - - async create(projectId: string): Promise { - if (this.localProject && this.localProject.projectId === projectId) { - return this._createLocalKernel(this.localProject); - } - - if (!this.controlPlaneDriver) { - throw new Error(`[ProjectKernelFactory] No controlPlaneDriver configured and no matching localProject for '${projectId}'`); - } - - let project: SysProjectRow | null = null; - let driver: IDataDriver | null = null; - - const cached = this.envRegistry?.peekById(projectId); - if (cached) { - project = cached.project as SysProjectRow; - driver = cached.driver; - } - - if (!project) { - if (this.envRegistry) { - const resolved = await this.envRegistry.resolveById(projectId); - if (resolved) { - const fresh = this.envRegistry.peekById(projectId); - if (fresh) { - project = fresh.project as SysProjectRow; - driver = fresh.driver; - } - } - } - } - - if (!project) { - project = await this.fetchProject(projectId); - } - if (!project) { - throw new Error(`[ProjectKernelFactory] Project not found: ${projectId}`); - } - if (!project.database_url || !project.database_driver) { - const status = (project as any).status ?? 'unknown'; - const hint = status === 'provisioning' || status === 'pending' - ? ' (project is still provisioning — set OS_PROVISION_SYNC=1 on serverless deployments where background work cannot finish after the response)' - : status === 'failed' - ? ' (project provisioning previously failed — inspect sys_project.metadata.provisioningError and recreate the project)' - : ''; - throw new Error(`[ProjectKernelFactory] Project ${projectId} missing database_url/database_driver — status='${status}'${hint}`); - } - - if (!driver) { - const credential = await this.fetchActiveCredential(projectId); - let authToken = credential - ? await Promise.resolve(this.encryptor.decrypt(credential.secret_ciphertext)) - : ''; - // Single-project / bootstrap fallback: when the project's database_url - // matches one of the well-known env vars and no credential row exists, - // pick the matching auth token from the environment. This lets - // operators deploy with just `OS_DATABASE_URL` + `OS_DATABASE_AUTH_TOKEN` - // (or the Vercel/Turso integration's `TURSO_DATABASE_URL` + - // `TURSO_AUTH_TOKEN`) without having to seed sys_project_credential. - if (!authToken && project.database_url) { - const envOsUrl = process.env.OS_DATABASE_URL?.trim(); - const envTursoUrl = process.env.TURSO_DATABASE_URL?.trim(); - if (envOsUrl && envOsUrl === project.database_url) { - authToken = process.env.OS_DATABASE_AUTH_TOKEN?.trim() - ?? process.env.TURSO_AUTH_TOKEN?.trim() - ?? ''; - } else if (envTursoUrl && envTursoUrl === project.database_url) { - authToken = process.env.TURSO_AUTH_TOKEN?.trim() - ?? process.env.OS_DATABASE_AUTH_TOKEN?.trim() - ?? ''; - } - } - driver = await this.createDriver(project.database_driver, project.database_url, authToken); - } - - const basePlugins = await this.basePlugins({ projectId, project, driver }); - const bundles = this.appBundles ? await this.appBundles.resolve(project) : []; - - const kernel = new ObjectKernel(this.kernelConfig); - - await kernel.use(new DriverPlugin(driver)); - - const orgId = project.organization_id; - if (!orgId) { - throw new Error(`[ProjectKernelFactory] project '${projectId}' is missing organization_id — cannot mount cloud datasource`); - } - const proxyDriver = new ControlPlaneProxyDriver(this.controlPlaneDriver!, orgId); - await kernel.use(new DriverPlugin(proxyDriver, { registerAsDefault: false, datasourceName: 'cloud' } as any)); - - for (const p of basePlugins) await kernel.use(p); - // Mount default per-project plugin slate (queue, job, cache, settings, - // email, storage). Mirrors `ALWAYS_CAPS` in - // `packages/cli/src/commands/serve.ts` for hosted tenants. Ops can - // override via `basePluginsExtra`. - const extra1 = this.basePluginsExtra - ? await Promise.resolve(this.basePluginsExtra({ projectId, kernel })) - : undefined; - await mountDefaultProjectPlugins(kernel, { - projectId, - logger: this.logger, - ...(extra1?.caps ? { caps: extra1.caps } : {}), - }); - if (extra1?.extraPlugins) { - for (const ep of extra1.extraPlugins) await kernel.use(ep); - } - const projectName = (project as any).name ?? (project as any).hostname; - await this.maybeRegisterI18n(kernel, bundles, projectId); - for (const b of bundles) { - const sys = b?.manifest || b; - const packageId = sys?.packageId ?? sys?.package_id ?? b?.packageId; - await kernel.use(new AppPlugin(b, { - projectId, - organizationId: orgId, - projectName, - packageId, - source: packageId ? 'package' : 'user', - } as any)); - } - - await kernel.bootstrap(); - - try { - const ql: any = await (kernel as any).getServiceAsync?.('objectql'); - if (ql && typeof ql.setDefaultBodyRunner === 'function' && typeof ql._defaultBodyRunner !== 'function') { - const runner = new QuickJSScriptRunner(); - ql.setDefaultBodyRunner(hookBodyRunnerFactory(runner, { ql, appId: `project:${projectId}` })); - } - } catch (err: any) { - this.logger.warn?.('[ProjectKernelFactory] default body-runner install failed', { projectId, error: err?.message }); - } - - try { - const currentNames = new Set( - bundles - .map((b: any) => { - const sys = b?.manifest || b; - return sys?.name ?? sys?.id; - }) - .filter((n: any): n is string => typeof n === 'string' && n.length > 0), - ); - const existing = await proxyDriver.find('sys_app', { - where: { environment_id: projectId }, - limit: 10_000, - } as any); - const rows: Array<{ name?: string }> = Array.isArray(existing) - ? existing - : (existing as any)?.value ?? []; - const stale = rows.filter((r) => r?.name && !currentNames.has(r.name)); - const deleteMany = (proxyDriver as any).deleteMany; - if (stale.length && typeof deleteMany === 'function') { - for (const row of stale) { - await deleteMany.call(proxyDriver, 'sys_app', { - where: { environment_id: projectId, name: row.name }, - }); - } - this.logger.info?.('[ProjectKernelFactory] sys_app catalog reconciled', { - projectId, - removed: stale.length, - }); - } - } catch (err: any) { - this.logger.warn?.('[ProjectKernelFactory] sys_app reconciliation skipped', { - projectId, - error: err?.message, - }); - } - - this.logger.info?.('[ProjectKernelFactory] kernel ready', { - projectId, - driver: project.database_driver, - bundles: bundles.length, - }); - return kernel; - } - - private async _createLocalKernel(cfg: LocalProjectConfig): Promise { - const { projectId, organizationId, databaseUrl, databaseDriver } = cfg; - - const syntheticProject: SysProjectRow = { - id: projectId, - organization_id: organizationId, - database_url: databaseUrl, - database_driver: databaseDriver, - }; - - const driver = await this.createDriver(databaseDriver, databaseUrl, ''); - const basePlugins = await this.basePlugins({ projectId, project: syntheticProject, driver }); - const bundles = this.appBundles ? await this.appBundles.resolve(syntheticProject) : []; - - const kernel = new ObjectKernel(this.kernelConfig); - await kernel.use(new DriverPlugin(driver)); - for (const p of basePlugins) await kernel.use(p); - // Mount default per-project plugin slate (same as cloud path). - const extra2 = this.basePluginsExtra - ? await Promise.resolve(this.basePluginsExtra({ projectId, kernel })) - : undefined; - await mountDefaultProjectPlugins(kernel, { - projectId, - logger: this.logger, - ...(extra2?.caps ? { caps: extra2.caps } : {}), - }); - if (extra2?.extraPlugins) { - for (const ep of extra2.extraPlugins) await kernel.use(ep); - } - - const projectName = syntheticProject.hostname ?? projectId; - await this.maybeRegisterI18n(kernel, bundles, projectId); - for (const b of bundles) { - const sys = b?.manifest || b; - const packageId = sys?.packageId ?? sys?.package_id ?? b?.packageId; - await kernel.use(new AppPlugin(b, { - projectId, - organizationId: organizationId ?? '', - projectName, - packageId, - source: packageId ? 'package' : 'user', - } as any)); - } - - await kernel.bootstrap(); - - try { - const ql: any = await (kernel as any).getServiceAsync?.('objectql'); - if (ql && typeof ql.setDefaultBodyRunner === 'function' && typeof ql._defaultBodyRunner !== 'function') { - const runner = new QuickJSScriptRunner(); - ql.setDefaultBodyRunner(hookBodyRunnerFactory(runner, { ql, appId: `project:${projectId}` })); - } - } catch (err: any) { - this.logger.warn?.('[ProjectKernelFactory] default body-runner install failed (local)', { projectId, error: err?.message }); - } - - this.logger.info?.('[ProjectKernelFactory] local kernel ready', { - projectId, - driver: databaseDriver, - bundles: bundles.length, - }); - return kernel; - } - - private async fetchProject(projectId: string): Promise { - if (!this.controlPlaneDriver) { - throw new Error(`[ProjectKernelFactory] controlPlaneDriver is required in cloud mode`); - } - const result = await this.controlPlaneDriver.find('sys_environment', { - object: 'sys_environment', - where: { id: projectId }, - limit: 1, - } as any); - const rows = Array.isArray(result) ? result : (result as any)?.value ?? []; - return rows[0] ?? null; - } - - private async fetchActiveCredential(projectId: string): Promise { - if (!this.controlPlaneDriver) { - throw new Error(`[ProjectKernelFactory] controlPlaneDriver is required in cloud mode`); - } - const result = await this.controlPlaneDriver.find('sys_environment_credential', { - object: 'sys_environment_credential', - where: { environment_id: projectId, status: 'active' }, - limit: 1, - } as any); - const rows = Array.isArray(result) ? result : (result as any)?.value ?? []; - return rows[0] ?? null; - } - - private async createDriver( - driverType: string, - databaseUrl: string, - authToken: string, - ): Promise { - switch (driverType) { - case 'memory': { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - const { resolve: resolvePath } = await import('node:path'); - const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); - const filePath = dbName - ? resolvePath(resolveDefaultDataDir(), '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 'libsql': - case 'turso': { - const { TursoDriver } = await import('@objectstack/driver-turso'); - return new TursoDriver({ url: databaseUrl, authToken }) 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(`[ProjectKernelFactory] Unsupported driver type: ${driverType}`); - } - } - - /** - * Inspect the resolved app bundles for translation data and, if present, - * register {@link I18nServicePlugin} on the kernel 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. - */ - private async maybeRegisterI18n(kernel: ObjectKernel, bundles: any[], projectId: string): Promise { - if (!Array.isArray(bundles) || bundles.length === 0) return; - - let defaultLocale: string | undefined; - let fallbackLocale: string | undefined; - let hasTranslations = false; - - for (const b of bundles) { - const sys = b?.manifest ?? b; - const i18nCfg = (b?.i18n ?? sys?.i18n ?? {}) as Record; - if (Array.isArray(b?.translations) && b.translations.length > 0) hasTranslations = true; - if (Array.isArray(sys?.translations) && sys.translations.length > 0) hasTranslations = true; - if (i18nCfg && Object.keys(i18nCfg).length > 0) hasTranslations = true; - defaultLocale ??= i18nCfg.defaultLocale; - fallbackLocale ??= i18nCfg.fallbackLocale; - } - - if (!hasTranslations) return; - - try { - const { I18nServicePlugin } = await import('@objectstack/service-i18n'); - await kernel.use(new I18nServicePlugin({ - defaultLocale, - fallbackLocale: fallbackLocale ?? defaultLocale ?? 'en', - registerRoutes: false, - } as any)); - } catch (err: any) { - this.logger.warn?.('[ProjectKernelFactory] I18nServicePlugin not registered', { - projectId, - error: err?.message, - }); - } - } -} diff --git a/packages/services/service-cloud/src/project-scope-manager.ts b/packages/services/service-cloud/src/project-scope-manager.ts deleted file mode 100644 index 3f35377b0..000000000 --- a/packages/services/service-cloud/src/project-scope-manager.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ProjectScopeManager - * - * Replaces KernelManager in shared-kernel mode. Instead of managing full - * ObjectKernel instances per project, it manages TTL/LRU eviction of - * SCOPED service instances inside a single shared kernel. - * - * The kernel's PluginLoader already stores scoped instances in: - * scopedServices: Map> - * - * This class tracks last-access timestamps and calls kernel.clearScope() - * to release driver connections and metadata caches for idle projects. - */ - -import type { ObjectKernel } from '@objectstack/core'; - -export interface ProjectScopeManagerConfig { - /** Shared kernel whose scoped services this manager evicts. */ - kernel: ObjectKernel; - /** Idle TTL in ms. Scopes not accessed within this window are evicted. Default: 15 min. */ - ttlMs?: number; - /** Max number of active scopes. LRU eviction when exceeded. Default: 200. */ - maxSize?: number; - /** Eviction check interval in ms. Default: 5 min. */ - checkIntervalMs?: number; -} - -export class ProjectScopeManager { - private readonly kernel: ObjectKernel; - private readonly ttlMs: number; - private readonly maxSize: number; - private readonly lastAccess: Map = new Map(); - private timer?: ReturnType; - - constructor(config: ProjectScopeManagerConfig) { - this.kernel = config.kernel; - this.ttlMs = config.ttlMs ?? 15 * 60 * 1000; - this.maxSize = config.maxSize ?? 200; - const checkIntervalMs = config.checkIntervalMs ?? 5 * 60 * 1000; - this.timer = setInterval(() => this.evictIdle(), checkIntervalMs); - // Don't block Node.js exit - if (this.timer.unref) this.timer.unref(); - } - - /** - * Touch a scope to reset its idle TTL. Call this on every request. - */ - touch(scopeId: string): void { - this.lastAccess.set(scopeId, Date.now()); - if (this.lastAccess.size > this.maxSize) { - this.evictLRU(); - } - } - - /** - * Evict all scopes not accessed within ttlMs. - */ - evictIdle(): void { - const now = Date.now(); - for (const [scopeId, ts] of this.lastAccess) { - if (now - ts > this.ttlMs) { - this.evict(scopeId); - } - } - } - - /** - * Evict a specific scope immediately. - */ - evict(scopeId: string): void { - this.lastAccess.delete(scopeId); - this.kernel.clearScope(scopeId); - } - - /** - * Evict all scopes (e.g. on shutdown). - */ - evictAll(): void { - for (const scopeId of Array.from(this.lastAccess.keys())) { - this.evict(scopeId); - } - } - - destroy(): void { - if (this.timer) { - clearInterval(this.timer); - this.timer = undefined; - } - this.evictAll(); - } - - get activeCount(): number { - return this.lastAccess.size; - } - - private evictLRU(): void { - // Evict the least recently used scope - let oldest = Infinity; - let oldestId: string | undefined; - for (const [scopeId, ts] of this.lastAccess) { - if (ts < oldest) { - oldest = ts; - oldestId = scopeId; - } - } - if (oldestId) { - this.evict(oldestId); - } - } -} diff --git a/packages/services/service-cloud/src/routes/branches.ts b/packages/services/service-cloud/src/routes/branches.ts deleted file mode 100644 index 9b2b09b7a..000000000 --- a/packages/services/service-cloud/src/routes/branches.ts +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Branch endpoints — git-style logical branches over `sys_environment_revision`. - * - * GET /cloud/projects/:id/branches - * POST /cloud/projects/:id/branches/:name/rename - * DELETE /cloud/projects/:id/branches/:name - * - * Branches are not separate rows in their own table; they are a property of - * each revision. A branch "exists" iff at least one revision carries that - * `branch` value. The "head" of a branch is the row with - * `is_branch_head = true` (at most one per (project_id, branch)). When we - * publish a new revision the cloud route flips the head pointer atomically. - * - * "Default branch" is `main`. There is no separate setting; the project - * simply behaves as if the most recently active branch on the dashboard is - * default. - */ - -import type { IHttpServer } from '@objectstack/spec/contracts'; -import { ok, fail } from '../cloud-artifact-helpers.js'; -import type { RouteDeps } from './types.js'; -import { makeCheckAuth, makeGetDriver, controlPlaneUnavailable } from './types.js'; - -/** Slug regex for valid branch names. ASCII lowercase, dot, underscore, slash, dash. */ -export const BRANCH_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,62}$/; -/** 12-hex pattern reserved for preview commit URLs — must not collide. */ -const HEX12_RE = /^[0-9a-f]{12}$/; - -export const DEFAULT_BRANCH = 'main'; - -/** - * Normalise a raw branch input. Empty / null → `main`. Throws on invalid. - * - * Rules: - * - lowercased and trimmed - * - matches BRANCH_SLUG_RE - * - cannot be exactly 12 hex chars (would clash with preview commit URL) - * - cannot equal reserved tokens ('HEAD' is rejected after lowercase too) - */ -export function normalizeBranch(raw: unknown): string { - const v = String(raw ?? DEFAULT_BRANCH).trim().toLowerCase(); - const final = v || DEFAULT_BRANCH; - if (!BRANCH_SLUG_RE.test(final)) { - throw new Error( - `Invalid branch name '${final}'. Must match ${BRANCH_SLUG_RE} ` + - `(start with [a-z0-9], up to 63 chars of [a-z0-9._/-]).`, - ); - } - if (HEX12_RE.test(final)) { - throw new Error( - `Branch name '${final}' is a 12-hex string, which would collide with ` + - `preview commit URLs. Pick a different name.`, - ); - } - return final; -} - -export interface BranchHeadRow { - id: string; - project_id: string; - commit_id: string; - branch?: string | null; - is_branch_head?: boolean | null; - is_current?: boolean | null; - published_at?: string | null; - note?: string | null; -} - -/** - * Promote `revisionId` to be the head of `branch`, demoting any prior head. - * - * Idempotent: safe to call when the row is already the head. Tolerates a - * driver that has not yet auto-migrated the new columns (best-effort: - * swallows errors and logs a warning). - */ -export async function setBranchHead( - driver: any, - projectId: string, - branch: string, - revisionId: string, -): Promise { - try { - const heads = (await driver.find('sys_environment_revision', { - where: { environment_id: projectId, branch, is_branch_head: true }, - limit: 100, - })) as BranchHeadRow[]; - for (const h of heads) { - if (h.id !== revisionId) { - await driver.update('sys_environment_revision', h.id, { is_branch_head: false }); - } - } - await driver.update('sys_environment_revision', revisionId, { - branch, - is_branch_head: true, - }); - } catch (err: any) { - console.warn('[CloudArtifactAPI] setBranchHead failed (column may be missing):', err?.message); - } -} - -/** - * Group revisions by branch and pick the head row per branch. Rows whose - * `branch` is null/undefined fall under `DEFAULT_BRANCH` so existing data - * does not disappear after the schema upgrade. - * - * Head selection priority: - * 1. row with `is_branch_head = true` (authoritative) - * 2. row with the most recent `published_at` (fallback for un-migrated data) - */ -export function groupByBranch(rows: BranchHeadRow[]): Array<{ - branch: string; - headCommitId: string; - headRevisionId: string; - revisionCount: number; - headPublishedAt: string | null; - headNote: string | null; - isCurrent: boolean; -}> { - const buckets = new Map(); - for (const r of rows) { - const b = (r.branch && r.branch.trim()) || DEFAULT_BRANCH; - const arr = buckets.get(b) ?? []; - arr.push(r); - buckets.set(b, arr); - } - - const out: ReturnType = []; - for (const [branch, items] of buckets) { - let head = items.find((r) => r.is_branch_head === true); - if (!head) { - head = [...items].sort((a, b) => { - const ta = a.published_at ?? ''; - const tb = b.published_at ?? ''; - return tb.localeCompare(ta); - })[0]; - } - if (!head) continue; - out.push({ - branch, - headCommitId: head.commit_id, - headRevisionId: head.id, - revisionCount: items.length, - headPublishedAt: head.published_at ?? null, - headNote: head.note ?? null, - isCurrent: items.some((r) => r.is_current === true), - }); - } - out.sort((a, b) => { - // `main` first, then by head publish time desc - if (a.branch === DEFAULT_BRANCH && b.branch !== DEFAULT_BRANCH) return -1; - if (b.branch === DEFAULT_BRANCH && a.branch !== DEFAULT_BRANCH) return 1; - return (b.headPublishedAt ?? '').localeCompare(a.headPublishedAt ?? ''); - }); - return out; -} - -export function registerBranchRoutes(server: IHttpServer, deps: RouteDeps): void { - const { prefix, requiredKey, controlDriverPromise, getCallerUserId } = deps; - const checkAuth = makeCheckAuth(requiredKey, getCallerUserId); - const getDriver = makeGetDriver(controlDriverPromise); - - // ───────────────────────────────────────────────────────────────── - // GET /cloud/projects/:id/branches - // List every distinct branch on this project + its head commit + count. - // ───────────────────────────────────────────────────────────────── - server.get(`${prefix}/cloud/projects/:id/branches`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - const rows = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId }, - orderBy: [{ field: 'published_at', direction: 'desc' }], - limit: 5000, - })) as BranchHeadRow[]; - const branches = groupByBranch(rows); - return res.json(ok({ projectId, branches })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to list branches:', err?.message ?? err); - return res.status(500).json(fail('Failed to list branches', 500)); - } - }); - - // ───────────────────────────────────────────────────────────────── - // POST /cloud/projects/:id/branches/:name/rename - // Body: { newName: string } - // Renames every revision row in `name` to `newName`. The head stays head. - // 409 if `newName` already has rows. - // ───────────────────────────────────────────────────────────────── - server.post(`${prefix}/cloud/projects/:id/branches/:name/rename`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - const oldName = String(req.params?.name ?? '').trim(); - if (!projectId || !oldName) return res.status(400).json(fail('project id and branch name required')); - - let normalizedNew: string; - try { - normalizedNew = normalizeBranch((req.body ?? {}).newName); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'invalid branch name')); - } - if (normalizedNew === oldName) { - return res.json(ok({ projectId, branch: oldName, renamed: 0 })); - } - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - const collisions = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId, branch: normalizedNew }, - limit: 1, - })) as any[]; - if (Array.isArray(collisions) && collisions.length > 0) { - return res.status(409).json(fail(`Branch '${normalizedNew}' already exists`, 409)); - } - - const rows = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId, branch: oldName }, - limit: 5000, - })) as BranchHeadRow[]; - for (const r of rows) { - await (driver.update as any)('sys_environment_revision', r.id, { branch: normalizedNew }); - } - return res.json(ok({ projectId, from: oldName, to: normalizedNew, renamed: rows.length })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to rename branch:', err?.message ?? err); - return res.status(500).json(fail('Failed to rename branch', 500)); - } - }); - - // ───────────────────────────────────────────────────────────────── - // DELETE /cloud/projects/:id/branches/:name - // Soft-delete: clears `is_branch_head` on every row in this branch. - // Revisions themselves remain (their commit URLs still resolve); - // branch-tracking preview URLs for `name` will 404. - // The DEFAULT_BRANCH ('main') cannot be deleted. - // The current revision's branch cannot be deleted. - // ───────────────────────────────────────────────────────────────── - server.delete(`${prefix}/cloud/projects/:id/branches/:name`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - const name = String(req.params?.name ?? '').trim().toLowerCase(); - if (!projectId || !name) return res.status(400).json(fail('project id and branch name required')); - if (name === DEFAULT_BRANCH) { - return res.status(400).json(fail(`Cannot delete the default branch '${DEFAULT_BRANCH}'`, 400)); - } - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - const rows = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId, branch: name }, - limit: 5000, - })) as BranchHeadRow[]; - if (rows.length === 0) { - return res.status(404).json(fail(`Branch '${name}' not found`, 404)); - } - const carriesCurrent = rows.some((r) => r.is_current === true); - if (carriesCurrent) { - return res.status(409).json(fail( - `Branch '${name}' carries the active (current) revision; activate another revision first`, - 409, - )); - } - for (const r of rows) { - if (r.is_branch_head) { - await (driver.update as any)('sys_environment_revision', r.id, { is_branch_head: false }); - } - } - return res.json(ok({ projectId, branch: name, demoted: rows.filter((r) => r.is_branch_head).length, totalRevisions: rows.length })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to delete branch:', err?.message ?? err); - return res.status(500).json(fail('Failed to delete branch', 500)); - } - }); -} diff --git a/packages/services/service-cloud/src/routes/cloud.ts b/packages/services/service-cloud/src/routes/cloud.ts deleted file mode 100644 index 1462df819..000000000 --- a/packages/services/service-cloud/src/routes/cloud.ts +++ /dev/null @@ -1,597 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Authenticated `/cloud/*` artifact routes. - * - * GET /cloud/resolve-hostname?host=... - * GET /cloud/projects/:id/artifact[?commit=...] - * POST /cloud/projects/:id/metadata - * GET /cloud/projects/:id/revisions?limit=&cursor= - * POST /cloud/projects/:id/revisions/:commit/activate - * POST /cloud/projects/:id/revisions/prune - * - * Every route is bearer-token gated (when `requiredKey` is set). - */ - -import { resolve as resolvePath, isAbsolute } from 'node:path'; -import { randomUUID } from 'node:crypto'; -import type { IHttpServer } from '@objectstack/spec/contracts'; -import { - ok, fail, parseMetadata, extractArtifactPaths, sha256Hex, - mergeArtifactMetadata, resolveProjectByHost, readProjectCredentials, - buildRuntimeBlock, -} from '../cloud-artifact-helpers.js'; -import type { SysProjectRow } from '../cloud-artifact-helpers.js'; -import { buildStorageKey, readLegacyArtifactFile } from './storage.js'; -import type { RouteDeps } from './types.js'; -import { makeCheckAuth, makeGetDriver, controlPlaneUnavailable } from './types.js'; -import { normalizeBranch, setBranchHead, DEFAULT_BRANCH } from './branches.js'; - -export function registerCloudRoutes(server: IHttpServer, deps: RouteDeps): void { - const { - prefix, artifactRoot, keyPrefix, storage, storageAdapterName, - requiredKey, controlDriverPromise, getCallerUserId, - } = deps; - - const checkAuth = makeCheckAuth(requiredKey, getCallerUserId); - const getDriver = makeGetDriver(controlDriverPromise); - const keyFor = (orgId: string | null | undefined, projectId: string, commitId: string) => - buildStorageKey(keyPrefix, orgId, projectId, commitId); - - // ================================================================ - // GET /cloud/resolve-hostname?host=... - // ================================================================ - server.get(`${prefix}/cloud/resolve-hostname`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const host = String(req.query?.host ?? req.query?.hostname ?? '').trim(); - if (!host) return res.status(400).json(fail('host query parameter is required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = await resolveProjectByHost(driver, host); - if (!project) return res.status(404).json(fail(`No project bound to hostname '${host}'`, 404)); - - const cred = await readProjectCredentials(driver, project.id); - const runtime = buildRuntimeBlock(project, cred); - return res.json(ok({ projectId: project.id, organizationId: project.organization_id, runtime })); - }); - - // ================================================================ - // GET /cloud/projects-by-short-id/:short - // Resolve a project's UUID prefix (>= 8 hex chars, dashes stripped) - // to its full id. Used by the preview runtime, which encodes project - // ids as 8-hex subdomains. Returns 404 on no match, 409 on ambiguity. - // - // The URL deliberately sits at `projects-by-short-id` (NOT - // `projects/by-short-id`) so it never collides with the catch-all - // `:id` param of `/cloud/projects/:id/...` routes — those would - // shadow this one in registration-order matchers. - // ================================================================ - server.get(`${prefix}/cloud/projects-by-short-id/:short`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const raw = String(req.params?.short ?? '').trim().toLowerCase(); - if (!/^[0-9a-f]{8,32}$/.test(raw)) { - return res.status(400).json(fail('short id must be 8-32 lowercase hex chars (no dashes)')); - } - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - // The short id is a UUID prefix without dashes; the stored - // `sys_project.id` is a canonical UUID *with* dashes. The first - // 8 hex chars of the UUID are always the bytes before the first - // dash. Most drivers expose `$contains` (LIKE %x%); we use that - // to over-fetch a small candidate set, then post-filter to an - // exact prefix match. UUIDs make collisions on 8 hex chars rare, - // and we cap the candidate scan at 16 rows. - const headHex = raw.slice(0, 8); - const candidates = (await (driver.find as any)('sys_environment', { - where: { id: { $contains: headHex } }, - limit: 16, - })) as Array<{ id: string; organization_id?: string }>; - const matches = candidates.filter((p) => p.id.replace(/-/g, '').toLowerCase().startsWith(raw)); - if (matches.length === 0) { - return res.status(404).json(fail(`No project matches short id '${raw}'`, 404)); - } - if (matches.length > 1) { - return res.status(409).json(fail( - `Short id '${raw}' is ambiguous (matches ${matches.length} projects)`, - 409, - )); - } - const p = matches[0]; - return res.json(ok({ projectId: p.id, organizationId: p.organization_id })); - } catch (err: any) { - console.error('[CloudArtifactAPI] by-short-id lookup failed:', err?.message ?? err); - return res.status(500).json(fail('lookup failed', 500)); - } - }); - - // ================================================================ - // GET /cloud/projects/:id/artifact[?commit=...] - // ================================================================ - server.get(`${prefix}/cloud/projects/:id/artifact`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = (await (driver.findOne as any)('sys_environment', { where: { id: projectId } })) as SysProjectRow | null; - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - - const requestedCommit = String(req.query?.commit ?? '').trim(); - - // --- Try loading from storage via revision table (P1 path) --- - let revisionBundle: any | null = null; - let revisionRow: any = null; - try { - let rev: any = null; - if (requestedCommit) { - rev = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, commit_id: requestedCommit }, - }); - if (!rev) return res.status(404).json(fail(`Revision '${requestedCommit}' not found for project '${projectId}'`, 404)); - } else { - rev = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - } - if (rev?.storage_key) { - const exists = await storage.exists(rev.storage_key); - if (exists) { - const buf = await storage.download(rev.storage_key); - revisionBundle = JSON.parse(buf.toString('utf-8')); - revisionRow = rev; - } - } - } catch (err: any) { - // Revision table may not exist yet (pre-migration); fall through to legacy path. - console.warn('[CloudArtifactAPI] revision lookup failed, falling through to legacy path:', err?.message); - } - - // --- Legacy path: read from artifact_path on disk --- - const bundles: any[] = []; - if (revisionBundle) { - bundles.push(revisionBundle); - } else { - const metadata = parseMetadata(project.metadata); - const paths = extractArtifactPaths(metadata); - for (const p of paths) { - const abs = isAbsolute(p) ? p : resolvePath(artifactRoot, p); - const bundle = await readLegacyArtifactFile(abs); - if (bundle) bundles.push(bundle); - } - } - - // --- Marketplace installs: append every enabled sys_package_installation - // row's manifest_json bundle so the runtime kernel registers them - // as additional packages. Without this, packages installed via - // `POST /cloud/packages/:id/install` would write a row but never - // reach the env runtime. - try { - const installs: any[] = await (async () => { - const r: any = await (driver.find as any)('sys_package_installation', { - where: { environment_id: projectId }, - limit: 1000, - }); - if (Array.isArray(r)) return r; - if (Array.isArray(r?.records)) return r.records; - if (Array.isArray(r?.value)) return r.value; - return []; - })(); - for (const inst of installs) { - if (!inst) continue; - if (inst.enabled === false || inst.enabled === 0) continue; - if (!inst.package_version_id) continue; - const ver: any = await (driver.findOne as any)('sys_package_version', { where: { id: inst.package_version_id } }); - if (!ver?.manifest_json) continue; - let manifest: any = null; - try { - manifest = typeof ver.manifest_json === 'string' - ? JSON.parse(ver.manifest_json) - : ver.manifest_json; - } catch { - continue; - } - if (!manifest || typeof manifest !== 'object') continue; - // Honor the per-install "Include sample data" opt-in. When - // false (default) we strip the `data` (and legacy `datasets`) - // arrays from this install's bundle so the env runtime - // doesn't replay demo records into the user's primary org. - const withSamples = inst.with_sample_data === true || inst.with_sample_data === 1; - if (!withSamples) { - if (Array.isArray((manifest as any).data)) delete (manifest as any).data; - if (Array.isArray((manifest as any).datasets)) delete (manifest as any).datasets; - } - // Wrap as a bundle-shaped object so mergeArtifactMetadata - // ingests its arrays (`objects`, `apps`, `views`, etc.). - bundles.push({ metadata: manifest, manifest }); - } - } catch (err: any) { - console.warn('[CloudArtifactAPI] installed-bundle merge failed:', err?.message ?? err); - } - - const cred = await readProjectCredentials(driver, project.id); - const runtime = buildRuntimeBlock(project, cred); - - const first = bundles[0] ?? {}; - const mergedMetadata = mergeArtifactMetadata(bundles); - const functions = bundles.flatMap((b) => Array.isArray(b?.functions) ? b.functions : []); - const manifest = first.manifest ?? { plugins: [], drivers: [], engines: {} }; - // Prefer revision row's identity (authoritative for published artifacts); - // fall back to bundle's own commitId; finally synthesize from content. - const commitId = revisionRow?.commit_id - ?? first.commitId - ?? sha256Hex(JSON.stringify(mergedMetadata) + ':' + JSON.stringify(functions)).slice(0, 16); - // checksum: ProjectArtifactSchema requires a 64-char hex string. - const computedChecksumHex = sha256Hex(JSON.stringify({ mergedMetadata, functions, manifest })); - const firstChecksum = typeof first.checksum === 'string' - ? first.checksum - : (first.checksum?.value ?? undefined); - const checksum = revisionRow?.checksum ?? firstChecksum ?? computedChecksumHex; - - const envelope = { - schemaVersion: '0.1', - projectId: project.id, - commitId, - checksum, - metadata: mergedMetadata, - functions, - manifest, - builtAt: first.builtAt ?? new Date().toISOString(), - builtWith: first.builtWith, - runtime, - }; - return res.json(ok(envelope)); - }); - - // ================================================================ - // POST /cloud/projects/:id/metadata - // ================================================================ - server.post(`${prefix}/cloud/projects/:id/metadata`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = (await (driver.findOne as any)('sys_environment', { where: { id: projectId } })) as SysProjectRow | null; - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - - const body = req.body ?? {}; - if (typeof body !== 'object' || Array.isArray(body)) { - return res.status(400).json(fail('Request body must be a JSON object')); - } - - const bodyStr = JSON.stringify(body); - const bodyBuf = Buffer.from(bodyStr, 'utf-8'); - const fullHash = sha256Hex(bodyStr); - const commitId = (body as any).commitId ?? fullHash.slice(0, 16); - // ProjectArtifactSchema demands a 64-char hex string for checksum. - const incomingChecksum = (body as any).checksum; - const checksum = typeof incomingChecksum === 'string' - ? incomingChecksum - : (incomingChecksum?.value ?? fullHash); - const key = keyFor(project.organization_id, projectId, commitId); - - // Branch — query string takes precedence over body so that the CLI - // can pass `?branch=foo` even when streaming a raw artifact body. - let branch: string; - try { - branch = normalizeBranch(req.query?.branch ?? (body as any).branch); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'invalid branch', 400)); - } - - // 1. Upload to storage (content-addressable: skip if same key exists) - try { - const exists = await storage.exists(key); - if (!exists) { - await storage.upload(key, bodyBuf); - } - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to upload artifact:', err?.message ?? err); - return res.status(500).json(fail('Failed to persist artifact', 500)); - } - - // 2. Insert revision row + flip is_current + flip is_branch_head - let revisionCreated = false; - let revisionId: string | null = null; - try { - const existing = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, commit_id: commitId }, - }); - - if (!existing) { - try { - const oldCurrent = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - if (oldCurrent) { - await (driver.update as any)('sys_environment_revision', oldCurrent.id, { is_current: false }); - } - } catch { /* table may not exist yet */ } - - revisionId = randomUUID(); - await (driver.create as any)('sys_environment_revision', { - id: revisionId, - project_id: projectId, - commit_id: commitId, - checksum: typeof checksum === 'string' ? checksum : fullHash, - storage_key: key, - storage_adapter: storageAdapterName, - size_bytes: bodyBuf.byteLength, - built_at: (body as any).builtAt ?? new Date().toISOString(), - built_with: (body as any).builtWith ? JSON.stringify((body as any).builtWith) : null, - published_at: new Date().toISOString(), - note: (body as any).note ?? (req.query?.note ? String(req.query.note) : null), - is_current: true, - branch, - is_branch_head: true, - }); - revisionCreated = true; - } else { - revisionId = existing.id; - // Re-publish same commit: ensure it's current AND that branch head reflects this push - if (!existing.is_current) { - try { - const oldCurrent = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - if (oldCurrent && oldCurrent.id !== existing.id) { - await (driver.update as any)('sys_environment_revision', oldCurrent.id, { is_current: false }); - } - } catch { /* ok */ } - await (driver.update as any)('sys_environment_revision', existing.id, { is_current: true }); - } - } - - // Always (re-)apply branch head pointer so that re-publishing the - // same commit on a different branch correctly moves the head. - if (revisionId) { - await setBranchHead(driver, projectId, branch, revisionId); - } - } catch (err: any) { - console.warn('[CloudArtifactAPI] Failed to write revision row (table may not exist yet):', err?.message); - } - - // 3. Update sys_project.metadata.current_commit_id (and legacy artifact_path) - const existingMeta = parseMetadata(project.metadata); - const updatedMeta = { ...existingMeta, current_commit_id: commitId, artifact_storage_key: key }; - try { - await (driver.update as any)('sys_environment', projectId, { metadata: JSON.stringify(updatedMeta) }); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to update project metadata:', err?.message ?? err); - } - - return res.json(ok({ - projectId, - commitId, - checksum, - storageKey: key, - revisionCreated, - branch, - })); - }); - - // ================================================================ - // GET /cloud/projects/:id/revisions?limit=&cursor= - // ================================================================ - server.get(`${prefix}/cloud/projects/:id/revisions`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const limit = Math.min(Math.max(parseInt(req.query?.limit ?? '20', 10) || 20, 1), 100); - const cursor = String(req.query?.cursor ?? '').trim(); - const branchFilterRaw = req.query?.branch; - let branchFilter: string | null = null; - if (branchFilterRaw !== undefined && branchFilterRaw !== null && String(branchFilterRaw).trim() !== '') { - try { - branchFilter = normalizeBranch(branchFilterRaw); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'invalid branch filter', 400)); - } - } - - try { - const query: any = { - where: { environment_id: projectId }, - orderBy: [{ field: 'published_at', direction: 'desc' }], - limit: limit + 1, - }; - if (cursor) { - query.where.published_at = { $lt: cursor }; - } - if (branchFilter) { - // Match either explicit branch value or NULL (treated as default) - if (branchFilter === DEFAULT_BRANCH) { - query.where.$or = [{ branch: branchFilter }, { branch: null }]; - } else { - query.where.branch = branchFilter; - } - } - const rows = await (driver.find as any)('sys_environment_revision', query); - const hasMore = rows.length > limit; - const items = hasMore ? rows.slice(0, limit) : rows; - const nextCursor = hasMore ? items[items.length - 1]?.published_at : undefined; - - return res.json(ok({ - items: items.map((r: any) => ({ - commitId: r.commit_id, - checksum: r.checksum, - storageKey: r.storage_key, - sizeBytes: r.size_bytes, - builtAt: r.built_at, - publishedAt: r.published_at, - publishedBy: r.published_by, - note: r.note, - isCurrent: !!r.is_current, - branch: (r.branch && String(r.branch).trim()) || DEFAULT_BRANCH, - isBranchHead: !!r.is_branch_head, - })), - nextCursor, - branch: branchFilter, - })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to list revisions:', err?.message ?? err); - return res.status(500).json(fail('Failed to list revisions', 500)); - } - }); - - // ================================================================ - // POST /cloud/projects/:id/revisions/:commit/activate - // ================================================================ - server.post(`${prefix}/cloud/projects/:id/revisions/:commit/activate`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - const commitId = String(req.params?.commit ?? '').trim(); - if (!projectId || !commitId) return res.status(400).json(fail('project id and commit id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - // Accept full commit id or a 8+ char prefix (matches the - // 12-char display in the Studio recent-revisions list). - let target = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, commit_id: commitId }, - }); - if (!target && commitId.length >= 8) { - const candidates = await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId, commit_id: { $like: `${commitId}%` } }, - limit: 2, - }); - if (Array.isArray(candidates) && candidates.length === 1) { - target = candidates[0]; - } else if (Array.isArray(candidates) && candidates.length > 1) { - return res.status(409).json(fail(`Commit prefix '${commitId}' is ambiguous (${candidates.length} matches)`, 409)); - } - } - if (!target) return res.status(404).json(fail(`Revision '${commitId}' not found`, 404)); - - const oldCurrent = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - if (oldCurrent && oldCurrent.id !== target.id) { - await (driver.update as any)('sys_environment_revision', oldCurrent.id, { is_current: false }); - } - - await (driver.update as any)('sys_environment_revision', target.id, { is_current: true }); - - const project = await (driver.findOne as any)('sys_environment', { where: { id: projectId } }); - if (project) { - const meta = parseMetadata(project.metadata); - meta.current_commit_id = target.commit_id; - meta.artifact_storage_key = target.storage_key; - await (driver.update as any)('sys_environment', projectId, { metadata: JSON.stringify(meta) }); - } - - return res.json(ok({ - projectId, - commitId: target.commit_id, - activated: true, - previousCommitId: oldCurrent?.commit_id ?? null, - })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to activate revision:', err?.message ?? err); - return res.status(500).json(fail('Failed to activate revision', 500)); - } - }); - - // ================================================================ - // POST /cloud/projects/:id/revisions/prune - // body: { keepN?: number, keepDays?: number } (defaults: 50, 30) - // Removes old revision rows + their object-storage keys. - // The current revision is ALWAYS preserved. - // ================================================================ - server.post(`${prefix}/cloud/projects/:id/revisions/prune`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const body = (req.body ?? {}) as { keepN?: number; keepDays?: number }; - const keepN = Math.max(1, Math.min(1000, Number(body.keepN ?? 50))); - const keepDays = Math.max(0, Math.min(3650, Number(body.keepDays ?? 30))); - const cutoffIso = keepDays > 0 - ? new Date(Date.now() - keepDays * 86_400_000).toISOString() - : null; - - try { - const all = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId }, - orderBy: [{ field: 'published_at', direction: 'desc' }], - limit: 10_000, - })) as any[]; - - // Decide what to KEEP: - // - the current revision (always) - // - the most recent `keepN` rows - // - anything published within the last `keepDays` - const keepIds = new Set(); - const recent = all.slice(0, keepN); - for (const r of recent) keepIds.add(r.id); - for (const r of all) { - if (r.is_current) keepIds.add(r.id); - if (cutoffIso && r.published_at && r.published_at >= cutoffIso) { - keepIds.add(r.id); - } - } - - const toDelete = all.filter((r) => !keepIds.has(r.id)); - let deletedRows = 0; - let deletedKeys = 0; - let storageErrors = 0; - - for (const r of toDelete) { - if (r.storage_key && typeof storage.delete === 'function') { - try { - await storage.delete(r.storage_key); - deletedKeys++; - } catch (storageErr: any) { - storageErrors++; - console.warn('[CloudArtifactAPI] Failed to delete artifact', r.storage_key, storageErr?.message); - } - } - try { - await (driver.delete as any)('sys_environment_revision', r.id); - deletedRows++; - } catch (delErr: any) { - console.warn('[CloudArtifactAPI] Failed to delete revision row', r.id, delErr?.message); - } - } - - return res.json(ok({ - projectId, - scanned: all.length, - kept: keepIds.size, - deletedRows, - deletedKeys, - storageErrors, - keepN, - keepDays, - })); - } catch (err: any) { - console.error('[CloudArtifactAPI] Failed to prune revisions:', err?.message ?? err); - return res.status(500).json(fail('Failed to prune revisions', 500)); - } - }); -} diff --git a/packages/services/service-cloud/src/routes/package-install.ts b/packages/services/service-cloud/src/routes/package-install.ts deleted file mode 100644 index e2dbdc61c..000000000 --- a/packages/services/service-cloud/src/routes/package-install.ts +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Package install routes — Marketplace install loop. - * - * POST /cloud/packages/:id/install - * body: { environment_id: string } - * Effect: - * 1. Verify sys_package exists and caller can install it - * (visibility=marketplace OR owner_org_id=caller's active org) - * 2. Verify environment (sys_project row) belongs to caller's active org - * 3. Lazy-snapshot the manifest into sys_package_version (if absent) - * — only for `is_starter` packages backed by a template registry - * 4. UPSERT sys_package_installation row (project_id + package_id unique) - * 5. Bump sys_project.last_published_at to nudge the env kernel to recycle - * - * POST /cloud/installations/:id/uninstall - * Soft-disable the install row (enabled=false). Env kernel will skip on - * next boot. We intentionally do NOT drop tables — that's destructive - * and irreversible; a future "purge" action can do that explicitly. - */ - -import { randomUUID } from 'node:crypto'; -import type { IHttpServer } from '@objectstack/spec/contracts'; -import { fail, ok, KNOWN_METADATA_CATEGORIES } from '../cloud-artifact-helpers.js'; -import type { RouteDeps } from './types.js'; -import { makeCheckAuth, makeGetDriver, controlPlaneUnavailable } from './types.js'; -import type { ProjectTemplate } from '../multi-project-plugin.js'; -import { starterManifestId } from '../starter-seeder-plugin.js'; - -const STARTER_MANIFEST_PREFIX = 'app.objectstack.starter.'; - -function nowIso(): string { - return new Date().toISOString(); -} - -function deriveTemplateIdFromManifest(manifestId: string): string | null { - if (!manifestId?.startsWith(STARTER_MANIFEST_PREFIX)) return null; - return manifestId.slice(STARTER_MANIFEST_PREFIX.length); -} - -/** - * Extract a portable manifest snapshot from a template bundle. The shape - * mirrors what `kernel.objectql.registry.getAllPackages()` returns so the - * multi-project-plugin can materialize it directly without normalization. - */ -function snapshotManifest(template: ProjectTemplate, bundle: any): string { - const safe: Record = { - id: starterManifestId(template.id), - name: template.label, - description: template.description, - category: template.category ?? 'starter', - version: '1.0.0', - }; - // Copy every known metadata category from the template bundle so the - // snapshot is a complete portable manifest (objects, views, apps, - // dashboards, flows, agents, tools, data, reports, hooks, actions, - // permissions, roles, sharingRules, i18n, etc.). - for (const key of KNOWN_METADATA_CATEGORIES) { - const val = (bundle as any)?.[key]; - if (Array.isArray(val) && val.length > 0) { - safe[key] = val; - } - } - // Translations may live under either `translations` or `i18n`. - if (!safe.translations && (bundle as any)?.i18n) { - safe.translations = (bundle as any).i18n; - } - return JSON.stringify(safe); -} - -export interface PackageInstallDeps extends RouteDeps { - templates?: Record; -} - -/** - * Result envelope from {@link installPackageIntoEnvironment}. Mirrors the - * shape we'd return over HTTP so callers (route handlers, server-action - * dispatchers) can forward verbatim. - */ -export interface InstallPackageResult { - status: number; - body: { success: boolean; data?: any; error?: string }; -} - -/** - * Transport-agnostic install helper — used by both: - * • POST /cloud/packages/:id/install (marketplace flow) - * • POST /cloud/environments/:id/install-package (env-detail flow) - * • POST /api/v1/actions/sys_environment/install_application - * (app-shell RecordDetailView script dispatcher — needed because - * its built-in apiHandler ignores action.target and falls back - * to dataSource.update, see RecordDetailView.js apiHandler). - * - * Behavior is identical to the original inline route: verify caller can - * install the package into the target env, lazy-snapshot starter manifests - * into sys_package_version, UPSERT sys_package_installation, bump - * last_published_at so the env kernel recycles on next request. - */ -export async function installPackageIntoEnvironment(args: { - deps: PackageInstallDeps; - packageId: string; - environmentId: string; - seedSampleData: boolean; - callerUserId?: string | null; - callerActiveOrgId?: string | null; -}): Promise { - const { deps, packageId, environmentId, seedSampleData, callerUserId, callerActiveOrgId } = args; - const { controlDriverPromise, templates = {} } = deps; - if (!packageId) return { status: 400, body: fail('package id is required') }; - if (!environmentId) return { status: 400, body: fail('environment_id is required') }; - - const driverEnvelope = await controlDriverPromise; - const driver = driverEnvelope?.driver; - if (!driver) return { status: 503, body: fail('Control-plane driver is unavailable') }; - - const pkg: any = await (driver as any).findOne?.('sys_package', { where: { id: packageId } }); - if (!pkg) return { status: 404, body: fail(`Package ${packageId} not found`) }; - - const env: any = await (driver as any).findOne?.('sys_environment', { where: { id: environmentId } }); - if (!env) return { status: 404, body: fail(`Environment ${environmentId} not found`) }; - - if (callerActiveOrgId) { - if (env.organization_id && env.organization_id !== callerActiveOrgId) { - return { status: 403, body: fail('Environment is not in your active organization') }; - } - if (pkg.visibility !== 'marketplace' && pkg.owner_org_id && pkg.owner_org_id !== callerActiveOrgId) { - return { status: 403, body: fail('You do not have access to this package') }; - } - } - - let version: any = await (driver as any).findOne?.('sys_package_version', { - where: { package_id: packageId, status: 'published' }, - orderBy: [{ field: 'published_at', direction: 'desc' }], - }); - if (!version) { - const tplId = deriveTemplateIdFromManifest(pkg.manifest_id); - const template = tplId ? templates[tplId] : undefined; - if (!template) { - return { status: 409, body: fail( - `No published version exists for package ${pkg.display_name ?? packageId} and no template snapshot is available`, - ) }; - } - try { - const bundle = await template.load(); - const manifestJson = snapshotManifest(template, bundle); - const versionId = `pkgv_${randomUUID()}`; - await (driver as any).create?.('sys_package_version', { - id: versionId, - created_at: nowIso(), - updated_at: nowIso(), - package_id: packageId, - version: '1.0.0', - status: 'published', - manifest_json: manifestJson, - is_pre_release: false, - published_at: nowIso(), - created_by: callerUserId ?? undefined, - }); - version = await (driver as any).findOne?.('sys_package_version', { where: { id: versionId } }); - } catch (err: any) { - console.error('[package-install] Snapshot failed:', err); - return { status: 500, body: fail(`Failed to snapshot template: ${err?.message ?? err}`) }; - } - } - - const existing: any = await (driver as any).findOne?.('sys_package_installation', { - where: { environment_id: environmentId, package_id: packageId }, - }); - let installationId: string; - if (existing && existing.id) { - installationId = existing.id; - await (driver as any).update?.('sys_package_installation', existing.id, { - updated_at: nowIso(), - package_version_id: version.id, - status: 'installed', - enabled: true, - with_sample_data: seedSampleData, - }); - } else { - installationId = `pkgi_${randomUUID()}`; - await (driver as any).create?.('sys_package_installation', { - id: installationId, - created_at: nowIso(), - updated_at: nowIso(), - environment_id: environmentId, - package_id: packageId, - package_version_id: version.id, - status: 'installed', - enabled: true, - with_sample_data: seedSampleData, - installed_at: nowIso(), - installed_by: callerUserId ?? undefined, - }); - } - - try { - await (driver as any).update?.('sys_environment', environmentId, { - last_published_at: nowIso(), - updated_at: nowIso(), - }); - } catch { /* non-fatal */ } - - return { status: 200, body: ok({ - installation_id: installationId, - package_id: packageId, - package_version_id: version.id, - environment_id: environmentId, - message: `Installed ${pkg.display_name ?? packageId} into environment ${env.name ?? environmentId}`, - }) }; -} - -export function registerPackageInstallRoutes(server: IHttpServer, deps: PackageInstallDeps): void { - const { prefix, requiredKey, controlDriverPromise, getCallerUserId, getCallerActiveOrgId } = deps; - const checkAuth = makeCheckAuth(requiredKey, getCallerUserId); - const getDriver = makeGetDriver(controlDriverPromise); - - // Shared install handler — invoked by both: - // POST /cloud/packages/:id/install (package-keyed, body.environment_id) - // POST /cloud/environments/:id/install-package (env-keyed, body.package_id) - async function runInstall(req: any, res: any, packageId: string, environmentId: string) { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - - const body = (req.body ?? {}) as Record; - const seedSampleData = body.seed_sample_data === true - || body.seed_sample_data === 'true' - || body.seedSampleData === true - || body.seedSampleData === 'true'; - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const callerActiveOrg = (auth.mode === 'user' && getCallerActiveOrgId) - ? await getCallerActiveOrgId(req) - : null; - - const result = await installPackageIntoEnvironment({ - deps, - packageId, - environmentId, - seedSampleData, - callerUserId: auth.mode === 'user' ? auth.userId : null, - callerActiveOrgId: callerActiveOrg ?? null, - }); - return res.status(result.status).json(result.body); - } - - // ================================================================ - // POST /cloud/packages/:id/install - // (marketplace-keyed install — Install dialog on sys_package row) - // ================================================================ - server.post(`${prefix}/cloud/packages/:id/install`, async (req: any, res: any) => { - const packageId = String(req.params?.id ?? '').trim(); - const body = (req.body ?? {}) as Record; - const environmentId = String( - body.environment_id ?? body.environmentId ?? body.project_id ?? body.projectId ?? '', - ).trim(); - return runInstall(req, res, packageId, environmentId); - }); - - // ================================================================ - // POST /cloud/environments/:id/install-package - // (env-keyed install — Install Application CTA on sys_environment row) - // ================================================================ - server.post(`${prefix}/cloud/environments/:id/install-package`, async (req: any, res: any) => { - const environmentId = String(req.params?.id ?? '').trim(); - const body = (req.body ?? {}) as Record; - const packageId = String(body.package_id ?? body.packageId ?? '').trim(); - return runInstall(req, res, packageId, environmentId); - }); - - // ================================================================ - // POST /cloud/installations/:id/uninstall (soft-disable) - // ================================================================ - server.post(`${prefix}/cloud/installations/:id/uninstall`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - - const installationId = String(req.params?.id ?? '').trim(); - if (!installationId) return res.status(400).json(fail('installation id is required')); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const install: any = await (driver as any).findOne?.('sys_package_installation', { where: { id: installationId } }); - if (!install) return res.status(404).json(fail(`Installation ${installationId} not found`)); - - if (auth.mode === 'user' && getCallerActiveOrgId) { - const env: any = await (driver as any).findOne?.('sys_environment', { where: { id: install.environment_id } }); - const activeOrg = await getCallerActiveOrgId(req); - if (env?.organization_id && activeOrg && env.organization_id !== activeOrg) { - return res.status(403).json(fail('Installation belongs to another organization')); - } - } - - await (driver as any).update?.('sys_package_installation', installationId, { - updated_at: nowIso(), - status: 'disabled', - enabled: false, - }); - - try { - await (driver as any).update?.('sys_environment', install.environment_id, { - last_published_at: nowIso(), - updated_at: nowIso(), - }); - } catch { /* non-fatal */ } - - return res.json(ok({ - installation_id: installationId, - message: 'Package uninstalled (soft-disable). Tables preserved.', - })); - }); -} diff --git a/packages/services/service-cloud/src/routes/package-publish.ts b/packages/services/service-cloud/src/routes/package-publish.ts deleted file mode 100644 index 9e1959153..000000000 --- a/packages/services/service-cloud/src/routes/package-publish.ts +++ /dev/null @@ -1,364 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Package publish routes — CLI / "upload my local package to my org" loop. - * - * POST /cloud/packages - * body: { - * manifest_id: string // reverse-domain id (e.g. local.acme.crm) - * display_name?: string - * description?: string - * visibility?: 'private' | 'org' | 'marketplace' (default: 'private') - * category?: string - * owner_org_id?: string // required in service (bearer) mode - * } - * Effect: idempotent upsert of one sys_package row keyed by manifest_id. - * - User mode (session cookie): owner_org_id = session.activeOrganizationId - * - Service mode (bearer key): owner_org_id = body.owner_org_id (required) - * - Existing rows are matched by (manifest_id) and patched with non-null - * fields from the body. Ownership is NEVER reassigned by upsert. - * - * POST /cloud/packages/:id/versions - * :id may be either the sys_package UUID or the manifest_id (slug). - * body: { - * version: string // semver, required - * bundle: object | string // compiled artifact (objectstack.json) - * // or pre-shaped manifest snapshot - * release_notes?: string - * is_pre_release?: boolean - * install_env_id?: string // optional: auto-install into env - * seed_sample_data?: boolean // forwarded to install if set - * } - * Effect: - * 1. Verify caller can publish into the target package (owner_org match). - * 2. Snapshot the bundle into the manifest_json shape understood by - * MultiProjectPlugin (top-level id/name/version + KNOWN_METADATA_CATEGORIES). - * 3. INSERT a new sys_package_version row (status='published'). - * (package_id, version) is UNIQUE — duplicate publishes are rejected. - * 4. If install_env_id is set, UPSERT sys_package_installation pointing - * the env at the new version (same code path as marketplace install). - * - * This is the missing half of the unified Package flow described in - * ADR-0006 v4 — once all CLI publishes flow through these endpoints, - * `sys_environment_revision` becomes redundant and can be retired. - */ - -import { createHash, randomUUID } from 'node:crypto'; -import type { IHttpServer, IDataDriver } from '@objectstack/spec/contracts'; -import { fail, ok, KNOWN_METADATA_CATEGORIES } from '../cloud-artifact-helpers.js'; -import type { RouteDeps } from './types.js'; -import { makeCheckAuth, makeGetDriver, controlPlaneUnavailable } from './types.js'; -import type { PackageInstallDeps } from './package-install.js'; -import { installPackageIntoEnvironment } from './package-install.js'; - -const VALID_VISIBILITY = new Set(['private', 'org', 'marketplace']); -const MANIFEST_ID_RE = /^[a-z0-9][a-z0-9._-]{0,254}$/i; - -function nowIso(): string { - return new Date().toISOString(); -} - -function sha256Hex(input: string): string { - return createHash('sha256').update(input).digest('hex'); -} - -/** - * Look up a sys_package by either its UUID id or its manifest_id slug. - * UUIDs are matched first (cheap, indexed) before the slug fallback. - */ -async function findPackageByIdOrManifest( - driver: IDataDriver, - idOrManifest: string, -): Promise { - if (!idOrManifest) return null; - const byId: any = await (driver as any).findOne?.('sys_package', { where: { id: idOrManifest } }); - if (byId) return byId; - const byManifest: any = await (driver as any).findOne?.('sys_package', { where: { manifest_id: idOrManifest } }); - return byManifest ?? null; -} - -/** - * Build a portable manifest snapshot from a CLI artifact bundle. - * Mirrors the snapshot shape produced by `package-install.ts::snapshotManifest` - * so the multi-project-plugin loader can consume either source uniformly. - * - * The incoming bundle can be either: - * - the raw compiled artifact ({ manifest, metadata: {...}, objects: [...], ... }) - * - an already-shaped manifest snapshot (flat top-level categories) - */ -export function snapshotBundleAsManifest(args: { - manifestId: string; - displayName?: string; - description?: string; - category?: string; - version: string; - bundle: any; -}): { json: string; checksum: string } { - const { manifestId, displayName, description, category, version, bundle } = args; - const safe: Record = { - id: manifestId, - name: displayName ?? bundle?.manifest?.name ?? manifestId, - description: description ?? bundle?.manifest?.description ?? undefined, - category: category ?? bundle?.manifest?.category ?? 'app', - version, - }; - - // Collect per-category arrays from either the flat top level or a nested - // `metadata` envelope (mergeArtifactMetadata accepts both shapes too). - const sources: any[] = []; - if (bundle && typeof bundle === 'object') { - if (bundle.metadata && typeof bundle.metadata === 'object' && !Array.isArray(bundle.metadata)) { - sources.push(bundle.metadata); - } - sources.push(bundle); - } - - for (const key of KNOWN_METADATA_CATEGORIES) { - for (const src of sources) { - const val = src?.[key]; - if (Array.isArray(val) && val.length > 0) { - const bucket = (safe[key] ??= []) as any[]; - bucket.push(...val); - } - } - } - // Translations may live under either `translations` or `i18n`. - if (!safe.translations) { - for (const src of sources) { - if (src?.i18n) { - safe.translations = src.i18n; - break; - } - } - } - - const json = JSON.stringify(safe); - const checksum = sha256Hex(json); - return { json, checksum }; -} - -export function registerPackagePublishRoutes(server: IHttpServer, deps: PackageInstallDeps): void { - const { prefix, requiredKey, controlDriverPromise, getCallerUserId, getCallerActiveOrgId } = deps; - const checkAuth = makeCheckAuth(requiredKey, getCallerUserId); - const getDriver = makeGetDriver(controlDriverPromise); - - // ================================================================ - // POST /cloud/packages - // Idempotent upsert by manifest_id. Creates a sys_package row if - // none exists; otherwise patches non-ownership fields. - // ================================================================ - server.post(`${prefix}/cloud/packages`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const body = (req.body ?? {}) as Record; - const manifestId = String(body.manifest_id ?? body.manifestId ?? '').trim(); - if (!manifestId) return res.status(400).json(fail('manifest_id is required')); - if (!MANIFEST_ID_RE.test(manifestId)) { - return res.status(400).json(fail( - 'manifest_id must match /^[a-z0-9][a-z0-9._-]{0,254}$/i (reverse-domain style, e.g. local.acme.crm)', - )); - } - - const visibility = body.visibility ? String(body.visibility) : 'private'; - if (!VALID_VISIBILITY.has(visibility)) { - return res.status(400).json(fail(`visibility must be one of: ${[...VALID_VISIBILITY].join(', ')}`)); - } - - // Resolve owner org. User-mode uses session.activeOrganizationId; - // service-mode (bearer) requires an explicit owner_org_id in the body - // since there's no session context. Marketplace publishes can omit it - // (platform-seeded packages use owner_org_id NULL). - let ownerOrgId: string | null = null; - if (auth.mode === 'user' && getCallerActiveOrgId) { - ownerOrgId = (await getCallerActiveOrgId(req)) ?? null; - if (!ownerOrgId) { - return res.status(400).json(fail( - 'No active organization on session. Switch to an organization in the Console before publishing.', - )); - } - } else if (auth.mode === 'service') { - const explicit = body.owner_org_id ?? body.ownerOrgId; - ownerOrgId = explicit != null ? String(explicit) : null; - if (!ownerOrgId && visibility !== 'marketplace') { - return res.status(400).json(fail( - 'owner_org_id is required in service mode (Authorization: Bearer …) when visibility is not marketplace', - )); - } - } - - const existing: any = await (driver as any).findOne?.('sys_package', { where: { manifest_id: manifestId } }); - - if (existing) { - // Don't reassign ownership on upsert — protects against accidental - // takeover of someone else's manifest_id. Caller must own it (or - // be in service mode, which is implicitly trusted). - if (auth.mode === 'user' && ownerOrgId && existing.owner_org_id && existing.owner_org_id !== ownerOrgId) { - return res.status(403).json(fail( - `Package '${manifestId}' is owned by another organization`, - )); - } - - const patch: Record = { updated_at: nowIso() }; - if (typeof body.display_name === 'string') patch.display_name = body.display_name; - if (typeof body.description === 'string') patch.description = body.description; - if (typeof body.category === 'string') patch.category = body.category; - if (typeof body.icon_url === 'string') patch.icon_url = body.icon_url; - if (typeof body.homepage_url === 'string') patch.homepage_url = body.homepage_url; - if (typeof body.license === 'string') patch.license = body.license; - if (typeof body.readme === 'string') patch.readme = body.readme; - if (body.visibility) patch.visibility = visibility; - await (driver as any).update?.('sys_package', existing.id, patch); - - return res.json(ok({ - id: existing.id, - manifest_id: manifestId, - created: false, - owner_org_id: existing.owner_org_id, - visibility: patch.visibility ?? existing.visibility, - })); - } - - // Create fresh sys_package row - const id = `pkg_${randomUUID()}`; - const row: Record = { - id, - created_at: nowIso(), - updated_at: nowIso(), - manifest_id: manifestId, - owner_org_id: ownerOrgId ?? undefined, - display_name: typeof body.display_name === 'string' && body.display_name.trim() - ? body.display_name.trim() - : manifestId, - description: typeof body.description === 'string' ? body.description : undefined, - visibility, - category: typeof body.category === 'string' ? body.category : undefined, - icon_url: typeof body.icon_url === 'string' ? body.icon_url : undefined, - homepage_url: typeof body.homepage_url === 'string' ? body.homepage_url : undefined, - license: typeof body.license === 'string' ? body.license : undefined, - readme: typeof body.readme === 'string' ? body.readme : undefined, - publisher: 'private', - is_starter: false, - created_by: auth.mode === 'user' ? auth.userId : undefined, - }; - await (driver as any).create?.('sys_package', row); - - return res.json(ok({ - id, - manifest_id: manifestId, - created: true, - owner_org_id: ownerOrgId, - visibility, - })); - }); - - // ================================================================ - // POST /cloud/packages/:id/versions - // Snapshot the supplied bundle into sys_package_version. Optionally - // auto-install into the target environment. - // ================================================================ - server.post(`${prefix}/cloud/packages/:id/versions`, async (req: any, res: any) => { - const auth = await checkAuth(req); - if (!auth.ok) return res.status(auth.status).json(auth.body); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const idOrManifest = String(req.params?.id ?? '').trim(); - if (!idOrManifest) return res.status(400).json(fail('package id (or manifest_id) is required')); - - const pkg = await findPackageByIdOrManifest(driver, idOrManifest); - if (!pkg) return res.status(404).json(fail(`Package '${idOrManifest}' not found`)); - - // User-mode RBAC: only the owning org may publish new versions - // (marketplace packages also require owner_org match — only the - // publisher can roll a new version). - if (auth.mode === 'user' && getCallerActiveOrgId) { - const activeOrg = await getCallerActiveOrgId(req); - if (pkg.owner_org_id && activeOrg && pkg.owner_org_id !== activeOrg) { - return res.status(403).json(fail('You do not own this package')); - } - } - - const body = (req.body ?? {}) as Record; - const version = String(body.version ?? '').trim(); - if (!version) return res.status(400).json(fail('version is required (semver string)')); - - const bundle = body.bundle ?? body.manifest_json ?? body.metadata; - const bundleObj = typeof bundle === 'string' - ? (() => { try { return JSON.parse(bundle); } catch { return null; } })() - : bundle; - if (!bundleObj || typeof bundleObj !== 'object') { - return res.status(400).json(fail('bundle is required and must be a JSON object (or stringified JSON)')); - } - - // Reject duplicate (package_id, version) up front for a clean error. - const dup: any = await (driver as any).findOne?.('sys_package_version', { - where: { package_id: pkg.id, version }, - }); - if (dup) { - return res.status(409).json(fail( - `Version '${version}' already exists for package '${pkg.manifest_id}'. Bump the version and retry.`, - )); - } - - const { json: manifestJson, checksum } = snapshotBundleAsManifest({ - manifestId: pkg.manifest_id, - displayName: pkg.display_name, - description: pkg.description, - category: pkg.category, - version, - bundle: bundleObj, - }); - - const versionId = `pkgv_${randomUUID()}`; - try { - await (driver as any).create?.('sys_package_version', { - id: versionId, - created_at: nowIso(), - updated_at: nowIso(), - package_id: pkg.id, - version, - status: 'published', - manifest_json: manifestJson, - checksum, - release_notes: typeof body.release_notes === 'string' ? body.release_notes : undefined, - is_pre_release: body.is_pre_release === true || /-(alpha|beta|rc|dev|preview|staging|pr)/i.test(version), - published_at: nowIso(), - published_by: auth.mode === 'user' ? auth.userId : undefined, - created_by: auth.mode === 'user' ? auth.userId : undefined, - }); - } catch (err: any) { - return res.status(500).json(fail(`Failed to create package version: ${err?.message ?? err}`)); - } - - // Optional auto-install into a target environment. - const installEnvId = String(body.install_env_id ?? body.installEnvId ?? '').trim(); - let installResult: any = null; - if (installEnvId) { - const r = await installPackageIntoEnvironment({ - deps, - packageId: pkg.id, - environmentId: installEnvId, - seedSampleData: body.seed_sample_data === true || body.seedSampleData === true, - callerUserId: auth.mode === 'user' ? auth.userId : null, - callerActiveOrgId: auth.mode === 'user' && getCallerActiveOrgId - ? (await getCallerActiveOrgId(req)) ?? null - : null, - }); - installResult = r.body?.data ?? r.body; - } - - return res.json(ok({ - id: versionId, - package_id: pkg.id, - manifest_id: pkg.manifest_id, - version, - checksum, - installation: installResult, - })); - }); -} diff --git a/packages/services/service-cloud/src/routes/project-lifecycle.ts b/packages/services/service-cloud/src/routes/project-lifecycle.ts deleted file mode 100644 index 866aca45b..000000000 --- a/packages/services/service-cloud/src/routes/project-lifecycle.ts +++ /dev/null @@ -1,677 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Project lifecycle routes — wraps {@link ProjectProvisioningService} and - * exposes project status-machine transitions used by the Cloud Control - * App row actions. - * - * POST /cloud/projects — create + provision DB - * POST /cloud/projects/:id/suspend — active → suspended - * POST /cloud/projects/:id/resume — suspended → active - * POST /cloud/projects/:id/archive — * → archived - * POST /cloud/projects/:id/set-default — set is_default true (clears others in org) - * POST /cloud/projects/:id/change-plan — change `plan` - * POST /cloud/projects/:id/change-hostname — change `hostname` - * - * Every route is bearer-token gated when `requiredKey` is set. - */ - -import { randomUUID } from 'node:crypto'; -import type { IHttpServer } from '@objectstack/spec/contracts'; -import { fail, ok } from '../cloud-artifact-helpers.js'; -import type { RouteDeps } from './types.js'; -import { makeCheckAuth, makeGetDriver, controlPlaneUnavailable } from './types.js'; -import { - ProjectProvisioningService, - createDefaultProjectAdapters, -} from '@objectstack/service-tenant'; -import { installPackageIntoEnvironment, type PackageInstallDeps } from './package-install.js'; - -type AnyRow = Record; - -const ALLOWED_PLANS = new Set(['free', 'starter', 'pro', 'enterprise', 'custom']); -const TERMINAL_STATUSES = new Set(['archived', 'failed']); - -function readActorIdFromHeaders(req: any): string | undefined { - const headerVal = req.headers?.['x-actor-id'] ?? req.headers?.['x-user-id']; - return typeof headerVal === 'string' && headerVal ? headerVal : undefined; -} - -function nowIso() { return new Date().toISOString(); } - -/** - * Best-effort re-seed of the per-project SSO client's `redirect_uris`. - * - * Called whenever a project's hostname changes (initial provision or - * subsequent `change_hostname`) so that the cloud OAuth2 provider accepts - * the new `https:///api/v1/auth/oauth2/callback/` - * callback. Without this, OAuth flows from a renamed environment fail - * with `INVALID_CALLBACK_URL` because the client's stored whitelist - * still references the OLD hostname. - * - * Failures are logged but never thrown — a missing SSO row should NOT - * block hostname changes (the user can always re-run the action). - */ -async function reseedPlatformSsoForHostname( - getDriver: () => Promise, - projectId: string, - hostname: string, -): Promise { - try { - const baseSecret = (process.env.OS_AUTH_SECRET ?? process.env.AUTH_SECRET ?? '').trim(); - if (!baseSecret) { - console.warn('[ProjectLifecycle] OS_AUTH_SECRET not set — skipping SSO re-seed', { projectId }); - return; - } - const driver = await getDriver(); - if (!driver) return; - const qlAdapter = { - find: async (object: string, q: any, _opts?: any) => { - return await (driver.find as any)(object, q); - }, - insert: async (object: string, data: any, _opts?: any) => { - return await (driver.create as any)(object, data); - }, - update: async (object: string, data: any, where: any, _opts?: any) => { - // `seedPlatformSsoClient` passes the QL-style `{ where: { id } }` - // wrapper as the 3rd arg. Older callers may pass a bare - // `{ id }`. Accept both shapes — without this unwrap the - // update silently no-ops because driver.update never gets - // called, leaving `redirect_uris` stale after a rename - // (manifests as INVALID_CALLBACK_URL on next SSO login). - const filter = where?.where ?? where ?? {}; - const id = filter?.id; - if (id) { - return await (driver.update as any)(object, id, data); - } - const rows = await (driver.find as any)(object, { where: filter, limit: 1 }); - const list = Array.isArray(rows) ? rows : Array.isArray((rows as any)?.records) ? (rows as any).records : []; - const row = list[0]; - if (row?.id) { - return await (driver.update as any)(object, row.id, data); - } - }, - }; - const { seedPlatformSsoClient } = await import('@objectstack/runtime'); - await seedPlatformSsoClient({ - ql: qlAdapter as any, - projectId, - hostname, - baseSecret, - logger: console, - }); - } catch (ssoErr: any) { - console.warn('[ProjectLifecycle] platform SSO re-seed failed (non-fatal):', ssoErr?.message ?? ssoErr); - } -} - -/** - * Resolve the new hostname for a change-hostname call. - * - * Users (and the Cloud Control UI) typically pass just a subdomain label - * — e.g. `acme-prod`. The root domain (`objectstack.app` by default, - * configurable via `OS_ROOT_DOMAIN` / `ROOT_DOMAIN`) is appended - * automatically so users never have to type `.objectstack.app` themselves. - * - * Backward compat: a fully-qualified hostname containing one or more - * dots is accepted as-is (so direct REST callers can still POST - * `{"hostname": "api.acme.com"}`). Reads `subdomain` first, then falls - * back to `hostname`. - * - * Returns either `{ ok: true, hostname }` or `{ ok: false, error, status }` - * suitable for direct response. - */ -function resolveNewHostname(body: AnyRow): { ok: true; hostname: string } | { ok: false; error: string; status: number } { - const rawSub = String(body?.subdomain ?? '').trim(); - const rawHost = String(body?.hostname ?? '').trim(); - const input = rawSub || rawHost; - if (!input) return { ok: false, error: 'subdomain or hostname is required', status: 400 }; - - const rootDomain = - (process.env.OS_ROOT_DOMAIN || - process.env.ROOT_DOMAIN || - (process.env.NODE_ENV === 'production' ? 'objectstack.app' : 'localhost')) - .toLowerCase() - .replace(/^\.+|\.+$/g, ''); - - // If caller passed a fully-qualified hostname (has at least one dot), - // treat it as the canonical value. Otherwise append the root domain. - const hasDot = input.includes('.'); - const hostname = (hasDot ? input : `${input}.${rootDomain}`).toLowerCase(); - - if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(hostname)) { - return { ok: false, error: `Hostname '${hostname}' contains invalid characters`, status: 400 }; - } - return { ok: true, hostname }; -} - -export function registerProjectLifecycleRoutes(server: IHttpServer, deps: PackageInstallDeps): void { - const { prefix, requiredKey, controlDriverPromise, getCallerUserId, getCallerActiveOrgId } = deps; - const checkAuth = makeCheckAuth(requiredKey, getCallerUserId); - const getDriver = makeGetDriver(controlDriverPromise); - - const resolveActorId = async (req: any): Promise => { - const sessionUserId = getCallerUserId ? await getCallerUserId(req) : undefined; - return sessionUserId ?? readActorIdFromHeaders(req); - }; - const resolveActiveOrgId = async (req: any): Promise => { - const fromSession = getCallerActiveOrgId ? await getCallerActiveOrgId(req) : undefined; - if (fromSession) return fromSession; - const headerOrg = req.headers?.['x-organization-id']; - return typeof headerOrg === 'string' && headerOrg ? headerOrg : undefined; - }; - - // Lazy provisioning service — built once per process. - let provisioningSvc: ProjectProvisioningService | null = null; - const getProvisioningService = async (): Promise => { - if (provisioningSvc) return provisioningSvc; - const driver = await getDriver(); - if (!driver) return null; - provisioningSvc = new ProjectProvisioningService({ - controlPlaneDriver: driver as any, - adapters: createDefaultProjectAdapters(), - defaultDriver: (process.env.OS_DEFAULT_PROJECT_DRIVER as any) ?? 'memory', - }); - return provisioningSvc; - }; - - const loadProject = async (id: string): Promise => { - const driver = await getDriver(); - if (!driver) return null; - try { - return (await (driver.findOne as any)('sys_environment', { where: { id } })) as AnyRow | null; - } catch { - return null; - } - }; - - const patchProject = async (id: string, patch: AnyRow): Promise => { - const driver = await getDriver(); - if (!driver) return false; - try { - await (driver.update as any)('sys_environment', id, { ...patch, updated_at: nowIso() }); - return true; - } catch (err: any) { - console.error('[ProjectLifecycle] update failed:', err?.message ?? err); - return false; - } - }; - - // ── POST /cloud/projects ───────────────────────────────────────── - server.post(`${prefix}/cloud/environments`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const svc = await getProvisioningService(); - if (!svc) return controlPlaneUnavailable(res); - - const body = (req.body ?? {}) as AnyRow; - const displayName = String(body.displayName ?? body.display_name ?? '').trim(); - if (!displayName) return res.status(400).json(fail('displayName is required', 400)); - - // organizationId — accept from body, or fall back to the actor's - // active organization context (resolved via better-auth session). - let organizationId = - (typeof body.organizationId === 'string' && body.organizationId) || - (typeof body.organization_id === 'string' && body.organization_id) || - ''; - organizationId = organizationId.trim(); - if (!organizationId) { - const fromSession = await resolveActiveOrgId(req); - if (fromSession) organizationId = fromSession; - } - - if (!organizationId) { - // Last resort: pick the actor's first organization membership. - const actorId = await resolveActorId(req); - const driver = await getDriver(); - if (actorId && driver) { - try { - const member = await (driver.findOne as any)('sys_member', { where: { user_id: actorId } }); - if (member?.organization_id) organizationId = String(member.organization_id); - } catch { /* sys_member may not exist */ } - } - } - if (!organizationId) return res.status(400).json(fail('organizationId is required (no active organization in session)', 400)); - - const createdBy = await resolveActorId(req); - if (!createdBy) return res.status(401).json(fail('Authenticated user required to create projects', 401)); - - try { - const result = await svc.provisionProject({ - organizationId, - displayName, - driver: body.driver, - plan: body.plan, - storageLimitMb: body.storageLimitMb != null && body.storageLimitMb !== '' - ? Number(body.storageLimitMb) - : undefined, - isDefault: Boolean(body.isDefault), - createdBy, - hostname: body.hostname || undefined, - visibility: body.visibility, - metadata: body.metadata, - }); - // ── Platform SSO: seed a `sys_oauth_application` row so the - // per-env runtime can immediately exchange auth codes with - // this control plane. Best-effort — failures are logged but do - // NOT abort the env-create flow. - try { - const baseSecret = (process.env.OS_AUTH_SECRET ?? process.env.AUTH_SECRET ?? '').trim(); - const newProjectId = (result.environment as AnyRow)?.id; - const newHostname = (result.environment as AnyRow)?.hostname; - if (baseSecret && newProjectId) { - const driver = await getDriver(); - if (driver) { - const qlAdapter = { - find: async (object: string, q: any, _opts?: any) => { - return await (driver.find as any)(object, q); - }, - insert: async (object: string, data: any, _opts?: any) => { - return await (driver.create as any)(object, data); - }, - update: async (object: string, data: any, where: any, _opts?: any) => { - const id = where?.id; - if (id) { - return await (driver.update as any)(object, id, data); - } - const rows = await (driver.find as any)(object, { where, limit: 1 }); - const list = Array.isArray(rows) ? rows : Array.isArray((rows as any)?.records) ? (rows as any).records : []; - const row = list[0]; - if (row?.id) { - return await (driver.update as any)(object, row.id, data); - } - }, - }; - const { seedPlatformSsoClient } = await import('@objectstack/runtime'); - await seedPlatformSsoClient({ - ql: qlAdapter as any, - projectId: String(newProjectId), - hostname: newHostname ? String(newHostname) : undefined, - baseSecret, - logger: console, - }); - } - } - } catch (ssoErr: any) { - console.warn('[ProjectLifecycle] platform SSO seed failed (non-fatal):', ssoErr?.message ?? ssoErr); - } - - return res.status(201).json(ok({ - project: result.environment, - warnings: result.warnings, - durationMs: result.durationMs, - })); - } catch (err: any) { - const msg = err?.message ?? String(err); - const status = /already has a default project/i.test(msg) ? 409 : 400; - return res.status(status).json(fail(msg, status)); - } - }); - - // Helper: build a status-transition endpoint. - const transition = ( - urlSuffix: string, - fromStatuses: string[] | null, - patch: (req: any, project: AnyRow) => AnyRow | Promise, - ) => { - server.post(`${prefix}/cloud/environments/:id/${urlSuffix}`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - - if (fromStatuses && !fromStatuses.includes(project.status)) { - return res.status(409).json(fail( - `Cannot ${urlSuffix} project in status '${project.status}'. Expected one of: ${fromStatuses.join(', ')}`, - 409, - )); - } - - try { - const patchObj = await patch(req, project); - const ok2 = await patchProject(projectId, patchObj); - if (!ok2) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, ...patchObj })); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'transition failed', 400)); - } - }); - }; - - // ── status transitions ── - transition('suspend', ['active', 'provisioning'], () => ({ status: 'suspended' })); - transition('resume', ['suspended'], () => ({ status: 'active' })); - transition('archive', null, (req) => { - const reason = String(req.body?.reason ?? '').trim(); - const existing: any = {}; - try { - const cur = JSON.parse(req.body?._currentMetadata ?? '{}'); - Object.assign(existing, cur); - } catch { /* ignore */ } - if (reason) existing.archive_reason = reason; - existing.archived_at = nowIso(); - return { status: 'archived', metadata: JSON.stringify(existing) }; - }); - - // ── set-default: clears other defaults in same org ── - server.post(`${prefix}/cloud/environments/:id/set-default`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - try { - const peers = await (driver.find as any)('sys_environment', { - where: { organization_id: project.organization_id, is_default: true }, - }); - for (const peer of (peers ?? [])) { - if (peer.id !== projectId) { - await (driver.update as any)('sys_environment', peer.id, { is_default: false, updated_at: nowIso() }); - } - } - const success = await patchProject(projectId, { is_default: true }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, is_default: true })); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'set-default failed', 400)); - } - }); - - // ── change-plan ── - server.post(`${prefix}/cloud/environments/:id/change-plan`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const plan = String(req.body?.plan ?? '').trim(); - if (!ALLOWED_PLANS.has(plan)) return res.status(400).json(fail(`Invalid plan '${plan}'`, 400)); - - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - if (TERMINAL_STATUSES.has(project.status)) { - return res.status(409).json(fail(`Cannot change plan on ${project.status} project`, 409)); - } - - const success = await patchProject(projectId, { plan }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, plan })); - }); - - // ── change-hostname ── - server.post(`${prefix}/cloud/environments/:id/change-hostname`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const resolved = resolveNewHostname(req.body ?? {}); - if (!resolved.ok) return res.status(resolved.status).json(fail(resolved.error, resolved.status)); - const hostname = resolved.hostname; - - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - - const driver = await getDriver(); - if (driver) { - try { - const conflict = await (driver.findOne as any)('sys_environment', { where: { hostname } }); - if (conflict && conflict.id !== projectId) { - return res.status(409).json(fail(`Hostname '${hostname}' is already in use`, 409)); - } - } catch { /* ignore */ } - } - - const success = await patchProject(projectId, { hostname, console_url: `https://${hostname}/_console`, api_base_url: `https://${hostname}/api/v1` }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - // Re-seed SSO client so OAuth callbacks at the new hostname pass - // the cloud OAuth2 provider's redirect-URI whitelist. - await reseedPlatformSsoForHostname(getDriver, projectId, hostname); - return res.json(ok({ projectId, hostname })); - }); - - // ──────────────────────────────────────────────────────────────────── - // Generic "script" action dispatcher. - // - // `@object-ui/app-shell`'s RecordDetailView ignores `action.target` for - // `type:'api'` actions (it routes hardcoded `opportunity_*` cases and - // falls through to a no-op `dataSource.update`). `type:'script'` - // actions, however, POST to `/api/v1/actions/{object}/{name}` with body - // `{ recordId, params }` on both list and detail surfaces. - // - // We expose a single dispatcher for `sys_project` that proxies the - // status-machine routes above. Each branch reuses the same handlers via - // a synthetic request rewrite (sets req.params.id / req.body) so all - // validation lives in one place. - // ──────────────────────────────────────────────────────────────────��─ - type ActionImpl = (req: any, res: any) => Promise | any; - const actionDispatch: Record = {}; - - const dispatchTransition = async ( - req: any, res: any, - urlSuffix: string, - fromStatuses: string[] | null, - patch: (req: any, project: AnyRow) => AnyRow | Promise, - ) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - if (fromStatuses && !fromStatuses.includes(project.status)) { - return res.status(409).json(fail( - `Cannot ${urlSuffix} project in status '${project.status}'. Expected one of: ${fromStatuses.join(', ')}`, - 409, - )); - } - try { - const patchObj = await patch(req, project); - const ok2 = await patchProject(projectId, patchObj); - if (!ok2) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, ...patchObj })); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'transition failed', 400)); - } - }; - - actionDispatch.suspend_environment = (req, res) => - dispatchTransition(req, res, 'suspend', ['active', 'provisioning'], () => ({ status: 'suspended' })); - actionDispatch.resume_environment = (req, res) => - dispatchTransition(req, res, 'resume', ['suspended'], () => ({ status: 'active' })); - actionDispatch.archive_environment = (req, res) => - dispatchTransition(req, res, 'archive', null, (r) => { - const reason = String(r.body?.reason ?? '').trim(); - const existing: any = {}; - if (reason) existing.archive_reason = reason; - existing.archived_at = nowIso(); - return { status: 'archived', metadata: JSON.stringify(existing) }; - }); - - actionDispatch.set_default_environment = async (req, res) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - try { - const peers = await (driver.find as any)('sys_environment', { - where: { organization_id: project.organization_id, is_default: true }, - }); - for (const peer of (peers ?? [])) { - if (peer.id !== projectId) { - await (driver.update as any)('sys_environment', peer.id, { is_default: false, updated_at: nowIso() }); - } - } - const success = await patchProject(projectId, { is_default: true }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, is_default: true })); - } catch (err: any) { - return res.status(400).json(fail(err?.message ?? 'set-default failed', 400)); - } - }; - - actionDispatch.change_plan = async (req, res) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const plan = String(req.body?.plan ?? '').trim(); - if (!ALLOWED_PLANS.has(plan)) return res.status(400).json(fail(`Invalid plan '${plan}'`, 400)); - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - if (TERMINAL_STATUSES.has(project.status)) { - return res.status(409).json(fail(`Cannot change plan on ${project.status} project`, 409)); - } - const success = await patchProject(projectId, { plan }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - return res.json(ok({ projectId, plan })); - }; - - actionDispatch.change_hostname = async (req, res) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(400).json(fail('project id required')); - const resolved = resolveNewHostname(req.body ?? {}); - if (!resolved.ok) return res.status(resolved.status).json(fail(resolved.error, resolved.status)); - const hostname = resolved.hostname; - const project = await loadProject(projectId); - if (!project) return res.status(404).json(fail(`Project '${projectId}' not found`, 404)); - const driver = await getDriver(); - if (driver) { - try { - const conflict = await (driver.findOne as any)('sys_environment', { where: { hostname } }); - if (conflict && conflict.id !== projectId) { - return res.status(409).json(fail(`Hostname '${hostname}' is already in use`, 409)); - } - } catch { /* ignore */ } - } - const success = await patchProject(projectId, { hostname, console_url: `https://${hostname}/_console`, api_base_url: `https://${hostname}/api/v1` }); - if (!success) return res.status(500).json(fail('Failed to persist update', 500)); - // Re-seed the project's SSO client so the cloud OAuth2 provider - // accepts callbacks at the new hostname (otherwise the per-env - // runtime gets `INVALID_CALLBACK_URL` on the next login). - await reseedPlatformSsoForHostname(getDriver, projectId, hostname); - return res.json(ok({ projectId, hostname })); - }; - - // Install an application from the Marketplace into this environment. - // Triggered by the `install_application` action on sys_environment - // (type: 'script') via app-shell's RecordDetailView serverActionHandler. - // We can't use type:'api' because RecordDetailView ignores action.target - // and falls back to dataSource.update — see RecordDetailView.js apiHandler. - actionDispatch.install_application = async (req, res) => { - const environmentId = String(req.params?.id ?? '').trim(); - if (!environmentId) return res.status(400).json(fail('environment id required')); - const body = (req.body ?? {}) as AnyRow; - const packageId = String(body.package_id ?? body.packageId ?? '').trim(); - if (!packageId) return res.status(400).json(fail('package_id is required')); - const seedSampleData = body.seed_sample_data === true - || body.seed_sample_data === 'true' - || body.seedSampleData === true - || body.seedSampleData === 'true'; - const callerUserId = (await resolveActorId(req)) ?? null; - const callerActiveOrgId = (await resolveActiveOrgId(req)) ?? null; - const result = await installPackageIntoEnvironment({ - deps, - packageId, - environmentId, - seedSampleData, - callerUserId, - callerActiveOrgId, - }); - return res.status(result.status).json(result.body); - }; - - // Both new and legacy action paths are accepted so the Console renderer - // (which still computes the URL from objectName=sys_environment) routes - // here regardless of which path the action descriptor advertised. - const actionHandler = async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const actionName = String(req.params?.actionName ?? '').trim(); - const impl = actionDispatch[actionName]; - if (!impl) return res.status(404).json(fail(`Unknown sys_environment action '${actionName}'`, 404)); - - // app-shell sends `{ recordId, params }`. Rewrite to the shape the - // existing transition handlers expect (req.params.id + req.body - // carrying the params). - // app-shell's list_item dispatcher attaches the clicked row as - // `params._rowRecord` but does NOT forward its id as the top-level - // `recordId`. Fall back to that row context so list_item invocations - // work without each action having to set `recordIdParam` manually. - const body = (req.body ?? {}) as AnyRow; - const params = (body.params && typeof body.params === 'object') ? (body.params as AnyRow) : {}; - const rowRecord = (params._rowRecord && typeof params._rowRecord === 'object') ? (params._rowRecord as AnyRow) : null; - const recordId = String( - body.recordId - ?? body.record_id - ?? params.recordId - ?? params.record_id - ?? rowRecord?.id - ?? '' - ).trim(); - if (!recordId) return res.status(400).json(fail('recordId is required', 400)); - // Strip the internal row-context marker before forwarding so dispatch - // handlers see a clean payload. - if ('_rowRecord' in params) delete (params as Record)._rowRecord; - - // Mutate req in place (handlers read req.params.id / req.body.*). - req.params = { ...(req.params ?? {}), id: recordId }; - req.body = { ...params }; - - return impl(req, res); - }; - server.post(`${prefix}/actions/sys_environment/:actionName`, actionHandler); - server.post(`${prefix}/actions/sys_project/:actionName`, actionHandler); // legacy alias - - // ── sys_package action dispatcher ────────────────────────────────────── - // The Marketplace "Install into Environment" action on sys_package is - // declared as `type: 'script'` (not 'api') because @object-ui's - // RecordDetailView.apiHandler ignores action.target for unknown action - // names and falls back to `dataSource.update(object, id, params)`, which - // tries to PATCH the sys_package row with non-existent fields - // (environment_id, seed_sample_data) and surfaces a misleading - // `Object 'sys_package' is not registered` error. Routing through - // `serverActionHandler` instead gives us this dedicated endpoint. - // - // Body shape from app-shell: `{ recordId, params: { environment_id, seed_sample_data } }` - server.post(`${prefix}/actions/sys_package/:actionName`, async (req: any, res: any) => { - const auth = await checkAuth(req); if (!auth.ok) return res.status(auth.status).json(auth.body); - const actionName = String(req.params?.actionName ?? '').trim(); - if (actionName !== 'install_package') { - return res.status(404).json(fail(`Unknown sys_package action '${actionName}'`, 404)); - } - const body = (req.body ?? {}) as AnyRow; - const params = (body.params && typeof body.params === 'object') ? (body.params as AnyRow) : {}; - const rowRecord = (params._rowRecord && typeof params._rowRecord === 'object') - ? (params._rowRecord as AnyRow) : null; - const packageId = String( - body.recordId - ?? body.record_id - ?? params.recordId - ?? params.record_id - ?? rowRecord?.id - ?? '' - ).trim(); - if (!packageId) return res.status(400).json(fail('recordId (package_id) is required', 400)); - const environmentId = String(params.environment_id ?? params.environmentId ?? '').trim(); - if (!environmentId) return res.status(400).json(fail('environment_id is required', 400)); - const seedSampleData = params.seed_sample_data === true - || params.seed_sample_data === 'true' - || params.seedSampleData === true - || params.seedSampleData === 'true'; - const callerUserId = (await resolveActorId(req)) ?? null; - const callerActiveOrgId = (await resolveActiveOrgId(req)) ?? null; - const result = await installPackageIntoEnvironment({ - deps, - packageId, - environmentId, - seedSampleData, - callerUserId, - callerActiveOrgId, - }); - return res.status(result.status).json(result.body); - }); - - // Reference the randomUUID import (silences unused warnings) — used by - // adapter fallbacks elsewhere in the file in the future. - void randomUUID; -} diff --git a/packages/services/service-cloud/src/routes/public.ts b/packages/services/service-cloud/src/routes/public.ts deleted file mode 100644 index c58a1ed6e..000000000 --- a/packages/services/service-cloud/src/routes/public.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Public, unauthenticated `/pub/v1/projects/:id/*` routes. - * - * GET /pub/v1/projects/:id/manifest.json - * GET /pub/v1/projects/:id/artifact[?commit=...][&redirect=1] - * GET /pub/v1/projects/:id/revisions - * - * Visibility model (post-`unlisted`-merge): - * `public` → listed; anonymous download of any/current revision; enumerable. - * `private` → hidden from enumeration; anonymous download ONLY with an - * exact `?commit=` (share-by-link). Members still get - * full authenticated access via `/cloud/projects/:id/*`. - * (Legacy `unlisted` rows are coerced to `private`.) - * - * Responses are content-addressable and immutable per commitId, so we set - * strong caching headers — a CDN in front of this server (Cloudflare, - * CloudFront, …) can serve everything from the edge. - */ - -import type { IHttpServer } from '@objectstack/spec/contracts'; -import { ok, fail } from '../cloud-artifact-helpers.js'; -import type { SysProjectRow } from '../cloud-artifact-helpers.js'; -import type { RouteDeps } from './types.js'; -import { makeGetDriver, controlPlaneUnavailable } from './types.js'; - -type VisibilityCheck = { ok: true } | { ok: false; status: number; body: any }; - -function checkVisibility(project: SysProjectRow, requestedCommit: string): VisibilityCheck { - const raw = project.visibility ?? 'private'; - const visibility = raw === 'unlisted' ? 'private' : raw; - if (visibility === 'private' && !requestedCommit) { - return { ok: false, status: 404, body: fail('not found', 404) }; - } - return { ok: true }; -} - -export function registerPublicRoutes(server: IHttpServer, deps: RouteDeps): void { - const { prefix, storage, controlDriverPromise } = deps; - const getDriver = makeGetDriver(controlDriverPromise); - const publicPrefix = `${prefix}/pub/v1/projects/:id`; - - // GET /pub/v1/projects/:id/manifest.json — lightweight project info - server.get(`${publicPrefix}/manifest.json`, async (req: any, res: any) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(404).json(fail('not found', 404)); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = (await (driver.findOne as any)('sys_environment', { where: { id: projectId } })) as SysProjectRow | null; - if (!project) return res.status(404).json(fail('not found', 404)); - - // For manifest we only expose `public` (no enumeration of `private`). - if ((project.visibility ?? 'private') !== 'public') { - return res.status(404).json(fail('not found', 404)); - } - - const current = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - - if (typeof res.set === 'function') { - res.set('Cache-Control', 'public, max-age=60'); - } - return res.json(ok({ - projectId: project.id, - organizationId: project.organization_id, - displayName: (project as any).display_name ?? null, - visibility: project.visibility, - currentCommitId: current?.commit_id ?? null, - currentChecksum: current?.checksum ?? null, - builtAt: current?.built_at ?? null, - })); - }); - - // GET /pub/v1/projects/:id/artifact[?commit=...] - server.get(`${publicPrefix}/artifact`, async (req: any, res: any) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(404).json(fail('not found', 404)); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = (await (driver.findOne as any)('sys_environment', { where: { id: projectId } })) as SysProjectRow | null; - if (!project) return res.status(404).json(fail('not found', 404)); - - const requestedCommit = String(req.query?.commit ?? '').trim(); - const vis = checkVisibility(project, requestedCommit); - if (!vis.ok) return res.status(vis.status).json(vis.body); - - let rev: any = null; - try { - if (requestedCommit) { - rev = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, commit_id: requestedCommit }, - }); - } else { - rev = await (driver.findOne as any)('sys_environment_revision', { - where: { environment_id: projectId, is_current: true }, - }); - } - } catch { /* no revision table yet */ } - - if (!rev?.storage_key) return res.status(404).json(fail('not found', 404)); - - const exists = await storage.exists(rev.storage_key); - if (!exists) return res.status(404).json(fail('not found', 404)); - - // Optional: skip the proxy and redirect the caller to a short-lived - // signed URL (S3 / R2). This offloads bandwidth from the control - // plane. Triggered by `?redirect=1` and only when the configured - // storage adapter supports `getSignedUrl`. - const wantRedirect = req.query?.redirect === '1' || req.query?.redirect === 'true'; - if (wantRedirect && typeof storage.getSignedUrl === 'function') { - try { - const signed = await storage.getSignedUrl(rev.storage_key, 300); - if (signed) { - if (typeof res.set === 'function') { - res.set('Cache-Control', 'private, max-age=60'); - res.set('X-Commit-Id', rev.commit_id); - } - return res.redirect(302, signed); - } - } catch (signErr: any) { - console.warn('[CloudArtifactAPI] getSignedUrl failed, falling back to inline:', signErr?.message); - } - } - - const buf = await storage.download(rev.storage_key); - const body = JSON.parse(buf.toString('utf-8')); - - // Always emit a consistent envelope, even if the stored bundle - // is "bare" (no top-level commitId/checksum). The revision row - // is authoritative for identity. - const envelope = { - schemaVersion: body.schemaVersion ?? '0.1', - projectId: project.id, - commitId: rev.commit_id, - checksum: rev.checksum, - metadata: body.metadata ?? body, - functions: Array.isArray(body.functions) ? body.functions : [], - manifest: body.manifest ?? { plugins: [], drivers: [], engines: {} }, - builtAt: rev.built_at ?? body.builtAt ?? null, - }; - - if (typeof res.set === 'function') { - // commitId is a content-hash → safe to cache forever. - res.set('Cache-Control', 'public, max-age=31536000, immutable'); - res.set('ETag', `"${rev.commit_id}"`); - res.set('X-Commit-Id', rev.commit_id); - } - return res.json(ok(envelope)); - }); - - // GET /pub/v1/projects/:id/revisions — public history (only for `public`) - server.get(`${publicPrefix}/revisions`, async (req: any, res: any) => { - const projectId = String(req.params?.id ?? '').trim(); - if (!projectId) return res.status(404).json(fail('not found', 404)); - - const driver = await getDriver(); - if (!driver) return controlPlaneUnavailable(res); - - const project = (await (driver.findOne as any)('sys_environment', { where: { id: projectId } })) as SysProjectRow | null; - if (!project) return res.status(404).json(fail('not found', 404)); - - // Listing reveals history → only allow on `public`. - if ((project.visibility ?? 'private') !== 'public') { - return res.status(404).json(fail('not found', 404)); - } - - const limit = Math.min(Math.max(Number(req.query?.limit ?? 20), 1), 100); - let rows: any[] = []; - try { - rows = (await (driver.find as any)('sys_environment_revision', { - where: { environment_id: projectId }, - orderBy: [{ field: 'published_at', direction: 'desc' }], - limit, - })) ?? []; - } catch { /* no revision table */ } - - if (typeof res.set === 'function') { - res.set('Cache-Control', 'public, max-age=30'); - } - return res.json(ok({ - items: rows.map((r) => ({ - commitId: r.commit_id, - checksum: r.checksum, - sizeBytes: r.size_bytes, - builtAt: r.built_at, - publishedAt: r.published_at, - note: r.note, - isCurrent: !!r.is_current, - })), - })); - }); -} diff --git a/packages/services/service-cloud/src/routes/storage.ts b/packages/services/service-cloud/src/routes/storage.ts deleted file mode 100644 index 9e413dff0..000000000 --- a/packages/services/service-cloud/src/routes/storage.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Storage backend resolution + key layout + legacy-FS fallback reader. - * - * Extracted from `cloud-artifact-api-plugin.ts` so the plugin file can stay - * focused on route assembly. - */ - -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { resolve as resolvePath, dirname } from 'node:path'; -import type { IStorageService } from '@objectstack/spec/contracts'; - -/** - * Subset of {@link IStorageService} the artifact API actually uses. Keeping - * a narrow shape lets us plug in a local-FS fallback without depending on - * the full storage contract. - */ -export interface StorageLike { - upload(key: string, data: Buffer): Promise; - download(key: string): Promise; - exists(key: string): Promise; - delete?(key: string): Promise; - getSignedUrl?(key: string, ttlSeconds?: number): Promise; -} - -/** Backwards-compatible local-FS adapter — used when no storage service is registered. */ -export function createLocalFsStorage(root: string): StorageLike { - const abs = (key: string) => resolvePath(root, key); - return { - async upload(key, data) { - const p = abs(key); - await mkdir(dirname(p), { recursive: true }); - await writeFile(p, data); - }, - async download(key) { - return readFile(abs(key)); - }, - async exists(key) { - try { await readFile(abs(key)); return true; } catch { return false; } - }, - async delete(key) { - const { unlink } = await import('node:fs/promises'); - try { await unlink(abs(key)); } catch { /* ignore missing */ } - }, - }; -} - -/** - * Resolve which storage backend to use, in order of preference: - * 1. Explicit {@link IStorageService} instance passed in options. - * 2. Kernel-registered `file-storage` service. - * 3. Local filesystem under `artifactRoot` (last-resort fallback). - */ -export function resolveStorage( - ctx: any, - options: { storage?: { service?: 'file-storage' | IStorageService } }, - artifactRoot: string, -): { storage: StorageLike; adapterName: string } { - if (options.storage?.service && typeof options.storage.service !== 'string') { - return { storage: options.storage.service as unknown as StorageLike, adapterName: 'file-storage:custom' }; - } - try { - const svc = ctx.getService('file-storage') as IStorageService | undefined; - if (svc && typeof svc.upload === 'function') { - return { storage: svc as unknown as StorageLike, adapterName: 'file-storage' }; - } - } catch { /* not registered */ } - - console.warn( - '[CloudArtifactAPI] No IStorageService registered (file-storage). ' + - 'Falling back to local filesystem at ' + artifactRoot + '. ' + - 'Register StorageServicePlugin for S3/production deployments.', - ); - return { storage: createLocalFsStorage(artifactRoot), adapterName: 'local-fs' }; -} - -/** - * Object-store key shape. - * - * Org-first prefixing makes per-tenant cleanup, billing, IAM bucket - * policies (e.g. allow read-only on `orgs//*`), and data-export much - * easier in a multi-tenant cloud. - * - * Falls back to the legacy `${keyPrefix}/${projectId}/${commitId}.json` - * shape when the project has no organization_id (single-tenant installs / - * very old data). The GET path always reads the exact key from - * `sys_environment_revision.storage_key`, so historical rows keep working - * regardless of which layout was active when they were written. - */ -export function buildStorageKey( - keyPrefix: string, - orgId: string | null | undefined, - projectId: string, - commitId: string, -): string { - return orgId - ? `${keyPrefix}/orgs/${orgId}/projects/${projectId}/${commitId}.json` - : `${keyPrefix}/${projectId}/${commitId}.json`; -} - -/** Legacy reader for `artifact_path` rows that pre-date the revision table. */ -export async function readLegacyArtifactFile(absPath: string): Promise { - try { - const raw = await readFile(absPath, 'utf-8'); - return JSON.parse(raw); - } catch (err: any) { - console.warn(`[CloudArtifactAPI] Failed to read artifact '${absPath}': ${err?.message ?? err}`); - return null; - } -} diff --git a/packages/services/service-cloud/src/routes/types.ts b/packages/services/service-cloud/src/routes/types.ts deleted file mode 100644 index b8086514b..000000000 --- a/packages/services/service-cloud/src/routes/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Shared route-level types + tiny auth/driver helpers used by every - * route module. - */ - -import type { IDataDriver } from '@objectstack/spec/contracts'; -import { fail } from '../cloud-artifact-helpers.js'; -import type { StorageLike } from './storage.js'; - -/** - * Bag of dependencies threaded through every route handler. - * Centralising it here means each `register*Routes(...)` function takes - * one argument and the assembly site stays compact. - */ -export interface RouteDeps { - prefix: string; - artifactRoot: string; - keyPrefix: string; - storage: StorageLike; - storageAdapterName: string; - requiredKey: string | undefined; - controlDriverPromise: Promise<{ driver: IDataDriver; driverName: string; databaseUrl: string }>; - /** - * Resolve the caller's user id from the request headers using better-auth's - * `getSession`. When the auth service is unavailable this resolves to - * `undefined`. Optional so unit tests / legacy callers can omit it. - */ - getCallerUserId?: (req: any) => Promise; - /** Resolve the caller's active organization id via better-auth. */ - getCallerActiveOrgId?: (req: any) => Promise; -} - -export type AuthResult = { ok: true; mode: 'service' | 'user' | 'open'; userId?: string } | { ok: false; status: number; body: any }; - -/** - * Two-mode auth gate: - * 1. **service-to-service**: `Authorization: Bearer ` matches `requiredKey` - * 2. **user-session**: `getCallerUserId(req)` resolves to a valid better-auth user id - * - * When `requiredKey` is unset, all requests pass (legacy self-host/local-dev - * behavior) — RBAC is then the responsibility of individual route handlers - * (which can still call `getCallerUserId` directly). - * - * When `requiredKey` is set, a request passes if EITHER the bearer matches - * OR a valid better-auth session is attached. This unblocks the Studio / - * Console UI calling these endpoints with the user's session cookie instead - * of the shared service token. Per-route RBAC (org membership, project - * ownership) is still enforced by each handler — this gate only proves the - * caller is *somebody*. - */ -export function makeCheckAuth( - requiredKey: string | undefined, - getCallerUserId?: (req: any) => Promise, -) { - return async (req: any): Promise => { - if (!requiredKey) return { ok: true, mode: 'open' }; - // Path 1: shared bearer secret (CLI / service-to-service) - const header = (req.headers?.authorization ?? req.headers?.Authorization ?? '') as string; - const token = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; - if (token && token === requiredKey) return { ok: true, mode: 'service' }; - // Path 2: better-auth user session (Studio / Console UI) - if (getCallerUserId) { - try { - const userId = await getCallerUserId(req); - if (userId) return { ok: true, mode: 'user', userId }; - } catch { /* fall through to 401 */ } - } - return { ok: false, status: 401, body: { success: false, error: 'Unauthorized' } }; - }; -} - -/** Lazy driver accessor — returns null and logs when control plane is unavailable. */ -export function makeGetDriver( - controlDriverPromise: Promise<{ driver: IDataDriver; driverName: string; databaseUrl: string }>, -) { - return async (): Promise => { - try { - const { driver } = await controlDriverPromise; - return driver ?? null; - } catch (err: any) { - console.error('[CloudArtifactAPI] control driver unavailable:', err?.message ?? err); - return null; - } - }; -} - -/** Helper to ship a "control plane unavailable" 503 envelope. */ -export function controlPlaneUnavailable(res: any) { - return res.status(503).json(fail('control plane unavailable', 503)); -} diff --git a/packages/services/service-cloud/src/runtime-stack.ts b/packages/services/service-cloud/src/runtime-stack.ts deleted file mode 100644 index 1e94de6f6..000000000 --- a/packages/services/service-cloud/src/runtime-stack.ts +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Runtime-mode stack factory. - * - * Default behavior — boots a "runtime node" connected to ObjectStack - * Cloud at `http://localhost:4000` (the local `apps/cloud` instance — - * start it before the runtime). For the hosted control plane, set - * `OS_CLOUD_URL=https://cloud.objectstack.ai`. The node - * fetches per-project artifacts over HTTP and routes incoming requests - * to the matching project kernel; no local control-plane database is - * provisioned. - * - * Local opt-out — set `OS_CLOUD_URL=local` (or - * `cloudUrl: 'local'`) to fall back to the legacy single-control-plane - * shape, which mirrors `createCloudStack()` with two SQLite files - * (`control.db` for the control plane and `.db` for the - * single project's business data). Used by self-hosted single-machine - * dev workflows that don't want to depend on a remote control plane. - * - * The legacy local dataset: - * - * / - * ├── control.db — control plane (sys_organization, sys_project, …) - * └── proj_local.db — single-project business data - */ - -import { resolve as resolvePath } from 'node:path'; -import { mkdirSync } from 'node:fs'; -import { z } from 'zod'; -import { createCloudStack } from './cloud-stack.js'; -import { createSingleProjectPlugin } from './single-project-plugin.js'; -import { resolveAuthSecret, resolveBaseUrl } from './boot-env.js'; -import type { AppBundleResolver } from './project-kernel-factory.js'; -import { createObjectOSStack } from './objectos-stack.js'; -import { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js'; - -/** - * Infer the storage driver type from a database connection-URL scheme. - * Returns `''` if the URL is empty or the scheme is unrecognised. - * - * mongodb://, mongodb+srv:// → 'mongodb' - * postgres://, postgresql:// → 'postgres' - * mysql://, mysql2:// → 'mysql' - * libsql://, https://*.turso.* → 'turso' - * file:, sqlite:, :memory:, *.db, - * *.sqlite, *.sqlite3 → 'sqlite' - */ -function inferDriverFromUrl(url: string | undefined): string { - if (!url) return ''; - const u = url.trim(); - if (/^mongodb(\+srv)?:\/\//i.test(u)) return 'mongodb'; - if (/^postgres(ql)?:\/\//i.test(u)) return 'postgres'; - if (/^mysql2?:\/\//i.test(u)) return 'mysql'; - if (/^libsql:\/\//i.test(u)) return 'turso'; - if (/^https?:\/\//i.test(u) && /\.turso\./i.test(u)) return 'turso'; - if (/^file:/i.test(u) || /^sqlite:/i.test(u) || u === ':memory:' || /\.(db|sqlite|sqlite3)$/i.test(u)) return 'sqlite'; - return ''; -} - -/** - * Default ObjectStack Cloud base URL — the local `apps/cloud` instance - * running on port 4000. Override via `OS_CLOUD_URL` (or - * `RuntimeStackConfig.cloudUrl`) to point at a remote control plane - * (e.g. `https://cloud.objectstack.ai`). Set to `local` to disable - * cloud routing entirely and boot from a local `control.db` instead. - * - * Why a local default? Runtime nodes are designed to be paired with a - * control plane. Defaulting to `localhost:4000` lets contributors run - * `apps/cloud` (the open-source control plane) and `apps/objectos` (the - * runtime) side-by-side with zero env config — the natural dev loop. - */ -export const DEFAULT_CLOUD_URL = 'http://localhost:4000'; - -export const RuntimeStackConfigSchema = z.object({ - /** Auth secret (defaults to env / dev fallback). Local-mode only. */ - authSecret: z.string().optional(), - /** Public origin used by better-auth (defaults to env). Local-mode only. */ - baseUrl: z.string().optional(), - /** Project id used as the seeded `sys_project.id`. Default: `proj_local`. Local-mode only. */ - projectId: z.string().optional(), - /** Compiled artifact path. Default: `/dist/objectstack.json`. Local-mode only. */ - artifactPath: z.string().optional(), - /** Data directory holding `control.db` + `.db`. Default: `/.objectstack/data`. Local-mode only. */ - dataDir: z.string().optional(), - /** Per-project AppBundleResolver. Local-mode only. */ - appBundles: z.custom().optional(), - /** API prefix (passed through to the cloud preset). */ - apiPrefix: z.string().optional(), - /** - * ObjectStack Cloud base URL. Defaults to `http://localhost:4000` - * (the local `apps/cloud` dev instance). For the hosted control - * plane, set `OS_CLOUD_URL=https://cloud.objectstack.ai`. - * - * When non-empty (the default), the runtime stack runs as a - * **cloud-connected runtime node**: no local control-plane database, - * projects are resolved by hostname against ObjectStack Cloud and - * per-project kernels are booted from artifacts pulled over HTTP. - * - * To run the legacy local-control-plane mode (single SQLite - * `control.db` shared with one `proj_local.db`) instead, set the env - * var to the sentinel value `local` (`OS_CLOUD_URL=local`) - * or pass `cloudUrl: 'local'`. - */ - cloudUrl: z.string().optional(), - /** Bearer token for the ObjectStack Cloud API (defaults to `OS_CLOUD_API_KEY`). */ - cloudApiKey: z.string().optional(), -}); - -export type RuntimeStackConfig = z.input; - -export interface RuntimeStackResult { - plugins: any[]; - api: { enableProjectScoping: true; projectResolution: 'auto' }; -} - -/** - * Build the plugin list for `runtime` mode. Returns the same shape as - * `createCloudStack()` so callers can return the result directly from a - * host config's `default export`. - */ -export async function createRuntimeStack(config?: RuntimeStackConfig): Promise { - const cfg = RuntimeStackConfigSchema.parse(config ?? {}); - - // ── Preview-mode short-circuit ──────────────────────────────────────── - // When OS_PREVIEW_MODE=1, this process becomes a sandbox preview node: - // hostnames `--.` resolve to (project, commit) - // pairs and each pair gets a fresh in-memory kernel. Talks to the same - // control-plane URL as the normal cloud-connected branch. - const previewMode = (process.env.OS_PREVIEW_MODE ?? '').trim().toLowerCase(); - const isPreview = previewMode === '1' || previewMode === 'true' || previewMode === 'yes'; - - // ── ObjectStack Cloud-connected branch ──────────────────────────────── - // Default: route every per-project boot through ObjectStack Cloud - // (https://cloud.objectstack.ai) — no local control-plane DB, projects - // are resolved by hostname against the cloud API and kernels are - // booted from remote-fetched artifacts. To opt out and use the legacy - // single-control-DB local mode, set OS_CLOUD_URL=local - // (or `cloudUrl: 'local'`). See objectos-stack.ts. - const rawCloudUrl = cfg.cloudUrl ?? process.env.OS_CLOUD_URL ?? DEFAULT_CLOUD_URL; - const cloudUrl = rawCloudUrl.trim(); - const localOptOut = cloudUrl === '' || cloudUrl.toLowerCase() === 'local' || cloudUrl.toLowerCase() === 'off'; - if (isPreview) { - if (localOptOut) { - throw new Error( - '[runtime-stack] OS_PREVIEW_MODE requires OS_CLOUD_URL to point at a control plane ' + - '(got "local"/"off"). Preview nodes always pull artifacts from a remote cloud.', - ); - } - const { createPreviewStack } = await import('./preview/preview-stack.js'); - return createPreviewStack({ - controlPlaneUrl: cloudUrl, - controlPlaneApiKey: cfg.cloudApiKey ?? process.env.OS_CLOUD_API_KEY, - apiPrefix: cfg.apiPrefix, - }) as Promise; - } - if (!localOptOut) { - return createObjectOSStack({ - controlPlaneUrl: cloudUrl, - controlPlaneApiKey: cfg.cloudApiKey ?? process.env.OS_CLOUD_API_KEY, - apiPrefix: cfg.apiPrefix, - }) as Promise; - } - - const cwd = process.cwd(); - const projectId = cfg.projectId ?? process.env.OS_PROJECT_ID ?? 'proj_local'; - const artifactPath = cfg.artifactPath - ?? process.env.OS_ARTIFACT_PATH - ?? resolvePath(cwd, 'dist/objectstack.json'); - - // Resolve DB URLs *before* deciding on a local data dir. When both the - // control plane and the project DB are remote (libsql/postgres/…) we - // never touch the filesystem — important on Vercel/Lambda where the - // bundle is read-only and `mkdirSync('.objectstack/data')` would crash - // at boot. The data dir (and its mkdir) is only needed for the - // file-backed SQLite fallback paths. - const envControlUrlExplicit = process.env.OS_CONTROL_DATABASE_URL?.trim(); - // Project DB. This is the user's business-data DB. When `OS_DATABASE_URL` - // is set, honour it (and infer the driver from its scheme unless - // `OS_DATABASE_DRIVER` overrides). On Vercel/Turso deployments the - // official Turso integration sets `TURSO_DATABASE_URL` + - // `TURSO_AUTH_TOKEN` — accept those as fallbacks so users don't have - // to duplicate the secret. Otherwise fall back to a local SQLite - // file beside `control.db`. - const envProjectDbUrl = process.env.OS_DATABASE_URL?.trim() - || process.env.TURSO_DATABASE_URL?.trim(); - // DX: when only the project DB URL is provided, transparently reuse - // it for the control plane too. This gives "set ONE Turso URL and - // login works" — the framework's `sys_*` tables (≈ 8 lookup tables - // for users / sessions / projects) just live alongside the business - // tables in the same DB. Operators who want a dedicated control - // plane can still set `OS_CONTROL_DATABASE_URL` explicitly. - const envControlUrl = envControlUrlExplicit || envProjectDbUrl; - - const needsLocalDataDir = !envControlUrl || !envProjectDbUrl; - let dataDir = cfg.dataDir ?? ''; - if (needsLocalDataDir && !dataDir) { - // On serverless read-only filesystems (Vercel/Lambda/Netlify) the - // bundle root is not writable. If the operator has set at least - // *one* remote DB URL we can keep booting by parking any leftover - // SQLite fallback files in `/tmp` (per-instance, ephemeral but - // writable). The remote DB carries all real state — the local - // file is only a placeholder we never actually read in this case. - if (isServerlessReadOnlyFs()) { - dataDir = resolvePath('/tmp', 'objectstack-data'); - // eslint-disable-next-line no-console - console.warn( - `[runtime-stack] serverless filesystem detected; using ephemeral ${dataDir} for SQLite fallback. ` + - `Set OS_CONTROL_DATABASE_URL and OS_DATABASE_URL (or TURSO_DATABASE_URL) to remote URLs to avoid touching disk entirely.`, - ); - } else { - dataDir = resolveDefaultDataDir(); - } - } - if (needsLocalDataDir && dataDir) { - try { - mkdirSync(dataDir, { recursive: true }); - } catch (err: any) { - // Non-fatal on serverless: if we couldn't create the dir but - // both DBs end up remote anyway, the file paths below are - // never opened. Surface a warning instead of crashing boot. - // eslint-disable-next-line no-console - console.warn(`[runtime-stack] mkdir ${dataDir} failed (${err?.code ?? err?.message}); continuing — ensure both control and project DB URLs are remote.`); - } - } - - // Control-plane DB. In single-project local mode this is the framework's - // bookkeeping DB (sys_organization / sys_project / …). It defaults to a - // local SQLite file. Users can override with `OS_CONTROL_DATABASE_URL` - // (preferred); `OS_DATABASE_URL` is reserved for the *project's* data. - const controlDbUrl = envControlUrl - || `file:${resolvePath(dataDir || cwd, 'control.db')}`; - - const projectDbUrl = envProjectDbUrl - || `file:${resolvePath(dataDir || cwd, `${projectId}.db`)}`; - const projectDbDriver = (process.env.OS_DATABASE_DRIVER?.trim().toLowerCase()) - || inferDriverFromUrl(projectDbUrl) - || 'sqlite'; - - const authSecret = cfg.authSecret ?? resolveAuthSecret(); - const baseUrl = cfg.baseUrl ?? resolveBaseUrl(); - - const stack = await createCloudStack({ - authSecret, - baseUrl, - controlDriverUrl: controlDbUrl, - appBundles: cfg.appBundles, - apiPrefix: cfg.apiPrefix, - basePlugins: async ({ projectId: pid }: { projectId: string }) => { - const { ObjectQLPlugin } = await import('@objectstack/objectql'); - const { MetadataPlugin } = await import('@objectstack/metadata'); - const { AppPlugin, loadArtifactBundle } = await import('@objectstack/runtime'); - - const artifactBundle = await loadArtifactBundle(artifactPath, { - tag: '[runtime-stack:basePlugins]', - unwrapEnvelope: true, - }); - - const plugins: any[] = [ - new ObjectQLPlugin({ projectId: pid }), - ]; - // MetadataPlugin's local-file source would crash on start when - // OS_ARTIFACT_PATH is unset and the default file is absent - // (e.g. when bundles arrive via OS_PROJECT_ARTIFACTS or the - // sys_project.metadata DB row instead). Only wire it when the - // artifact actually loaded. - if (artifactBundle) { - plugins.push( - new MetadataPlugin({ - watch: false, - projectId: pid, - artifactSource: { mode: 'local-file', path: artifactPath }, - registerSystemObjects: false, - }), - new AppPlugin(artifactBundle), - ); - } - return plugins; - }, - }); - - const filtered = stack.plugins.filter( - (p: any) => p?.name !== 'com.objectstack.studio.runtime-config', - ); - filtered.push( - createSingleProjectPlugin({ - projectId, - projectDatabaseUrl: projectDbUrl, - projectDatabaseDriver: projectDbDriver, - apiPrefix: cfg.apiPrefix, - }), - ); - - return { - plugins: filtered, - api: stack.api, - }; -} - -// ── Deprecated aliases (renamed v4.x: `project` → `runtime`) ────────────────── -/** @deprecated Use {@link RuntimeStackConfigSchema}. */ -export const ProjectStackConfigSchema = RuntimeStackConfigSchema; -/** @deprecated Use {@link RuntimeStackConfig}. */ -export type ProjectStackConfig = RuntimeStackConfig; -/** @deprecated Use {@link RuntimeStackResult}. */ -export type ProjectStackResult = RuntimeStackResult; -/** @deprecated Use {@link createRuntimeStack}. */ -export const createProjectStack = createRuntimeStack; diff --git a/packages/services/service-cloud/src/shared-project-plugin.ts b/packages/services/service-cloud/src/shared-project-plugin.ts deleted file mode 100644 index 7a6c4168c..000000000 --- a/packages/services/service-cloud/src/shared-project-plugin.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * SharedProjectPlugin - * - * Registers `driver`, `metadata`, and `objectql` as SCOPED services on a - * shared kernel. Each unique `scopeId` (projectId) gets its own isolated - * instances of these three services, while all other plugin code (HTTP - * handlers, auth, realtime, etc.) is shared across all projects. - * - * Memory profile vs per-kernel mode: - * Before: 32 projects × ~30 MB = ~960 MB - * After: ~50 MB kernel + 32 × ~5 MB scoped context = ~210 MB - * - * Usage: - * const sharedKernel = new ObjectKernel(); - * sharedKernel.use(new SharedProjectPlugin({ envRegistry, basePlugins })); - * await sharedKernel.bootstrap(); - * // Per-request: const ql = await ctx.getServiceScoped('objectql', projectId); - */ - -import { Plugin, PluginContext } from '@objectstack/core'; -import { ServiceLifecycle } from '@objectstack/core'; -import type { EnvironmentDriverRegistry } from './environment-registry.js'; -import { ProjectScopeManager } from './project-scope-manager.js'; - -export interface SharedProjectPluginConfig { - /** Registry used to resolve per-project drivers by projectId. */ - envRegistry: EnvironmentDriverRegistry; - /** Optional TTL config for the scope manager. Defaults: ttlMs=15min, maxSize=200. */ - scopeTtlMs?: number; - scopeMaxSize?: number; -} - -export class SharedProjectPlugin implements Plugin { - readonly name = 'com.objectstack.runtime.shared-project'; - readonly version = '1.0.0'; - - private readonly config: SharedProjectPluginConfig; - - constructor(config: SharedProjectPluginConfig) { - this.config = config; - } - - init = async (ctx: PluginContext): Promise => { - const { envRegistry } = this.config; - - // Register the env-registry so other services can access it - ctx.registerService('env-registry', envRegistry); - - // SCOPED: per-project driver — resolved from EnvironmentDriverRegistry - ctx.registerServiceFactory( - 'driver', - async (_ctx, scopeId) => { - if (!scopeId) { - throw new Error('[SharedProjectPlugin] scopeId (projectId) required for scoped driver'); - } - const driver = await envRegistry.resolveById(scopeId); - if (!driver) { - throw new Error(`[SharedProjectPlugin] No driver found for project: ${scopeId}`); - } - return driver; - }, - ServiceLifecycle.SCOPED, - ); - - // SCOPED: per-project MetadataManager — each project gets a fresh instance - // backed by its own driver, loaded lazily on first metadata access. - ctx.registerServiceFactory( - 'metadata', - async (_ctx, scopeId) => { - if (!scopeId) { - throw new Error('[SharedProjectPlugin] scopeId (projectId) required for scoped metadata'); - } - // Dynamic import — @objectstack/metadata is a peer dep, not a hard dep of runtime. - // new Function prevents bundlers (Vite/Rolldown) from resolving this as a bare specifier. - const metadataMod = await new Function('m', 'return import(m)')('@objectstack/metadata'); - const MetadataManager = metadataMod.MetadataManager; - const driver = await _ctx.getServiceScoped('driver', scopeId); - const manager = new MetadataManager(); - (manager as any)._projectId = scopeId; - (manager as any)._driver = driver; - return manager; - }, - ServiceLifecycle.SCOPED, - ); - - // SCOPED: per-project ObjectQL engine - ctx.registerServiceFactory( - 'objectql', - async (_ctx, scopeId) => { - if (!scopeId) { - throw new Error('[SharedProjectPlugin] scopeId (projectId) required for scoped objectql'); - } - // Dynamic import — @objectstack/objectql is a peer dep, not a hard dep of runtime. - // new Function prevents bundlers (Vite/Rolldown) from resolving this as a bare specifier. - const objectqlMod = await new Function('m', 'return import(m)')('@objectstack/objectql'); - const ObjectQL = objectqlMod.ObjectQL; - const driver = await _ctx.getServiceScoped('driver', scopeId); - const ql = new ObjectQL({ logger: _ctx.logger }); - ql.registerDriver(driver); - return ql; - }, - ServiceLifecycle.SCOPED, - ); - - ctx.logger.info('SharedProjectPlugin: registered scoped driver + metadata + objectql factories'); - - // Register a ProjectScopeManager so HttpDispatcher can auto-wire TTL eviction - const kernel = ctx.getKernel() as any; - const scopeManager = new ProjectScopeManager({ - kernel, - ttlMs: this.config.scopeTtlMs, - maxSize: this.config.scopeMaxSize, - }); - ctx.registerService('scope-manager', scopeManager); - }; -} diff --git a/packages/services/service-cloud/src/single-project-plugin.ts b/packages/services/service-cloud/src/single-project-plugin.ts deleted file mode 100644 index 6c5ca4092..000000000 --- a/packages/services/service-cloud/src/single-project-plugin.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Project-mode bootstrap plugin. - * - * Companion to `createCloudStack()` for the single-project local - * deployment shape. It is registered last in the plugin chain by - * `createProjectStack()` and performs two jobs: - * - * 1. **Idempotent identity seed** — writes the local - * `sys_organization` and `sys_project` rows to the control-plane - * DB on every boot via `ensureLocalIdentity()`. - * - * 2. **Studio runtime-config signal** — exposes - * `GET /api/v1/studio/runtime-config` returning - * `{ singleProject: true, defaultOrgId, defaultProjectId }`. The - * route registered here overrides the cloud preset's - * `{ singleProject: false }` response (the cloud preset is filtered - * out by `createProjectStack`). - * - * It does NOT mock `/cloud/projects`, `/cloud/organizations`, or - * `/auth/*` — those routes are served by real plugins backed by the - * seeded control plane. - */ - -import type { IHttpServer } from '@objectstack/spec/contracts'; - -type AnyContext = any; - -export const DEFAULT_LOCAL_ORG_ID = 'org_local'; -export const DEFAULT_LOCAL_PROJECT_ID = 'proj_local'; - -export interface SingleProjectPluginOptions { - orgId?: string; - projectId?: string; - /** Display name written to the seeded `sys_organization`. */ - orgName?: string; - apiPrefix?: string; - /** Project DB URL stored in `sys_project.database_url`. */ - projectDatabaseUrl?: string; - /** Driver name for the project DB (e.g. `sqlite`, `turso`). */ - projectDatabaseDriver?: string; -} - -export function createSingleProjectPlugin(options: SingleProjectPluginOptions = {}): any { - const orgId = options.orgId ?? DEFAULT_LOCAL_ORG_ID; - const projectId = options.projectId ?? DEFAULT_LOCAL_PROJECT_ID; - const orgName = options.orgName ?? 'Local'; - const prefix = options.apiPrefix ?? '/api/v1'; - - return { - name: 'com.objectstack.studio.single-project', - version: '2.0.0', - - init: async (ctx: AnyContext) => { - // Publish the local org/project as the runtime "default" so - // RestServer / HttpDispatcher can route bare URLs (no - // `/projects/` prefix, no hostname mapping) into the lone - // project kernel instead of the control plane. - try { - ctx.registerService?.('default-project', { projectId, orgId }); - } catch { - // registerService unavailable on this kernel shape — the - // dispatcher and rest layer will simply skip the fallback. - } - }, - - start: async (ctx: AnyContext) => { - // Re-register in `start` for boot shapes where a service - // registry is rebuilt between init and start. - try { - ctx.registerService?.('default-project', { projectId, orgId }); - } catch { /* best-effort */ } - - // ── 1. Idempotent identity seed ────────────────────────────── - if (options.projectDatabaseUrl) { - let objectql: any; - try { - objectql = ctx.getService('objectql'); - } catch { - // ObjectQL not registered yet — control-plane preset must - // run first; if that's not the case we skip silently. - } - if (objectql) { - const { ensureLocalIdentity } = await import('./local-identity.js'); - await ensureLocalIdentity({ - objectql, - orgId, - projectId, - orgName, - projectDatabaseUrl: options.projectDatabaseUrl, - projectDatabaseDriver: options.projectDatabaseDriver ?? 'sqlite', - }); - } - } - - // ── 2. Studio runtime-config (single-project signal) ───────── - let server: IHttpServer | undefined; - try { - server = ctx.getService('http.server') as IHttpServer | undefined; - } catch { - return; - } - if (!server) return; - - server.get(`${prefix}/studio/runtime-config`, async (_req: any, res: any) => { - res.json({ - singleProject: true, - defaultOrgId: orgId, - defaultProjectId: projectId, - }); - }); - }, - - stop: async (_ctx: AnyContext) => { - // http.server routes are torn down by the server plugin. - }, - }; -} diff --git a/packages/services/service-cloud/src/starter-seeder-plugin.ts b/packages/services/service-cloud/src/starter-seeder-plugin.ts deleted file mode 100644 index 8e912e174..000000000 --- a/packages/services/service-cloud/src/starter-seeder-plugin.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * createStarterSeederPlugin — Marketplace seeding plugin. - * - * Ensures one `sys_package` row exists per starter template in the - * control-plane DB so the Console Marketplace view has something to - * show. Idempotent — upserts by `manifest_id` and only writes new rows. - * - * `sys_package_version` rows (with `manifest_json` snapshot) are NOT - * created at seed time — the template bundles (e.g. CRM, ~8k lines) - * are lazy-loaded only when the user clicks Install (see - * `package-install.ts`). This keeps control-plane cold-start fast. - * - * Seeded rows are: - * - `is_starter: true` — surfaces in the "Starter Template" filter - * - `publisher: 'objectstack'` — first-party - * - `visibility: 'marketplace'` — public, installable by any env - * - `owner_org_id: '__platform__'` — sentinel for platform-owned - * - * The plugin runs in the `start` phase, after the control-plane DB - * is fully provisioned by `ObjectQLPlugin`. - */ - -import type { IDataDriver } from '@objectstack/spec/contracts'; -import type { ProjectTemplate } from './multi-project-plugin.js'; - -type AnyContext = any; - -const PLATFORM_OWNER_ORG_ID = '__platform__'; -const STARTER_MANIFEST_PREFIX = 'app.objectstack.starter.'; - -interface SeederConfig { - templates: Record; - controlDriverPromise: Promise<{ driver: IDataDriver }>; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -/** - * Stable manifest_id for a starter template. Keeps the seeded - * sys_package row addressable across restarts. - */ -export function starterManifestId(templateId: string): string { - return `${STARTER_MANIFEST_PREFIX}${templateId}`; -} - -export function createStarterSeederPlugin(config: SeederConfig): any { - return { - name: 'com.objectstack.cloud.starter-seeder', - version: '1.0.0', - init: async (_ctx: AnyContext) => {}, - start: async (ctx: AnyContext) => { - const templateList = Object.values(config.templates); - if (templateList.length === 0) { - ctx.logger?.info?.('[StarterSeeder] No templates configured — skipping seed.'); - return; - } - - let driver: IDataDriver; - try { - ({ driver } = await config.controlDriverPromise); - } catch (err: any) { - console.warn('[StarterSeeder] Control driver unavailable — skipping seed:', err?.message ?? err); - return; - } - - for (const tpl of templateList) { - const manifestId = starterManifestId(tpl.id); - try { - // Idempotent upsert: skip if a row with this manifest_id - // already exists. We don't UPDATE existing rows because - // operators may have edited display_name / description. - const existing: any = await (driver as any).findOne?.('sys_package', { where: { manifest_id: manifestId } }); - if (existing && existing.id) { - continue; - } - const id = `pkg_starter_${tpl.id}`; - await (driver as any).create?.('sys_package', { - id, - created_at: nowIso(), - updated_at: nowIso(), - manifest_id: manifestId, - // owner_org_id intentionally null — platform-seeded. - display_name: tpl.label, - description: tpl.description, - visibility: 'marketplace', - category: tpl.category ?? 'starter', - is_starter: true, - publisher: 'objectstack', - }); - ctx.logger?.info?.(`[StarterSeeder] Seeded starter package: ${tpl.id} (${manifestId})`); - } catch (err: any) { - console.warn(`[StarterSeeder] Failed to seed ${tpl.id}:`, err?.message ?? err); - } - } - }, - stop: async (_ctx: AnyContext) => {}, - }; -} diff --git a/packages/services/service-cloud/src/storage-env.ts b/packages/services/service-cloud/src/storage-env.ts deleted file mode 100644 index e99555e77..000000000 --- a/packages/services/service-cloud/src/storage-env.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Env-driven storage backend selection for the cloud control plane. - * - * Wires `StorageServicePlugin` (from `@objectstack/service-storage`) using - * environment variables so deployments — especially serverless ones like - * Vercel — can switch between local-FS (development) and S3-compatible - * object storage (production) with zero code changes. - * - * Supported env vars: - * - OS_STORAGE_ADAPTER : 'local' | 's3' (default: 'local') - * - OS_STORAGE_LOCAL_DIR : root dir for local adapter (default: ./storage) - * - * When OS_STORAGE_ADAPTER=s3: - * - OS_S3_BUCKET : (required) bucket name - * - OS_S3_REGION : (required) AWS region (e.g. us-east-1) - * - OS_S3_ENDPOINT : custom endpoint for S3-compatible services - * (Cloudflare R2, MinIO, Backblaze B2, etc.) - * - OS_S3_ACCESS_KEY_ID : credentials (else AWS SDK chain is used) - * - OS_S3_SECRET_ACCESS_KEY : credentials (else AWS SDK chain is used) - * - OS_S3_FORCE_PATH_STYLE : '1' | 'true' to force path-style URLs - * - * Returns an empty list when explicitly disabled - * (OS_STORAGE_ADAPTER=none/disabled). The cloud-artifact plugin will then - * fall back to its local-FS path with a startup warning. - */ -export async function resolveStoragePluginFromEnv(): Promise { - const r = await resolveStorageFromEnv(); - return r.plugin ? [r.plugin] : []; -} - -/** - * Same as {@link resolveStoragePluginFromEnv} but also returns the underlying - * storage adapter instance so callers can pass it to other plugins (e.g. - * MultiProjectPlugin's getStorage hook) without going through kernel service - * lookup. This avoids ordering / proxy issues where the host kernel's service - * registry doesn't surface 'file-storage' to closures captured during init. - */ -export async function resolveStorageFromEnv(): Promise<{ plugin: any | null; storage: any | null; adapterName: string }> { - const adapter = (process.env.OS_STORAGE_ADAPTER ?? 'local').trim().toLowerCase(); - - if (adapter === 'none' || adapter === 'disabled' || adapter === 'off') { - return { plugin: null, storage: null, adapterName: 'none' }; - } - - if (adapter === 's3') { - const bucket = process.env.OS_S3_BUCKET?.trim(); - const region = process.env.OS_S3_REGION?.trim(); - if (!bucket || !region) { - throw new Error( - '[service-cloud] OS_STORAGE_ADAPTER=s3 requires OS_S3_BUCKET and OS_S3_REGION. ' + - 'Set them in your hosting provider (e.g. Vercel project settings) ' + - 'or set OS_STORAGE_ADAPTER=local for local development.', - ); - } - const { StorageServicePlugin, S3StorageAdapter } = await import('@objectstack/service-storage'); - const s3Opts = { - bucket, - region, - endpoint: process.env.OS_S3_ENDPOINT?.trim() || undefined, - accessKeyId: process.env.OS_S3_ACCESS_KEY_ID?.trim() || undefined, - secretAccessKey: process.env.OS_S3_SECRET_ACCESS_KEY?.trim() || undefined, - forcePathStyle: /^(1|true|yes)$/i.test(process.env.OS_S3_FORCE_PATH_STYLE ?? ''), - }; - const storage = new S3StorageAdapter(s3Opts); - const plugin = new StorageServicePlugin({ adapter: 's3', s3: s3Opts }); - return { plugin, storage, adapterName: 's3' }; - } - - // 'local' (default) - const { StorageServicePlugin, LocalStorageAdapter } = await import('@objectstack/service-storage'); - const rootDir = process.env.OS_STORAGE_LOCAL_DIR?.trim() || './storage'; - const storage = new LocalStorageAdapter({ rootDir, basePath: '/api/v1/storage' }); - const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir } }); - return { plugin, storage, adapterName: 'local' }; -} diff --git a/packages/services/service-cloud/test/artifact-api-client.test.ts b/packages/services/service-cloud/test/artifact-api-client.test.ts deleted file mode 100644 index 04f4b4db1..000000000 --- a/packages/services/service-cloud/test/artifact-api-client.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { ArtifactApiClient } from '../src/artifact-api-client.js'; - -function mockJson(body: any, init?: { status?: number }): Response { - return { - ok: (init?.status ?? 200) < 400, - status: init?.status ?? 200, - json: async () => body, - } as unknown as Response; -} - -describe('ArtifactApiClient', () => { - it('throws when controlPlaneUrl is missing', () => { - expect(() => new ArtifactApiClient({ controlPlaneUrl: '' as any, fetch: globalThis.fetch })) - .toThrow(/controlPlaneUrl/); - }); - - it('resolves a hostname and unwraps `{ success, data }`', async () => { - const fetch = vi.fn().mockResolvedValue(mockJson({ - success: true, - data: { projectId: 'proj_a', organizationId: 'org_x' }, - })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'https://cp.example.com/', - fetch: fetch as any, - }); - const out = await client.resolveHostname('acme.example.com'); - expect(out).toEqual({ projectId: 'proj_a', organizationId: 'org_x', runtime: undefined }); - const calledUrl = fetch.mock.calls[0][0]; - expect(calledUrl).toBe('https://cp.example.com/api/v1/cloud/resolve-hostname?host=acme.example.com'); - }); - - it('caches hostname resolutions until invalidated', async () => { - const fetch = vi.fn().mockResolvedValue(mockJson({ projectId: 'proj_a' })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fetch as any, - }); - await client.resolveHostname('a.example'); - await client.resolveHostname('a.example'); - expect(fetch).toHaveBeenCalledTimes(1); - client.invalidate('proj_a'); - await client.resolveHostname('a.example'); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it('returns null on 404 hostname', async () => { - const fetch = vi.fn().mockResolvedValue(mockJson(null, { status: 404 })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fetch as any, - }); - expect(await client.resolveHostname('missing')).toBeNull(); - }); - - it('fetches an artifact and validates `metadata`', async () => { - const artifact = { - schemaVersion: '0.1', - projectId: 'proj_a', - commitId: 'c1', - checksum: 'a'.repeat(64), - metadata: { manifest: { name: 'demo' } }, - runtime: { databaseDriver: 'memory', databaseUrl: 'memory://demo' }, - }; - const fetch = vi.fn().mockResolvedValue(mockJson({ success: true, data: artifact })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fetch as any, - }); - const out = await client.fetchArtifact('proj_a'); - expect(out).toMatchObject({ projectId: 'proj_a', commitId: 'c1' }); - expect(out?.runtime?.databaseDriver).toBe('memory'); - }); - - it('rejects an artifact response without metadata', async () => { - const fetch = vi.fn().mockResolvedValue(mockJson({ projectId: 'proj_a' })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fetch as any, - logger: { warn: () => undefined }, - }); - expect(await client.fetchArtifact('proj_a')).toBeNull(); - }); - - it('forwards bearer token when apiKey is set', async () => { - const fetch = vi.fn().mockResolvedValue(mockJson({ projectId: 'proj_a' })); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - apiKey: 'sek', - fetch: fetch as any, - }); - await client.resolveHostname('h'); - const headers = fetch.mock.calls[0][1].headers as Record; - expect(headers.authorization).toBe('Bearer sek'); - }); - - it('throws on non-404 HTTP errors', async () => { - const fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({}), - }); - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fetch as any, - }); - await expect(client.resolveHostname('h')).rejects.toThrow(/HTTP 500/); - }); -}); diff --git a/packages/services/service-cloud/test/artifact-environment-registry.test.ts b/packages/services/service-cloud/test/artifact-environment-registry.test.ts deleted file mode 100644 index 1f2c85edf..000000000 --- a/packages/services/service-cloud/test/artifact-environment-registry.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { ArtifactApiClient } from '../src/artifact-api-client.js'; -import { ArtifactEnvironmentRegistry } from '../src/artifact-environment-registry.js'; - -function mockJson(body: any): Response { - return { ok: true, status: 200, json: async () => body } as unknown as Response; -} - -describe('ArtifactEnvironmentRegistry', () => { - it('resolves a hostname end-to-end through the artifact API', async () => { - // Driver dynamic-import warmup can exceed 5s on cold CI runners. - let fetchCalls: string[] = []; - const fakeFetch = async (url: string) => { - fetchCalls.push(url); - if (url.includes('resolve-hostname')) { - return mockJson({ - success: true, - data: { - projectId: 'proj_demo', - organizationId: 'org_demo', - runtime: { - databaseDriver: 'memory', - databaseUrl: 'memory://demo-env', - }, - }, - }); - } - // artifact endpoint not actually needed when resolve-hostname returns runtime - return mockJson({ - success: true, - data: { - schemaVersion: '0.1', - projectId: 'proj_demo', - commitId: 'c1', - checksum: 'a'.repeat(64), - metadata: { manifest: { name: 'demo' } }, - }, - }); - }; - - const client = new ArtifactApiClient({ - controlPlaneUrl: 'http://cp', - fetch: fakeFetch as any, - }); - const registry = new ArtifactEnvironmentRegistry({ - client, - logger: { error: () => undefined, warn: () => undefined, info: () => undefined }, - }); - - const result = await registry.resolveByHostname('acme.dev'); - expect(result).not.toBeNull(); - expect(result?.projectId).toBe('proj_demo'); - expect(result?.driver).toBeDefined(); - - const peek = registry.peekById('proj_demo'); - expect(peek?.project.organization_id).toBe('org_demo'); - expect(peek?.project.database_driver).toBe('memory'); - }, 30_000); - - it('returns null when hostname cannot be resolved', async () => { - const fakeFetch = async () => ({ ok: false, status: 404, json: async () => null }) as unknown as Response; - const client = new ArtifactApiClient({ controlPlaneUrl: 'http://cp', fetch: fakeFetch as any }); - const registry = new ArtifactEnvironmentRegistry({ client }); - expect(await registry.resolveByHostname('nope')).toBeNull(); - }); - - it('falls back to artifact metadata datasources when runtime block is absent', async () => { - const fakeFetch = async (url: string) => { - if (url.includes('resolve-hostname')) { - return mockJson({ success: true, data: { projectId: 'proj_b' } }); - } - return mockJson({ - success: true, - data: { - schemaVersion: '0.1', - projectId: 'proj_b', - commitId: 'c2', - checksum: 'b'.repeat(64), - metadata: { - datasources: [{ - name: 'default', - driver: 'memory', - config: { url: 'memory://fallback' }, - }], - datasourceMapping: [{ default: true, datasource: 'default' }], - }, - }, - }); - }; - const client = new ArtifactApiClient({ controlPlaneUrl: 'http://cp', fetch: fakeFetch as any }); - const registry = new ArtifactEnvironmentRegistry({ - client, - logger: { warn: () => undefined, info: () => undefined, error: () => undefined }, - }); - const result = await registry.resolveByHostname('b.example'); - expect(result?.projectId).toBe('proj_b'); - const peek = registry.peekById('proj_b'); - expect(peek?.project.database_driver).toBe('memory'); - expect(peek?.project.database_url).toBe('memory://fallback'); - }, 30_000); -}); diff --git a/packages/services/service-cloud/test/branches.test.ts b/packages/services/service-cloud/test/branches.test.ts deleted file mode 100644 index 7df243be7..000000000 --- a/packages/services/service-cloud/test/branches.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tests for branch endpoints + helpers. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { - normalizeBranch, - setBranchHead, - groupByBranch, - DEFAULT_BRANCH, - BRANCH_SLUG_RE, - registerBranchRoutes, - type BranchHeadRow, -} from '../src/routes/branches.js'; - -describe('normalizeBranch', () => { - it('returns DEFAULT_BRANCH for null/undefined/empty', () => { - expect(normalizeBranch(undefined)).toBe('main'); - expect(normalizeBranch(null)).toBe('main'); - expect(normalizeBranch('')).toBe('main'); - expect(normalizeBranch(' ')).toBe('main'); - }); - - it('lowercases and trims', () => { - expect(normalizeBranch(' MAIN ')).toBe('main'); - expect(normalizeBranch('Feature-X')).toBe('feature-x'); - }); - - it('accepts dot, slash, dash, underscore', () => { - expect(normalizeBranch('release/1.2.3')).toBe('release/1.2.3'); - expect(normalizeBranch('feat_billing')).toBe('feat_billing'); - expect(normalizeBranch('hotfix-abc')).toBe('hotfix-abc'); - }); - - it('rejects names that do not start with [a-z0-9]', () => { - expect(() => normalizeBranch('-bad')).toThrow(/Invalid branch name/); - expect(() => normalizeBranch('.bad')).toThrow(/Invalid branch name/); - expect(() => normalizeBranch('/bad')).toThrow(/Invalid branch name/); - }); - - it('rejects names with disallowed chars', () => { - expect(() => normalizeBranch('feat space')).toThrow(/Invalid branch name/); - expect(() => normalizeBranch('feat@x')).toThrow(/Invalid branch name/); - expect(() => normalizeBranch('UPPER')).not.toThrow(); // gets lowercased first - }); - - it('rejects 12-hex strings (preview URL collision)', () => { - expect(() => normalizeBranch('abcdef123456')).toThrow(/12-hex string/); - expect(() => normalizeBranch('AABBCCDDEEFF')).toThrow(/12-hex string/); - // 11 or 13 chars are fine - expect(normalizeBranch('abcdef12345')).toBe('abcdef12345'); - expect(normalizeBranch('abcdef1234567')).toBe('abcdef1234567'); - }); - - it('rejects names too long', () => { - expect(() => normalizeBranch('a'.repeat(64))).toThrow(/Invalid branch name/); - expect(normalizeBranch('a'.repeat(63))).toBe('a'.repeat(63)); - }); - - it('BRANCH_SLUG_RE smoke', () => { - expect(BRANCH_SLUG_RE.test('main')).toBe(true); - expect(BRANCH_SLUG_RE.test('a')).toBe(true); - expect(BRANCH_SLUG_RE.test('A')).toBe(false); - }); -}); - -describe('groupByBranch', () => { - const mk = (over: Partial): BranchHeadRow => ({ - id: over.id ?? 'r' + Math.random(), - project_id: over.project_id ?? 'p1', - commit_id: over.commit_id ?? 'c' + Math.random(), - branch: over.branch, - is_branch_head: over.is_branch_head, - is_current: over.is_current, - published_at: over.published_at, - note: over.note, - }); - - it('treats null/empty branch as DEFAULT_BRANCH', () => { - const rows = [ - mk({ id: 'r1', commit_id: 'c1', branch: null, is_branch_head: true, published_at: '2024-01-01' }), - mk({ id: 'r2', commit_id: 'c2', branch: '', published_at: '2023-01-01' }), - ]; - const out = groupByBranch(rows); - expect(out).toHaveLength(1); - expect(out[0]?.branch).toBe('main'); - expect(out[0]?.headCommitId).toBe('c1'); - expect(out[0]?.revisionCount).toBe(2); - }); - - it('falls back to most recent when no is_branch_head set', () => { - const rows = [ - mk({ id: 'r1', commit_id: 'old', branch: 'main', published_at: '2023-01-01' }), - mk({ id: 'r2', commit_id: 'mid', branch: 'main', published_at: '2024-01-01' }), - mk({ id: 'r3', commit_id: 'new', branch: 'main', published_at: '2025-01-01' }), - ]; - const out = groupByBranch(rows); - expect(out[0]?.headCommitId).toBe('new'); - }); - - it('prefers is_branch_head over recency', () => { - const rows = [ - mk({ id: 'r1', commit_id: 'old', branch: 'main', is_branch_head: true, published_at: '2023-01-01' }), - mk({ id: 'r2', commit_id: 'new', branch: 'main', is_branch_head: false, published_at: '2025-01-01' }), - ]; - const out = groupByBranch(rows); - expect(out[0]?.headCommitId).toBe('old'); - }); - - it('main branch sorts first, then by published_at desc', () => { - const rows = [ - mk({ id: 'r1', commit_id: 'c1', branch: 'feature-a', is_branch_head: true, published_at: '2024-06-01' }), - mk({ id: 'r2', commit_id: 'c2', branch: 'main', is_branch_head: true, published_at: '2024-01-01' }), - mk({ id: 'r3', commit_id: 'c3', branch: 'feature-b', is_branch_head: true, published_at: '2024-12-01' }), - ]; - const out = groupByBranch(rows); - expect(out.map((b) => b.branch)).toEqual(['main', 'feature-b', 'feature-a']); - }); - - it('isCurrent reflects whether ANY row in the branch has is_current=true', () => { - const rows = [ - mk({ id: 'r1', commit_id: 'a', branch: 'main', is_branch_head: true, is_current: false }), - mk({ id: 'r2', commit_id: 'b', branch: 'staging', is_branch_head: true, is_current: true }), - ]; - const out = groupByBranch(rows); - const main = out.find((b) => b.branch === 'main')!; - const staging = out.find((b) => b.branch === 'staging')!; - expect(main.isCurrent).toBe(false); - expect(staging.isCurrent).toBe(true); - }); - - it('returns empty array for empty input', () => { - expect(groupByBranch([])).toEqual([]); - }); -}); - -describe('setBranchHead', () => { - class FakeDriver { - rows: Map = new Map(); - constructor(initial: any[] = []) { - for (const r of initial) this.rows.set(r.id, r); - } - async find(_table: string, q: any): Promise { - return [...this.rows.values()].filter((r) => - Object.entries(q.where).every(([k, v]) => r[k] === v), - ); - } - async update(_table: string, id: string, patch: any) { - const cur = this.rows.get(id); - if (cur) this.rows.set(id, { ...cur, ...patch }); - } - } - - it('promotes the new row and demotes prior heads on the same branch', async () => { - const driver = new FakeDriver([ - { id: 'r1', project_id: 'p1', branch: 'main', is_branch_head: true, commit_id: 'old' }, - { id: 'r2', project_id: 'p1', branch: 'main', is_branch_head: false, commit_id: 'mid' }, - { id: 'r3', project_id: 'p1', branch: 'main', is_branch_head: false, commit_id: 'new' }, - ]); - await setBranchHead(driver as any, 'p1', 'main', 'r3'); - expect(driver.rows.get('r1').is_branch_head).toBe(false); - expect(driver.rows.get('r2').is_branch_head).toBe(false); - expect(driver.rows.get('r3').is_branch_head).toBe(true); - expect(driver.rows.get('r3').branch).toBe('main'); - }); - - it('does not touch heads on other branches', async () => { - const driver = new FakeDriver([ - { id: 'r1', project_id: 'p1', branch: 'staging', is_branch_head: true, commit_id: 'sx' }, - { id: 'r2', project_id: 'p1', branch: 'main', is_branch_head: false, commit_id: 'mx' }, - ]); - await setBranchHead(driver as any, 'p1', 'main', 'r2'); - expect(driver.rows.get('r1').is_branch_head).toBe(true); // staging untouched - expect(driver.rows.get('r2').is_branch_head).toBe(true); - }); - - it('does not touch heads on other projects', async () => { - const driver = new FakeDriver([ - { id: 'r1', project_id: 'p1', branch: 'main', is_branch_head: true, commit_id: 'a' }, - { id: 'r2', project_id: 'p2', branch: 'main', is_branch_head: true, commit_id: 'b' }, - ]); - await setBranchHead(driver as any, 'p1', 'main', 'r1'); - expect(driver.rows.get('r2').is_branch_head).toBe(true); - }); - - it('idempotent: calling on an already-head row leaves it head', async () => { - const driver = new FakeDriver([ - { id: 'r1', project_id: 'p1', branch: 'main', is_branch_head: true, commit_id: 'c' }, - ]); - await setBranchHead(driver as any, 'p1', 'main', 'r1'); - await setBranchHead(driver as any, 'p1', 'main', 'r1'); - expect(driver.rows.get('r1').is_branch_head).toBe(true); - }); - - it('moves head atomically when re-publishing same commit on a different branch', async () => { - const driver = new FakeDriver([ - { id: 'r1', project_id: 'p1', branch: 'main', is_branch_head: true, commit_id: 'shared' }, - ]); - // Simulate publishing the same content under a new branch label - await setBranchHead(driver as any, 'p1', 'staging', 'r1'); - expect(driver.rows.get('r1').branch).toBe('staging'); - expect(driver.rows.get('r1').is_branch_head).toBe(true); - }); -}); - -describe('registerBranchRoutes', () => { - interface Route { method: string; path: string; handler: (req: any, res: any) => any } - class FakeServer { - routes: Route[] = []; - get(p: string, h: any) { this.routes.push({ method: 'GET', path: p, handler: h }); } - post(p: string, h: any) { this.routes.push({ method: 'POST', path: p, handler: h }); } - put(p: string, h: any) { this.routes.push({ method: 'PUT', path: p, handler: h }); } - patch(p: string, h: any) { this.routes.push({ method: 'PATCH', path: p, handler: h }); } - delete(p: string, h: any) { this.routes.push({ method: 'DELETE', path: p, handler: h }); } - async invoke(method: string, path: string, opts: { params?: Record; body?: any } = {}) { - const route = this.routes.find((r) => - r.method === method && - r.path.split('/').length === path.split('/').length && - r.path.split('/').every((seg, i) => seg.startsWith(':') || seg === path.split('/')[i]), - ); - if (!route) return { status: 404, body: { error: 'no route' } }; - const a = route.path.split('/'); - const b = path.split('/'); - const params: Record = {}; - a.forEach((seg, i) => { if (seg.startsWith(':')) params[seg.slice(1)] = b[i]!; }); - const req = { params: { ...params, ...opts.params }, query: {}, headers: {}, body: opts.body }; - let captured: any = { status: 200, body: undefined }; - const res: any = { - status(c: number) { captured.status = c; return res; }, - json(b: any) { captured.body = b; return res; }, - }; - await route.handler(req, res); - return captured; - } - } - - let driver: any; - let server: FakeServer; - - beforeEach(() => { - driver = { - store: [] as any[], - async find(_t: string, q: any) { - return this.store.filter((r: any) => - Object.entries(q.where).every(([k, v]) => r[k] === v), - ).slice(0, q.limit ?? this.store.length); - }, - async findOne(_t: string, q: any) { - return this.store.find((r: any) => - Object.entries(q.where).every(([k, v]) => r[k] === v), - ) ?? null; - }, - async update(_t: string, id: string, patch: any) { - const r = this.store.find((x: any) => x.id === id); - if (r) Object.assign(r, patch); - }, - }; - server = new FakeServer(); - registerBranchRoutes(server as any, { - prefix: '/api/v1', - artifactRoot: '/tmp', - keyPrefix: 'artifacts', - storage: {} as any, - storageAdapterName: 'test', - requiredKey: undefined, - controlDriverPromise: Promise.resolve({ driver, driverName: 'memory', databaseUrl: 'mem://' }), - }); - }); - - it('GET /branches returns grouped branches', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'main', is_branch_head: true, published_at: '2024-01-01', is_current: true }, - { id: 'r2', project_id: 'p1', commit_id: 'c2', branch: 'staging', is_branch_head: true, published_at: '2024-06-01' }, - { id: 'r3', project_id: 'p1', commit_id: 'c3', branch: 'staging', is_branch_head: false, published_at: '2024-05-01' }, - ]; - const res = await server.invoke('GET', '/api/v1/cloud/projects/p1/branches'); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.branches).toHaveLength(2); - expect(res.body.data.branches[0].branch).toBe('main'); - const staging = res.body.data.branches.find((b: any) => b.branch === 'staging'); - expect(staging.headCommitId).toBe('c2'); - expect(staging.revisionCount).toBe(2); - }); - - it('DELETE /branches/main is rejected', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'main', is_branch_head: true }, - ]; - const res = await server.invoke('DELETE', '/api/v1/cloud/projects/p1/branches/main'); - expect(res.status).toBe(400); - expect(res.body.success).toBe(false); - }); - - it('DELETE /branches/ demotes heads, leaves rows', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'staging', is_branch_head: true, is_current: false }, - { id: 'r2', project_id: 'p1', commit_id: 'c2', branch: 'staging', is_branch_head: false, is_current: false }, - ]; - const res = await server.invoke('DELETE', '/api/v1/cloud/projects/p1/branches/staging'); - expect(res.status).toBe(200); - expect(res.body.data.totalRevisions).toBe(2); - expect(driver.store[0].is_branch_head).toBe(false); - // Rows themselves still exist - expect(driver.store).toHaveLength(2); - }); - - it('DELETE /branches/ rejects if branch carries the active revision', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'staging', is_branch_head: true, is_current: true }, - ]; - const res = await server.invoke('DELETE', '/api/v1/cloud/projects/p1/branches/staging'); - expect(res.status).toBe(409); - }); - - it('POST /rename succeeds when target name is free', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'feat-a', is_branch_head: true }, - { id: 'r2', project_id: 'p1', commit_id: 'c2', branch: 'feat-a', is_branch_head: false }, - ]; - const res = await server.invoke( - 'POST', - '/api/v1/cloud/projects/p1/branches/feat-a/rename', - { body: { newName: 'feat-A-renamed' } }, - ); - expect(res.status).toBe(200); - expect(res.body.data.renamed).toBe(2); - expect(driver.store.every((r: any) => r.branch === 'feat-a-renamed')).toBe(true); - }); - - it('POST /rename rejects target collision with 409', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'feat-a', is_branch_head: true }, - { id: 'r2', project_id: 'p1', commit_id: 'c2', branch: 'feat-b', is_branch_head: true }, - ]; - const res = await server.invoke( - 'POST', - '/api/v1/cloud/projects/p1/branches/feat-a/rename', - { body: { newName: 'feat-b' } }, - ); - expect(res.status).toBe(409); - }); - - it('POST /rename rejects invalid branch name', async () => { - driver.store = [ - { id: 'r1', project_id: 'p1', commit_id: 'c1', branch: 'feat-a', is_branch_head: true }, - ]; - const res = await server.invoke( - 'POST', - '/api/v1/cloud/projects/p1/branches/feat-a/rename', - { body: { newName: '-bad' } }, - ); - expect(res.status).toBe(400); - }); -}); - -describe('DEFAULT_BRANCH', () => { - it('is "main"', () => { - expect(DEFAULT_BRANCH).toBe('main'); - }); -}); diff --git a/packages/services/service-cloud/test/cloud-artifact-api-plugin.test.ts b/packages/services/service-cloud/test/cloud-artifact-api-plugin.test.ts deleted file mode 100644 index 667b47a4e..000000000 --- a/packages/services/service-cloud/test/cloud-artifact-api-plugin.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tests for the cloud-side Artifact API plugin. - * - * Spins up the plugin against an in-memory `IHttpServer` mock and a - * fake control-plane driver to verify it correctly: - * - * - Resolves a hostname to a project and returns the runtime block. - * - Falls back to the wildcard (`*`) project when no exact host matches. - * - Returns 404 when no project is bound. - * - Loads the artifact bundle file referenced by `metadata.artifact_path` - * and returns a well-formed `ProjectArtifact` envelope. - * - Enforces bearer auth when `apiKey` is configured. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createCloudArtifactApiPlugin } from '../src/cloud-artifact-api-plugin.js'; - -interface Route { - method: 'GET' | 'POST'; - path: string; - handler: (req: any, res: any) => any; -} - -class FakeHttpServer { - routes: Route[] = []; - get(path: string, handler: (req: any, res: any) => any) { - this.routes.push({ method: 'GET', path, handler }); - } - post(path: string, handler: (req: any, res: any) => any) { - this.routes.push({ method: 'POST', path, handler }); - } - delete(path: string, handler: (req: any, res: any) => any) { - this.routes.push({ method: 'DELETE', path, handler }); - } - put() {} patch() {} - async invoke(method: 'GET' | 'POST' | 'DELETE', urlPath: string, opts: { params?: Record; query?: Record; headers?: Record; body?: any } = {}) { - const route = this.routes.find((r) => r.method === method && pathMatches(r.path, urlPath)); - if (!route) return { status: 404, body: { error: 'no route' } }; - const params = extractParams(route.path, urlPath); - const req = { params: { ...params, ...opts.params }, query: opts.query ?? {}, headers: opts.headers ?? {} }; - let captured: { status: number; body: any } = { status: 200, body: undefined }; - const res: any = { - status(code: number) { captured.status = code; return res; }, - json(body: any) { captured.body = body; return res; }, - }; - await route.handler(req, res); - return captured; - } -} - -function pathMatches(pattern: string, actual: string): boolean { - const a = pattern.split('/'); - const b = actual.split('/'); - if (a.length !== b.length) return false; - return a.every((seg, i) => seg.startsWith(':') || seg === b[i]); -} -function extractParams(pattern: string, actual: string): Record { - const out: Record = {}; - const a = pattern.split('/'); - const b = actual.split('/'); - a.forEach((seg, i) => { if (seg.startsWith(':')) out[seg.slice(1)] = b[i]; }); - return out; -} - -interface FakeProject { - id: string; organization_id?: string; hostname?: string; - database_driver?: string; database_url?: string; database_auth_token?: string; - metadata?: any; is_system?: boolean; -} - -class FakeDriver { - constructor(public projects: FakeProject[] = [], public credentials: Array<{ project_id: string; database_driver?: string; database_url?: string; database_auth_token?: string }> = []) {} - async findOne(table: string, query: any): Promise { - const where = query?.where ?? {}; - if (table === 'sys_project') { - return this.projects.find((p) => Object.entries(where).every(([k, v]) => (p as any)[k] === v)) ?? null; - } - if (table === 'sys_project_credential') { - return this.credentials.find((c) => Object.entries(where).every(([k, v]) => (c as any)[k] === v)) ?? null; - } - return null; - } - async find(table: string, query: any): Promise { - const where = query?.where ?? {}; - if (table === 'sys_project') { - return this.projects.filter((p) => Object.entries(where).every(([k, v]) => { - if (v && typeof v === 'object') { - if ('$like' in (v as any)) { - const pattern = String((v as any).$like).replace(/%/g, '.*'); - return new RegExp(`^${pattern}$`).test(String((p as any)[k] ?? '')); - } - if ('$contains' in (v as any)) { - return String((p as any)[k] ?? '').includes(String((v as any).$contains)); - } - } - return (p as any)[k] === v; - })).slice(0, query?.limit ?? 100); - } - return []; - } -} - -describe('createCloudArtifactApiPlugin', () => { - let server: FakeHttpServer; - let artifactRoot: string; - - beforeEach(() => { - server = new FakeHttpServer(); - artifactRoot = mkdtempSync(join(tmpdir(), 'cloud-artifact-api-')); - }); - - async function bootPlugin(driver: FakeDriver, opts: { apiKey?: string } = {}) { - const plugin = createCloudArtifactApiPlugin({ - controlDriverPromise: Promise.resolve({ driver: driver as any, driverName: 'memory', databaseUrl: 'memory://' }), - artifactRoot, - apiKey: opts.apiKey, - }); - const ctx: any = { getService: (n: string) => (n === 'http.server' ? server : undefined), logger: console }; - await plugin.init(ctx); - await plugin.start(ctx); - } - - it('resolves a hostname to its project + runtime block', async () => { - const driver = new FakeDriver( - [{ id: 'proj_a', organization_id: 'org_1', hostname: 'tenant.example.com', database_driver: 'sqlite', database_url: 'file:./a.db' }], - [], - ); - await bootPlugin(driver); - - const res = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { query: { host: 'tenant.example.com' } }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ - success: true, - data: { - projectId: 'proj_a', - organizationId: 'org_1', - runtime: { - organizationId: 'org_1', - hostname: 'tenant.example.com', - databaseDriver: 'sqlite', - databaseUrl: 'file:./a.db', - }, - }, - }); - }); - - it('falls back to the wildcard project when no exact host match', async () => { - const driver = new FakeDriver([ - { id: 'proj_default', hostname: '*', database_driver: 'sqlite', database_url: 'file:./d.db' }, - ]); - await bootPlugin(driver); - - const res = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { query: { host: 'unknown.example.com' } }); - expect(res.status).toBe(200); - expect(res.body.data.projectId).toBe('proj_default'); - }); - - it('returns 404 when no project is bound to the hostname', async () => { - await bootPlugin(new FakeDriver([])); - const res = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { query: { host: 'nope.example.com' } }); - expect(res.status).toBe(404); - expect(res.body.success).toBe(false); - }); - - it('returns 400 without host query parameter', async () => { - await bootPlugin(new FakeDriver([{ id: 'p', hostname: 'h' }])); - const res = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', {}); - expect(res.status).toBe(400); - }); - - describe('GET /cloud/projects-by-short-id/:short', () => { - it('resolves an 8-hex prefix to the full project id', async () => { - const driver = new FakeDriver([ - { id: '7f3e9a01-1234-5678-9abc-def012345678', organization_id: 'org_x', hostname: 'a.com' }, - { id: 'aaaaaaaa-1111-2222-3333-444444444444', hostname: 'b.com' }, - ]); - await bootPlugin(driver); - - const res = await server.invoke('GET', '/api/v1/cloud/projects-by-short-id/:short', { params: { short: '7f3e9a01' } }); - expect(res.status).toBe(200); - expect(res.body.data.projectId).toBe('7f3e9a01-1234-5678-9abc-def012345678'); - expect(res.body.data.organizationId).toBe('org_x'); - }); - - it('returns 404 when no project matches', async () => { - await bootPlugin(new FakeDriver([{ id: 'aaaaaaaa-1111-2222-3333-444444444444', hostname: 'a' }])); - const res = await server.invoke('GET', '/api/v1/cloud/projects-by-short-id/:short', { params: { short: 'deadbeef' } }); - expect(res.status).toBe(404); - }); - - it('returns 400 for malformed input', async () => { - await bootPlugin(new FakeDriver([{ id: 'p', hostname: 'h' }])); - const res = await server.invoke('GET', '/api/v1/cloud/projects-by-short-id/:short', { params: { short: 'zzz' } }); - expect(res.status).toBe(400); - }); - - it('returns 409 on ambiguous prefix', async () => { - const driver = new FakeDriver([ - { id: '7f3e9a01-1111-1111-1111-111111111111', hostname: 'a.com' }, - { id: '7f3e9a01-2222-2222-2222-222222222222', hostname: 'b.com' }, - ]); - await bootPlugin(driver); - const res = await server.invoke('GET', '/api/v1/cloud/projects-by-short-id/:short', { params: { short: '7f3e9a01' } }); - expect(res.status).toBe(409); - }); - }); - - it('serves an artifact assembled from metadata.artifact_path', async () => { - const artifactPath = join(artifactRoot, 'artifact.json'); - writeFileSync(artifactPath, JSON.stringify({ - schemaVersion: '0.1', - projectId: 'proj_a', - commitId: 'abc123', - checksum: { algorithm: 'sha256', value: 'aa' }, - metadata: { objects: [{ name: 'account', label: 'Account' }] }, - functions: [], - manifest: { plugins: ['hono'], drivers: ['sqlite'], engines: { node: '>=20' } }, - builtAt: '2026-01-01T00:00:00Z', - })); - - const driver = new FakeDriver( - [{ id: 'proj_a', organization_id: 'org_1', database_driver: 'sqlite', database_url: 'file:./a.db', metadata: { artifact_path: 'artifact.json' } }], - [{ project_id: 'proj_a', database_driver: 'sqlite', database_url: 'file:./a.db', database_auth_token: 'tk_123' }], - ); - await bootPlugin(driver); - - const res = await server.invoke('GET', '/api/v1/cloud/projects/:id/artifact', { params: { id: 'proj_a' } }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.data.projectId).toBe('proj_a'); - expect(res.body.data.commitId).toBe('abc123'); - expect(res.body.data.metadata.objects).toEqual([{ name: 'account', label: 'Account' }]); - expect(res.body.data.runtime).toEqual({ - organizationId: 'org_1', - hostname: undefined, - databaseDriver: 'sqlite', - databaseUrl: 'file:./a.db', - databaseAuthToken: 'tk_123', - metadata: { artifact_path: 'artifact.json' }, - }); - }); - - it('returns 404 for unknown project id', async () => { - await bootPlugin(new FakeDriver([])); - const res = await server.invoke('GET', '/api/v1/cloud/projects/:id/artifact', { params: { id: 'missing' } }); - expect(res.status).toBe(404); - }); - - it('handles a project with no artifact_path (returns empty metadata envelope)', async () => { - const driver = new FakeDriver([{ id: 'proj_empty', database_driver: 'sqlite', database_url: 'file:./e.db', metadata: {} }]); - await bootPlugin(driver); - const res = await server.invoke('GET', '/api/v1/cloud/projects/:id/artifact', { params: { id: 'proj_empty' } }); - expect(res.status).toBe(200); - expect(res.body.data.projectId).toBe('proj_empty'); - expect(res.body.data.metadata).toEqual({}); - expect(res.body.data.functions).toEqual([]); - }); - - it('merges multiple artifact bundles', async () => { - mkdirSync(join(artifactRoot, 'bundles'), { recursive: true }); - writeFileSync(join(artifactRoot, 'bundles/a.json'), JSON.stringify({ - metadata: { objects: [{ name: 'a' }] }, functions: [{ name: 'fn_a' }], - })); - writeFileSync(join(artifactRoot, 'bundles/b.json'), JSON.stringify({ - metadata: { objects: [{ name: 'b' }], fields: [{ name: 'f' }] }, functions: [{ name: 'fn_b' }], - })); - - const driver = new FakeDriver([{ - id: 'proj_multi', database_driver: 'sqlite', database_url: 'file:./m.db', - metadata: { artifact_paths: ['bundles/a.json', 'bundles/b.json'] }, - }]); - await bootPlugin(driver); - - const res = await server.invoke('GET', '/api/v1/cloud/projects/:id/artifact', { params: { id: 'proj_multi' } }); - expect(res.status).toBe(200); - expect(res.body.data.metadata.objects).toEqual([{ name: 'a' }, { name: 'b' }]); - expect(res.body.data.metadata.fields).toEqual([{ name: 'f' }]); - expect(res.body.data.functions.map((f: any) => f.name)).toEqual(['fn_a', 'fn_b']); - }); - - it('enforces bearer auth when apiKey is configured', async () => { - const driver = new FakeDriver([{ id: 'proj_a', hostname: 'h', database_driver: 'sqlite', database_url: 'file:./a.db' }]); - await bootPlugin(driver, { apiKey: 'secret_xyz' }); - - const unauth = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { query: { host: 'h' } }); - expect(unauth.status).toBe(401); - - const wrong = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { - query: { host: 'h' }, headers: { authorization: 'Bearer wrong' }, - }); - expect(wrong.status).toBe(401); - - const ok = await server.invoke('GET', '/api/v1/cloud/resolve-hostname', { - query: { host: 'h' }, headers: { authorization: 'Bearer secret_xyz' }, - }); - expect(ok.status).toBe(200); - }); -}); diff --git a/packages/services/service-cloud/test/data-dir.test.ts b/packages/services/service-cloud/test/data-dir.test.ts deleted file mode 100644 index cad4635a3..000000000 --- a/packages/services/service-cloud/test/data-dir.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { resolve as resolvePath } from 'node:path'; -import { - resolveDefaultDataDir, - isServerlessReadOnlyFs, - buildServerlessPersistenceError, -} from '../src/data-dir.js'; - -describe('resolveDefaultDataDir', () => { - it('honours OS_DATA_DIR when set', () => { - const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' }); - expect(dir).toBe(resolvePath('/custom/path')); - }); - - it('OS_DATA_DIR wins over serverless detection (escape hatch for EFS / mounted volumes)', () => { - const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/mnt/efs', VERCEL: '1' }); - expect(dir).toBe(resolvePath('/mnt/efs')); - }); - - it('defaults to /.objectstack/data on a writable filesystem', () => { - const dir = resolveDefaultDataDir({}); - expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data')); - }); - - it('throws on Vercel without OS_DATA_DIR — points at TURSO_DATABASE_URL', () => { - expect(() => resolveDefaultDataDir({ VERCEL: '1' })).toThrowError(/TURSO_DATABASE_URL/); - }); - - it('throws on AWS Lambda without OS_DATA_DIR', () => { - expect(() => resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toThrowError( - /serverless read-only filesystem/, - ); - }); - - it('throws on Netlify without OS_DATA_DIR', () => { - expect(() => resolveDefaultDataDir({ NETLIFY: 'true' })).toThrowError(/Netlify/); - }); - - it('throws when OS_READONLY_FS=1 escape hatch is set without OS_DATA_DIR', () => { - expect(() => resolveDefaultDataDir({ OS_READONLY_FS: '1' })).toThrowError( - /TURSO_DATABASE_URL/, - ); - }); - - it('error message mentions both URL and auth-token env vars and explains why /tmp is rejected', () => { - try { - resolveDefaultDataDir({ VERCEL: '1' }); - expect.fail('should have thrown'); - } catch (e: any) { - expect(e.message).toMatch(/TURSO_DATABASE_URL/); - expect(e.message).toMatch(/TURSO_AUTH_TOKEN/); - expect(e.message).toMatch(/OS_CONTROL_DATABASE_URL/); - expect(e.message).toMatch(/OS_DATA_DIR/); - expect(e.message).toMatch(/per-instance|ephemeral/); - } - }); -}); - -describe('isServerlessReadOnlyFs', () => { - it('detects Vercel via VERCEL=1', () => { - expect(isServerlessReadOnlyFs({ VERCEL: '1' })).toBe(true); - }); - it('detects AWS Lambda via AWS_LAMBDA_FUNCTION_NAME', () => { - expect(isServerlessReadOnlyFs({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toBe(true); - }); - it('detects Netlify via NETLIFY=true', () => { - expect(isServerlessReadOnlyFs({ NETLIFY: 'true' })).toBe(true); - }); - it('returns false for an empty environment', () => { - expect(isServerlessReadOnlyFs({})).toBe(false); - }); - it('respects the OS_READONLY_FS escape hatch', () => { - expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '1' })).toBe(true); - expect(isServerlessReadOnlyFs({ OS_READONLY_FS: 'true' })).toBe(true); - expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false); - }); -}); - -describe('buildServerlessPersistenceError', () => { - it('control-plane variant mentions TURSO_DATABASE_URL', () => { - expect(buildServerlessPersistenceError('control').message).toMatch(/TURSO_DATABASE_URL/); - }); - it('project variant mentions OS_DATABASE_URL', () => { - expect(buildServerlessPersistenceError('project').message).toMatch(/OS_DATABASE_URL/); - }); -}); - diff --git a/packages/services/service-cloud/test/default-project-plugins.test.ts b/packages/services/service-cloud/test/default-project-plugins.test.ts deleted file mode 100644 index 5f04258e0..000000000 --- a/packages/services/service-cloud/test/default-project-plugins.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Smoke test for the per-project default plugin slate. - * - * Boots a bare ObjectKernel with an in-memory driver and asserts that - * `mountDefaultProjectPlugins` registers all six caps in the right - * order. This is the contract the hosted runtime (objectos) and the - * single-tenant CLI both depend on; regressing it would silently break - * Settings / Email / Storage on hosted tenants. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; -import { DriverPlugin } from '@objectstack/runtime'; -import { InMemoryDriver } from '@objectstack/driver-memory'; -import { mountDefaultProjectPlugins } from '../src/default-project-plugins.js'; - -function pluginNames(kernel: ObjectKernel): string[] { - const map: Map = (kernel as any).plugins; - return Array.from(map.values()).map((p: any) => p?.constructor?.name ?? p?.name ?? ''); -} - -describe('mountDefaultProjectPlugins', () => { - let kernel: ObjectKernel; - - beforeEach(async () => { - kernel = new ObjectKernel(); - await kernel.use(new DriverPlugin(new InMemoryDriver())); - }); - - it('mounts queue, job, cache, settings, email, storage in order', async () => { - await mountDefaultProjectPlugins(kernel, { projectId: 'p1' }); - - const slate = pluginNames(kernel).filter((n) => - /Queue|Job|Cache|Settings|Email|Storage/.test(n), - ); - expect(slate).toEqual([ - 'QueueServicePlugin', - 'JobServicePlugin', - 'CacheServicePlugin', - 'SettingsServicePlugin', - 'EmailServicePlugin', - 'StorageServicePlugin', - ]); - }); - - it('skips individual caps when caps[] === false', async () => { - await mountDefaultProjectPlugins(kernel, { - projectId: 'p1', - caps: { email: false, storage: false }, - }); - - const names = pluginNames(kernel); - expect(names.some((n) => /EmailServicePlugin/.test(n))).toBe(false); - expect(names.some((n) => /StorageServicePlugin/.test(n))).toBe(false); - expect(names.some((n) => /SettingsServicePlugin/.test(n))).toBe(true); - }); - - it('mounts isolated storage instances per project', async () => { - const k1 = new ObjectKernel(); - await k1.use(new DriverPlugin(new InMemoryDriver())); - const k2 = new ObjectKernel(); - await k2.use(new DriverPlugin(new InMemoryDriver())); - - await mountDefaultProjectPlugins(k1, { - projectId: 'tenant-a', - dataRoot: '/tmp/test-default-plugins', - caps: { queue: false, job: false, cache: false, settings: false, email: false }, - }); - await mountDefaultProjectPlugins(k2, { - projectId: 'tenant-b', - dataRoot: '/tmp/test-default-plugins', - caps: { queue: false, job: false, cache: false, settings: false, email: false }, - }); - - const sp1 = Array.from(((k1 as any).plugins as Map).values()).find( - (p: any) => /Storage/.test(p?.constructor?.name ?? ''), - ); - const sp2 = Array.from(((k2 as any).plugins as Map).values()).find( - (p: any) => /Storage/.test(p?.constructor?.name ?? ''), - ); - expect(sp1).toBeDefined(); - expect(sp2).toBeDefined(); - expect(sp1).not.toBe(sp2); - }); - - it('survives missing caps gracefully (does not throw)', async () => { - await expect( - mountDefaultProjectPlugins(kernel, { projectId: 'p1' }), - ).resolves.not.toThrow(); - }); -}); diff --git a/packages/services/service-cloud/test/fs-bundle-resolver.test.ts b/packages/services/service-cloud/test/fs-bundle-resolver.test.ts deleted file mode 100644 index 57e8435e3..000000000 --- a/packages/services/service-cloud/test/fs-bundle-resolver.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Unit tests for `createFsAppBundleResolver`. - * - * Exercises the three binding modes and their interaction: - * - Mode 2 (`OS_PROJECT_ARTIFACTS` env override) — wins when set - * - Mode 3 (`metadata.artifact_path[s]` on the project row) — fallback - * - Missing/unreadable files — logged, dropped from result - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createFsAppBundleResolver } from '../src/fs-bundle-resolver.js'; - -const ENV_MAP_VAR = 'OS_PROJECT_ARTIFACTS'; -const ARTIFACT_ROOT_VAR = 'OS_PROJECT_ARTIFACT_ROOT'; - -function writeBundle(dir: string, name: string, manifestId: string): string { - const path = join(dir, name); - writeFileSync( - path, - JSON.stringify({ - manifest: { id: manifestId, namespace: manifestId.split('.').pop() }, - objects: [], - }), - ); - return path; -} - -describe('createFsAppBundleResolver', () => { - let workdir: string; - let originalEnvMap: string | undefined; - let originalRoot: string | undefined; - let warnSpy: ReturnType; - - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), 'fs-bundle-resolver-')); - originalEnvMap = process.env[ENV_MAP_VAR]; - originalRoot = process.env[ARTIFACT_ROOT_VAR]; - delete process.env[ENV_MAP_VAR]; - delete process.env[ARTIFACT_ROOT_VAR]; - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - }); - - afterEach(() => { - if (originalEnvMap === undefined) delete process.env[ENV_MAP_VAR]; - else process.env[ENV_MAP_VAR] = originalEnvMap; - if (originalRoot === undefined) delete process.env[ARTIFACT_ROOT_VAR]; - else process.env[ARTIFACT_ROOT_VAR] = originalRoot; - warnSpy.mockRestore(); - try { rmSync(workdir, { recursive: true, force: true }); } catch { /* best-effort */ } - }); - - it('returns [] when no binding sources are present', async () => { - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ id: 'proj_x' }); - expect(out).toEqual([]); - }); - - it('loads bundles from `metadata.artifact_path` (mode 3)', async () => { - const path = writeBundle(workdir, 'crm.json', 'com.example.crm'); - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_crm', - metadata: { artifact_path: path }, - }); - expect(out).toHaveLength(1); - expect((out[0] as any).manifest.id).toBe('com.example.crm'); - }); - - it('loads bundles from `metadata.artifact_paths` array', async () => { - const a = writeBundle(workdir, 'a.json', 'com.example.a'); - const b = writeBundle(workdir, 'b.json', 'com.example.b'); - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_multi', - metadata: { artifact_paths: [a, b] }, - }); - expect(out).toHaveLength(2); - expect((out[0] as any).manifest.id).toBe('com.example.a'); - expect((out[1] as any).manifest.id).toBe('com.example.b'); - }); - - it('parses metadata when stored as a JSON string', async () => { - const path = writeBundle(workdir, 'crm.json', 'com.example.crm'); - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_crm', - metadata: JSON.stringify({ artifact_path: path }), - }); - expect(out).toHaveLength(1); - }); - - it('OS_PROJECT_ARTIFACTS env override takes precedence over metadata (mode 2)', async () => { - const fromEnv = writeBundle(workdir, 'env.json', 'com.example.env'); - const fromDb = writeBundle(workdir, 'db.json', 'com.example.db'); - process.env[ENV_MAP_VAR] = `proj_a:${fromEnv}`; - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_a', - metadata: { artifact_path: fromDb }, - }); - expect(out).toHaveLength(1); - expect((out[0] as any).manifest.id).toBe('com.example.env'); - }); - - it('env override only applies to matching project ids', async () => { - const fromEnv = writeBundle(workdir, 'env.json', 'com.example.env'); - const fromDb = writeBundle(workdir, 'db.json', 'com.example.db'); - process.env[ENV_MAP_VAR] = `proj_other:${fromEnv}`; - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_a', - metadata: { artifact_path: fromDb }, - }); - expect(out).toHaveLength(1); - expect((out[0] as any).manifest.id).toBe('com.example.db'); - }); - - it('parses comma-separated multi-project env mapping', async () => { - const a = writeBundle(workdir, 'a.json', 'com.example.a'); - const b = writeBundle(workdir, 'b.json', 'com.example.b'); - process.env[ENV_MAP_VAR] = `proj_a:${a},proj_b:${b}`; - const resolver = createFsAppBundleResolver(); - const outA = await resolver.resolve({ id: 'proj_a' }); - const outB = await resolver.resolve({ id: 'proj_b' }); - expect((outA[0] as any).manifest.id).toBe('com.example.a'); - expect((outB[0] as any).manifest.id).toBe('com.example.b'); - }); - - it('drops missing files and continues with the rest', async () => { - const ok = writeBundle(workdir, 'ok.json', 'com.example.ok'); - const missing = join(workdir, 'does-not-exist.json'); - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_x', - metadata: { artifact_paths: [missing, ok] }, - }); - expect(out).toHaveLength(1); - expect((out[0] as any).manifest.id).toBe('com.example.ok'); - }); - - it('caches repeated loads of the same path', async () => { - const path = writeBundle(workdir, 'crm.json', 'com.example.crm'); - const resolver = createFsAppBundleResolver(); - const a = await resolver.resolve({ id: 'proj_a', metadata: { artifact_path: path } }); - const b = await resolver.resolve({ id: 'proj_b', metadata: { artifact_path: path } }); - expect(a[0]).toBe(b[0]); - }); - - it('resolves relative paths against OS_PROJECT_ARTIFACT_ROOT', async () => { - writeBundle(workdir, 'rel.json', 'com.example.rel'); - process.env[ARTIFACT_ROOT_VAR] = workdir; - const resolver = createFsAppBundleResolver(); - const out = await resolver.resolve({ - id: 'proj_x', - metadata: { artifact_path: 'rel.json' }, - }); - expect(out).toHaveLength(1); - expect((out[0] as any).manifest.id).toBe('com.example.rel'); - }); -}); diff --git a/packages/services/service-cloud/test/preview-environment-registry.test.ts b/packages/services/service-cloud/test/preview-environment-registry.test.ts deleted file mode 100644 index 542ef4f49..000000000 --- a/packages/services/service-cloud/test/preview-environment-registry.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, beforeEach } from 'vitest'; -import { PreviewEnvironmentRegistry } from '../src/preview/environment-registry.js'; -import type { ArtifactApiClient } from '../src/artifact-api-client.js'; - -// ── Test doubles ───────────────────────────────────────────────────────────── - -interface FakeBranchHead { commitId: string; publishedAt?: string | null } - -class FakeClient { - public lookups = 0; - public headLookups = 0; - public artifactInvalidations = 0; - constructor( - public projects: Record, - public branches: Record> = {}, - ) { } - async lookupProjectByShortId(short: string) { - this.lookups++; - return this.projects[short.toLowerCase()] ?? null; - } - async fetchBranchHead(projectId: string, branch: string): Promise { - this.headLookups++; - return this.branches[projectId]?.[branch.toLowerCase()] ?? null; - } - invalidate(_projectId: string) { this.artifactInvalidations++; } -} - -class FakeDriver { - static instances = 0; - public id: number; - constructor() { this.id = ++FakeDriver.instances; } -} - -function makeRegistry(client: FakeClient) { - return new PreviewEnvironmentRegistry({ - client: client as unknown as ArtifactApiClient, - driverFactory: async () => new FakeDriver() as any, - logger: { info() { }, warn() { }, error() { } }, - }); -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('PreviewEnvironmentRegistry', () => { - beforeEach(() => { FakeDriver.instances = 0; }); - - describe('non-preview hostnames', () => { - it('returns null for unrecognised hosts', async () => { - const client = new FakeClient({}); - const reg = makeRegistry(client); - expect(await reg.resolveByHostname('myapp.objectstack.ai')).toBeNull(); - expect(await reg.resolveByHostname('localhost')).toBeNull(); - expect(client.lookups).toBe(0); - expect(client.headLookups).toBe(0); - }); - }); - - describe('commit-pinned previews', () => { - it('resolves a commit host and uses composite key', async () => { - const client = new FakeClient({ - '7f3e9a01': { projectId: '7f3e9a01-1234-5678-9abc-def012345678' }, - }); - const reg = makeRegistry(client); - const r = await reg.resolveByHostname('abc123def4567890--7f3e9a01.preview.objectstack.ai'); - expect(r).not.toBeNull(); - expect(r!.projectId).toBe('7f3e9a01-1234-5678-9abc-def012345678:abc123def4567890'); - expect(client.lookups).toBe(1); - expect(client.headLookups).toBe(0); - expect(FakeDriver.instances).toBe(1); - }); - - it('caches commit entries (no second lookup)', async () => { - const client = new FakeClient({ - '7f3e9a01': { projectId: '7f3e9a01-1234-5678-9abc-def012345678' }, - }); - const reg = makeRegistry(client); - const host = 'abc123def4567890--7f3e9a01.preview.objectstack.ai'; - await reg.resolveByHostname(host); - await reg.resolveByHostname(host); - await reg.resolveByHostname(host); - expect(client.lookups).toBe(1); - expect(FakeDriver.instances).toBe(1); - }); - - it('peekById returns the cached project + driver', async () => { - const client = new FakeClient({ - '7f3e9a01': { projectId: '7f3e9a01-1234-5678-9abc-def012345678' }, - }); - const reg = makeRegistry(client); - await reg.resolveByHostname('abc123def4567890--7f3e9a01.preview.objectstack.ai'); - const peek = reg.peekById('7f3e9a01-1234-5678-9abc-def012345678:abc123def4567890'); - expect(peek).not.toBeNull(); - expect(peek!.project.commitId).toBe('abc123def4567890'); - expect(peek!.project.projectId).toBe('7f3e9a01-1234-5678-9abc-def012345678'); - }); - }); - - describe('branch-tracking previews', () => { - it('resolves a branch host using its current head', async () => { - const client = new FakeClient( - { '7f3e9a01': { projectId: 'proj-1' } }, - { 'proj-1': { main: { commitId: 'abc123def4567890' } } }, - ); - const reg = makeRegistry(client); - const r = await reg.resolveByHostname('main--7f3e9a01.preview.objectstack.ai'); - expect(r!.projectId).toBe('proj-1:abc123def4567890'); - expect(client.headLookups).toBe(1); - }); - - it('re-checks branch head on every request (per-request semantics)', async () => { - const client = new FakeClient( - { '7f3e9a01': { projectId: 'proj-1' } }, - { 'proj-1': { main: { commitId: 'abc123def4567890' } } }, - ); - const reg = makeRegistry(client); - const host = 'main--7f3e9a01.preview.objectstack.ai'; - await reg.resolveByHostname(host); - await reg.resolveByHostname(host); - await reg.resolveByHostname(host); - // Initial resolve = 1 head call; then 2 re-checks. - expect(client.headLookups).toBe(3); - // Driver instance count stays at 1 because head didn't change. - expect(FakeDriver.instances).toBe(1); - }); - - it('evicts and rebuilds when branch head advances', async () => { - const client = new FakeClient( - { '7f3e9a01': { projectId: 'proj-1' } }, - { 'proj-1': { main: { commitId: 'aaaaaaaaaaaaaaaa' } } }, - ); - const reg = makeRegistry(client); - const host = 'main--7f3e9a01.preview.objectstack.ai'; - const first = await reg.resolveByHostname(host); - expect(first!.projectId).toBe('proj-1:aaaaaaaaaaaaaaaa'); - - // Advance the head. - client.branches['proj-1']!.main = { commitId: 'bbbbbbbbbbbbbbbb' }; - - const second = await reg.resolveByHostname(host); - expect(second!.projectId).toBe('proj-1:bbbbbbbbbbbbbbbb'); - expect(client.artifactInvalidations).toBeGreaterThanOrEqual(1); - expect(FakeDriver.instances).toBe(2); // fresh driver for the new commit - }); - - it('returns null when the branch has no head', async () => { - const client = new FakeClient( - { '7f3e9a01': { projectId: 'proj-1' } }, - { 'proj-1': {} }, // no `main` - ); - const reg = makeRegistry(client); - const r = await reg.resolveByHostname('main--7f3e9a01.preview.objectstack.ai'); - expect(r).toBeNull(); - }); - }); - - describe('error paths', () => { - it('returns null when the short id is unknown', async () => { - const client = new FakeClient({}); - const reg = makeRegistry(client); - const r = await reg.resolveByHostname('main--00000000.preview.objectstack.ai'); - expect(r).toBeNull(); - expect(FakeDriver.instances).toBe(0); - }); - - it('singleflights concurrent first-resolve requests', async () => { - const client = new FakeClient( - { '7f3e9a01': { projectId: 'proj-1' } }, - { 'proj-1': { main: { commitId: 'aaaaaaaaaaaaaaaa' } } }, - ); - const reg = makeRegistry(client); - const host = 'main--7f3e9a01.preview.objectstack.ai'; - const [a, b, c] = await Promise.all([ - reg.resolveByHostname(host), - reg.resolveByHostname(host), - reg.resolveByHostname(host), - ]); - expect(a!.projectId).toBe('proj-1:aaaaaaaaaaaaaaaa'); - expect(b!.projectId).toBe('proj-1:aaaaaaaaaaaaaaaa'); - expect(c!.projectId).toBe('proj-1:aaaaaaaaaaaaaaaa'); - expect(client.lookups).toBe(1); - // One head fetch during initial build, no per-request re-check - // because all three calls dedupe through `pending`. - expect(client.headLookups).toBe(1); - expect(FakeDriver.instances).toBe(1); - }); - }); - - describe('clear()', () => { - it('drops all cached entries', async () => { - const client = new FakeClient({ - '7f3e9a01': { projectId: 'proj-1' }, - }); - const reg = makeRegistry(client); - await reg.resolveByHostname('abc123def4567890--7f3e9a01.localhost'); - expect(reg.peekById('proj-1:abc123def4567890')).not.toBeNull(); - reg.clear(); - expect(reg.peekById('proj-1:abc123def4567890')).toBeNull(); - }); - }); -}); diff --git a/packages/services/service-cloud/test/preview-host-parser.test.ts b/packages/services/service-cloud/test/preview-host-parser.test.ts deleted file mode 100644 index 944fdfb46..000000000 --- a/packages/services/service-cloud/test/preview-host-parser.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { parsePreviewHost, projectIdToShort } from '../src/preview/host-parser.js'; - -describe('parsePreviewHost', () => { - describe('commit-pinned (16 hex)', () => { - it('parses prod hostname', () => { - const r = parsePreviewHost('abc123def4567890--7f3e9a01.preview.objectstack.ai'); - expect(r).toEqual({ kind: 'commit', pidShort: '7f3e9a01', ref: 'abc123def4567890' }); - }); - - it('parses localhost hostname (RFC 6761)', () => { - const r = parsePreviewHost('abc123def4567890--7f3e9a01.localhost'); - expect(r).toEqual({ kind: 'commit', pidShort: '7f3e9a01', ref: 'abc123def4567890' }); - }); - - it('strips :port', () => { - const r = parsePreviewHost('abc123def4567890--7f3e9a01.localhost:4100'); - expect(r).toEqual({ kind: 'commit', pidShort: '7f3e9a01', ref: 'abc123def4567890' }); - }); - - it('lowercases input', () => { - const r = parsePreviewHost('ABC123DEF4567890--7F3E9A01.PREVIEW.OBJECTSTACK.AI'); - expect(r).toEqual({ kind: 'commit', pidShort: '7f3e9a01', ref: 'abc123def4567890' }); - }); - }); - - describe('branch-tracking (slug)', () => { - it('parses main', () => { - const r = parsePreviewHost('main--7f3e9a01.preview.objectstack.ai'); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'main' }); - }); - - it('parses slash-containing slug', () => { - const r = parsePreviewHost('feature/login--7f3e9a01.localhost:4100'); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'feature/login' }); - }); - - it('parses dotted slug', () => { - const r = parsePreviewHost('release.v2--7f3e9a01.preview.objectstack.ai'); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'release.v2' }); - }); - - it('parses dash-containing slug', () => { - const r = parsePreviewHost('hot-fix--7f3e9a01.localhost'); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'hot-fix' }); - }); - }); - - describe('rejections', () => { - it('null/empty → null', () => { - expect(parsePreviewHost('')).toBeNull(); - expect(parsePreviewHost(null as any)).toBeNull(); - expect(parsePreviewHost(undefined as any)).toBeNull(); - }); - - it('bare base host → null', () => { - expect(parsePreviewHost('preview.objectstack.ai')).toBeNull(); - expect(parsePreviewHost('localhost')).toBeNull(); - }); - - it('non-preview hostname → null', () => { - expect(parsePreviewHost('myapp.objectstack.ai')).toBeNull(); - expect(parsePreviewHost('cloud.objectstack.ai')).toBeNull(); - }); - - it('missing -- separator → null', () => { - expect(parsePreviewHost('abc7f3e9a01.preview.objectstack.ai')).toBeNull(); - }); - - it('pid not exactly 8 hex → null', () => { - expect(parsePreviewHost('main--7f3e9a.preview.objectstack.ai')).toBeNull(); // 6 hex - expect(parsePreviewHost('main--7f3e9a012.preview.objectstack.ai')).toBeNull(); // 9 hex - expect(parsePreviewHost('main--zzzzzzzz.preview.objectstack.ai')).toBeNull(); // not hex - }); - - it('empty ref → null', () => { - expect(parsePreviewHost('--7f3e9a01.preview.objectstack.ai')).toBeNull(); - }); - - it('uppercase pid → lowercased and accepted', () => { - const r = parsePreviewHost('main--7F3E9A01.preview.objectstack.ai'); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'main' }); - }); - - it('rejects when base domain not in allowlist', () => { - const r = parsePreviewHost('main--7f3e9a01.notmydomain.com'); - expect(r).toBeNull(); - }); - - it('respects custom baseDomains', () => { - const r = parsePreviewHost('main--7f3e9a01.example.dev', { - baseDomains: ['example.dev'], - }); - expect(r).toEqual({ kind: 'branch', pidShort: '7f3e9a01', ref: 'main' }); - // Default base no longer matches when overridden. - expect(parsePreviewHost('main--7f3e9a01.preview.objectstack.ai', { - baseDomains: ['example.dev'], - })).toBeNull(); - }); - }); - - describe('commit/branch disambiguation', () => { - it('exactly 16 hex → commit', () => { - expect(parsePreviewHost('0123456789abcdef--7f3e9a01.localhost')?.kind).toBe('commit'); - }); - - it('11 hex → branch (slug regex still matches)', () => { - expect(parsePreviewHost('0123456789a--7f3e9a01.localhost')?.kind).toBe('branch'); - }); - - it('13 hex → branch (slug regex still matches)', () => { - expect(parsePreviewHost('0123456789abcde--7f3e9a01.localhost')?.kind).toBe('branch'); - }); - }); -}); - -describe('projectIdToShort', () => { - it('strips dashes and returns first 8 hex', () => { - expect(projectIdToShort('7f3e9a01-1234-5678-9abc-def012345678')).toBe('7f3e9a01'); - }); - - it('lowercases', () => { - expect(projectIdToShort('7F3E9A01-1234-5678-9ABC-DEF012345678')).toBe('7f3e9a01'); - }); - - it('returns null on too-short input', () => { - expect(projectIdToShort('7f3e')).toBeNull(); - }); - - it('returns null on non-hex', () => { - expect(projectIdToShort('zzzzzzzz-1234-5678-9abc-def012345678')).toBeNull(); - }); - - it('handles UUID without dashes', () => { - expect(projectIdToShort('7f3e9a0112345678')).toBe('7f3e9a01'); - }); -}); diff --git a/packages/services/service-cloud/test/public-artifact-routes.test.ts b/packages/services/service-cloud/test/public-artifact-routes.test.ts deleted file mode 100644 index 6ac0ed69d..000000000 --- a/packages/services/service-cloud/test/public-artifact-routes.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tests for the public, unauthenticated artifact API - * (`/pub/v1/projects/:id/*`) and its visibility gating. - * - * - sys_project.visibility = 'private' → 404 on /artifact without ?commit= - * (and on /revisions + /manifest.json), - * 200 on /artifact?commit= (share-by-link) - * - sys_project.visibility = 'public' → 200 on all three routes - * - sys_project.visibility = 'unlisted' (legacy) is coerced to 'private' - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { createCloudArtifactApiPlugin } from '../src/cloud-artifact-api-plugin.js'; - -interface Route { method: 'GET' | 'POST'; path: string; handler: (req: any, res: any) => any; } -interface Captured { status: number; body: any; headers: Record; } - -class FakeHttpServer { - routes: Route[] = []; - get(path: string, handler: (req: any, res: any) => any) { this.routes.push({ method: 'GET', path, handler }); } - post(path: string, handler: (req: any, res: any) => any) { this.routes.push({ method: 'POST', path, handler }); } - delete(path: string, handler: (req: any, res: any) => any) { this.routes.push({ method: 'DELETE', path, handler }); } - put() {} patch() {} - async invoke(method: 'GET' | 'POST' | 'DELETE', urlPath: string, opts: { query?: Record; headers?: Record } = {}): Promise { - const route = this.routes.find((r) => r.method === method && pathMatches(r.path, urlPath)); - if (!route) return { status: 404, body: { error: 'no route' }, headers: {} }; - const params = extractParams(route.path, urlPath); - const req = { params, query: opts.query ?? {}, headers: opts.headers ?? {} }; - const captured: Captured = { status: 200, body: undefined, headers: {} }; - const res: any = { - status(code: number) { captured.status = code; return res; }, - json(body: any) { captured.body = body; return res; }, - set(name: string, value: string) { captured.headers[name.toLowerCase()] = value; return res; }, - }; - await route.handler(req, res); - return captured; - } -} -function pathMatches(pattern: string, actual: string): boolean { - const a = pattern.split('/'); const b = actual.split('/'); - if (a.length !== b.length) return false; - return a.every((seg, i) => seg.startsWith(':') || seg === b[i]); -} -function extractParams(pattern: string, actual: string): Record { - const out: Record = {}; - pattern.split('/').forEach((seg, i) => { if (seg.startsWith(':')) out[seg.slice(1)] = actual.split('/')[i]; }); - return out; -} - -interface FakeProject { - id: string; organization_id?: string; visibility?: 'private' | 'unlisted' | 'public'; - metadata?: any; display_name?: string; -} -interface FakeRevision { - project_id: string; commit_id: string; checksum: string; storage_key: string; - size_bytes: number; built_at: string; published_at: string; is_current: boolean; note?: string; -} - -class FakeDriver { - constructor(public projects: FakeProject[], public revisions: FakeRevision[] = []) {} - async findOne(table: string, query: any): Promise { - const where = query?.where ?? {}; - const all = table === 'sys_project' ? this.projects - : table === 'sys_project_revision' ? this.revisions - : []; - return all.find((row: any) => Object.entries(where).every(([k, v]) => row[k] === v)) ?? null; - } - async find(table: string, query: any): Promise { - const where = query?.where ?? {}; - const rows = table === 'sys_project_revision' ? this.revisions : []; - let filtered = rows.filter((r: any) => Object.entries(where).every(([k, v]) => r[k] === v)); - if (query?.orderBy?.[0]) { - const { field, direction } = query.orderBy[0]; - filtered = [...filtered].sort((a: any, b: any) => - direction === 'desc' ? String(b[field]).localeCompare(String(a[field])) : String(a[field]).localeCompare(String(b[field])), - ); - } - if (typeof query?.limit === 'number') filtered = filtered.slice(0, query.limit); - return filtered; - } -} - -class FakeStorage { - constructor(public files: Map = new Map()) {} - async exists(key: string) { return this.files.has(key); } - async download(key: string) { return this.files.get(key)!; } - async upload(key: string, data: Buffer) { this.files.set(key, data); } -} - -const sampleArtifact = { - schemaVersion: '0.1', - projectId: 'proj_a', - commitId: 'cafebabe', - metadata: { objects: [{ name: 'task' }] }, - functions: [], - manifest: { plugins: [], drivers: [], engines: {} }, -}; - -describe('public artifact API (/pub/v1/projects/:id/*)', () => { - let server: FakeHttpServer; - let storage: FakeStorage; - - beforeEach(() => { - server = new FakeHttpServer(); - storage = new FakeStorage(); - storage.files.set('artifacts/orgs/org_1/projects/proj_a/cafebabe.json', Buffer.from(JSON.stringify(sampleArtifact))); - }); - - async function boot(projects: FakeProject[]) { - const driver = new FakeDriver(projects, [{ - project_id: 'proj_a', commit_id: 'cafebabe', checksum: 'cafebabe' + '0'.repeat(56), - storage_key: 'artifacts/orgs/org_1/projects/proj_a/cafebabe.json', - size_bytes: 100, built_at: '2026-01-01T00:00:00Z', published_at: '2026-01-01T00:00:00Z', - is_current: true, - }]); - const plugin = createCloudArtifactApiPlugin({ - controlDriverPromise: Promise.resolve({ driver: driver as any, driverName: 'memory', databaseUrl: 'memory://' }), - storage: { service: storage as any }, - }); - const ctx: any = { getService: (n: string) => (n === 'http.server' ? server : n === 'file-storage' ? storage : undefined) }; - await plugin.init(ctx); await plugin.start(ctx); - } - - // --- private (share-by-link) --------------------------------------------- - it('private project: enumeration routes return 404, but ?commit= returns 200', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'private' }]); - - const enumArtifact = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact'); - const shareByLink = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact', { query: { commit: 'cafebabe' } }); - const revs = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/revisions'); - const mani = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/manifest.json'); - expect([enumArtifact.status, shareByLink.status, revs.status, mani.status]).toEqual([404, 200, 404, 404]); - expect(shareByLink.body.data.commitId).toBe('cafebabe'); - }); - - it('private project: defaults when visibility is undefined', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1' /* no visibility */ }]); - // Defaulted-to-private: enumeration is hidden but share-by-link works. - const noCommit = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact'); - expect(noCommit.status).toBe(404); - const withCommit = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact', { query: { commit: 'cafebabe' } }); - expect(withCommit.status).toBe(200); - }); - - // --- unlisted (legacy → coerced to private) ------------------------------ - it('legacy `unlisted` rows behave like `private` (share-by-link)', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'unlisted' }]); - - const enumAttempt = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact'); - expect(enumAttempt.status).toBe(404); - - const ok = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact', { query: { commit: 'cafebabe' } }); - expect(ok.status).toBe(200); - expect(ok.body.success).toBe(true); - expect(ok.body.data.commitId).toBe('cafebabe'); - }); - - it('legacy `unlisted` rows still hide /revisions and /manifest.json', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'unlisted' }]); - const revs = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/revisions'); - const mani = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/manifest.json'); - expect(revs.status).toBe(404); - expect(mani.status).toBe(404); - }); - - // --- public --------------------------------------------------------------- - it('public project: artifact (current) returns 200 + immutable cache headers', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'public' }]); - - const r = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact'); - expect(r.status).toBe(200); - expect(r.body.data.commitId).toBe('cafebabe'); - expect(r.headers['cache-control']).toContain('immutable'); - expect(r.headers['etag']).toBe('"cafebabe"'); - expect(r.headers['x-commit-id']).toBe('cafebabe'); - }); - - it('public project: revisions returns history list', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'public' }]); - const r = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/revisions'); - expect(r.status).toBe(200); - expect(Array.isArray(r.body.data.items)).toBe(true); - expect(r.body.data.items[0].commitId).toBe('cafebabe'); - expect(r.body.data.items[0].isCurrent).toBe(true); - }); - - it('public project: manifest.json returns lightweight metadata', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'public', display_name: 'CRM' }]); - const r = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/manifest.json'); - expect(r.status).toBe(200); - expect(r.body.data).toMatchObject({ - projectId: 'proj_a', - organizationId: 'org_1', - displayName: 'CRM', - visibility: 'public', - currentCommitId: 'cafebabe', - }); - }); - - it('public project: requesting an unknown commit returns 404', async () => { - await boot([{ id: 'proj_a', organization_id: 'org_1', visibility: 'public' }]); - const r = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact', { query: { commit: 'deadbeef' } }); - expect(r.status).toBe(404); - }); - - it('public routes do NOT require a bearer token even when apiKey is set', async () => { - // Boot with apiKey set (would normally enforce auth on /cloud/* routes) - const driver = new FakeDriver( - [{ id: 'proj_a', organization_id: 'org_1', visibility: 'public' }], - [{ project_id: 'proj_a', commit_id: 'cafebabe', checksum: 'cafebabe' + '0'.repeat(56), - storage_key: 'artifacts/orgs/org_1/projects/proj_a/cafebabe.json', - size_bytes: 100, built_at: '2026-01-01T00:00:00Z', published_at: '2026-01-01T00:00:00Z', is_current: true }], - ); - const plugin = createCloudArtifactApiPlugin({ - controlDriverPromise: Promise.resolve({ driver: driver as any, driverName: 'memory', databaseUrl: 'memory://' }), - storage: { service: storage as any }, - apiKey: 'super-secret', - }); - const ctx: any = { getService: (n: string) => (n === 'http.server' ? server : storage) }; - await plugin.init(ctx); await plugin.start(ctx); - - // Authenticated /cloud route should still work; unauthenticated should fail - const privateUnauth = await server.invoke('GET', '/api/v1/cloud/projects/proj_a/artifact'); - expect(privateUnauth.status).toBe(401); - - // Public route works with NO Authorization header - const pub = await server.invoke('GET', '/api/v1/pub/v1/projects/proj_a/artifact'); - expect(pub.status).toBe(200); - }); -}); diff --git a/packages/services/service-cloud/tsconfig.json b/packages/services/service-cloud/tsconfig.json deleted file mode 100644 index 2be4d1f47..000000000 --- a/packages/services/service-cloud/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/services/service-cloud/tsup.config.ts b/packages/services/service-cloud/tsup.config.ts deleted file mode 100644 index 66713eb59..000000000 --- a/packages/services/service-cloud/tsup.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - dts: false, - sourcemap: true, - clean: true, - target: 'node18', - outDir: 'dist', - external: [ - '@objectstack/driver-turso', - '@objectstack/driver-sql', - '@objectstack/driver-memory', - '@objectstack/objectql', - '@objectstack/metadata', - '@objectstack/plugin-auth', - '@objectstack/plugin-security', - '@objectstack/plugin-audit', - '@objectstack/service-tenant', - '@objectstack/service-package', - // Native / CJS-heavy DB drivers that can't survive being bundled - // into ESM (they use `require('events')` etc. internally and rely - // on Node's actual module graph, not esbuild's). Resolved at runtime - // from the host app's node_modules — declared as optional deps so - // pnpm makes them available to this package. - 'pg', - 'pg-native', - 'pg-cloudflare', - ], -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3defae2a6..396ba3e9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -925,9 +925,6 @@ importers: '@objectstack/service-cache': specifier: workspace:* version: link:../services/service-cache - '@objectstack/service-cloud': - specifier: workspace:* - version: link:../services/service-cloud '@objectstack/service-feed': specifier: workspace:* version: link:../services/service-feed @@ -1794,92 +1791,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - packages/services/service-cloud: - dependencies: - '@objectstack/core': - specifier: workspace:^ - version: link:../../core - '@objectstack/driver-memory': - specifier: workspace:^ - version: link:../../plugins/driver-memory - '@objectstack/driver-mongodb': - specifier: workspace:^ - version: link:../../plugins/driver-mongodb - '@objectstack/driver-sql': - specifier: workspace:^ - version: link:../../plugins/driver-sql - '@objectstack/driver-turso': - specifier: workspace:^ - version: link:../../plugins/driver-turso - '@objectstack/metadata': - specifier: workspace:^ - version: link:../../metadata - '@objectstack/objectql': - specifier: workspace:^ - version: link:../../objectql - '@objectstack/plugin-audit': - specifier: workspace:^ - version: link:../../plugins/plugin-audit - '@objectstack/plugin-auth': - specifier: workspace:^ - version: link:../../plugins/plugin-auth - '@objectstack/plugin-email': - specifier: workspace:^ - version: link:../../plugins/plugin-email - '@objectstack/plugin-security': - specifier: workspace:^ - version: link:../../plugins/plugin-security - '@objectstack/runtime': - specifier: workspace:^ - version: link:../../runtime - '@objectstack/service-cache': - specifier: workspace:^ - version: link:../service-cache - '@objectstack/service-i18n': - specifier: workspace:^ - version: link:../service-i18n - '@objectstack/service-job': - specifier: workspace:^ - version: link:../service-job - '@objectstack/service-package': - specifier: workspace:^ - version: link:../service-package - '@objectstack/service-queue': - specifier: workspace:^ - version: link:../service-queue - '@objectstack/service-settings': - specifier: workspace:^ - version: link:../service-settings - '@objectstack/service-storage': - specifier: workspace:* - version: link:../service-storage - '@objectstack/service-tenant': - specifier: workspace:^ - version: link:../service-tenant - '@objectstack/spec': - specifier: workspace:^ - version: link:../../spec - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@types/node': - specifier: ^22.19.19 - version: 22.19.19 - tsup: - specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vitest: - specifier: ^4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(msw@2.14.6(@types/node@22.19.19)(typescript@6.0.3))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) - optionalDependencies: - pg: - specifier: ^8.21.0 - version: 8.21.0 - packages/services/service-feed: dependencies: '@objectstack/core':