phase(L0): tenant container runs full Core + Platform stack

Per-tenant container now boots all five processes behind a single
exposed port (:8080), with the Core/Platform boundary enforced at
the filesystem level (two Linux users, mode 0700 on cial-core).

- @cial/edge: http-proxy edge (HTTP+WS) + node supervisor (PID 1
  under tini, spawns each service via gosu as the right user)
- Routes: /.cial/api/* -> back (prefix stripped), /.cial/* -> core
  front (basePath kept), /* -> platform front. Platform Back is
  internal-only for v1.
- Dockerfile: multi-stage (builder + runtime). Builds protocol/sdk/
  back/edge/front/platform-back/platform-front. Runtime installs
  tini+gosu, creates cial:1000 / agent:1001, locks down cial-core
  to 0700.
- Placeholder pages now render TENANT_ID at request time so the
  smoke can verify per-tenant env propagation end-to-end.
- scripts/smoke-tenant.mjs: docker-driven L0 acceptance — boots the
  image, polls healthz, probes the four route classes, and asserts
  the agent user cannot read /opt/cial-monorepo/cial-core.
- PLAN-LOCAL.md: phased local-mode roadmap (L0..L6).

Verify on a host with docker:
  docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
  pnpm smoke:tenant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eliot M 2026-04-26 10:46:16 +00:00
parent d5cc3f320e
commit d4a39d425a
16 changed files with 857 additions and 58 deletions

259
PLAN-LOCAL.md Normal file
View file

@ -0,0 +1,259 @@
# Local mode — full multi-tenant Cial on your laptop
> Goal: `pnpm local:up` brings up the entire Cial platform locally with Docker
> standing in for Fly Machines. You sign up as the owner in the App admin,
> create a "client", a real Docker container is spawned for that tenant, you
> click the tenant URL, and you land in their per-tenant Cial — SSO'd in,
> already authenticated.
This document scopes "**plumbing v1**" — same code path as production, only
the orchestrator's *driver* swaps (Docker ↔ Fly). It pulls **Phase 2** of
PLAN.md (real container entrypoint) and parts of **Phases 1113** (App auth,
tenant CRUD, orchestrator, router) forward.
---
## Decisions (locked in)
| | choice |
|-|-|
| Tenant URL | `{slug}.localhost:8080` *(modern browsers resolve `*.localhost` → 127.0.0.1; no /etc/hosts edits)* |
| Signup | owner-only (you log into admin and create tenants) |
| Scope | plumbing v1 — spawn tenant, route to it, SSO works, tenant container responds with an identifiable placeholder |
| Orchestration | `docker compose` for App+Postgres+Router; the App spawns tenant containers via the host Docker socket (DooD pattern) |
## Tech picks
| concern | choice | why |
|-|-|-|
| App DB | `postgres:16` in compose | matches prod |
| App ORM | `drizzle-orm` + `drizzle-kit` | already in deps, lightweight |
| App Auth | `better-auth` | locked-in choice from infra deck |
| Orchestrator driver | `dockerode` | typed, supports the full Docker API; no shelling out |
| Router proxy | tiny Node service using `http-proxy` (lives in `cial-app/router`) | dedicated process, separates concerns, matches `@cial/app-router` package we already scaffolded |
| SSO | HS256 JWT (shared secret in `.env.local`) for v1; bumps to RS256 in prod | smaller blast radius for a dev mode |
| Tenant data | named docker volume per tenant: `cial-tenant-{id}-data` | survives `docker stop`, gone with the tenant on destroy |
## Topology
```
host (your laptop)
┌──────────────────────────────────────────────────────────────────┐
│ │
│ browser → http://acme.localhost:8080 │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ @cial/app- │ reads tenants.{slug, container_port} from PG │
│ │ router │ forwards to 172.x.x.x:port │
│ │ (:8080) │ │
│ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ @cial/app- │ │ postgres │ │ tenant containers │ │
│ │ api │◄──►│ :5432 │ │ (one per client) │ │
│ │ (:3100) │ └────────────┘ │ cial-tenant:dev │ │
│ └────────────┘ └────────────────────┘ │
│ │ │
│ │ spawns/stops via /var/run/docker.sock (DooD) │
│ ▼ │
│ host Docker daemon │
│ │
└──────────────────────────────────────────────────────────────────┘
```
---
## Phasing
Each phase is independently runnable and ends with a verifiable check.
### L0 — Tenant image runs the full Core + Platform stack
**No scope reduction.** The tenant image runs all four processes with proper
user separation and a single exposed port. This derisks the hardest piece of
the infra (multi-process container, internal edge, two-user model) before any
of the orchestration / routing is built. Chat / agent / deploy logic stays as
501 stubs — those come back via PLAN.md phases — but the *plumbing* is real.
**Container topology (single exposed port: 8080):**
```
external :8080
┌──────────────────┐
@cial/edge │ PID 1 supervises and routes
│ (Node, user cial)│
└──────────────────┘
│ │ │ │
/.cial/api/* │ │ │
▼ │ │ │
@cial/back │ │ │ user: cial :4000
(Express) │ │ │ data: /var/lib/cial (cial:0700)
/.cial/* │ │
▼ │ │
@cial/front │ │ user: cial :4001
(Next, basePath=/.cial)
│ │
/api/p/* │ (internal — NOT exposed for v1)
▼ │
@cial/platform-back :3001 user: agent
│ /* (everything else)
@cial/platform-front :3000 user: agent
(Next, basePath=/)
```
**Key decisions (calling these now to avoid round-trips):**
- **Supervisor**: small Node script as PID 1 wrapped by `tini`. It spawns
the four children, pipes their stdout/stderr with prefixes, and crashes
the container if any child dies (so Docker restarts it). No s6/supervisord
— keeps the runtime stack pure Node.
- **Internal edge**: tiny Node `http-proxy` server in a new package
`cial-core/edge` (~100 LOC). Handles HTTP and WebSocket upgrade. The only
process bound to `:8080`.
- **Two-user model preserved**: `cial` user owns `/opt/cial-core` (mode 0700),
runs edge + back + front; `agent` user owns `/opt/cial-platform`, runs
platform-back + platform-front. Edge is `cial`-owned so the agent process
can never bind the public port.
- **Platform Back exposure**: **internal-only for v1** — reachable from
Platform Front server-side via `http://localhost:3001`, not from the
browser. Avoids fighting Next.js for the `/api/*` namespace. We pick a
public mount path later when we know the convention.
- **Next.js mode**: production builds with `output: 'standalone'` so each
Next app ships only what it needs (smaller image, faster cold start).
- **No watch / dev mode in the container** — that's purely for local dev
outside the container (where `pnpm smoke` already works).
**Tasks:**
1. Add `cial-core/edge` package: Node + `http-proxy` + ws upgrade handling.
2. Add `cial-core/supervisor` (or a small `bin/supervisor.mjs` inside `edge`):
spawns the four children with the right user via `process.setuid` (or via
`su-exec` in the entrypoint).
3. Add `output: 'standalone'` + `outputFileTracingRoot` to both Next apps
(`@cial/front`, `@cial/platform-front`) so monorepo symlinks resolve in
the standalone bundle.
4. Add a tiny placeholder route to each surface so we can prove routing:
- `@cial/platform-front` `/``<h1>Platform · Tenant {TENANT_ID}</h1>`
- `@cial/front` `/.cial``<h1>Cial Core · Tenant {TENANT_ID}</h1>`
- `@cial/back` `/.cial/api/health` already exists (`/healthz` mounted there)
- `@cial/platform-back` `/health` already exists (internal only)
5. Rewrite `cial-core/docker/Dockerfile` as a real multi-stage build that
produces the runtime image with all four services + the supervisor +
correct ownership/permissions. Entry: `tini -- node /opt/cial-core/edge/bin/supervisor.mjs`.
6. Add `cial-core/docker/.dockerignore` (already there).
**Verify (acceptance for L0):**
```bash
docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
docker run --rm -e TENANT_ID=demo -p 9000:8080 cial-tenant:dev
# In another shell:
curl http://localhost:9000/ # → "Platform · Tenant demo"
curl http://localhost:9000/.cial # → "Cial Core · Tenant demo"
curl http://localhost:9000/.cial/api/healthz # → {"status":"ok",…}
docker exec <id> ls -la /opt/cial-core # → owner cial, mode 0700
docker exec <id> sudo -u agent cat /opt/cial-core/back/dist/index.js
# → permission denied (proves boundary)
```
If all five checks pass, L0 is done. The container is then a real,
production-shaped artifact — every later phase just plugs into it.
### L1 — docker compose + Postgres + App API + owner signup
- `docker-compose.yml` at repo root with services: `postgres`, `app-api`
- `cial-app/api`:
- Drizzle schema: `users`, `tenants` (slug, name, container_id, container_port, state, owner_id)
- `drizzle-kit generate` + on-boot migrate
- Better-Auth wired at `/api/auth/[...all]` — email+password, no verification in dev
- `/admin/login` and `/admin/signup` pages (signup disabled after first user; first user is owner)
- `app-api` Dockerfile updated to actually run `next start` on :3100
- **Verify**: `docker compose up`, browser to `http://localhost:3100/admin/signup`,
create owner account, redirected to `/admin` (empty tenant list).
### L2 — Admin tenant CRUD
- `/admin` page: list tenants from DB
- "New tenant" form (slug, name) → POST `/api/admin/tenants`
- Validates slug (`^[a-z0-9-]{2,40}$`), inserts row with `state='provisioning'`
- For now, no orchestrator call — just DB row + UI confirmation
- **Verify**: create a tenant in UI, see it in the list with state=provisioning.
### L3 — Orchestrator Docker driver
- `cial-app/orchestrator/src/drivers/docker.ts`: implements `Orchestrator` via
`dockerode`
- `create(tenant)`: `docker create` from `cial-tenant:dev` with env
`TENANT_ID`, label `cial.tenant=<id>`, named volume, exposed port mapped
to a free host port. Sets state=`starting`.
- `start/stop/destroy/status`: trivial dockerode calls
- `app-api` calls `orchestrator.create()` after the DB insert in L2;
records `container_id`, `container_port`; flips state to `running` once
the container's `/healthz` responds.
- App container needs `/var/run/docker.sock` mounted in compose.
- **Verify**: create a tenant in UI → admin list shows state=running with a
port; `docker ps` shows the container; `curl localhost:<port>` returns the
L0 placeholder page.
### L4 — Router reverse proxy on :8080
- `cial-app/router/src/index.ts`: small Node HTTP+WS server on :8080 using
`http-proxy`
- Reads tenant routes from Postgres on each request (cache with 5s TTL)
- `Host` header `acme.localhost``tenants WHERE slug='acme' AND state='running'`
→ forward to `localhost:<container_port>`
- 404 if unknown slug; 503 if state ≠ running
- Add `router` service to compose
- **Verify**: `http://acme.localhost:8080` shows the tenant's page (Tenant acme).
### L5 — SSO handoff App → tenant Core
- App admin: tenant detail page has an **Open** button → `/api/admin/tenants/:slug/open`
- That endpoint mints a short-lived (60s) HS256 JWT `{ sub: ownerId, tenant: slug, role: 'owner' }`
signed with `CIAL_SSO_SECRET`, redirects browser to `http://{slug}.localhost:8080/.cial/sso?token=...`
- Core Back: `/.cial/sso` validates token, sets a session cookie scoped to
the tenant subdomain, redirects to `/`. (For v1 the cookie is the marker;
Phase 3 of PLAN.md replaces this with real Better-Auth in Core.)
- The tenant `/` page now shows `Tenant {id} · signed in as {sub}`.
- **Verify**: from admin, click "Open acme" → land on `acme.localhost:8080`
with the tenant page showing your owner email.
### L6 — `pnpm local:up` wraps everything
- One-shot script that:
1. Builds `cial-tenant:dev` image
2. Runs `docker compose up --build -d`
3. Tails logs, prints `→ http://localhost:3100/admin` when ready
- `pnpm local:down` tears compose + prunes any orphan tenant containers
(label-selected: `label=cial.tenant`)
- A new `scripts/smoke-local.mjs` end-to-end:
1. local:up
2. sign up owner via API
3. create tenant via API
4. probe `acme.localhost:8080` until 200
5. local:down
- **Verify**: green E2E smoke from a clean state.
---
## Working agreement
- Same as PLAN.md: implement → self-test → commit `phase(L<n>): …` → push.
- Each phase ends with a green check. If a phase's verify step fails, we
fix before moving on.
- Anything bigger than L0L6 (e.g., bring Core Front + Platform processes
inside the tenant image) is a follow-up scoped after L6 is green.
## Open questions to revisit later (NOT for v1)
- Multi-process tenant (Core Front + Platform Back + Platform Front inside
the container) — needed for the real Platform editing experience
- Trigger fabric / scheduler — needed once tenants have cron triggers
- Custom domains for tenants
- Per-tenant resource limits (memory/CPU caps via Docker)
- Tenant suspend (Docker stop) ↔ cold start time on first request

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -1,79 +1,97 @@
# syntax=docker/dockerfile:1.7
# ---------------------------------------------------------------------------
# Cial — per-tenant container image.
# Cial — per-tenant container image (cial-tenant).
#
# One container per tenant. Five processes inside, single exposed port.
#
# PID 1 (root) tini → node supervisor
# ├─ edge (cial) :8080 (only exposed)
# ├─ core-back (cial) :4000
# ├─ core-front (cial) :4001 basePath=/.cial
# ├─ platform-back (agent) :3001 internal-only
# └─ platform-front (agent) :3000
#
# Two Linux users:
# - cial (uid 1000) — owns Core code (cial-core/*) at mode 0700.
# Runs @cial/back and @cial/front.
# - agent (uid 1001) — runs the Claude/Codex agent and the platform code
# (cial-platform/*). Cannot read Core files. Talks to
# Core only over /run/cial-core.sock.
# - cial (uid 1000) — owns /opt/cial-monorepo/cial-core (mode 0700).
# Runs edge + back + front. Agent cannot read it.
# - agent (uid 1001) — owns /opt/cial-monorepo/cial-platform.
# Runs platform-back + platform-front + (later) the AI agent.
#
# This Dockerfile is intentionally a skeleton — phase 2 of PLAN.md fills in
# the build steps. Everything below is the minimum needed to boot.
# Built from the repo root:
# docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
# ---------------------------------------------------------------------------
ARG NODE_VERSION=22.12.0
# ---------------------------------------------------------------------------
# Base — shared deps (pnpm, build toolchain for better-sqlite3).
# Base — pnpm + native build toolchain (better-sqlite3 / sharp need it)
# ---------------------------------------------------------------------------
FROM node:${NODE_VERSION}-bookworm-slim AS base
ENV PNPM_HOME=/pnpm \
PATH=/pnpm:$PATH \
NODE_ENV=production
PATH=/pnpm:$PATH
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates git \
&& apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
# ---------------------------------------------------------------------------
# Builder — install + build all workspaces.
# Builder — install deps + build every workspace this image needs
# ---------------------------------------------------------------------------
FROM base AS builder
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json turbo.json tsconfig.base.json ./
ENV NODE_ENV=development
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json turbo.json tsconfig.base.json eslint.config.js ./
COPY cial-core ./cial-core
COPY cial-platform ./cial-platform
COPY cial-app ./cial-app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile=false
RUN pnpm turbo run build \
--filter @cial/protocol \
--filter @cial/sdk \
--filter @cial/back \
--filter @cial/front
--filter @cial/edge \
--filter @cial/front \
--filter @cial/platform-back \
--filter @cial/platform-front
# ---------------------------------------------------------------------------
# Runtime — two users, locked-down filesystem.
# Runtime — two users + tini + gosu, the supervisor as PID 1
# ---------------------------------------------------------------------------
FROM node:${NODE_VERSION}-bookworm-slim AS runtime
ENV NODE_ENV=production \
CIAL_BACK_PORT=4000 \
CIAL_FRONT_PORT=4001 \
CIAL_SOCK=/run/cial-core.sock
RUN groupadd --system --gid 1000 cial \
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini gosu ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 cial \
&& useradd --system --uid 1000 --gid 1000 --home /home/cial --create-home --shell /bin/bash cial \
&& groupadd --system --gid 1001 agent \
&& useradd --system --uid 1001 --gid 1001 --home /home/agent --create-home --shell /bin/bash agent \
&& mkdir -p /run /opt/cial-core /opt/cial-platform /var/lib/cial \
&& chown -R cial:cial /opt/cial-core /var/lib/cial \
&& chown -R agent:agent /opt/cial-platform \
&& chmod 0700 /opt/cial-core
&& mkdir -p /opt/cial-monorepo /var/lib/cial \
&& chown cial:cial /var/lib/cial \
&& chmod 0700 /var/lib/cial
# Copy built Core (owned by `cial`, mode 0700 — agent CANNOT read it).
COPY --from=builder --chown=cial:cial /workspace/cial-core /opt/cial-core
# Copy the entire built workspace (root-owned by default) so node_modules
# symlinks line up.
COPY --from=builder /workspace /opt/cial-monorepo
# Copy built Platform (owned by `agent` — editable).
COPY --from=builder --chown=agent:agent /workspace/cial-platform /opt/cial-platform
# Lock down /opt/cial-monorepo/cial-core to user `cial` only (mode 0700).
# Agent literally cannot enter the directory — the filesystem enforces the
# Core/Platform boundary.
RUN chown -R cial:cial /opt/cial-monorepo/cial-core \
&& chmod -R u=rwX,go= /opt/cial-monorepo/cial-core \
&& chown -R agent:agent /opt/cial-monorepo/cial-platform \
&& chmod -R u=rwX,go=rX /opt/cial-monorepo/cial-platform \
# /opt/cial-app is built but not used at runtime in tenant containers; remove
# to avoid confusion + shrink image.
&& rm -rf /opt/cial-monorepo/cial-app
# Entry script (phase 2 will turn this into a tini-based supervisor that
# launches @cial/back as `cial` and the platform process as `agent`).
COPY cial-core/docker/entrypoint.sh /usr/local/bin/cial-entrypoint
RUN chmod 0755 /usr/local/bin/cial-entrypoint
USER cial
WORKDIR /opt/cial-core
EXPOSE 4000 4001
ENTRYPOINT ["/usr/local/bin/cial-entrypoint"]
WORKDIR /opt/cial-monorepo
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--", "node", "/opt/cial-monorepo/cial-core/edge/dist/supervisor.js"]

View file

@ -1,14 +0,0 @@
#!/usr/bin/env bash
# Per-tenant container entrypoint.
#
# Phase 2 of PLAN.md replaces this with a real supervisor (tini + two child
# processes). For now this just prints the boot intent so the image can be
# built and smoke-tested.
set -euo pipefail
echo "[cial] entrypoint placeholder — phase 2 will start:"
echo " - @cial/back on :${CIAL_BACK_PORT:-4000} (user: cial)"
echo " - @cial/front on :${CIAL_FRONT_PORT:-4001} (user: cial)"
echo " - platform process (user: agent)"
echo "[cial] sleeping so container stays up for inspection..."
exec sleep infinity

41
cial-core/edge/README.md Normal file
View file

@ -0,0 +1,41 @@
# `@cial/edge` — Tenant container edge + supervisor
Two scripts that together make the tenant container behave like a single
HTTP origin while preserving the four-process layout and the two-user
filesystem boundary.
## Scripts
- **`dist/supervisor.js`** — PID 1 (under `tini`). Runs as `root`. Spawns
the four service processes plus the edge using `gosu` to drop to the
correct Linux user (`cial` for Core + edge, `agent` for Platform). If
any child exits, tears down and exits non-zero.
- **`dist/edge.js`** — runs as `cial`. Tiny `http-proxy`-based reverse
proxy on `:8080`. Multiplexes:
| path | → target | notes |
|------------------|--------------------------------|-------------------------|
| `/.cial/api/*` | `http://127.0.0.1:4000` (back) | strips `/.cial/api` |
| `/.cial/*` | `http://127.0.0.1:4001` (front)| basePath stays intact |
| `/*` | `http://127.0.0.1:3000` (platform-front) | catch-all |
Platform Back (`http://127.0.0.1:3001`) is **not exposed** publicly in v1.
It's reachable from Platform Front server-side only. We pick a public
mount path later when there's a real use case.
## Env
| var | default |
|------------------------------|--------------------------|
| `EDGE_PORT` | `8080` |
| `TENANT_ID` | `unknown` (logged only) |
| `EDGE_TARGET_BACK` | `http://127.0.0.1:4000` |
| `EDGE_TARGET_CORE_FRONT` | `http://127.0.0.1:4001` |
| `EDGE_TARGET_PLATFORM_FRONT` | `http://127.0.0.1:3000` |
## Local
This package is not meant to be run on the host. The supervisor expects
`gosu` and the `cial`/`agent` users to exist, and looks for built code at
`/opt/cial-monorepo/...`. Use the tenant Dockerfile (see
`cial-core/docker/`).

View file

@ -0,0 +1,20 @@
{
"name": "@cial/edge",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/edge.js",
"scripts": {
"build": "tsc -b",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"http-proxy": "1.18.1"
},
"devDependencies": {
"@types/http-proxy": "1.17.15",
"@types/node": "22.10.5",
"typescript": "5.7.3"
}
}

View file

@ -0,0 +1,84 @@
/**
* Per-tenant edge proxy.
*
* Single process bound to the container's exposed port (8080 by default).
* Multiplexes inbound HTTP and WebSocket traffic to four internal services
* on loopback:
*
* /.cial/api/* @cial/back (strip /.cial/api prefix)
* /.cial/* @cial/front (basePath=/.cial keep prefix)
* /* @cial/platform-front
*
* @cial/platform-back is reachable only from Platform Front server-side
* (http://127.0.0.1:3001) — no public route in v1.
*
* Runs as the `cial` user inside the tenant container. Started by the
* supervisor.
*/
import http from 'node:http';
import httpProxy from 'http-proxy';
const PORT = Number(process.env.EDGE_PORT ?? 8080);
const TENANT_ID = process.env.TENANT_ID ?? 'unknown';
const targets = {
back: process.env.EDGE_TARGET_BACK ?? 'http://127.0.0.1:4000',
coreFront: process.env.EDGE_TARGET_CORE_FRONT ?? 'http://127.0.0.1:4001',
platformFront: process.env.EDGE_TARGET_PLATFORM_FRONT ?? 'http://127.0.0.1:3000',
};
const proxy = httpProxy.createProxyServer({
ws: true,
changeOrigin: false,
xfwd: true,
});
proxy.on('error', (err: Error, _req, res) => {
process.stderr.write(`[edge] proxy error: ${err.message}\n`);
if (res && 'writeHead' in res && !res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { code: 'BAD_GATEWAY', message: err.message } }));
}
});
interface RouteDecision {
target: string;
rewrittenUrl: string;
}
function route(url: string): RouteDecision {
if (url.startsWith('/.cial/api/') || url === '/.cial/api') {
const stripped = url.replace(/^\/\.cial\/api/, '') || '/';
return { target: targets.back, rewrittenUrl: stripped };
}
if (url.startsWith('/.cial/') || url === '/.cial') {
return { target: targets.coreFront, rewrittenUrl: url };
}
return { target: targets.platformFront, rewrittenUrl: url };
}
const server = http.createServer((req, res) => {
const decision = route(req.url ?? '/');
req.url = decision.rewrittenUrl;
proxy.web(req, res, { target: decision.target });
});
server.on('upgrade', (req, socket, head) => {
const decision = route(req.url ?? '/');
req.url = decision.rewrittenUrl;
proxy.ws(req, socket, head, { target: decision.target });
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`[edge] tenant=${TENANT_ID} listening on :${PORT}\n`);
});
function shutdown(signal: string): void {
process.stdout.write(`[edge] received ${signal}, closing\n`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View file

@ -0,0 +1,132 @@
/**
* Tenant container supervisor.
*
* PID 1 (under tini). Spawns the four service processes Core Back, Core
* Front, Platform Back, Platform Front plus the edge proxy. Uses `gosu`
* to run each child as the right Linux user (cial vs agent), enforcing the
* filesystem-level boundary defined in cial-core/docker/Dockerfile.
*
* If any child exits unexpectedly, the supervisor tears the others down and
* exits non-zero so Docker's restart policy kicks in.
*/
import { spawn, type ChildProcess } from 'node:child_process';
interface ServiceSpec {
name: string;
user: 'cial' | 'agent';
cmd: string[];
cwd?: string;
env?: Record<string, string>;
}
const MONOREPO = '/opt/cial-monorepo';
const SERVICES: ServiceSpec[] = [
{
name: 'core-back',
user: 'cial',
cmd: ['node', `${MONOREPO}/cial-core/back/dist/index.js`],
env: { PORT: '4000', DB_PATH: '/var/lib/cial/cial.db', LOG_LEVEL: 'info' },
},
{
name: 'core-front',
user: 'cial',
cmd: ['node', 'node_modules/next/dist/bin/next', 'start'],
cwd: `${MONOREPO}/cial-core/front`,
env: { PORT: '4001', HOSTNAME: '127.0.0.1' },
},
{
name: 'platform-back',
user: 'agent',
cmd: ['node', `${MONOREPO}/cial-platform/back/dist/index.js`],
env: { PLATFORM_BACK_PORT: '3001' },
},
{
name: 'platform-front',
user: 'agent',
cmd: ['node', 'node_modules/next/dist/bin/next', 'start'],
cwd: `${MONOREPO}/cial-platform/front`,
env: { PORT: '3000', HOSTNAME: '127.0.0.1' },
},
{
name: 'edge',
user: 'cial',
cmd: ['node', `${MONOREPO}/cial-core/edge/dist/edge.js`],
env: { EDGE_PORT: '8080' },
},
];
const TENANT_ID = process.env.TENANT_ID ?? 'unknown';
const baseEnv: Record<string, string> = {
...(process.env as Record<string, string>),
TENANT_ID,
NODE_ENV: 'production',
};
const children = new Map<string, ChildProcess>();
let shuttingDown = false;
let exitCode = 0;
function logLine(stream: NodeJS.WriteStream, name: string, chunk: Buffer): void {
const lines = chunk.toString('utf8').split('\n');
if (lines[lines.length - 1] === '') lines.pop();
for (const line of lines) {
stream.write(`[${name}] ${line}\n`);
}
}
function start(svc: ServiceSpec): void {
const env = { ...baseEnv, ...svc.env };
const child = spawn('gosu', [svc.user, '--', ...svc.cmd], {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout?.on('data', (d: Buffer) => logLine(process.stdout, svc.name, d));
child.stderr?.on('data', (d: Buffer) => logLine(process.stderr, svc.name, d));
child.on('exit', (code, sig) => {
process.stderr.write(
`[supervisor] child "${svc.name}" exited code=${code} sig=${sig}\n`,
);
if (!shuttingDown) {
exitCode = code === 0 ? 1 : (code ?? 1);
shutdown('child-exit');
}
});
children.set(svc.name, child);
process.stdout.write(
`[supervisor] started ${svc.name} as ${svc.user} (pid=${child.pid})\n`,
);
}
function shutdown(reason: string): void {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write(`[supervisor] shutting down (${reason})\n`);
for (const child of children.values()) {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
}
setTimeout(() => {
for (const child of children.values()) {
try {
child.kill('SIGKILL');
} catch {
// ignore
}
}
process.exit(exitCode);
}, 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.stdout.write(
`[supervisor] tenant=${TENANT_ID} starting ${SERVICES.length} services\n`,
);
for (const svc of SERVICES) start(svc);

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -1,10 +1,16 @@
export const dynamic = 'force-dynamic';
export default function CoreHome() {
const tenantId = process.env.TENANT_ID ?? 'unknown';
return (
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
<h1>Cial Core</h1>
<h1>Cial · Core</h1>
<p>
Rescue UI placeholder. Phase 4 will replace this with the chat shell, deploy
controls, and trigger panel.
Tenant: <code>{tenantId}</code>
</p>
<p>
Rescue UI placeholder. Phase 4 of <code>PLAN.md</code> replaces this with the
chat shell, deploy controls, and trigger panel.
</p>
</main>
);

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -1,7 +1,13 @@
export const dynamic = 'force-dynamic';
export default function PlatformHome() {
const tenantId = process.env.TENANT_ID ?? 'unknown';
return (
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
<h1>Platform</h1>
<p>
Tenant: <code>{tenantId}</code>
</p>
<p>This is the editable surface. The agent owns this code.</p>
</main>
);

View file

@ -16,6 +16,7 @@
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules .turbo",
"smoke": "node scripts/smoke.mjs",
"smoke:tenant": "node scripts/smoke-tenant.mjs",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},

View file

@ -157,6 +157,22 @@ importers:
specifier: ^5.7.3
version: 5.7.3
cial-core/edge:
dependencies:
http-proxy:
specifier: 1.18.1
version: 1.18.1
devDependencies:
'@types/http-proxy':
specifier: 1.17.15
version: 1.17.15
'@types/node':
specifier: 22.10.5
version: 22.10.5
typescript:
specifier: 5.7.3
version: 5.7.3
cial-core/front:
dependencies:
next:
@ -1003,6 +1019,9 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/http-proxy@1.17.15':
resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1474,6 +1493,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@ -1526,6 +1548,15 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@ -1591,6 +1622,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
http-proxy@1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -1969,6 +2004,9 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -2700,6 +2738,10 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/http-proxy@1.17.15':
dependencies:
'@types/node': 22.10.5
'@types/json-schema@7.0.15': {}
'@types/mime@1.3.5': {}
@ -3197,6 +3239,8 @@ snapshots:
etag@1.8.1: {}
eventemitter3@4.0.7: {}
expand-template@2.0.3: {}
express@4.21.2:
@ -3277,6 +3321,8 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0: {}
forwarded@0.2.0: {}
fresh@0.5.2: {}
@ -3338,6 +3384,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
http-proxy@1.18.1:
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.0
requires-port: 1.0.0
transitivePeerDependencies:
- debug
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -3682,6 +3736,8 @@ snapshots:
real-require@0.2.0: {}
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}

179
scripts/smoke-tenant.mjs Normal file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Tenant container acceptance test (L0 of PLAN-LOCAL.md).
*
* Pre-req:
* docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
*
* Usage:
* pnpm smoke:tenant
*
* What it does:
* 1. Runs `docker run -d` with TENANT_ID=demo on host port 19090
* 2. Polls /.cial/api/healthz until 200 (60s budget)
* 3. Probes the four route classes through the edge proxy
* 4. Verifies the two-user filesystem boundary (agent cannot read Core)
* 5. Tears the container down
*
* Exits 0 on success, 1 otherwise.
*/
import { spawnSync, spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const IMAGE = process.env.CIAL_TENANT_IMAGE ?? 'cial-tenant:dev';
const HOST_PORT = Number(process.env.CIAL_TENANT_HOST_PORT ?? 19090);
const TENANT_ID = process.env.CIAL_TENANT_ID ?? 'demo';
const NAME = `cial-tenant-smoke-${process.pid}`;
let failed = false;
function dockerRun(args, opts = {}) {
const r = spawnSync('docker', args, { encoding: 'utf8', ...opts });
if (r.status !== 0 && !opts.allowFail) {
process.stderr.write(`docker ${args.join(' ')}\nstdout: ${r.stdout}\nstderr: ${r.stderr}\n`);
}
return r;
}
function teardown() {
process.stdout.write('\n▶ Tearing down…\n');
dockerRun(['rm', '-f', NAME], { allowFail: true });
}
process.on('SIGINT', () => {
teardown();
process.exit(130);
});
async function probe(url) {
try {
return await fetch(url, { signal: AbortSignal.timeout(5000) });
} catch {
return null;
}
}
async function waitReady(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await probe(url);
if (res && res.status < 500) return true;
await sleep(750);
}
return false;
}
function check(label, ok, detail = '') {
const mark = ok ? '✓' : '✗';
process.stdout.write(`${mark} ${label}${detail ? ` ${detail}` : ''}\n`);
if (!ok) failed = true;
}
async function main() {
process.stdout.write(`▶ Starting ${IMAGE} as ${NAME} on host :${HOST_PORT}\n`);
// Image present?
const img = dockerRun(['image', 'inspect', IMAGE], { allowFail: true });
if (img.status !== 0) {
process.stderr.write(
`✗ image "${IMAGE}" not found. Build it first:\n` +
` docker build -f cial-core/docker/Dockerfile -t ${IMAGE} .\n`,
);
process.exit(1);
}
// Clean any leftover container with the same name (defensive).
dockerRun(['rm', '-f', NAME], { allowFail: true });
const run = dockerRun([
'run',
'-d',
'--name', NAME,
'-e', `TENANT_ID=${TENANT_ID}`,
'-p', `${HOST_PORT}:8080`,
IMAGE,
]);
if (run.status !== 0) {
process.exit(1);
}
// Tail container logs in the background so failures are debuggable.
const logs = spawn('docker', ['logs', '-f', NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
logs.stdout.on('data', (d) => process.stdout.write(`[container] ${d}`));
logs.stderr.on('data', (d) => process.stderr.write(`[container!] ${d}`));
try {
process.stdout.write('\n▶ Waiting for /.cial/api/healthz…\n');
const ready = await waitReady(`http://127.0.0.1:${HOST_PORT}/.cial/api/healthz`, 60_000);
check('Core Back reachable through edge', ready);
if (!ready) return;
process.stdout.write('\n▶ Probes…\n');
// Core Back via edge (prefix stripped: /.cial/api/healthz → /healthz)
const healthz = await probe(`http://127.0.0.1:${HOST_PORT}/.cial/api/healthz`);
const healthzBody = healthz ? await healthz.json().catch(() => null) : null;
check(
'GET /.cial/api/healthz → 200 ok',
healthz?.status === 200 && healthzBody?.status === 'ok',
`(${healthz?.status})`,
);
// Core Back 501 stub envelope through edge
const vault = await probe(`http://127.0.0.1:${HOST_PORT}/.cial/api/vault`);
const vaultBody = vault ? await vault.json().catch(() => null) : null;
check(
'GET /.cial/api/vault → 501 NOT_IMPLEMENTED',
vault?.status === 501 && vaultBody?.error?.code === 'NOT_IMPLEMENTED',
`(${vault?.status})`,
);
// Core Front (Next, basePath=/.cial)
const coreFront = await probe(`http://127.0.0.1:${HOST_PORT}/.cial`);
const coreFrontHtml = coreFront ? await coreFront.text() : '';
check(
'GET /.cial → Core Front shows tenant id',
coreFront?.status === 200 && coreFrontHtml.includes(TENANT_ID),
`(${coreFront?.status})`,
);
// Platform Front (Next, /)
const platformFront = await probe(`http://127.0.0.1:${HOST_PORT}/`);
const platformFrontHtml = platformFront ? await platformFront.text() : '';
check(
'GET / → Platform Front shows tenant id',
platformFront?.status === 200 && platformFrontHtml.includes(TENANT_ID),
`(${platformFront?.status})`,
);
// Two-user boundary: agent must NOT be able to read Core code
const exec = dockerRun(
[
'exec',
'--user', 'agent',
NAME,
'cat', '/opt/cial-monorepo/cial-core/back/dist/index.js',
],
{ allowFail: true },
);
check(
'agent cannot read /opt/cial-monorepo/cial-core/* (mode 0700)',
exec.status !== 0,
`(exit ${exec.status})`,
);
process.stdout.write(`\n${failed ? '✗ FAIL' : '✓ PASS'}\n`);
} finally {
logs.kill('SIGTERM');
teardown();
}
process.exit(failed ? 1 : 0);
}
main().catch((err) => {
process.stderr.write(`smoke-tenant harness error: ${err.stack || err}\n`);
teardown();
process.exit(1);
});