Skip to content

v8 N-17: hosted mode — CDN releases, CDN drafts, site token telemetry - #634

Draft
tlgimenes wants to merge 8 commits into
v8-16-draft-overlaysfrom
v8-17-hosted
Draft

tlgimenes wants to merge 8 commits into
v8-16-draft-overlaysfrom
v8-17-hosted

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #619 (v8-16-draft-overlays). This is the SDK side of the hosted contract (2026-10-06) plus the PO amendments. Studio, the telemetry ingest, the analytics collector and the docs ship as separate PRs.

Contract summary (SDK side)

Delivery (remoteLoader.ts, rewritten). The PO rules are "do not try to be smart" and, between the bundle and the CDN, "whoever is newer wins":

  • Boot: load() serves the bundled content and never touches the network. Nothing persists across restarts.
  • Background check: on the existing interval (60 s minimum, ±10 s jitter), it reads https://delivery.decocms.com/sites/<site>/latest.json = { revision, schemaHash, publishedAt }, with no credentials.
    • It downloads and swaps sites/<site>/revisions/<revision>.json = { revision, schemaHash, blocks } only when all three hold:
      • revision differs from the one this process last swapped in;
      • schemaHash equals the bundled content module's;
      • publishedAt is later than the bundle's committedAt (equal keeps the bundle). A bundle without committedAt (a custom loader, a content module generated before this change, a build outside git) counts as the oldest, so the CDN wins when the schema matches.
    • A publishedAt that doesn't parse as a date makes latest.json malformed.
    • Otherwise, and on any error (404, 5xx, network, malformed body), it keeps what it serves.
    • revision is the git commit SHA (40 lowercase hex). The revision body must carry the same revision and schemaHash as latest.json.
  • Removed:
    • channel manifest, generation ordering, snapshot paths;
    • content-hash verification;
    • swap-back-to-bundle;
    • the Authorization header;
    • the site && token gate.
  • What the SDK never does: ordering pointers among themselves, comparing content with the bundle, any git or GitHub call. The only comparison with the bundle is publishedAt vs committedAt.
  • Dev rule: with NODE_ENV=development it never swaps, so local files win. This is the only env read left in the SDK.

schemaHash (cli/content.ts):

  • deco content reads .deco/schema.gen.json and writes schemaHash: sha256Hex(canonicalJson(JSON.parse(text))) into .deco/blocks.gen.ts, using the @decocms/blocks/protocol helpers Studio already imports.
  • With no schema file it writes no schemaHash, and the SDK never swaps.
  • A shared test vector pins the formula for Studio: 00b083655ee7af02aa92dbff85e402857bb1e50c253a579dbab23498a7842c98 (in cli/content.test.ts).
  • Snapshot gains schemaHash?: string.

committedAt (cli/content.ts, PO: stamp with commit time, not build time):

  • deco content writes committedAt into .deco/blocks.gen.ts: the committer date of git HEAD in the root being built, ISO 8601 (git log -1 --format=%cI). No env is read. Snapshot gains committedAt?: string.
  • Why commit time: with build time, a slow build of an older commit that finished after a newer publish would win, and content would go backwards. With commit time, that build loses to the publish.
  • Without git (not a repo, no commits, git missing) it writes no stamp and prints one line saying so; the bundle then counts as the oldest.
  • The module is the same for the same content and commit, so rebuilding a commit doesn't rewrite it.
  • Timeline: publish then deploy → bundle; deploy then publish → CDN; rollback after deploy → CDN; deploy after rollback → bundle; schema mismatch → bundle; no stamp → CDN; a build of an older commit that finishes after a publish → CDN. Here "deploy" means a deploy of a commit made after the publish (or rollback): what counts is when the commit was made, not when it was built.

Studio-side contract (for the Studio PR; the SDK only reads it): latest.json.publishedAt must be written with the current time on Publish, on "Make current"/rollback, and on Resync. A rollback then wins over bundles of commits made before it, and a deploy of a later commit wins over the rollback.

