# `3000cloud.json` — manifest reference (preview)

The manifest is the single deploy input: your agent writes it at the repo root, the platform deploys from it. JSON Schema: https://3000cloud.com/schema/v1.json. Unknown keys are rejected everywhere (strict objects). Every validation error is `{path, message, hint}` — fix each path per its hint and resubmit.

> **Preview status.** The validator enforces the full schema below, but the preview cluster honors a subset. **Honored today:** `name`; `services` with `type: "web"` on **node**/**python** runtimes — including both at once for a frontend-build + backend shape (`root`, `install`, `build`, `start`, `port`, `healthCheckPath`) and `type: "static"` — `install`/`start` run in-cluster when the app boots (no Dockerfile builds yet); `volumes[]` node-local persistence; `access` — `public` (the default) and `oidc-allowlist` (Google sign-in against an email/`@domain` allowlist). **Validated but NOT yet in effect** (marked *(soon)* below): worker services, `cron[]`, `db`, the `secret-link` / `password` access modes (rejected at deploy), secret `env` handling, `aptPackages`, `preDeploy`, `region: "eu"`, `lockfileHashes` verification. Fields you set there are accepted and stored, but do not rely on their behavior yet.

## Top level

| Field | Required | Meaning |
|---|---|---|
| `schemaVersion` | yes | Always `1`. |
| `name` | yes | App name; lowercase DNS label. Becomes `https://<name>.3000cloud.app`. |
| `region` | no | `"us"` (default). `"eu"` *(soon)*. |
| `services` | yes | 1–5 services (below). At most **one** `web` service. |
| `env` | no | Env var specs (below). Secret handling *(soon)* — see the env section. |
| `volumes` | no | Persistent disks, e.g. `[{"mountPath": "/data", "sizeGB": 5}]` — node-local storage mounted at `mountPath`. Survives restarts and redeploys, but is pinned to the machine it was created on; **no backups yet**. |
| `db` | no | *(soon)* `{"kind": "sqlite", "path": "/data/app.db"}` or `{"kind": "postgres"}` (+$5/mo add-on at launch). |
| `cron` | no | *(soon)* Scheduled jobs; max 20, unique names, 5-field UTC cron, command runs without a shell. |
| `egress` | no | FQDN allowlist; default `[]` = no outbound network. Hostnames only (`"api.openai.com"`, one leading `"*."` label allowed, never bare `"*"`). The cluster is default-deny — list every external API your app calls. |
| `access` | no | Who can open the app (below). Default `{"mode": "public"}` — anyone with the URL. `{"mode": "oidc-allowlist", ...}` requires Google sign-in. Seeds the app's stored access setting on the **first** deploy only; afterwards the owner's dashboard/MCP/REST setting wins and redeploys never reset it. |
| `resources` | no | `{"tier": "starter" \| "base" \| "plus" \| "pro"}`. Default `starter`. Sizes your dedicated resources (requests = limits). See [pricing.md](https://3000cloud.com/docs/pricing.md). |
| `lockfileHashes` | no | *(soon)* Map of repo-relative lockfile path to `"sha256:<64 hex>"`. |

## `services[]`

| Field | Meaning |
|---|---|
| `name` | Unique DNS label. |
| `type` | `"web"` (default; routed + health-checked), `"static"` (serves prebuilt files), `"worker"` *(soon — validates but will not run yet)*. |
| `root` | Subdirectory the service lives in (repo-relative, no `..`). |
| `runtime` | Version map, e.g. `{"node": "22"}` or `{"python": "3.12"}`. The preview runs **node** and **python**; other keys (go, ruby, ...) validate but will not deploy yet. Declaring **both** node and python makes a multi-runtime bundle: node builds the frontend first (`build`, default `npm install && npm run build`), then python runs the backend (`install`, then `start`) — the backend should serve the built static dir itself. |
| `packageManager` | `pip \| uv \| poetry \| pipenv \| npm \| pnpm \| yarn \| bun` (advisory). |
| `aptPackages` | *(soon)* Debian packages for the build image. |
| `install` / `build` / `start` | Commands, run in-cluster at app boot. `start` is required for web services and **must bind `$PORT`** on `0.0.0.0` (or the literal declared `port`). |
| `port` | 1–65535; required for web services. |
| `healthCheckPath` | URL path (starts with `/`) polled for readiness; web only. |
| `preDeploy` | *(soon)* Runs once per deploy before the new version starts (migrations). |
| `framework` | Advisory slug (`fastapi`, `nextjs`, ...). |
| `static` | `{"dir": "...", "urlPrefix": "/"}` for static services. `dir` is relative to `root` and may use `../` (e.g. a built frontend). |

## `env`

Keys are `UPPER_SNAKE_CASE`. Each spec takes **exactly one** value source:

- `{"value": "literal"}` — non-secret config. **Never put real secrets here** — the manifest travels in plain requests.
- `{"generator": "secret"}` — *(soon)* platform-generated secret at first deploy.
- `{"fromDatabase": true}` — *(soon)* injected DB connection string (requires `db`).
- `{"required": true, "description": "..."}` — *(soon)* value supplied after deploy via a secret channel. **The secret upload channel is not live yet**: apps that need secret env vars cannot receive them in the preview — hold off deploying those, or ask the operator.

## `egress[]`

Hostnames only — `"api.stripe.com"`, or a single leading wildcard label `"*.googleapis.com"`. No schemes, paths, ports, IPs, or bare `"*"`. Everything not listed is unreachable (the cluster denies outbound by default).

## `access`

| Mode | Meaning | Status |
|---|---|---|
| `public` | Anyone with the URL (default). | live |
| `oidc-allowlist` | Visitors sign in with Google and must match an `allow` entry: full emails (`"a@b.com"`) or whole domains (`"@example.org"`), case-insensitive, max 200 entries. Requires a non-empty `allow` or `requestAccess: true` — an empty allowlist with no request-access would lock everyone out and is rejected. | live |
| `secret-link` | Unguessable URL token. | **not yet supported** — the deploy is rejected with `{"stage": "validation", "message": "access.mode \"secret-link\" is not supported yet", "hint": "use \"public\" (default) or \"oidc-allowlist\""}` |
| `password` | Shared password, set out-of-band. | **not yet supported** — rejected at deploy, same error |

```json
"access": { "mode": "oidc-allowlist", "allow": ["you@example.com", "@yourteam.com"], "requestAccess": true }
```

What a visitor of an `oidc-allowlist` app sees: a **Login is required** page with a Sign in with Google button (non-browser clients get `401 {"stage": "auth", "message": "sign-in required", "hint": "<login URL>"}`); after sign-in, an allowlisted email is sent straight into the app with a 7-day host-scoped session (re-checked against the allowlist on every request — removing an email takes effect within about 10 seconds). Anyone else sees **You have not been granted access to this application.** — with a **Request access** button when `requestAccess` is on and they have not already asked. Pending, rejected and request-access-off look identical to the visitor.

`requestAccess: true` — the owner decides each request from the dashboard (https://dashboard.3000cloud.com → the app → Access requests), with the MCP tools `list_access_requests` / `decide_access_request`, with `POST /v1/apps/:name/access/requests/:id/approve` (or `…/reject`), or from the approve/reject links in the notification email (`notify: true`, default off; each link asks the owner to sign in with Google first). Approval appends the visitor's email to `allow`; a rejected request stays listed and can be approved later. A reject (or a remove) that would not actually keep the person out — because a `@domain` entry on the list still admits them — is refused with `409 {"stage": "conflict"}` naming that entry, rather than reported as a revocation that did not happen. Approving on a `public` app is refused the same way: there is no allowlist to add anyone to.

The manifest block **seeds** the stored setting on the first deploy only. Afterwards it lives with the app: change it in the dashboard, with MCP `set_access`, or `PUT /v1/apps/:name/access` — see [deploy.md](https://3000cloud.com/docs/deploy.md). **Public is the default: ask your human whether the app should require Google sign-in, and who to allow, before you deploy — do not decide silently in either direction.**

A gated app receives `x-3000cloud-user: <email>` and `x-3000cloud-mode: oidc-allowlist` on every request (a public app sees `anonymous` / `public`). They cannot be spoofed — the edge strips inbound copies before routing, and the edge is the only network path to the app — and the gate's own session cookie never reaches your app. The gate runs at the platform edge, so it answers even while the app is stopped.

## `resources`

`{"tier": "starter"}` (the default — $10/mo, 0.5 vCPU / 1 GiB / 5 GiB; higher tiers are unavailable in v1) — the priced menu is at `GET https://api.3000cloud.com/v1/tiers` (no auth) and [pricing.md](https://3000cloud.com/docs/pricing.md). Resources are dedicated: requests = limits, what you buy is what you get. Google sign-in and Stripe Checkout are required before an API token is issued. Starter includes one WebApp; there is no free tier.

## Validation errors

The API returns `{stage: "validation", message, hint, errors: [{path, message, hint}]}` — e.g. `path: "egress[0]"`, `message: '"https://api.stripe.com" is a URL, not a hostname'`, `hint: "egress entries must be hostnames like api.stripe.com — remove the URL scheme"`. Fix each `path` per its `hint` and resubmit.

> Keep-in-sync note: this page mirrors the `@3000cloud/manifest` package (`packages/manifest`), which is the source of truth for the schema and tier constants. If they disagree, the package wins.
