uniweb 0.61.0 → 0.63.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.61.0",
3
+ "version": "0.63.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/core": "^0.29.7",
44
+ "@uniweb/content-writer": "^0.3.4",
45
45
  "@uniweb/kit": "^0.19.4",
46
- "@uniweb/schemas": "^0.3.3",
47
- "@uniweb/semantic-parser": "^1.4.1",
48
- "@uniweb/runtime": "^0.27.3",
49
- "@uniweb/content-writer": "^0.3.4"
46
+ "@uniweb/core": "^0.29.8",
47
+ "@uniweb/runtime": "^0.27.4",
48
+ "@uniweb/schemas": "^0.3.4",
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.57.0",
52
+ "@uniweb/build": "^0.59.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -129,7 +129,7 @@ my-project/
129
129
  └── pnpm-workspace.yaml
130
130
  ```
131
131
 
132
- A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site`.
132
+ A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site` — a **workspace** name, how the site depends on it. What the foundation **registers as** (`@org/<name>`, the name sites pin) is `name` in its `main.js`, which `uniweb create` sets to the project's name. **`src` and `foundation` are never a foundation's name** — `uniweb register` refuses them, because every project in an org would register the same one.
133
133
 
134
134
  - **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
135
135
  - **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
@@ -845,13 +845,13 @@ It is a small database, not a page tree. Three things, deliberately separate:
845
845
  ```
846
846
  site/
847
847
  ├── pages/
848
- ├── records/ # your site's records — every file here is one
848
+ ├── records/ # your site's records — every file in a schema folder is one
849
849
  │ ├── article/ # the folder names their data schema
850
850
  │ │ ├── getting-started.md
851
851
  │ │ └── design-tips.md
852
- │ └── person/
853
- │ └── alice.yml
854
- ├── records.yml # optional: sorts records into folders
852
+ │ ├── person/
853
+ │ │ └── alice.yml
854
+ │ └── folder.yml # optional: sorts records into folders
855
855
  ├── queries.yml # how content is REACHED
856
856
  └── site.yml
857
857
  ```
@@ -864,7 +864,7 @@ site/
864
864
  | `records/std/person/…` | `@std/person` — the shared standard set |
865
865
  | `records/acme/project/…` | `@acme/project` — an org's |
866
866
 
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.
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/folder.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,9 +877,9 @@ Keep those folders flat: `records/article/design-tips.md` works; `records/articl
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`, `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.)
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 a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
881
881
 
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:
882
+ **`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.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
885
  - folder: archive
@@ -888,7 +888,7 @@ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `descript
888
888
  - article/2025-*.md
889
889
  ```
890
890
 
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).
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 `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
892
892
 
893
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
894
 
@@ -942,7 +942,7 @@ pages/blog/
942
942
 
943
943
  > **The record arrives as a single-element array under the key the component declares** — `content.data.recent[0]` for a component declaring `recent`, `content.data.article[0]` for one declaring `article: '@std/article'` over an `@std/article` query. The runtime never coerces it to an object. See *Data* in Part 4.
944
944
 
945
- **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
945
+ **Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records/folder.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
946
946
 
947
947
  **Two options for bigger sets:**
948
948
 
@@ -1006,7 +1006,7 @@ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you re
1006
1006
 
1007
1007
  Within the same directory, use normal relative imports (`./AIFeedbackCard`).
1008
1008
 
1009
- **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. Everything here is read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1009
+ **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the foundation's identity — `name` (what it registers as, `@org/<name>`) and `description` — and the capabilities it provides — `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. The capabilities are read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1010
1010
 
1011
1011
  ### Props interface
1012
1012
 
@@ -2575,7 +2575,9 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2575
2575
  > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2576
2576
  > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2577
2577
  > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2578
- > ownership is settled, so nothing is asked.
2578
+ > ownership is settled, so nothing is asked. After the create, every command names the
2579
+ > recorded org for you; an `--org` that disagrees with where the backend keeps the site
2580
+ > **stops the command** rather than acting in another org.
2579
2581
  >
2580
2582
  > Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