Drafts on the CDN (draftChanges.ts, content.ts):

  • The pointer is delivery.decocms.com/sites/<site>/drafts/<slug>.json@<version>, already admitted by .decocms.com.
  • The body is exactly { set, delete }, with set and delete disjoint. format: 1 is gone, and a delete list may repeat a name.
  • Every read revalidates: it sends If-None-Match with the held ETag. Only a 200 (new body) or a 304 to that ETag is accepted; anything else is LOADER_FAILED, never production.
  • The 60 s reuse TTL is gone.
  • Concurrent reads of one draft share one request.
  • The draft view's revision is <base>~<ETag>, so each save re-keys caches.

Telemetry (telemetry.ts):

  • A top-level createCMS({ token }) sends to https://otel.decocms.com/v1/<signal> with authorization: Bearer <token>. site labels service.name and deco.site.
  • token requires site: createCMS({ token }) without site throws (PO createCMS surface, 2026-10-06).
  • telemetry: { endpoint, headers } wins over the token. telemetry: false is off.
  • The telemetry: { site, token } form is deleted.

No env vars (PO: "do NEVER EVER use envvars"):

Removed env read Replacement
DECO_CONTENT_INTERVAL existing interval
DECO_PREVIEW_API_DOMAINS new preview.draftHosts (default: v7's list)
OTEL_EXPORTER_OTLP_ENDPOINT / _HEADERS, DECO_OTEL_HEADERS, DECO_OTEL_AUTH_TOKEN existing telemetry.endpoint / telemetry.headers
OTEL_EXPORTER_OTLP_{METRICS,LOGS,TRACES}_ENDPOINT, DECO_OTEL_*_ENDPOINT none (see OPEN)
OTEL_RESOURCE_ATTRIBUTES, OTEL_SERVICE_NAME, commit SHA variables (DECO_COMMIT_SHA, WORKERS_CI_COMMIT_SHA, CF_PAGES_COMMIT_SHA, VERCEL_GIT_COMMIT_SHA, GITHUB_SHA, RENDER_GIT_COMMIT, SOURCE_VERSION, COMMIT_SHA), VERCEL_ENV, NODE_ENV (the environment label) new telemetry.resource (covers the service version)
readEnv() helper deleted
  • Params added (the only ones): preview.draftHosts and telemetry.resource.
  • Redefined: site alone turns on hosted releases; token (with site) turns on hosted telemetry.
  • Removed from the surface: cms.forRevision(). The CMS reads only through forRelease() and forDraft(pointer).
  • Kept: NODE_ENV === "development" in remoteLoader.ts.

Site token format (the SDK only forwards it; Studio signs it and the ingest verifies it, so there is no shared helper in this package). It's a JWS Compact token with EdDSA/Ed25519 and no expiry:

token     = B64U(header) "." B64U(payload) "." B64U(signature)
header    = {"alg":"EdDSA","typ":"JWT"}
payload   = {"site":"<site slug>","kid":"<token id>","iat":<unix seconds>}
signature = Ed25519.sign(studioPrivateKey, ASCII(B64U(header) "." B64U(payload)))
B64U      = base64url without padding (RFC 4648 §5)

The edge checks four things:

  1. Three segments.
  2. A header equal to the one above.
  3. crypto.subtle.verify("Ed25519", publicKey, sig, ASCII(seg0 "." seg1)).
  4. site, kid and iat present with those types, and no revoked:<kid> in the KV denylist. A kill:<site> key answers 403.

Changes

  • src/v8/remoteLoader.ts: the rewrite above, plus a test-only Symbol.for("decocms.blocks.test.deliveryOrigin") origin override (undocumented, not exported).
  • src/v8/cms.ts:
    • site alone wraps the content;
    • the instance key is site with no token hash;
    • a different token warns as an option conflict;
    • no env in resolveInterval;
    • validates preview.draftHosts.
  • src/v8/draftChanges.ts: { set, delete } only, parseDraftHosts, ETag/304 in fetchDraftChanges, and the domains passed in as a param.
  • src/v8/content.ts: ETag revalidation, shared in-flight reads, and isSnapshot accepts schemaHash and committedAt.
  • src/v8/telemetry.ts: resolveDestination(config, site, token), telemetry.resource, and every env read deleted.
  • src/v8/identity.ts: readEnv deleted.
  • src/v8/types.ts: Snapshot.schemaHash?, Snapshot.committedAt?, TelemetryConfig { endpoint?, headers?, resource?, limits? }, preview.draftHosts, and new site/token docs.
  • src/v8/cli/content.ts: writes schemaHash and committedAt.
  • src/v8/cli/run.ts: the deco publish signpost names Studio's Publish/Resync.
  • conformance/observability/*.ts: the doc snippets pass params, with no telemetry: { site, token }.
  • .agents/skills/deco-v7-to-v8-migration/reference/{gotchas,faststore}.md: these said the SDK reads env; they now say the site reads its own env and passes params.

Tests

File Coverage
remoteLoader.test.ts (rewritten, 31 tests) newer wins: publish→deploy keeps the bundle, deploy→publish swaps, equal timestamps keep the bundle, rollback after deploy swaps, deploy after rollback keeps the bundle, a newer release with another schema keeps the bundle, no committedAt → CDN, an older commit built after a publish → CDN, a fallback loader's committedAt, an unparseable publishedAt refused; no network at boot; latest → revision → swap; no credentials; the first check downloads even content equal to the bundle; the same revision isn't downloaded again; rollback via latest.json followed with no ordering; schema mismatch keeps the bundle or the last swap; a bundle without schemaHash never fetches; 404/500/network errors keep memory; malformed latest.json or revision body refused; NODE_ENV=development never fetches; fallback loaders
noEnv.test.ts (new guardrail) no env read in packages/blocks/src in any spelling (process.env, process?.env, globalThis.process?.env, { env } = process, Bun.env, Deno.env, import.meta.env, .env[) except the one NODE_ENV line; a self-test pins each spelling
cli/content.test.ts committedAt stamped from git HEAD's commit time (same commit → unchanged module, later commit → new stamp), no stamp and one line outside git, schemaHash written, the shared vector, formatting-independent, absent without a schema file, invalid JSON fails, a schema change rewrites the module
cms.test.ts ETag revalidation (304 reuse, new save → 200), shared in-flight reads, preview.draftHosts (replaces the defaults, no env, validation), no DECO_CONTENT_INTERVAL, the token isn't in the key
telemetry.test.ts endpoint > token > off, no OTEL_*/DECO_OTEL_* reads, telemetry.resource, a resource default that ignores env
Conformance (delivery, delivery.snippets, sdk, observability, guides, extra, cli) channel, generation and token claims rewritten to the latest.json/revisions contract (CD-1/3/4–10/12, HRI-6/8, new HRI-16..22 for the newer-wins scenarios (HRI-22: an older commit built after a publish loses) plus an SDK no-git/no-env guard, cli-05 checks no committedAt outside git, H-3/6/12, HP-6/9/16, DP-1/4/6/7/11/13/14/14b/17, HD-2/8, AR-05–08/22/57, si-09/10, tel-03–07/33, mig-08/09). The env cases became "no env is read" cases. New snippet: preview.draftHosts
  • bun run check passes: typecheck, biome and knip.
  • bun run test (packages/blocks) passes: 1733 passed, 64 skipped. cli-11 (deco schema --watch, which this PR doesn't touch) timed out once under full-suite load and passes on its own.

OPEN items (smallest choice taken, each marked // OPEN: in code)

  • O-B1 (remoteLoader.ts): a process that already swapped a release in keeps it if a later latest.json turns out older than the bundle. Memory stays as it is, like the schema-mismatch case; it doesn't swap back to the bundle.
  • O-B2 (remoteLoader.ts): a committedAt that doesn't parse as a date counts as missing (the oldest).
  • O-F1 (remoteLoader.ts): the revision is validated as /^[0-9a-f]{40}$/ (a SHA-1 commit). SHA-256 object-format repos aren't accepted.
  • O-F3 (telemetry.ts): per-signal OTLP URLs (v7 OTEL_EXPORTER_OTLP_<SIGNAL>_ENDPOINT / DECO_OTEL_<SIGNAL>_ENDPOINT) have no param. Every signal goes to <endpoint>/v1/<signal>. No site in ~/code/next-major uses them.
  • O-F4 (telemetry.ts): deployment.environment.name defaults to "production" and telemetry.resource overrides it. It no longer reads NODE_ENV, because the only allowed NODE_ENV read is the dev rule.
  • OPEN-8 (content.ts): the draft view's revision is <base>~<response ETag>, falling back to the pointer's <version> when the response has no ETag.
  • Not marked in code:
    • preview.draftHosts: [] means no draft host at all. Only an omitted list gives the defaults.
    • A fallback loader that fails to load can't be compared by schemaHash, so that check doesn't swap. Before, it downloaded anyway.

Follow-ups (not in this PR)

  • Docs (deco-sites/docs-tanstack): hosted-releases-internals should state the newer-wins rule (HRI-16..22, by commit time) in place of "no comparison with the bundle"; delete the env tables and channel pages, then document preview.draftHosts, telemetry.resource and the new site/token meaning. The conformance claim IDs above are what those pages should state.
  • Studio: write publishedAt = now on Publish, Make current/rollback and Resync.
  • Sites: each one reads its own env and passes params once 8.1.0-next.7 is published (needs 2FA).

🤖 Generated with Claude Code

tlgimenes and others added 8 commits October 6, 2026 23:03
…emetry, no env vars

- remoteLoader follows sites/<site>/latest.json { revision, schemaHash,
  publishedAt } and swaps revisions/<sha>.json only when the revision differs
  from the one last swapped in and schemaHash matches the bundled content's.
  Channel manifest, generations, content-hash verification, bundle
  comparison and the Authorization header are gone; site alone turns it on.
- deco content writes schemaHash = sha256Hex(canonicalJson(schema.gen.json)).
- Drafts are { set, delete } on the CDN, revalidated with If-None-Match on
  every read (200 or 304 only); the view revision is keyed by the ETag.
- Telemetry: top-level token -> otel.decocms.com Bearer; telemetry.endpoint
  wins; telemetry: { site, token } removed; new telemetry.resource.
- No environment reads except NODE_ENV=development in remoteLoader:
  DECO_CONTENT_INTERVAL, DECO_PREVIEW_API_DOMAINS (-> preview.apiDomains),
  OTEL_*, DECO_OTEL_*, commit-SHA variables removed; guardrail test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ftHosts, token needs site

- Remove cms.forRevision(revision) and the served-revisions map behind it;
  the CMS reads only through forRelease() and forDraft(pointer).
- Rename preview.apiDomains to preview.draftHosts (the hosts a draft
  pointer may point at; defaults to v7's list).
- createCMS({ token }) without site throws "token needs site: pass both,
  or site alone".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…lete name

The no-env guard now also catches process?.env, globalThis.process?.env,
destructured { env } = process and Bun.env. The draft parser no longer
rejects a delete list that repeats a name (not in the contract).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…builtAt)

deco content stamps the content module with builtAt, the build machine's
clock (ISO 8601; no git, no env). The background release check swaps in the
CDN revision only when latest.json's publishedAt is later than the bundle's
builtAt and its schemaHash matches; otherwise it keeps serving the bundle. A
bundle without builtAt counts as the oldest. Studio writes publishedAt on
Publish, Make current/rollback and Resync, so a rollback wins over older
bundles and a later deploy wins over the rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…mittedAt)

deco content now stamps the content module with committedAt, the committer
date of git HEAD in the root being built (git log -1 --format=%cI), instead of
the build machine's clock. With build time, a slow build of an older commit
that finished after a newer publish would win and content would go backwards.

Without git (not a repo, no commits, git missing) it writes no stamp and prints
one line saying so; the SDK still treats a bundle without a stamp as the
oldest. The SDK serves the CDN revision when latest.json's publishedAt is later
than committedAt and the schemaHash matches; otherwise the bundle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
esbuild leaves /dev/stdout empty on Linux, so the browser-bundle import
check failed there with 'Unexpected end of JSON input'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant