uniweb 0.59.2 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.59.2",
3
+ "version": "0.61.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.29.6",
46
- "@uniweb/runtime": "^0.27.2",
44
+ "@uniweb/core": "^0.29.7",
45
+ "@uniweb/kit": "^0.19.4",
46
+ "@uniweb/schemas": "^0.3.3",
47
47
  "@uniweb/semantic-parser": "^1.4.1",
48
- "@uniweb/kit": "^0.19.3",
49
- "@uniweb/schemas": "^0.3.3"
48
+ "@uniweb/runtime": "^0.27.3",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.55.1",
52
+ "@uniweb/build": "^0.57.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -845,26 +845,26 @@ It is a small database, not a page tree. Three things, deliberately separate:
845
845
  ```
846
846
  site/
847
847
  ├── pages/
848
- ├── entities/ # your stored things. The folder names their data schema
849
- │ ├── article/
848
+ ├── records/ # your site's records — every file here is one
849
+ │ ├── article/ # the folder names their data schema
850
850
  │ │ ├── getting-started.md
851
851
  │ │ └── design-tips.md
852
852
  │ └── person/
853
853
  │ └── alice.yml
854
- ├── records.yml # what is PUBLISHED
854
+ ├── records.yml # optional: sorts records into folders
855
855
  ├── queries.yml # how content is REACHED
856
856
  └── site.yml
857
857
  ```
858
858
 
859
- **`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
859
+ **`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
860
860
 
861
861
  | on disk | schema |
862
862
  |---|---|
863
- | `entities/article/…` | `@/article` — your foundation's own |
864
- | `entities/std/person/…` | `@std/person` — the shared standard set |
865
- | `entities/acme/project/…` | `@acme/project` — an org's |
863
+ | `records/article/…` | `@/article` — your foundation's own |
864
+ | `records/std/person/…` | `@std/person` — the shared standard set |
865
+ | `records/acme/project/…` | `@acme/project` — an org's |
866
866
 
867
- Keep those folders flat: `entities/article/design-tips.md` works; `entities/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead.
867
+ Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
868
868
 
869
869
  **Four formats, one shape.** All of these produce the same records at runtime:
870
870
 
@@ -877,32 +877,22 @@ Keep those folders flat: `entities/article/design-tips.md` works; `entities/arti
877
877
 
878
878
  **One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
879
879
 
880
- Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
880
+ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out — and `uniweb push` stops at it, naming it. (`published: false`, an older spelling, is refused.)
881
881
 
882
- **`records.yml` — listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
882
+ **`records.yml` — optional, and only for folders.** Every record sits at the top of the site's records folder unless `records.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
883
883
 
884
884
  ```yaml
885
- - article/*.md
886
- - person/*.yml
887
- ```
888
-
889
- A bare string is a path under `entities/`, naming one file or matching many.
890
-
891
- ⚠️ **An empty `records.yml` is not the same as having none.** With no file, a build publishes every entity in `entities/`, and `uniweb push` leaves the published set alone. An empty file means "nothing is published": a build publishes none, and a push REMOVES what is published. The CLI asks before it does that.
892
-
893
- **Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
894
-
895
- ```yaml
896
- - article/2026-*.md
897
885
  - folder: archive
898
886
  label: The Archive
899
887
  records:
900
888
  - article/2025-*.md
901
889
  ```
902
890
 
903
- A query reads one branch with `scope: archive` — the folder and everything inside it (the older `where: { path: { under: … } }` is refused by the build). An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
891
+ A path under a folder is relative to `records/`, naming one file or matching many. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `records.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
904
892
 
905
- **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
893
+ ⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
894
+
895
+ **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
906
896
 
907
897
  ```yaml
908
898
  recent:
@@ -912,7 +902,7 @@ recent:
912
902
 
913
903
  team:
914
904
  schema: '@std/person'
