Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/artifact-pinned-boot-os-artifact-url.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/runtime": minor
"@objectstack/cli": minor
---

feat(cli,runtime): `OS_ARTIFACT_URL` — boot a stack from a published artifact by reference (#8368)

`objectstack start` / `serve` can now be pointed at an artifact **by reference**
with a single environment variable, so a fixed runtime image plus one env var is
a running app. Upgrading the app becomes an env change and a restart rather than
an image rebuild — the runtime image and the app artifact become two independent
release axes.

```bash
OS_ARTIFACT_URL=https://cdn.example.com/hotcrm-2.2.2.json # fetched at boot
OS_ARTIFACT_URL=file:///srv/app/objectstack.json # read directly
OS_ARTIFACT_URL='https://cdn.example.com/hotcrm-2.2.2.json#sha256=<64 hex>' # content-verified
```

**One variable, not two.** The optional integrity pin is SRI-style and lives
inside the URL **fragment**; there is deliberately no companion
`OS_ARTIFACT_SHA256`. A fragment is client-side by standard and is never sent to
the server, so the pin travels with the reference — one value to copy, one value
to rotate — without changing anything the artifact host sees. A second variable
would make "URL updated, hash not" a reachable state; this shape makes it
unspellable.

**Precedence.** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` >
`<cwd>/dist/objectstack.json`. Beating `OS_ARTIFACT_PATH` matters in practice:
the official runtime image sets it to `/srv/app/objectstack.json`, so on a
container carrying no app it is always set and always points at a file that does
not exist. `OS_ARTIFACT_URL` also wins over an `objectstack.config.ts` in the
working directory — naming a published artifact is an explicit instruction, and
a deployed app must not depend on which directory the process is standing in.

**What it refuses, and how loudly:**

- **No pin → no verification.** A fetch or read failure fails the boot loudly so
container orchestration retries. There is no cache-fallback on this path: with
no pin there is nothing to authenticate a cached copy with.
- **Pin present → verified before boot.** A mismatch refuses and names the
**expected and the actual** digest, so a republished artifact is
distinguishable from a substituted one. A fetch failure may fall back to a
locally cached copy, but only one whose bytes still hash to the pin — the
cache is re-hashed on every read, so the filename is never the authority — and
it says so with a loud warning.
- **`engines.protocol` is validated against the runtime** at reference
resolution, before anything connects, and an incompatible artifact refuses
with both ways out named (repoint the reference, or run a matching image).
- **Migration policy.** Safe migrations run at boot; a destructive change (the
`os migrate apply --allow-destructive` class) refuses the boot with an
operator message naming every change. Never skipped in silence. This applies
to the artifact-pinned boot only — every other boot keeps the standing
production policy, under which the schema is never auto-altered.

**Secrets.** The reference may be a pre-signed URL, i.e. the credential *is* the
URL. Nothing downstream of resolution ever sees it: remote bytes are
materialised to a local file under `<home>/artifacts` and the boot continues
against that path, so the URL reaches neither the banner, nor the metadata
service's artifact-source record, nor any log line. Every message this path
produces — including messages originating inside `fetch`, which routinely carry
the whole URL — is scrubbed of userinfo and query material. Userinfo is moved
into an `Authorization: Basic` header, both because `fetch` refuses to construct
a request from a URL carrying credentials and because a credential in the
request line lands in the artifact host's access log.

Materialising the fetched bytes is also what makes the pin mean anything: the
bytes that were hashed are the bytes that boot, and the artifact is fetched
exactly once.

Not included, by design: `OS_PACKAGE_REF` registry resolution, signature
enforcement and entitlements; multi-tenant fleet / hostname routing. Fetching
and booting an artifact is open-framework mechanism — walled tenancy postures
remain entitled through `@objectstack/organizations` regardless of how the
artifact arrives.
3 changes: 2 additions & 1 deletion content/docs/deployment/cli.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -319,6 +319,7 @@ os start
| Flag | Env equivalent | Purpose |
|---|---|---|
| `-a, --artifact <path\|url>` | `OS_ARTIFACT_PATH` | File path or `http(s)://` URL to the compiled artifact |
| — | `OS_ARTIFACT_URL` | Boot a published artifact **by reference**, optionally content-hash pinned via a `#sha256=` fragment. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url) |
| `-d, --database <url>` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` |
| `--database-driver <kind>` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` \| `memory` when the URL is ambiguous |
| `--database-auth-token <token>` | `OS_DATABASE_AUTH_TOKEN` | Auth token for libsql/Turso |
Expand All@@ -336,7 +337,7 @@ os start
> (CORS). **Pin the port explicitly** (`OS_PORT=8080 os start`) and keep
> `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` in sync when you change it.

