feat(cloud-connection): publish the control plane's upgrade entry as an absolute upgradeUrl on /api/v1/runtime/config - #14590
Conversation
…an absolute upgradeUrl on /api/v1/runtime/config RuntimeConfigPlugin gains one optional host option, upgradeUrl, on the exported RuntimeConfigPluginConfig, and the served payload gains one optional top-level key of the same name beside cloudUrl. Declared, it is served verbatim; undeclared (or empty), the key is absent; not an absolute http(s) URL, it is refused and named at mount and no key is served. Contract test covers both directions on key presence plus the refusal group; README gains the field row and semantics; changeset minor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f5606c0cf0f3ae5a51d89249dae4a83c51ee3d28 && git checkout f5606c0cf0f3ae5a51d89249dae4a83c51ee3d28
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b8562ff2620641def6a23234e97613da879fa8f6 c0c1f03a7f39d19bc0f3c5e824d4ed05a86d7f5f && git checkout -B drift-repro b8562ff2620641def6a23234e97613da879fa8f6 && git merge --no-ff c0c1f03a7f39d19bc0f3c5e824d4ed05a86d7f5f
node scripts/docs-audit/affected-docs.mjs --json b8562ff2620641def6a23234e97613da879fa8f6 |
Fixes #14514
The framework half of objectstack-ai/cloud#1850 (maintainer ruling 2026-09-02, option A).
GET /api/v1/runtime/configcan now carry the control plane's upgrade / billing entry as an absolute URL, declared by the host that owns the page, instead of the tenant Console composing it fromcloudUrlplus a guessed console mount, app slug and page route (which landed on the control plane's API 404 — objectui#7273 already consumes "key present → render link, key absent → no link").The key, for the cloud side to fill
upgradeUrl(optional string), besidecloudUrl. Named after the concept the stack already spellsupgrade_url(environment-lifecycle 402/403 envelopes) andupgradeUrl(environment-lifecycle.ts, objectuientitlements.ts), camel-cased like every sibling key on this payload.upgradeUrl?: stringon the exportedRuntimeConfigPluginConfig. The cloud subclass (packages/objectos-runtime/src/cloud-runtime-config-plugin.tsextends the open plugin;objectos-stack.tsconstructs it) passes e.g.upgradeUrl: 'https://cloud.example.com/_console/apps/cloud_control/page/pricing'after the pin bump. ⛔ Not done here — no cloud, no objectui, no other key on this payload touched.undefined); not an absolutehttp:/https:URL → refused and named at mount, no key served. The Console opens this URL from the tenant origin, so the control plane's current relative/settings/billingdialect would resolve against the wrong host and recreate the defect — it is refused, not forwarded, the same shape asbranding.stageand the telemetry DSN in this file.objectstack dev, a self-hosted box and an air-gapped deployment have no billing page.Premise checks against
origin/mainRuntimeConfigPluginitself on the raw Hono app (rawApp.get('/api/v1/runtime/config', handler)); there is no objectos-runtime wrapper that re-shapes it, so the new key sits at the same level ascloudUrlwith nothing in between. PM assumption confirmed in substance, corrected in detail.packages/specschema owns this payload — the onlyRuntimeConfigSchemain spec is the unrelated plugin-security runtime config. No spec touch, no artifact regeneration.main(README orcontent/docs). The row now lives in a new README section (with a one-row field table, JSON and host examples) plus the plugin's Response-shape block; the changeset isminoron@objectstack/cloud-connection.RuntimeConfigPluginConfig. No response-side payload type is introduced — the payload has never had one, and typing it whole would touch every other key, which the ruling puts out of scope.Contract review
Clause-② yes: this widens a public payload, so the PR carries
needs:contract-review. The widening is additive and default-absent — a runtime that declares nothing serves a byte-identical payload.Tests
New
packages/cloud-connection/src/runtime-config-upgrade-url.test.ts— 22 cases: direction 1 (declared → verbatim, besidecloudUrl, not insidefeatures/branding, independent ofcloudUrl, survives JSON), direction 2 (undeclared → key NOT THERE, empty reads as unset and silent, no derivation from a resolvedcloudUrl, siblings untouched), and the refusal group (four relative spellings, three non-http schemes, the warning names option + value + requirement, refusal leaves the rest of the payload intact, acceptance is silent).Ablation, from the committed state: the spread was replaced with an always-present
upgradeUrl: this.upgradeUrl(markerABLATION-14514counted 1, spread counted 0, blob8bf0740cvs HEAD418e09a9) → 11 of 22 red (every key-absence and refusal case); the JSON-round-trip case correctly stayed green becauseJSON.stringifydropsundefined— which is exactly why the load-bearing assertions are on key presence. Restore viagit checkout HEAD -- PATH: blob hash back to418e09a9,git diff HEADempty, marker count 0. No dist ablation needed: the suite imports./runtime-config-plugin.jsfrom source (vitest), not throughexports.Local verification at
c0c1f03a7(branch fast-forwarded ontoorigin/main5563bfb32before the final commit)Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shfound no usableflockon this macOS host, so every locked run printedVERDICT command-exit N · UNLOCKED (declared); nothing was serialized, wall-clock figures are shared-box readings.pnpm --filter '@objectstack/cloud-connection^...' build(dependency closure, rerun after the fast-forward) →VERDICT command-exit 0.pnpm --filter @objectstack/cloud-connection build→VERDICT command-exit 0;DTS ⚡️ Build success;dist/index.d.tsanddist/index.d.ctscarryupgradeUrl(6 mentions each);require('./dist/index.cjs')loads and exportsRuntimeConfigPlugin.pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2→Test Files 28 passed (28),Tests 362 passed (362);VERDICT command-exit 0.tsc --noEmit -p tsconfig.json --listFiles(the package has notypecheckscript — a zero-matchpnpm --filter … typecheckwould have exited 0 having run nothing): exit 2 with 13 errors, all in three test files this PR does not touch (cloud-connection-plugin.test.ts4,connection-credential-store.test.ts7,marketplace-install-local-bundle.test.ts2; TS2493 x11 + TS2550 x2). That is exactly theDEBTledger's frozen entry for this package (errors: 13, "code-tier 11 (TS2493) + 2 config-tier"), so the count does not move. 0 errors in the two files this PR touches; the new test file IS in the program (--listFileshit 1).node scripts/check-nul-bytes.mjs→check-nul-bytes: OK (scanned 7923 text file(s) … no raw ASCII control bytes); control-byte self-scan of the four touched files: 0 hits.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths; derived from git atc0c1f03a7, 4 files) → 35 families. 31 print their own OK / holds line, among themcheck:route-envelope(matched on this very file),check:published-files,check:test-source-alias,check:type-source-resolution,check:type-check-coverage,check:doc-authoring,check:slot-lookup,check:logger-receiver-detach,check:plugin-teardown-shape,check-adr-0087-registration("adds no declared-breaking changeset"),check-changeset-no-major,check-empty-changeset,check:objectui-changeset,check:pm-half-states(self-test),check-keyed-text-bounds,check-undeclared-dep-imports,check-comment-mask-adoption,check-system-context-census,check:engine-double-contract,check:objectql-double-limit,check:query-options-erasure,check:where-matcher,check:page-declaration-shape,check-ci-filter-parity,check-cross-package-test-inputs(both spellings),check-shard-attestation, docs-auditcheck-affected-docs+check-drift-comment,release-rehearsal-clone --self-test,check:changeset-gate-self-tests.PREREQUISITE NOT METexit 3, not a red:check-test-completeness.mjs(grades a savedturbo run testlog CI tees; none locally),pm/check-half-states.mjs(anonymous GitHub rate limit exhausted on this egress; a repo-wide card patrol unrelated to this diff, matched only via the.changesetglob),check:dual-build-cjs-loadsandcheck:type-check-debt --re-measure(both need every workspace package built; 35 / 23 packages have nodist/). For the last two, the reading that this diff CAN move was taken directly: the package'sexportsmap is untouched, its rebuiltdist/index.cjsloads underrequire, and the debt count above is unchanged at 13. CI'sBuild Core/Lint & Repo Gatesrun all four on the full farm.eslint --no-inline-config --format json packages/cloud-connection→ 43 files, 0 errors, 0 warnings (counts read from the JSON output). Untouched packages were not linted locally; that is safe becauseeslint.config.jsenables no type-aware linting (its own comment: noparserOptions.project, no typed@typescript-eslintrules), so this diff cannot move a verdict on a file it does not contain. CI runspnpm lintover the whole tree regardless.Not addressed here (remains open)
upgradeUrl, converging the control plane's two URL spellings, and the requested evaluation of retiring the relativeupgrade_urlin the environment-lifecycle envelopes — is cloud-side work after the pin bump.Generated by Claude Code