915
- where: { published: { ne: false } } # a predicate — see authoring/predicates.md
905
+ where: { active: true } # a predicate — see authoring/predicates.md
916
906
  ```
917
907
 
918
908
  You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
@@ -944,7 +934,7 @@ pages/blog/
944
934
  └── article.md # just `type: Article` — the record arrives automatically
945
935
  ```
946
936
 
947
- `entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
937
+ `records/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
948
938
 
949
939
  **Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
950
940
 
@@ -1871,7 +1861,7 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
1871
1861
  # pages/blog/page.yml
1872
1862
  fetch:
1873
1863
  query: recent
1874
- where: { published: true, tags: featured }
1864
+ where: { tags: featured }
1875
1865
  sort: date desc
1876
1866
  limit: 3
1877
1867
  ```
@@ -2526,7 +2516,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
2526
2516
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2527
2517
  uniweb push --org @acme # First push/publish of a site: who owns it (see below)
2528
2518
  uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
2529
- uniweb login / logout # Start or clear the backend session the verbs above reuse
2519
+ uniweb login / logout # One backend at a time: log in (--backend <url>) or out
2530
2520
  uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
2531
2521
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2532
2522
 
@@ -2560,7 +2550,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2560
2550
  | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2561
2551
  | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2562
2552
 
2563
- `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `lastDeploy:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2553
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2564
2554
 
2565
2555
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2566
2556
 
@@ -2582,7 +2572,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2582
2572
  > uniweb publish --personal # your personal account, deliberately
2583
2573
  > ```
2584
2574
  >
2585
- > The answer is recorded in `site.yml::$org` and committed, so it is a one-time choice per site,
2575
+ > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2586
2576
  > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2587
2577
  > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2588
2578
  > ownership is settled, so nothing is asked.
@@ -2598,11 +2588,13 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2598
2588
 
2599
2589
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2600
2590
 
2591
+ **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2592
+
2601
2593
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2602
2594
 
2603
2595
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2604
2596
 
2605
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login`, `--token`, or `UNIWEB_TOKEN`; preview with `--dry-run`.
2597
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2606
2598
 
2607
2599
  ### Staying current
2608
2600
 
@@ -2695,7 +2687,7 @@ Running `extract` before a build is the usual first mistake — it reads the com
2695
2687
 
2696
2688
  ```
2697
2689
  locales/freeform/es/pages/about/hero.md # by page route
