@rayspec/server
v1.8.0
Published
LOCAL/single-node production-style boot entrypoint + AppDeps composition root. Pre-external-hardening (no RLS/KMS/per-tenant isolate/DPoP) — NOT internet-facing. Assembles the platform from env, applies the committed migration chain via the real programma
Readme
@rayspec/server — LOCAL boot entrypoint + AppDeps composition root
The supported LOCAL production-style boot for the RaySpec platform: it reads config from the environment, fails closed on missing secrets, applies the committed migration chain via the real programmatic migrator, assembles the full app (auth + OIDC + optional declared product routes), and serves it on a port.
LOCAL / single-node / pre-external-hardening — NOT internet-facing. The external-hardening suite (RLS · KMS-wrapped DEKs · per-tenant sandbox · DPoP) is the gate before any external exposure and is not built yet. Do not place this server behind a public address. The boot prints a loud banner saying the same; see the security model in
docs/ARCHITECTURE.md.
Run
pnpm db:up # Docker Postgres on :5433
pnpm --filter @rayspec/server serve # tsx src/serve.ts (local dev)
# or the built bin:
pnpm --filter @rayspec/server build
node packages/app/server/dist/serve.js # the `rayspec-serve` binA successful boot prints the banner + the routes, then listens on PORT. Probe it:
curl -s http://127.0.0.1:8080/health
# {"status":"ok","db":"ok"}Environment
| Var | Required | Meaning |
| --- | --- | --- |
| DATABASE_URL | yes | Postgres connection string. The committed migration chain is applied here at boot (bootstraps a clean DB; idempotent on an up-to-date one). |
| RAYSPEC_JWT_SIGNING_KEY | yes | RS256 PKCS#8 PEM — the JWT signing key AND the OIDC provider signing key. Secret-manager/env only (never DB/git). |
| RAYSPEC_API_KEY_PEPPER | yes | The api-key pepper. Secret-manager/env only. |
| ALLOWED_ORIGINS | no | Comma-separated cookie-CSRF allow-list. Unset ⇒ EMPTY (no cross-origin). NEVER dev-permissive by default. |
| OIDC_ISSUER | no | The OIDC issuer (drives emitted URLs). Default http://127.0.0.1:<port>/oidc. |
| PORT | no | TCP port. Default 8080. A non-numeric/out-of-range value fails closed. |
| RAYSPEC_SPEC_PATH | no | Absolute path to a rayspec.yaml to deploy at boot (the declarative engine). The platform ships none — the deployer injects it. Absent ⇒ an auth-only boot. |
| RAYSPEC_HANDLER_ROOT | no | The path-jail root for declared escape-hatch handlers. Defaults to the spec file's directory. |
| RAYSPEC_SKIP_DOTENV | no | Set to 1 to skip the local-DX .env loader (prove a pure-ambient-env boot). That loader reads $PWD/.env first and the install-root .env second (the install root is resolved from the loader's own module location), per key, and never overrides a variable already set. |
Missing DATABASE_URL / RAYSPEC_JWT_SIGNING_KEY / RAYSPEC_API_KEY_PEPPER → the boot aborts
with an actionable message (fail-closed), never a partial start.
Reading the boot values from a file
Each of those three also accepts a <VAR>_FILE variant — DATABASE_URL_FILE,
RAYSPEC_JWT_SIGNING_KEY_FILE, RAYSPEC_API_KEY_PEPPER_FILE — naming a file (a mounted secret,
mode 600) to read the value from. The value then stays out of the image, out of the compose file,
out of the container's declared environment (docker inspect does not show it), and out of the
server's own exec environment in /proc/<pid>/environ — and it removes the need for a wrapper
entrypoint that materializes secrets into the environment before starting the server.
It also stays out of the environment of every child the server spawns: the boot hands the two auth
secrets to the components that need them in-process and never writes them back into its environment,
so a spawned child is exec'd without them and they do not appear in that child's
/proc/<pid>/environ either. A value supplied as the plain variable instead is left exactly where
you put it, and a child inherits it like any other environment variable — that is the difference the
file mount buys.
Precedence: when
<VAR>_FILEis set it wins — the plain variable is not consulted at all. A<VAR>_FILEleft in a local.envtherefore takes precedence for every component that resolves its configuration from the ambient environment.Blank counts as unset: an empty / whitespace-only
<VAR>_FILEis treated as not set, so the plain variable is used (orchestrators routinely materialize an unset variable as"").Fail-closed: a
<VAR>_FILEpointing at a missing, unreadable, empty, or non-regular file aborts the boot. It never falls back to the plain variable — a broken secret mount must not silently downgrade to the weaker source. The abort names the variable, the path, and — when the read itself failed — the OS error code, never the file content.The variable holds a path, not the secret: the abort quotes that path so the error is actionable, which is a deliberate trade — a secret pasted into
<VAR>_FILEby mistake is quoted back in the abort. Treat such a value as exposed and rotate it.Content: the real bytes of the value, with surrounding whitespace trimmed. That covers both a trailing newline and a leading newline / space / byte-order mark — the latter would otherwise reject the signing key at signer construction, well after the database is open. The flip side is that a secret whose real bytes begin or end with whitespace cannot be expressed in the file form; that limit applies to the two auth secrets, which are used exactly as written, and not to the connection string, which the plain path trims as well. The signing-key file holds a real multi-line PEM, not the single-line
\n-escaped form a.envfile uses.Where: point
<VAR>_FILEoutside the repository — a tmpfs or orchestrator secret mount such as/run/secrets/, or another path only the server user can read. Create the file so it is never readable by others — the umask in the same subshell as the redirect and achmodafter it:(umask 077; openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ > /run/secrets/jwt-signing-key) chmod 600 /run/secrets/jwt-signing-keyBoth, because they cover different failure modes: the umask constrains a file being created, so it closes the window a later
chmodwould leave open — but it does not touch a file that already exists, so a rotation onto a path an earlier key or a configuration-management copy left group- or world-readable keeps that mode when the redirect truncates it, and only thechmodcorrects that.Server boot and
tenant ensure:rayspec-serve,rayspec deployandrayspec tenant ensureread<VAR>_FILE; the CLI subcommands that need a database URL of their own (rayspec plan,rayspec dev db) read the plainDATABASE_URL. With onlyDATABASE_URL_FILEset,rayspec planhas no connection string to compareSHADOW_DATABASE_URLagainst, so its guard against a dry-run landing on the real database does not fire — set the plainDATABASE_URLas well wherever you run those.tenant ensureresolves exactly two secrets,DATABASE_URLandRAYSPEC_API_KEY_PEPPER, through the same resolver with the same precedence and the same fail-closed abort. It deliberately does not ask forRAYSPEC_JWT_SIGNING_KEY: it mints no JWT, so a provisioning job never has to carry the platform signing key.The local development wrapper is both:
examples/local-bootrequires all three plain variables of its own accord, because it provisions a throwaway dev database fromDATABASE_URLbefore the resolver is ever reached — so a_FILE-only environment fails there first, early and loudly. It then pointsDATABASE_URLat that dev database and hands over to the ordinary server boot, which resolves from the ambient environment, where<VAR>_FILEstill wins. An ambientDATABASE_URL_FILEtherefore outranks the dev database the wrapper just provisioned, while the boot banner still names the dev database. On a machine that has no such file the boot aborts fail-closed instead of retargeting anything silently; leave<VAR>_FILEout of a local.envunless you mean it for every boot.
What it is (and is not)
- Product-free: the boot names no product table, route, agent, or domain. An auth-only
boot is the default. If a spec is injected, EVERYTHING product comes from that injected
rayspec.yaml— the platform ships none. - The composition root is the one place a raw
Dbhandle is built (makeDbon the public@rayspec/dbsurface — the production analogue of the testmakeDbWithSchema). Request/run-core code still holds only aTenantDb(enforced bygate:chokepoint). - A spec WITH agents also needs its backend instances wired (the platform ships none).
This generic entrypoint ships no backend, so a spec-with-agents boot uses a wrapper that supplies
an
AgentBackendsFactory+ aregisterProductTableshook (the local table-registration stand-in) — seeexamples/local-boot, the local backend-boot wrapper. An auth-only or stores/api/handler-only spec boots here directly.
Provisioning the deployment's organization
A product deployment binds to one organization (RAYSPEC_PRODUCT_TENANT_ID) and refuses to boot
when that id names no live org — and, because deploy also serves the auth surface, it cannot create
its own. The supported production path is rayspec tenant ensure, which this package backs
(provisionTenant). It talks to DATABASE_URL directly, so it needs no running server, and it is
idempotent: the chosen org id is the operation id, orgs.id is the primary key, and a re-run
against the same id resolves the same organization instead of creating a second one.
It mounts no HTTP route in any posture. That is the point of doing it here rather than behind an
endpoint: RAYSPEC_TENANT_BOOTSTRAP_ENABLED never has to be set on a production deployment, so
POST /v1/auth/bootstrap-tenant is never registered on a production listener at all.
Three things about it are worth stating plainly before you run it:
- It applies the committed migration chain to whatever
DATABASE_URLnames. On a first bootstrap that is required and it is idempotent afterwards — but point it at an unexpected database and you have migrated that database. - The owner-invite token is a tenant-takeover credential until it is consumed or expires.
POST /v1/invites/acceptlets any holder provision the target account with their own password when that address has no account yet, so whoever can read the--owner-invite-outfile owns the tenant. The exclusive-create mode-600 file and the short default lifetime (1 hour, clamped to the shipped 5-minute/30-day bounds) bound that exposure; they do not remove it. - It runs the same data-integrity checks as the HTTP surface, and none of its authorization. The
tenant predicate, email normalization, the role, the TTL clamp and the single-flight on
orgs.idare the same code the routes use. What is absent isrequirePermission(deps, 'org:member:add')— there is no principal to check. The command's authority is possession ofDATABASE_URLandRAYSPEC_API_KEY_PEPPER.
A window exists between the reservation and the human redeeming the invite in which the organization has zero members. Nothing can act inside it — every principal path requires a membership — but the product boot gate checks only that the org exists and is not soft-deleted, so a reserved organization is bootable before it is claimed.
Migration application
The boot applies the committed chain (packages/kernel/db/drizzle/*) via the real programmatic migrator
(drizzle-orm/postgres-js/migrator, migrate(db, { migrationsFolder })) — exactly the chain
drizzle-kit migrate / the gate:migrate-clean forcing-function apply. It
records the high-water mark in drizzle.__drizzle_migrations (the default table/schema) and is
idempotent: it bootstraps a clean empty DB AND no-ops on an already-migrated one.
Smoke test
src/boot.smoke.test.ts boots the real composition root against a throwaway database (created +
dropped per run), proving the migration-chain boot path, then exercises a real authed round-trip
(/health → register → me → login → 401) with no live-LLM call — deterministic, CI-safe.
