Environment routing — dev, preview, prod
Route REST, metadata, automation, AI, and package calls through /api/v1/environments/:environmentId/....
Environment-scoped routing makes the target runtime explicit in the URL:
/api/v1/environments/:environmentId/data/:object
/api/v1/environments/:environmentId/meta
/api/v1/environments/:environmentId/automation/...
/api/v1/environments/:environmentId/ai/...The scoped URL wins over hostname, header, session, and default-environment resolution. Use it when a caller must address a specific environment without depending on request context.
Server configuration
Environment scoping is a host decision, not an application one. The two keys
are declared on the stack's top-level api block, but the host that boots the
stack is what decides whether they take effect:
import { defineStack } from '@objectstack/spec';
export default defineStack({
// ...your manifest, objects, apis, etc.
api: {
enableProjectScoping: true,
projectResolution: 'auto',
},
});On the default os serve path this block does not turn scoping on. A bare
defineStack() config carries no instantiated plugins, so the CLI boots
createStandaloneStack() and merges that boot result over the authored config
(mergeBootConfig). The boot builder ships
api: { enableProjectScoping: false, projectResolution: 'auto' } and wins on
exactly those two keys — deliberately, because scoping is not the author's call
on a standalone host.
The two keys are not overridden the same way:
enableProjectScoping: trueis contradicted. The value forwarded to the REST and dispatcher plugins isfalse, and no scoped routes are registered.projectResolution: 'auto'is redundant. The boot builder pins the same value, so writing it changes nothing; declaring any other strategy here is silently replaced by'auto'.
Every other authored api key — enforceProjectMembership, for example —
survives the merge untouched.
The block above is live when the CLI does not boot the standalone stack:
when the exported config is host-shaped, meaning its plugins array already
carries instantiated plugin objects. That is the shape a multi-environment host
assembles, and this open-core CLI supports the standalone boot mode only —
cloud / multi-environment hosts ship from a separate distribution. Setting
OS_MODE=off (or bootMode: 'off' on the exported config) also skips the
standalone boot, but it drops the CLI to its legacy lightweight assembler rather
than producing a scoped standalone host; bootMode is additionally not a
declared stack key, so defineStack() rejects it as an unrecognized key.
api is a declared top-level field on ObjectStackDefinitionSchema, so it
survives defineStack's strict parsing — you can pass it directly inside the
defineStack({ ... }) call as shown above — and keep it there. Spreading it onto
a copy of the built stack instead, e.g. export default { ...stack, api: {...} },
exports an object defineStack did not return, which os validate and os build
refuse (STACK_PROVENANCE_MISSING). The CLI reads the resolved value from the exported
config (config.api) when registering the REST and dispatcher plugins — but it
reads it after the boot result has been merged in, which is why the standalone
path forwards the boot builder's scoping decision rather than the author's.
The option names are historical for compatibility with existing config files;
the route, header, env var, and request context all use environment.
Once scoping is enabled by the host, projectResolution selects the route
surface:
| Strategy | Behavior | When to use |
|---|---|---|
auto | Registers both unscoped /api/v1/... and scoped /api/v1/environments/:environmentId/... routes. | Default migration mode. |
optional | Same route surface as auto; resolution may come from URL, hostname, header, session, or default. | Multi-environment hosts that still accept unscoped callers. |
required | Registers only environment-scoped routes for data/meta/AI/automation handlers, and for the REST POST /packages/publish route. The dispatcher's own /packages* bridge is the exception — it stays mounted unscoped too. | Hardened clients that always pass an environment id. |
required does not close the unscoped /packages* surface. The dispatcher
plugin mounts the package routes at the unscoped prefix unconditionally and adds
the scoped mount beside it, so GET/POST/PATCH/DELETE
/api/v1/packages... — the destructive lifecycle verbs included — stay served
on a required host. The asymmetry with the automation / action / AI families
is deliberate and recorded at the mount site
(packages/runtime/src/dispatcher-plugin.ts): removing a mounted route surface
is a different change, with a different blast radius, from adding a missing
door.
It is not an isolation hole. An unscoped request names no environment, so it is
resolved by steps 2–6 of the order below and is served by whichever environment
that resolves to, behind the same project-membership and capability gates as the
scoped URL — the scoped URL is step 1, the stronger way to address an
environment, not a weaker one. What required does not give you for package
routes is the guarantee that every caller has named an environment in the URL.
Client SDK
The client keeps the legacy factory name project(id), but the value is an
environment id and the generated URLs are environment-scoped:
import { ObjectStackClient } from '@objectstack/client';
const client = new ObjectStackClient({
baseUrl: 'https://api.example.com',
});
const env = client.environment('env_prod');
await env.data.find('customer', { top: 20 });
await env.meta.getItems('object');
await env.packages.list();The top-level client can also set a default environment header:
const client = new ObjectStackClient({
baseUrl: 'https://api.example.com',
environmentId: 'env_prod',
});That sends X-Environment-Id: env_prod on unscoped requests.
Resolution order
The open-source HttpDispatcher only parses the scoped URL for an
environment-id hint (extractEnvironmentIdFromPath) and forwards it via
prepareResolverHints. Actual resolution is owned by the host's
KernelResolver (resolveKernel, per ADR-0006 Phase 5), which resolves the
environment in this order:
:environmentIdfrom/api/v1/environments/:environmentId/...- Hostname through the configured environment registry
X-Environment-Idsession.activeEnvironmentId- A configured default environment
- A single available environment, when the registry can prove the choice is unambiguous
Control-plane endpoints remain unscoped. Requests such as
/api/v1/cloud/environments/:id manage environments rather than running inside
one, so they are intentionally excluded from data-plane resolution.
Migration checklist
- Replace
/api/v1/projects/:projectId/...with/api/v1/environments/:environmentId/.... - Replace
X-Project-IdwithX-Environment-Id. - Replace
OS_PROJECT_IDwithOS_ENVIRONMENT_ID. - Store runtime rows under
environment_id. - Replace the SDK
client.project(id)call withclient.environment(id). It was a compatibility method name until ADR-0006 D2; there is no alias, so the old spelling does not resolve.
Related
Client SDK
The official TypeScript client for ObjectStack — auto-discovery, typed metadata, CRUD, batch operations, and service-aware feature detection.
Data Flow Diagrams
How data moves through the ObjectStack system — from defineStack() configuration to database queries, realtime events, and metadata resolution