2698
- locales/freeform/es/entities/article/x.md # records work too
2690
+ locales/freeform/es/records/article/x.md # records work too
2699
2691
  ```
2700
2692
 
2701
2693
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * ## Where an asset lands, and why there are two answers
9
9
  *
10
- * - **The map knows it** (`assets.json`) — the file goes back to the path its
11
- * author wrote. That is the whole reason the map is committed: a fresh clone
12
- * restores `public/images/hero.png`, not a hash.
10
+ * - **The map knows it** (this backend's `assets` in `sync.json`) — the file goes
11
+ * back to the path its author wrote. That is the whole reason the map is
12
+ * committed: a fresh clone restores `public/images/hero.png`, not a hash.
13
13
  * - **The map does not know it** — this project has never held these bytes:
14
14
  * authored in the app, or pushed from a machine whose map entry has not
15
15
  * arrived. There is no local path to guess, so it lands at a **generic,
@@ -21,8 +21,8 @@
21
21
  * Identity rides beside the reference (`sync-package.js` stamps `assetId` next
22
22
  * to the serve URL), so the address is already on the node. Composing one would
23
23
  * mean holding the host's route layout — the coupling deleting `buildAssetUrl`
24
- * removed from this CLI, and the reason `assets.json` stores no URL either. An
25
- * id with no URL beside it is skipped, not guessed at.
24
+ * removed from this CLI, and the reason the asset map stores no URL either (only a
25
+ * fingerprint of one). An id with no URL beside it is skipped, not guessed at.
26
26
  *
27
27
  * ## ⛔ A failed download is a WARNING, never a failed pull
28
28
  *
@@ -35,8 +35,9 @@ import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
35
35
  import { dirname, join } from 'node:path'
36
36
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
37
37
  import {
38
- readAssetMap,
39
- updateAssetMap,
38
+ readBackendState,
39
+ updateBackendMap,
40
+ carryServed,
40
41
  servedFingerprint
41
42
  } from '@uniweb/build/uwx'
42
43
 
@@ -116,7 +117,7 @@ export async function downloadMissingAssets({
116
117
  }) {
117
118
  const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
118
119
  // Read first: a bare-string reference is only recognizable through the map.
119
- const map = readAssetMap(siteDir)
120
+ const map = readBackendState(siteDir, origin).assets || {}
120
121
  const refs = collectAssetRefs(document, map)
121
122
  const out = { downloaded: [], present: [], failed: [], skipped: [] }
122
123
  if (!refs.length) return out
@@ -166,6 +167,7 @@ export async function downloadMissingAssets({
166
167
 
167
168
  // Newly-landed assets become known ones, so the next pull restores them to
168
169
  // this path rather than fetching them again to a second location.
169
- if (Object.keys(learned).length) updateAssetMap(siteDir, learned)
170
+ if (Object.keys(learned).length)
171
+ updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
170
172
  return out
171
173
  }
@@ -42,65 +42,31 @@ import { uploadFoundationCode } from '../utils/code-upload.js'
42
42
  import { uploadSiteAssets } from '../utils/asset-upload.js'
43
43
 
44
44
  /**
45
- * Resolve the backend origin via the resolution ladder (highest precedence
46
- * first). A full URL is reduced to its origin, so callers may pass a whole
47
- * endpoint URL; an unparseable value falls through to the next tier.
45
+ * Resolve the backend a command talks to:
48
46
  *
49
- * 1. `flag` — the raw --backend value (this command)
50
- * 2. UNIWEB_REGISTER_URL env — session-wide override (CI / local dev)
51
- * 3. `siteScope` — the project's `site.yml::$backend` (site verbs)
52
- * 4. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
53
- * 5–7. the logged-in session origin > ~/.uniweb/config.json > the default
54
- * (uniweb.app) — all via getRegistryApiBaseUrl()
47
+ * 1. UNIWEB_REGISTER_URL env — the override for automation (CI, scripts), one process
48
+ * 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
49
+ * 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
50
+ * where the command's first request then asks the user to log in
55
51
  *
56
- * ⭐ **`siteScope` outranks the SESSION, and that is the whole reason it is a tier.** The
57
- * session is a machine-wide default; `$backend` is this project's statement about where
58
- * its stored identity is meaningful. Without the tier, a teammate who clones a project
59
- * bound to a non-default backend resolves to whatever they last logged into, and is then
60
- * REFUSED by `assertSiteBackendScope` — nagged instead of routed, on a question the
61
- * project already answered.
52
+ * ⭐ **That is the whole ladder, for every command** *[Diego, 2026-09-21: "push and pull
53
+ * are also meant to go to the backend you are logged into" · "We do not allow any
54
+ * communication with backend if the user is not logged into a backend. The default
55
+ * backend for login, if not specified, is uniweb.app" · switching "via login to another
56
+ * backend is good, and the only way to switch"]*. Logging in is how a backend is chosen.
62
57
  *
63
- * ⭐ **And it outranks `siteBackend`, which looks like the same fact and is not.**
64
- * `deploy.yml`'s target `backend` records *where this ships*; `$backend` records *whose
65
- * namespace the uuid is in*. They agree in the normal case. When they disagree the
66
- * identity wins, because resolving to the other one only reaches the guard and stops.
58
+ * ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
59
+ * `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
60
+ * slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
61
+ * a script aims with UNIWEB_REGISTER_URL without touching the machine's login. A
62
+ * project's sync.json and deploy.yml routed commands too, until the same day.
63
+ * `--backend` survives only where it SELECTS rather than routes: `login` (where to log
64
+ * in) and `forget` (which backend's records to remove). `logout` needs none: there is
65
+ * one session.
67
66
  *
68
- * ⚠️ The explicit overrides stay ON TOP deliberately. `--backend` and the env var are how
69
- * you deliberately aim elsewhere — at a staging mirror, say — and a project file must not
70
- * be able to veto a flag the user just typed. A wrong aim is then caught by the guard
71
- * rather than silently redirected.
72
- *
73
- * @param {string} [flag] - the raw value of --backend, if supplied
74
- * @param {object} [opts]
75
- * @param {string} [opts.siteScope] - the project's `site.yml::$backend`
76
- * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
77
67
  * @returns {string} a bare origin with no trailing slash
78
68
  */
79
- export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
80
- const norm = (v) => {
81
- try {
82
- return new URL(v).origin
83
- } catch {
84
- return null
85
- }
86
- }
87
- if (flag) {
88
- const o = norm(flag)
89
- if (o) return o
90
- }
91
- const env = process.env.UNIWEB_REGISTER_URL
92
- if (env) {
93
- const o = norm(env)
94
- if (o) return o
95
- }
96
- if (siteScope) {
97
- const o = norm(siteScope)
98
- if (o) return o
99
- }
100
- if (siteBackend) {
101
- const o = norm(siteBackend)
102
- if (o) return o
103
- }
69
+ export function resolveBackendOrigin() {
104
70
  return getRegistryApiBaseUrl()
105
71
  }
106
72
 
@@ -142,10 +108,8 @@ export const DISCOVERY_DEFAULTS = {}
142
108
  export class BackendClient {
143
109
  /**
144
110
  * @param {object} [opts]
145
- * @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
146
- * @param {string} [opts.originFlag] - raw --backend value to resolve
147
- * @param {string} [opts.siteScope] - the project's `site.yml::$backend` (site verbs)
148
- * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
111
+ * @param {string} [opts.origin] - explicit origin (wins over the ladder; tests and
112
+ * internal callers — no command takes one from the user)
149
113
  * @param {string} [opts.token] - explicit bearer (wins over env + stored session)
150
114
  * @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
151
115
  * callers with their own auth); used when no explicit token/env is present
@@ -155,9 +119,6 @@ export class BackendClient {
155
119
  */
156
120
  constructor({
157
121
  origin,
158
- originFlag,
159
- siteScope,
160
- siteBackend,
161
122
  token,
162
123
  getToken,
163
124
  args = [],
@@ -165,7 +126,7 @@ export class BackendClient {
165
126
  fetchImpl
166
127
  } = {}) {
167
128
  this.origin = (
168
- origin || resolveBackendOrigin(originFlag, { siteScope, siteBackend })
129
+ origin || resolveBackendOrigin()
169
130
  ).replace(/\/+$/, '')
170
131
  this._token = token || process.env.UNIWEB_TOKEN || null
171
132
  this._getToken = getToken || null
@@ -176,9 +137,9 @@ export class BackendClient {
176
137
  }
177
138
 
178
139
  /**
179
- * The session bearer, resolved lazily and memoized. Order (matching the
180
- * standalone verbs): explicit --token / UNIWEB_TOKEN (constructor) > stored
181
- * session > interactive login (ensureRegistryAuth).
140
+ * The session bearer, resolved lazily and memoized. Order: an explicit `token`
141
+ * (constructor — tests and internal callers; no command takes one from the user) /
142
+ * UNIWEB_TOKEN > the stored session > interactive login (ensureRegistryAuth).
182
143
  * @returns {Promise<string>}
183
144
  */
184
145
  async token() {
@@ -187,27 +148,13 @@ export class BackendClient {
187
148
  this._token = await this._getToken()
188
149
  return this._token
189
150
  }
190
- // Origin-mismatch guard: a usable session authed against a DIFFERENT origin
191
- // than this command targets means the bearer will be rejected — warn once
192
- // and point at the fix (the stored token IS for `stored.origin`, not here).
193
- if (!this._warnedOriginMismatch) {
194
- this._warnedOriginMismatch = true
195
- try {
196
- const stored = await readRegistryAuth()
197
- if (
198
- stored?.token &&
199
- !isExpired(stored) &&
200
- stored.origin &&
201
- stored.origin.replace(/\/+$/, '') !== this.origin
202
- ) {
203
- console.error(
204
- `\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`
205
- )
206
- }
207
- } catch {
208
- /* advisory only */
209
- }
210
- }
151
+ // ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
152
+ // It existed because the session store held ONE record: a session for another
153
+ // backend was the only session, so it was about to be sent here and rejected, and a
154
+ // warning was the best available move. Sessions are keyed by origin now
155
+ // (utils/registry-auth.js), so being logged into another backend is just a fact
156
+ // about another backend — `ensureRegistryAuth` below finds this origin's session or
157
+ // asks for one. Warning about the other would be noise about nothing.
211
158
  this._token = await ensureRegistryAuth({
212
159
  apiBase: this.origin,
213
160
  command: this._command,
@@ -255,8 +202,8 @@ export class BackendClient {
255
202
  // ── Discovery ─────────────────────────────────────────────────────────────────
256
203
 
257
204
  /**
258
- * The session bearer IF one can be had without asking — an explicit `--token`, an
259
- * env var, or a stored unexpired session. Never prompts, never logs in, returns null
205
+ * The session bearer IF one can be had without asking — an explicit `token`, the
206
+ * UNIWEB_TOKEN env var, or a stored unexpired session. Never prompts, never logs in, returns null
260
207
  * instead. `token()` is the one that may block; this is for calls that want to be
261
208
  * authenticated when possible but must not *cause* an authentication.
262
209
  * @returns {Promise<string|null>}
@@ -276,7 +223,7 @@ export class BackendClient {
276
223
  }
277
224
  }
278
225
  try {
279
- const stored = await readRegistryAuth()
226
+ const stored = await readRegistryAuth(this.origin)
280
227
  if (stored?.token && !isExpired(stored)) return stored.token
281
228
  } catch {
282
229
  /* advisory — a missing or unreadable session is simply "no token" */
@@ -455,8 +402,8 @@ export class BackendClient {
455
402
  * the field itself is required and cannot be blank.
456
403
  *
457
404
  * NOT idempotent: two calls mint two sites (a name is not a unique key, by
458
- * design). Callers must guard on `site.yml::$uuid` and write the result back
459
- * immediately — see `ensureSiteExists`.
405
+ * design). Callers must guard on the site's uuid for this backend (sync.json) and
406
+ * write the result back immediately — see `ensureSiteExists`.
460
407
  *
461
408
  * @param {{ name: string, foundation: string, asOrg?: string|null }} opts
462
409
  * @returns {Promise<Response>} `{ site_content_uuid }`
@@ -545,7 +492,7 @@ export class BackendClient {
545
492
  /**
546
493
  * POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
547
494
  * backend state live; it does NOT push local files). `{uuid}` is the site-content
548
- * uuid (`site.yml::$uuid`); a never-synced site 404s (sync first, or use deploy).
495
+ * uuid on this backend (sync.json); a never-synced site 404s (sync first, or use deploy).
549
496
  * Languages, when present, go in the body; absent → no body. Returns the raw
550
497
  * Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
551
498
  *
@@ -38,8 +38,8 @@ import { execFileSync } from 'node:child_process'
38
38
 
39
39
  import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
- import { readFlagValue } from '../utils/args.js'
42
41
  import { isNonInteractive } from '../utils/interactive.js'
42
+ import { publishScope } from '../utils/registry-orgs.js'
43
43
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
44
44
 
45
45
  /**
@@ -127,13 +127,17 @@ export function resolveLocalExtensions(siteDir, siteYml) {
127
127
  // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
128
  // the catalog does not have, so every push re-released it, and pinned the site
129
129
  // to that same wrong name.
130
+ //
131
+ // ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
132
+ // may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
133
+ // bare one looked the foundation up as `acme/src` and pinned the site to that.
130
134
  function foundationScopedName(dir) {
131
135
  try {
132
136
  const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
133
137
  const name = pkg?.uniweb?.id || pkg?.name
134
138
  if (typeof name === 'string' && name.startsWith('@')) return name
135
- const scope = pkg?.uniweb?.scope
136
- if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
139
+ const scope = publishScope(pkg?.uniweb?.scope)
140
+ if (scope && name) return `${scope}/${name}`
137
141
  return null
138
142
  } catch {
139
143
  return null
@@ -151,14 +155,12 @@ function readPkgField(dir, field) {
151
155
  }
152
156
  }
153
157
 
154
- // Forward the origin + auth flags so the spawned `uniweb register` / `build`
155
- // hits the SAME backend with the SAME session as the publish that called it.
158
+ // What travels to the spawned `uniweb register` / `build` on the command line: only
159
+ // --non-interactive. The BACKEND travels in the child's environment
160
+ // (releaseFoundation), and the SESSION is the shared session file — or UNIWEB_TOKEN,
161
+ // which the child inherits. The backend commands take no `--backend` or `--token`.
156
162
  function forwardedFlags(args) {
157
163
  const out = []
158
- for (const name of ['--backend', '--token']) {
159
- const v = readFlagValue(args, name)
160
- if (v) out.push(name, v)
161
- }
162
164
  if (isNonInteractive(args)) out.push('--non-interactive')
163
165
  return out
164
166
  }
@@ -322,7 +324,7 @@ async function bringLocalCodeAlong({
322
324
  }
323
325
  say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
324
326
  return {
325
- released: releaseFoundation(local, args, cliBin, say),
327
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
326
328
  proceed: true,
327
329
  ref: pinnedRef()
328
330
  }
@@ -365,7 +367,7 @@ async function bringLocalCodeAlong({
365
367
  : `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
366
368
  )
367
369
  return {
368
- released: releaseFoundation(local, args, cliBin, say),
370
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
369
371
  proceed: true,
370
372
  ref: pinnedRef()
371
373
  }
@@ -396,7 +398,7 @@ async function bringLocalCodeAlong({
396
398
  )
397
399
  if (reRelease)
398
400
  return {
399
- released: releaseFoundation(local, args, cliBin, say),
401
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
400
402
  proceed: true,
401
403
  ref: pinnedRef()
402
404
  }
@@ -490,12 +492,15 @@ function buildFoundation(local, cliBin) {
490
492
  // Release = `uniweb register` in the foundation directory (the one foundation
491
493
  // release path). Returns true on success; throws to the caller on failure so
492
494
  // publish stops before going live with missing code.
493
- function releaseFoundation(local, args, cliBin, say) {
495
+ function releaseFoundation(local, args, cliBin, say, origin) {
494
496
  console.log('')
497
+ // ⭐ Pinned to the parent's backend through the environment, so the release lands
498
+ // where the publish goes by construction — not because two processes happen to read
499
+ // the same session file the same way.
495
500
  execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
496
501
  cwd: local.dir,
497
502
  stdio: 'inherit',
498
- env: process.env
503
+ env: origin ? { ...process.env, UNIWEB_REGISTER_URL: origin } : process.env
499
504
  })
500
505
  console.log('')
501
506
  return true
@@ -38,7 +38,8 @@
38
38
  *
39
39
  * ## ⛔ A HASH, never the block
40
40
  *
41
- * `$secrets` carries secret material, and `$services[].config` is opaque and
41
+ * `$secrets` names every secret the site has (values are the `#ref` marker, never
42
+ * secret material — the old wording here said otherwise), and `$services[].config` is opaque and
42
43
  * per-service — anything may be in it. `deploy.yml` is committed, so recording
43
44
  * either verbatim would write them into git. Equality is all this gate needs;
44
45
  * *what* differs is a question for the backend's own copy of the request, not for
@@ -60,7 +61,8 @@ import { createHash } from 'node:crypto'
60
61
  * Rows are ordered by their serialized form and object keys sorted, so reordering
61
62
  * rows or keys in the file is not a change. It is not a request to move a line.
62
63
  *
63
- * @param {*} declared - the raw `site.yml::$services` / `$secrets` value
64
+ * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
+ * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
64
66
  * @returns {string|null} 16 hex chars, or null when undeclared
65
67
  */
66
68
  export function fingerprintDeclaration(declared) {
@@ -91,11 +93,14 @@ function stableString(value) {
91
93
  * @param {object} siteYml
92
94
  * @returns {{servicesRequest?: string, secretsRequest?: string}}
93
95
  */
94
- export function fingerprintRequest(siteYml) {
96
+ export function fingerprintRequest(siteYml, provisioned = {}) {
95
97
  const out = {}
96
- const services = fingerprintDeclaration(siteYml?.$services)
98
+ // ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
99
+ // backend being published to (they were `site.yml::$services` / `$secrets` until
100
+ // 2026-09-20). The language selection below is still authored, so still site.yml.
101
+ const services = fingerprintDeclaration(provisioned?.services)
97
102
  if (services) out.servicesRequest = services
98
- const secrets = fingerprintDeclaration(siteYml?.$secrets)
103
+ const secrets = fingerprintDeclaration(provisioned?.secrets)
99
104
  if (secrets) out.secretsRequest = secrets
100
105
  // ⭐ The language selection is a request too, and it is the one that moves a
101
106
  // PRICE — the line is billed on how many languages go out. Banking it is what
@@ -124,8 +129,8 @@ export function fingerprintRequest(siteYml) {
124
129
  * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
125
130
  * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
126
131
  */
127
- export function decideDeclaration(siteYml, lastDeploy) {
128
- const now = fingerprintRequest(siteYml)
132
+ export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
133
+ const now = fingerprintRequest(siteYml, provisioned)
129
134
  if (!now.servicesRequest && !now.secretsRequest) {
130
135
  // Nothing in the file to send. The gate is moot; say so rather than
131
136
  // reporting "unchanged", which would imply a comparison happened.
@@ -178,8 +183,8 @@ export function decideDeclaration(siteYml, lastDeploy) {
178
183
  * @param {object|null} lastDeploy - the banked base
179
184
  * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
180
185
  */
181
- export function reconcileRequest(siteYml, remoteServices, lastDeploy) {
182
- return reconcile(siteYml?.$services, remoteServices, lastDeploy?.servicesRequest)
186
+ export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
187
+ return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
183
188
  }
184
189
 
185
190
  /**
@@ -72,18 +72,18 @@ export async function uploadSiteMedia(
72
72
  warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
73
73
 
74
74
  const map = {}
75
- // The identity half, for `assets.json`. The plan returns an authoritative
76
- // `id`+`ext` for every entry INCLUDING a `present: true` dedup skip, so a
77
- // re-push of unchanged media still records identity without moving bytes —
78
- // which is what makes the committed map cheap to keep accurate.
75
+ // The identity half, for this backend's asset map in `sync.json`. The plan
76
+ // returns an authoritative `id`+`ext` for every entry INCLUDING a `present: true`
77
+ // dedup skip, so a re-push of unchanged media still records identity without
78
+ // moving bytes — which is what makes the committed map cheap to keep accurate.
79
79
  const ids = {}
80
80
  for (const ref of refs) {
81
81
  const entry = result.assetsByLocalUrl[ref]
82
82
  if (!entry) continue
83
83
  // `served` — a fingerprint of the address, never the address. It is how a pull
84
84
  // recognizes this asset where the reference is a bare string with no room for an
85
- // id beside it (`info.preview`, `seo.image`, a section param); `assets.json`'s
86
- // header says why a hash and not the URL.
85
+ // id beside it (`info.preview`, `seo.image`, a section param); the header of
86
+ // `@uniweb/build`'s asset-map.js says why a hash and not the URL.
87
87
  if (entry.id) {
88
88
  ids[ref] = {
89
89
  id: entry.id,