everything-dev
extends-config
How the authored config's extends chains work (bos.app.ts preferred; legacy bos.config.json supported), deep merge semantics, resolved config lifecycle, env-specific extends, and canonical field ordering. Use when debugging extends inheritance, configuring per-environment parents, understanding what dev writes vs publish writes, or reasoning about config merging.
Config form: the authored config is bos.app.ts (canonical, preferred when both exist). A legacy bos.config.json is still supported for older children. Where this doc says bos.config.json for the local authored file, read "the authored config". The published artifact on FastKV keeps the key name bos.config.json.
extends & Config Merging
extends Field
The extends field in the authored config (bos.app.ts, or legacy bos.config.json) specifies a parent config to inherit from. Supports two forms:
String (all environments use same parent)
{ "extends": "bos://dev.everything.near/everything.dev" }Object (per-environment parent)
{
"extends": {
"development": "bos://dev.everything.near/everything.dev",
"production": "bos://dev.everything.near/everything.dev",
"staging": "bos://staging.everything.near/everything.dev"
}
}Fallback chain: requested env → production → first defined value.
platform pin (ADR 0034)
The root-level platform field pins the framework release this runtime tracks:
{ "platform": "2.0.0-rc.14" }- Exact string in v1 — no ranges. Resolution picks exactly one platform release manifest (the root's published platform.json); a pin that no published manifest satisfies fails loud. The slot is a pointer: a root republish overwrites it, so a stale pin survives only while the fresh .bos/platform-manifest.json cache (24h TTL) stands in — past that, resolution fails loud and bos upgrade (the re-pin pathway) is the repair (#546).
- Absent → latest via the extends chain (nearest ancestor publishing a manifest wins). Sync/upgrade resolve it explicitly — they read the authored config, not just the merged result.
- It is a scalar for merge purposes: child overrides parent, parent inherited when child omits.
- The field is on sync's managed-key list, so it survives template personalization.
- Everything else derives from the resolved manifest: the upgrade catalog, the image ref (ref@v<version>), the release-scoped sync source, and the compat contract. Offline dev caches the resolved manifest at .bos/platform-manifest.json (gitignored memo).
- Inspect with bos platform manifest (--check adds the compat gate); bos upgrade --check is the manual drift alarm against the latest release.
Deep Merge Semantics
Uses defu (with createDefu for custom merge rules):
| Field type | Merge behavior |
|---|---|
| Scalars (account, domain, repository) | Child overrides parent; parent inherited when child omits |
| app.api.shared, app.auth.shared, plugins.*.shared dep entries | Deep merged — child overrides specific keys, parent deps preserved |
| plugins | Deep merged — child overrides per-key, parent plugins preserved unless removed |
| secrets arrays | Unioned (deduplicated) |
| routes arrays | Child replaces parent |
| variables | Deep merged per-key |
Null Sentinel Removal
Set a plugin to null to explicitly remove an inherited plugin:
{
"plugins": {
"template": null
}
}Plugin UI Opt-Out
A plugin entry that extends another app (extends: "bos://acct/app#plugins.<id>") also attaches that app's app.ui as the plugin's ui. Set ui: false on the entry to take only the plugin and drop the attached ui:
plugins: {
nostr: { extends: "bos://nearbuilding.near/nostr.nearbuilders.org#plugins.nostr", ui: false },
}Fixed-Core Tenant Mode
For shared-host tenant setups, extends is both the config inheritance mechanism and the lineage edge between runtimes.
Example:
{
"extends": "bos://pingpayio.near/pingpay.io",
"account": "pizza.pingpayio.near",
"domain": "pizza.com"
}Use this mental model:
- extends says which runtime this one remixes or inherits from
- account is the tenant namespace root for this runtime when it is served as a base app
- domain is the public ingress for this runtime
- a runtime can be a child in lineage and still become a new tenant root on its own domain
Tenant permissions come from the DB, not env vars. The host fetches the tenant bindings map from the API's GET /tenants/bindings endpoint (cached for 30s), and the tenant record's allow flags gate what may be overridden and whether SSR is permitted.
Design target for request mapping:
- pizza.com -> base runtime bos://pizza.pingpayio.near/pizza.com
- chicago.pizza.com -> bos://chicago.pizza.pingpayio.near/pizza.com
Current implementation note:
- the host still applies a single request-scoped tenant overlay on top of one process-wide base runtime
- nested label routing and account-relative tenant derivation are the intended direction, but not the complete runtime behavior today
What the tenant may override today
In fixed-core mode, the host keeps the server core from the active base runtime and only applies request-scoped UI-facing overrides from the child config:
- app.ui
- existing plugins.<id>.ui
- existing plugins.<id>.sidebar
The tenant config must extend the base BOS runtime. Tenant API/auth overrides and dynamic new plugin IDs are not part of this mode.
SSR behavior
Tenant SSR is gated separately from inheritance:
- a tenant may SSR only when its record has allow_ssr set
- tenants without allow_ssr fall back to client rendering
Resolved Config: .bos/bos.resolved-config.json
Generated by: bos dev, bos build, syncResolvedSharedDeps() Gitignored: Yes (inside .bos/)
When bos dev or bos build runs:
- The full extends chain is resolved in memory
- The merged config is written to .bos/bos.resolved-config.json
- The authored config is NOT modified during dev
Structure:
{
"_resolved": {
"env": "development",
"resolvedAt": "2026-05-11T...",
"extendsChain": ["bos://dev.everything.near/everything.dev"]
},
"account": "me.near",
"domain": "my.dev",
"app": { ... },
"plugins": { ... }
}Build configs read resolved config first
All build configs (ui/rsbuild.config.ts, host/rsbuild.config.ts, api/rspack.config.js, plugins/*/rspack.config.js) try .bos/bos.resolved-config.json first, falling back to the authored config.
The _resolved metadata is stripped before use.
When the authored config IS written
| Command | Writes the authored config? | Why |
|---|---|---|
| bos dev | No | Uses resolved config |
| bos build | No | Uses resolved config |
| bos publish --deploy | Yes | Snapshot moment — pins production URLs + versions |
| bos plugin publish | Yes | Records production URL + integrity |
| bos plugin add/remove | Yes | Changes project's own plugin list |
| bos upgrade | Yes | Stamps/moves the platform pin (ADR 0034) |
Remote host mode (bos->catalog)
When host is remote, syncResolvedSharedDeps() reads versions from the authored config and writes them into package.json catalog. No resolved config is written — the remote host reads the published config directly.
_resolved.resolvedAt
The resolvedAt timestamp in .bos/bos.resolved-config.json is metadata for debugging only. It is not used for staleness detection — the CLI always re-resolves on bos dev / bos build.
Circular Extends Detection
If the extends chain contains a cycle (e.g., A→B→A), the resolver detects it during config loading and throws an error listing the cycle path. This prevents infinite recursion during merge.
Detection occurs in resolveExtendsRef() → mergeBosConfigWithExtends(). The extends chain is tracked as a set of visited BOS refs; a duplicate visit triggers the error.
Canonical Field Ordering
BOS_CONFIG_ORDER enforces consistent key order:
- extends — always first
- account
- domain
- title
- description
- testnet
- staging
- repository
- publish
- platform
- ci
- app
- plugins
- rolledBackFrom
Unknown keys go after known keys. rebuildOrderedConfig() is applied before every write.
API
From packages/everything-dev/src/config.ts:
- writeResolvedConfig(configDir, config, env, extendsChain?) — writes .bos/bos.resolved-config.json
- loadResolvedConfig(configDir) — reads resolved config, returns BosConfig | null
- resolveBosConfigPath(configDir) — returns resolved config path if exists, else the authored config
- readBosConfigForBuild(configDir) — reads resolved config stripping _resolved, falls back to the authored config
From packages/everything-dev/src/services/resolution/merge.ts:
- mergeBosConfigWithExtends(parent, child) — deep merge for extends chain
- mergeBosConfigWithTemplate(local, template) — merge for sync (local wins)
- resolveExtendsRef(extendsField, env) — resolve string|object extends for a given env
- rebuildOrderedConfig(config) — enforce canonical ordering
- BOS_CONFIG_ORDER — ordered field names
The tasks you will actually be given
"Remove a plugin I inherited." Set it to null in the authored config's plugins — the null sentinel deletes it from the merged result. Deleting the entry entirely just inherits the parent's.
"Use another app's plugin without its website." Set ui: false on the plugins.<id> entry — the extended app's app.ui is otherwise attached as the plugin ui and loaded on every page.
"Different parent for staging." Object-form extends keyed by env (development / staging / production); the fallback chain is requested env → production → first defined value.
"Why doesn't the runtime see my config edit?" Dev reads .bos/bos.resolved-config.json (regenerated on every bos dev); production reads FastKV. The authored config is only the publish input — bos publish is the write.
What comes back when it refuses
| word | do |
|---|---|
| CircularExtendsError — "Circular extends detected: A -> B -> A" | stop — the extends chain loops; restructure so no runtime extends itself transitively |
| Circular dependency detected among: … dependsOn | build-order cycle in the build train — fix the dependsOn declarations in the authored config |
| inherited plugin still present after "removing" it | deep merge keeps parent plugins unless nulled — you need the explicit null sentinel, keyed exactly as the parent's plugin key |
| your section is missing from .bos/bos.resolved-config.json | ordering never drops keys — if it's absent, the authored config or its parent didn't define it; check the extends chain with bos config --full |