**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel.
**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel.
**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:<home>/data/objectstack.db`.

<Callout type="warn">
Expand Down
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
| `OS_STORAGE_ROOT` | path | — | **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. |
| `OS_ARTIFACT_PATH` | path | — | Path or `http(s)://` URL to a compiled `objectstack.json` artifact to boot the kernel from. |
| `OS_ARTIFACT_URL` | url | — | Boot a **published artifact by reference** — `https://…/hotcrm-2.2.2.json` (fetched at boot) or `file:///…/objectstack.json` (read directly, the volume-mount workflow). Overrides `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working directory; `--artifact` still wins. Optionally pinned with an SRI-style fragment: `…/hotcrm-2.2.2.json#sha256=<64 hex chars>` — there is deliberately **no** companion `OS_ARTIFACT_SHA256`, because a URL fragment is client-side by standard (never sent to the server) and so travels with the reference as one value. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url). |

---

Expand Down
47 changes: 47 additions & 0 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,53 @@ docker run -p 8080:8080 \
(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
straight from release storage instead of a mount.)

### Artifact-pinned boot (`OS_ARTIFACT_URL`)

The image above carries no app. `OS_ARTIFACT_URL` names one **by reference**, so
a fixed runtime image plus one environment variable is a running app — and
upgrading the app is an env change plus a restart, never an image rebuild. The
runtime image and the app artifact become two independent release axes.

```bash
docker run -p 8080:8080 \
-e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
ghcr.io/objectstack-ai/objectstack:14.8.0
```

Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly
(the volume-mount workflow above, spelled as a URL). The variable overrides the
image's preset `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working
directory.

**The integrity pin lives in the URL fragment.** `#sha256=<64 hex chars>` is
SRI-style and there is deliberately no companion `OS_ARTIFACT_SHA256`: a
fragment is client-side by standard and is never sent to the server, so the pin
travels with the reference as a single value to copy and a single value to
rotate. Two variables would make "URL updated, hash not" a state you can reach.

| Situation | What the runtime does |
|---|---|
| No `#sha256=` fragment | Boots without verification. A fetch or read failure **fails the boot** so your orchestrator retries — there is no cache fallback, because there is nothing to authenticate a cached copy with. |
| `#sha256=` present, content matches | Boots, and keeps the verified copy under `<home>/artifacts`. |
| `#sha256=` present, content differs | **Refuses to boot**, naming the expected *and* the actual digest. |
| `#sha256=` present, artifact host unreachable | Falls back to the cached copy **only** if it still hashes to the pin, with a loud warning that the instance is running on cached content. |
| Artifact's `engines.protocol` excludes this runtime | **Refuses to boot** — the safety belt of the two-axis split. Repoint the reference, or run a matching image version. |
| The artifact needs a destructive schema change | Safe migrations run at boot; a destructive one **refuses to boot** and names each change. Run `os migrate apply --allow-destructive` deliberately, then restart. Never skipped in silence. |

**Recommended production discipline** (convention, not enforced by the runtime):
publish immutable, version-named objects; give only CI write access to the
artifact host; and pin the digest in the fragment. Together these make "which
bytes is this instance running?" a question with one answer.

**Pre-signed URLs are safe to use.** The reference may carry auth material — a
signature query parameter, or `user:token@host` — and it is never echoed into
logs or HTTP responses. Userinfo is sent as an `Authorization: Basic` header
rather than in the request line (so it does not land in your artifact host's
access log), and remote bytes are materialised to a local file before the boot
continues, so the URL does not reach any downstream surface at all.

For a self-contained deployable image, extend it. The Dockerfile below (plus
the compose stack in the next section and a `.dockerignore`) ships ready-made
in the project scaffold —
Expand Down
13 changes: 13 additions & 0 deletions docker/Dockerfile
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,19 @@
# OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can be
# fetched from your release storage instead of copied in.
#
# Or name the artifact BY REFERENCE and skip the image build entirely
# (#8368) — OS_ARTIFACT_URL overrides the OS_ARTIFACT_PATH preset below, so a
# container carrying no app boots the referenced one:
#
# docker run -p 8080:8080 \
# -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
# -e OS_DATABASE_URL=... -e OS_AUTH_SECRET -e OS_SECRET_KEY \
# ghcr.io/objectstack-ai/objectstack:<version>
#
# The `#sha256=` fragment is an optional SRI-style integrity pin, verified
# before boot; a mismatch refuses to boot. Docs:
# https://docs.objectstack.ai/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url
#
# Published by .github/workflows/docker-publish.yml on every framework
# release; the image tag always matches the @objectstack/cli version inside.
# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting
Expand Down
Loading
Loading