2581
2583
  > If you get `Unknown flag`, read the suggestion — it is usually a near miss.
@@ -2594,7 +2596,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2594
2596
 
2595
2597
  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.
2596
2598
 
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`.
2599
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. 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`.
2598
2600
 
2599
2601
  ### Staying current
2600
2602
 
@@ -2630,10 +2632,10 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2630
2632
 
2631
2633
  | Field | Where used | Default | Purpose |
2632
2634
  |---|---|---|---|
2633
- | `id` | `uniweb register` | bare segment of a scoped `name` | The foundation's registered id — the bare name in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming on the registry doesn't ripple through site dependencies. |
2634
- | `namespace` | `uniweb register` | none | Legacy explicit org-namespace override; equivalent to a scoped `package.json::name`. Rarely needed. |
2635
2635
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2636
2636
 
2637
+ **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the org it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope @org` (else one of your orgs) and writes it into the name, and a `--scope` naming another org than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
2638
+
2637
2639
  **Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
2638
2640
 
2639
2641
  You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** — you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
@@ -105,6 +105,141 @@ export function resolveBackendOrigin() {
105
105
  */
106
106
  export const DISCOVERY_DEFAULTS = {}
107
107
 
108
+ /**
109
+ * The header a request names its WORKSPACE with — the unit it works in.
110
+ *
111
+ * ⭐ `@<handle>`, the `@` required: a bare value is read as a unit's uuid — the spelling
112
+ * for a workspace with no handle — so the two can never be mistaken for each other.
113
+ * Every `/dev` route accepts it; the client names it on the site routes, the ones that
114
+ * work in a workspace (`namesWorkspace`). ⛔ It replaced `?as_org=` (2026-09-23), which
115
+ * only routes that declared it accepted — the rest answered `400`.
116
+ */
117
+ export const WORKSPACE_HEADER = 'x-uniweb-workspace'
118
+
119
+ /**
120
+ * Whether a request works IN a workspace — a site route, `/dev/site…`.
121
+ *
122
+ * Only those name one. Every `/dev` route accepts the header, but registration reads
123
+ * none (the `@scope` in the name decides, membership-gated), `/dev/assets` reads none
124
+ * (a charge lands on the entity's own workspace), and auth, orgs and config are about
125
+ * the account. Naming the site's workspace there would be harmless and meaningless —
126
+ * and a workspace that is refused (`400`/`403`) would then fail a registration that
127
+ * never involved it.
128
+ *
129
+ * @param {string} path - leading-slash path
130
+ * @returns {boolean}
131
+ */
132
+ export function namesWorkspace(path) {
133
+ return /^\/dev\/site(\/|$)/.test(String(path))
134
+ }
135
+
136
+ /**
137
+ * A workspace as it may be written — `@acme`, `acme`, `@acme/…` — as the header's
138
+ * `@acme`; null for none (the personal workspace, which a request names by omission).
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {string|null}
142
+ */
143
+ export function workspaceHandle(value) {
144
+ const h =
145
+ typeof value === 'string' ? value.trim().replace(/^@/, '').replace(/\/.*$/, '') : ''
146
+ return h ? `@${h}` : null
147
+ }
148
+
149
+ const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
150
+
151
+ /**
152
+ * A workspace as the header names it — `@handle`, or a unit's bare uuid when the unit
153
+ * has no handle — or null for none.
154
+ *
155
+ * ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
156
+ * handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
157
+ * So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
158
+ * record through `readSiteWorkspace` — and a bare uuid here is a unit.
159
+ *
160
+ * @param {unknown} value
161
+ * @returns {string|null}
162
+ */
163
+ export function workspaceHeader(value) {
164
+ if (typeof value !== 'string' || !value.trim()) return null
165
+ const v = value.trim()
166
+ return !v.startsWith('@') && UNIT_UUID.test(v) ? v : workspaceHandle(v)
167
+ }
168
+
169
+ /**
170
+ * The request named its workspace EXPLICITLY — `--org`, `--personal`, a choice made at
171
+ * create — and the backend works on the site from another.
172
+ *
173
+ * ⛔ Stopping is the point. It is the mismatch check [Diego, 2026-09-22: "there is no org
174
+ * mismatch when the site is set for an org and the user tries a different one"], and it
175
+ * is the backend's `409` that decides it, never a handle comparison here: a site in
176
+ * `@acme/labs` is legitimately worked on from `@acme`. Adopting the answer would override
177
+ * a choice the user made.
178
+ */
179
+ export class WorkspaceMismatchError extends Error {
180
+ /**
181
+ * @param {{ named: string|null, answer: string|null }} p - the workspace the request
182
+ * named, and the one the backend named (`@handle`, null for personal)
183
+ */
184
+ constructor({ named, answer }) {
185
+ const where = (w) =>
186
+ !w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
187
+ const flag = named ? '--org' : '--personal'
188
+ super(
189
+ `The backend works on this site from ${where(answer)}, not ${where(named)} ` +
190
+ `(${named ? `--org ${named}` : flag}). ` +
191
+ // `--org` takes a handle; a unit without one is reached by naming nothing.
192
+ (answer?.startsWith('@') ? `Pass --org ${answer}, or drop ${flag}.` : `Drop ${flag}.`)
193
+ )
194
+ this.name = 'WorkspaceMismatchError'
195
+ this.status = 409
196
+ this.named = named
197
+ this.answer = answer
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The line to print when a request THREW rather than answered.
203
+ *
204
+ * A `WorkspaceMismatchError` is the backend's answer, not a transport failure, so it is
205
+ * printed as itself: "Could not reach the backend" in front of it would send the user
206
+ * to check their network over a choice they made on the command line.
207
+ *
208
+ * @param {unknown} err
209
+ * @param {string} origin
210
+ * @returns {string}
211
+ */
212
+ export function describeRequestError(err, origin) {
213
+ if (err instanceof WorkspaceMismatchError) return err.message
214
+ return `Could not reach the backend at ${origin}: ${err?.message ?? err}`
215
+ }
216
+
217
+ /**
218
+ * The sentence a refusal carries — a problem document's `detail`, else its `title`, else
219
+ * the start of a prose body — or null. Consumes the body. For the surfaces that would
220
+ * otherwise print a status line alone, so a refusal the backend explains is shown as it
221
+ * is: a deployment with one workspace answers a request naming another with a plain
222
+ * `409 Conflict` whose `detail` names the one there is.
223
+ *
224
+ * @param {Response} res
225
+ * @returns {Promise<string|null>}
226
+ */
227
+ export async function refusalDetail(res) {
228
+ let body = ''
229
+ try {
230
+ body = await res.text()
231
+ } catch {
232
+ return null
233
+ }
234
+ try {
235
+ const problem = JSON.parse(body)
236
+ const line = problem?.detail || problem?.title
237
+ return typeof line === 'string' && line ? line : null
238
+ } catch {
239
+ return body ? body.slice(0, 300) : null
240
+ }
241
+ }
242
+
108
243
  export class BackendClient {
109
244
  /**
110
245
  * @param {object} [opts]
@@ -134,6 +269,35 @@ export class BackendClient {
134
269
  this._command = command
135
270
  this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
136
271
  this._discovery = null
272
+ this._workspace = null
273
+ this._workspaceExplicit = false
274
+ this._onWorkspaceAdopted = null
275
+ }
276
+
277
+ /** The workspace this client's requests name — `@handle`, or null (none named). */
278
+ get workspace() {
279
+ return this._workspace
280
+ }
281
+
282
+ /**
283
+ * Name the workspace every request from here on works in (`x-uniweb-workspace`).
284
+ *
285
+ * @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null to
286
+ * name none (`workspaceHeader`)
287
+ * @param {object} [opts]
288
+ * @param {boolean} [opts.explicit=false] - the user named it (`--org`, `--personal`,
289
+ * the create picker). A `409 wrong_workspace` then STOPS the command
290
+ * (`WorkspaceMismatchError`) instead of adopting the backend's answer.
291
+ * @param {(handle: string|null) => void|Promise<void>} [opts.onAdopted] - called when a
292
+ * request that named nothing, or a stale workspace, adopts the one the backend named;
293
+ * the caller records it (sync.json) and says so
294
+ * @returns {this}
295
+ */
296
+ setWorkspace(value, { explicit = false, onAdopted = null } = {}) {
297
+ this._workspace = workspaceHeader(value)
298
+ this._workspaceExplicit = Boolean(explicit)
299
+ this._onWorkspaceAdopted = onAdopted || null
300
+ return this
137
301
  }
138
302
 
139
303
  /**
@@ -168,7 +332,9 @@ export class BackendClient {
168
332
  * applies query params, and infers a content-type from the body when unset
169
333
  * (string → application/json, Buffer/Uint8Array → application/zip). Returns
170
334
  * the raw Response so callers branch on status themselves (409 resume,
171
- * 404 → null, 401/403 messaging, …).
335
+ * 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, which is
336
+ * answered here: adopted and retried once when the workspace was not named, and
337
+ * thrown as `WorkspaceMismatchError` when it was (see the body).
172
338
  *
173
339
  * @param {string} path - leading-slash path, e.g. '/dev/site/content'
174
340
  * @param {object} [opts]
@@ -179,10 +345,41 @@ export class BackendClient {
179
345
  * @param {boolean} [opts.auth=true]
180
346
  * @returns {Promise<Response>}
181
347
  */
182
- async request(
183
- path,
184
- { method = 'GET', body, headers = {}, query, auth = true } = {}
185
- ) {
348
+ async request(path, opts = {}) {
349
+ const res = await this._send(path, opts)
350
+ if (res?.status !== 409) return res
351
+
352
+ // ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request that
353
+ // names the wrong one for an existing site is refused with `409 wrong_workspace`,
354
+ // naming a workspace the caller CAN open that contains the site — its own unit, or a
355
+ // parent the caller belongs to. Two answers, and which one depends on the user:
356
+ // - they named it (`--org`, `--personal`) → stop and say so: the mismatch check;
357
+ // - they named nothing, or a stale one → adopt the backend's answer, let the caller
358
+ // record it, and retry ONCE.
359
+ // ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
360
+ const problem =
361
+ typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
362
+ if (problem?.reason !== 'wrong_workspace') return res
363
+ const where = problem.workspace || {}
364
+ // A unit with no handle is named by its uuid, bare — the header takes either.
365
+ const answer = where.handle
366
+ ? workspaceHandle(where.handle)
367
+ : typeof where.unit_uuid === 'string' && where.unit_uuid
368
+ ? where.unit_uuid
369
+ : null
370
+ // Naming what the backend names and still refused: nothing to adopt, or to report
371
+ // as a mismatch. The caller reads the 409 like any other.
372
+ if (answer === this._workspace) return res
373
+ if (this._workspaceExplicit) {
374
+ throw new WorkspaceMismatchError({ named: this._workspace, answer })
375
+ }
376
+ this._workspace = answer
377
+ if (this._onWorkspaceAdopted) await this._onWorkspaceAdopted(answer)
378
+ return this._send(path, opts)
379
+ }
380
+
381
+ /** One request, as `request` documents it — naming the client's workspace on a site route. */
382
+ async _send(path, { method = 'GET', body, headers = {}, query, auth = true } = {}) {
186
383
  const url = new URL(path, this.origin)
187
384
  if (query) {
188
385
  for (const [k, v] of Object.entries(query)) {
@@ -191,6 +388,7 @@ export class BackendClient {
191
388
  }
192
389
  const h = { ...headers }
193
390
  if (auth) h.Authorization = `Bearer ${await this.token()}`
391
+ if (this._workspace && namesWorkspace(path)) h[WORKSPACE_HEADER] = this._workspace
194
392
  if (body != null && h['Content-Type'] == null) {
195
393
  if (typeof body === 'string') h['Content-Type'] = 'application/json'
196
394
  else if (body instanceof Uint8Array || Buffer.isBuffer(body))
@@ -405,10 +603,13 @@ export class BackendClient {
405
603
  * design). Callers must guard on the site's uuid for this backend (sync.json) and
406
604
  * write the result back immediately — see `ensureSiteExists`.
407
605
  *
408
- * @param {{ name: string, foundation: string, asOrg?: string|null }} opts
606
+ * The owner is the workspace the client names (`setWorkspace`) — the create is where
607
+ * that choice is made, once.
608
+ *
609
+ * @param {{ name: string, foundation: string }} opts
409
610
  * @returns {Promise<Response>} `{ site_content_uuid }`
410
611
  */
411
- async createSite({ name, foundation, asOrg } = {}) {
612
+ async createSite({ name, foundation } = {}) {
412
613
  return this.request('/dev/site', {
413
614
  method: 'POST',
414
615
  // Both fields are REQUIRED: `info.name` is `required: true` on the model
@@ -417,35 +618,34 @@ export class BackendClient {
417
618
  // render), and a site must resolve to a foundation. Adoption of the ref is
418
619
  // best-effort on the backend, but the field itself cannot be blank — so send
419
620
  // whatever the site declares and let the backend judge it.
420
- body: JSON.stringify({ name, foundation }),
421
- query: { as_org: asOrg }
621
+ body: JSON.stringify({ name, foundation })
422
622
  })
423
623
  }
424
624
 
425
625
  /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
426
- async createSiteContent(buffer, { asOrg } = {}) {
626
+ async createSiteContent(buffer) {
427
627
  return this.request('/dev/site/content', {
428
628
  method: 'POST',
429
629
  body: buffer,
430
- query: pushQuery(asOrg)
630
+ query: PUSH_QUERY
431
631
  })
432
632
  }
433
633
 
434
634
  /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
435
- async updateSiteContent(uuid, buffer, { asOrg } = {}) {
635
+ async updateSiteContent(uuid, buffer) {
436
636
  return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
437
637
  method: 'POST',
438
638
  body: buffer,
439
- query: pushQuery(asOrg)
639
+ query: PUSH_QUERY
440
640
  })
441
641
  }
442
642
 
443
643
  /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
444
- async pushFolder(uuid, buffer, { asOrg } = {}) {
644
+ async pushFolder(uuid, buffer) {
445
645
  return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
446
646
  method: 'POST',
447
647
  body: buffer,
448
- query: pushQuery(asOrg)
648
+ query: PUSH_QUERY
449
649
  })
450
650
  }
451
651
 
@@ -565,8 +765,17 @@ export class BackendClient {
565
765
  const res = await this.request(
566
766
  `/dev/site/status/${encodeURIComponent(uuid)}`
567
767
  )
768
+ if (res.status === 409) {
769
+ // A refusal about the workspace — e.g. a deployment with one workspace, naming
770
+ // it — is an answer to report, not an absent status.
771
+ const refused = new Error((await refusalDetail(res)) || 'HTTP 409 Conflict')
772
+ refused.status = 409
773
+ throw refused
774
+ }
568
775
  return res.ok ? await res.json().catch(() => null) : null
569
- } catch {
776
+ } catch (err) {
777
+ // A mismatch or a refusal is an answer, not an absent status.
778
+ if (err instanceof WorkspaceMismatchError || err?.status === 409) throw err
570
779
  return null
571
780
  }
572
781
  }
@@ -596,7 +805,9 @@ export function dataSchemaPath(modelName) {
596
805
  return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
597
806
  }
598
807
 
599
- /** The shared push query: last-push-wins, plus an optional acting-org. */
600
- function pushQuery(asOrg) {
601
- return { collision: 'force', ...(asOrg ? { as_org: asOrg } : {}) }
602
- }
808
+ /**
809
+ * The shared push query: last-push-wins. ⛔ No `as_org` — the workspace rides the
810
+ * `x-uniweb-workspace` header on every site request (`BackendClient.setWorkspace`),
811
+ * since 2026-09-23.
812
+ */
813
+ const PUSH_QUERY = { collision: 'force' }