uniweb 1.6.2 → 1.7.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": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "~1.1.2",
45
- "@uniweb/schemas": "~1.3.0",
46
45
  "@uniweb/core": "~1.2.2",
46
+ "@uniweb/schemas": "~1.3.0",
47
47
  "@uniweb/semantic-parser": "~1.8.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/content-reader": "~1.3.3",
51
- "@uniweb/semantic-parser": "~1.8.1",
52
- "@uniweb/build": "~1.3.4"
50
+ "@uniweb/build": "~1.3.5",
51
+ "@uniweb/content-reader": "~1.4.0",
52
+ "@uniweb/semantic-parser": "~1.8.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -572,6 +572,8 @@ Content is markdown. Components decide how it renders, so a tag written in it
572
572
 
573
573
  The one exception is `<br/>`, a line break where markdown has no other spelling — inside a heading. For a headline split across lines, the multi-line heading above usually says it better.
574
574
 
575
+ A plain newline inside a paragraph is a space, so wrap prose however you like — re-wrapping a paragraph changes nothing, and pushes nothing. A line break in a paragraph is written on purpose: a backslash (`\`) at the end of the line.
576
+
575
577
  ### Fenced code: data blocks vs snippets
576
578
 
577
579
  Fenced code serves three purposes depending on its info string: `yaml:`/`json:` for data, `md:` for a named kind of prose (see *Concept blocks*), and a bare language for a code sample.
@@ -192,12 +192,14 @@ export function workspaceHeader(value) {
192
192
  */
193
193
  export class WorkspaceMismatchError extends Error {
194
194
  /**
195
- * @param {{ named: string|null, answer: string|null, source?: string, origin?: string|null }} p -
195
+ * @param {{ named: string|null, answer: string|null, nowhere?: boolean, source?: string, origin?: string|null, message?: string|null }} p -
196
196
  * the workspace the request named and the one the backend named (`@handle`, a unit's
197
197
  * uuid, null for personal), where the named one came from (`workspace.js`), and the
198
- * backend — a login that switches must name it unless it is the default (`loginCommand`)
198
+ * backend — a login that switches must name it unless it is the default (`loginCommand`).
199
+ * `nowhere`: the backend named NO workspace the caller may open (`workspace: null`), so
200
+ * there is nothing to switch to. `message` replaces the sentence (`WorkspaceRefusedError`).
199
201
  */
200
- constructor({ named, answer, source = 'login', origin = null }) {
202
+ constructor({ named, answer, nowhere = false, source = 'login', origin = null, message = null }) {
201
203
  const where = (w) =>
202
204
  !w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
203
205
  // How to work in the site's workspace, said for the way this one was chosen.
@@ -219,15 +221,64 @@ export class WorkspaceMismatchError extends Error {
219
221
  ? `To work on it, ${switchTo.env}.`
220
222
  : `To work on it: ${login}`
221
223
  const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
222
- super(`This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
224
+ super(
225
+ message ??
226
+ (nowhere
227
+ ? `This site is not in a workspace you can open${origin ? ` on ${origin}` : ''}, so it cannot be worked on from here — there is none to switch to.`
228
+ : `This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
229
+ )
223
230
  this.name = 'WorkspaceMismatchError'
224
231
  this.status = 409
225
232
  this.named = named
226
233
  this.answer = answer
234
+ this.nowhere = nowhere
227
235
  this.source = source
228
236
  }
229
237
  }
230
238
 
239
+ /**
240
+ * The workspace this command named is not one it may open — `403`, `op: "act_as_unit"`.
241
+ *
242
+ * ⭐ The header admits exactly the workspaces the account may open: an organization it is a
243
+ * direct member of, and not an archived one — its own members included [backend, 2026-10-16].
244
+ * So a login whose workspace was archived after it was chosen hears this on every site
245
+ * request. ⛔ It is not a refused credential, and "log in again" is the wrong advice for it:
246
+ * the session is fine, the workspace is not.
247
+ *
248
+ * ⭐ Keyed on `op`, never on `title`, which is the `"Forbidden"` every denial shares; and only for
249
+ * a request that named a workspace, the one kind that can answer it [backend, 2026-10-16].
250
+ *
251
+ * A kind of `WorkspaceMismatchError` because every caller treats the two alike — the command
252
+ * stops and says how to choose — except `siteState`: a refused workspace says nothing about the
253
+ * site, so it is `unknown` there, never `live`.
254
+ */
255
+ export class WorkspaceRefusedError extends WorkspaceMismatchError {
256
+ /**
257
+ * @param {{ named: string, source?: string, origin?: string|null }} p - the workspace the
258
+ * request named (`@handle`, or a unit's uuid), where it came from (`workspace.js`), the backend
259
+ */
260
+ constructor({ named, source = 'login', origin = null }) {
261
+ const where = named.startsWith('@') ? named : `the unit ${named}`
262
+ const on = origin ? ` on ${origin}` : ''
263
+ const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
264
+ const fix =
265
+ source === 'flag'
266
+ ? 'Name one you can open, or pass --personal.'
267
+ : source === 'env'
268
+ ? 'Set UNIWEB_WORKSPACE to one you can open, or to personal.'
269
+ : `Your login works in it. Choose another: ${loginCommand(origin)} --org @<org> (or --personal) — it does not sign you in again.`
270
+ super({
271
+ named,
272
+ answer: null,
273
+ source,
274
+ origin,
275
+ message: `${where}${why} is not a workspace you can open${on}: you are not a member of it, or it is archived. ${fix}`
276
+ })
277
+ this.name = 'WorkspaceRefusedError'
278
+ this.status = 403
279
+ }
280
+ }
281
+
231
282
  /**
232
283
  * The backend could not be reached at all — the request never got an answer.
233
284
  *
@@ -329,6 +380,11 @@ export class BackendClient {
329
380
  return this._workspace
330
381
  }
331
382
 
383
+ /** Where that workspace came from (`workspace.js`) — so a refusal says how to switch. */
384
+ get workspaceSource() {
385
+ return this._workspaceSource
386
+ }
387
+
332
388
  /**
333
389
  * Name the workspace every site request from here on works in (`x-uniweb-workspace`)
334
390
  * — the one `resolveWorkspace` answered for this command.
@@ -383,7 +439,9 @@ export class BackendClient {
383
439
  * (string → application/json, Buffer/Uint8Array → application/zip). Returns
384
440
  * the raw Response so callers branch on status themselves (409 resume,
385
441
  * 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, thrown as
386
- * `WorkspaceMismatchError`: the site is in another workspace than this command's.
442
+ * `WorkspaceMismatchError`: the site is in another workspace than this command's, or in
443
+ * none the caller may open; and a `403` refusing the workspace this request named, thrown
444
+ * as `WorkspaceRefusedError`.
387
445
  *
388
446
  * @param {string} path - leading-slash path, e.g. '/dev/site/content'
389
447
  * @param {object} [opts]
@@ -396,16 +454,43 @@ export class BackendClient {
396
454
  */
397
455
  async request(path, opts = {}) {
398
456
  const res = await this._send(path, opts)
399
- if (res?.status !== 409) return res
457
+ if (res?.status !== 409 && res?.status !== 403) return res
458
+ const problem =
459
+ typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
460
+
461
+ // ⭐ THE WORKSPACE THIS REQUEST NAMED IS NOT ONE THE ACCOUNT MAY OPEN — `403`,
462
+ // `op: "act_as_unit"`: not a member of it, or it is archived (`WorkspaceRefusedError`).
463
+ // Only for a request that named one: any other 403 — a credential, a foundation the site
464
+ // may not take — is the caller's, as before.
465
+ if (res.status === 403) {
466
+ if (problem?.op !== 'act_as_unit' || !this._workspace || !namesWorkspace(path)) return res
467
+ throw new WorkspaceRefusedError({
468
+ named: this._workspace,
469
+ source: this._workspaceSource,
470
+ origin: this.origin
471
+ })
472
+ }
400
473
 
401
474
  // ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request whose
402
475
  // workspace does not contain the site is refused with `409 wrong_workspace`, naming a
403
476
  // workspace the caller CAN open that does — the site's own unit, or a parent. The
404
477
  // command works in one workspace, so this stops it (`WorkspaceMismatchError`).
405
478
  // ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
406
- const problem =
407
- typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
408
479
  if (problem?.reason !== 'wrong_workspace') return res
480
+ // ⭐ `workspace: null` — NO workspace the caller may open holds the site — is not the
481
+ // personal workspace, which is the object with both fields null (backend, 2026-10-16). There
482
+ // is nothing to switch to, so it stops the command whatever workspace it works in.
483
+ // ⛔ Until 2026-10-16 it was read as personal, and the command said "To work on it: uniweb
484
+ // login --personal" — false, and following it ended on the same 409.
485
+ if (problem.workspace === null) {
486
+ throw new WorkspaceMismatchError({
487
+ named: this._workspace,
488
+ answer: null,
489
+ nowhere: true,
490
+ source: this._workspaceSource,
491
+ origin: this.origin
492
+ })
493
+ }
409
494
  const where = problem.workspace || {}
410
495
  // A unit with no handle is named by its uuid, bare — the header takes either.
411
496
  const answer = where.handle
@@ -885,23 +970,30 @@ export class BackendClient {
885
970
  * the first. Folding `unknown` into `gone` would be worse than no answer.
886
971
  *
887
972
  * live the site's status came back, or the site was refused as belonging to
888
- * another workspace (`409 wrong_workspace` names where it is: it exists)
973
+ * another workspace (`409 wrong_workspace` names where it is: it exists) —
974
+ * or to none the caller may open (`workspace: null`: it exists too)
889
975
  * gone a `404` naming this site
890
- * unknown anything else — no answer, a credential or an id refused, a `404` that
891
- * names something else (a backend too old for the route)
976
+ * unknown anything else — no answer, a credential or an id refused, the workspace
977
+ * this request named refused (`refused: true`), a `404` that names something
978
+ * else (a backend too old for the route)
892
979
  *
893
980
  * *Measured 2026-10-06 against a local backend — its wire, its to change:* `200` with
894
981
  * the status; `409 wrong_workspace` naming the site's workspace; `404 {kind: "site",
895
982
  * key}`; `400` for a malformed id; `401` for a bad bearer.
896
983
  *
897
984
  * @param {string} uuid
898
- * @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, detail?: string|null }>}
985
+ * @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, refused?: boolean, detail?: string|null }>}
899
986
  */
900
987
  async siteState(uuid) {
901
988
  let res
902
989
  try {
903
990
  res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
904
991
  } catch (err) {
992
+ // The workspace this request named was refused: that says nothing about the site.
993
+ if (err instanceof WorkspaceRefusedError) {
994
+ return { state: 'unknown', status: 403, refused: true, detail: err.message }
995
+ }
996
+ // In another workspace, or in none the caller may open: either way it exists.
905
997
  if (err instanceof WorkspaceMismatchError) {
906
998
  return { state: 'live', status: 409, workspace: err.answer, detail: err.message }
907
999
  }
@@ -855,13 +855,18 @@ export function writeSyncCache(siteDir, backend, hashes, applied) {
855
855
  * Lives beside the content hashes because it is the same kind of thing — "the
856
856
  * backend state this clone last agreed with" — and is already per-entity, which
857
857
  * the per-lane `pull-cache.json` (two ETags) is not. Gitignored, per-clone,
858
- * deletable: losing it means the next push is unconditional, i.e. today's
859
- * behavior, never a wrong-but-plausible token.
858
+ * deletable: losing it leaves the next write no base to claim, which a pull
859
+ * resolves — never a wrong-but-plausible token.
860
860
  *
861
861
  * Fed from BOTH directions, which is what makes the gate usable: `pull` records
862
- * the manifest's `extra.version`, and every successful push records the
863
- * post-write `finalized[].version` the backend returns. Without the push half a
864
- * second consecutive push would be stale by construction and refused.
862
+ * each manifest entry's top-level `version` (`readPullVersions`), and every
863
+ * successful push records the post-write `finalized[].version` the backend returns.
864
+ * Without the push half a second consecutive push would be stale by construction
865
+ * and refused.
866
+ *
867
+ * ⛔ Until 2026-10-16 this said `pull` records the manifest's `extra.version`. There is
868
+ * no `extra` on the wire in either direction — the token rides beside `sha256` — and
869
+ * building against that name disarms the gate without an error.
865
870
  */
866
871
  export function readBaseVersions(siteDir, backend) {
867
872
  return readMap(siteDir, backend, 'baseVersions')
@@ -8,9 +8,15 @@
8
8
  *
9
9
  * 1. resolves WHICH site (your location, or the workspace's one site; multiple
10
10
  * → prompt);
11
- * 2. BRINGS THE FOUNDATION ALONG — if the site's local foundation changed
12
- * since its last release, releases the new version first (or asks); a
13
- * published registry ref needs nothing (§4, foundation-bring-along.js);
11
+ * 2. BRINGS THE FOUNDATION ALONG — a local foundation is released when its
12
+ * version is not registered yet, and under the next patch (written into its
13
+ * package.json) when its code changed since that version was registered;
14
+ * unchanged code is not re-released, and a newer registered version stops
15
+ * the publish (`--bump`, `--no-release`). It asks only when the backend
16
+ * cannot say whether the code changed. A published registry ref needs
17
+ * nothing (§4, foundation-bring-along.js). *(This read "releases the new
18
+ * version first (or asks)" until 2026-10-16; changed code has been released
19
+ * without asking since 2026-09-24.)*
14
20
  * 3. SYNCS — builds the site data (link mode), uploads media and the
15
21
  * schema-less data files, and pushes content (the same two-lane sync
16
22
  * `uniweb push` uses);
@@ -752,6 +758,7 @@ export async function publish(args = []) {
752
758
  siteUuid: site.uuid,
753
759
  // The plan and every direct PUT name the workspace the rest of the publish did.
754
760
  workspace: client.workspace,
761
+ workspaceSource: client.workspaceSource,
755
762
  ball,
756
763
  onProgress: (m) => say.dim(` ${m}`)
757
764
  })
@@ -766,6 +773,11 @@ export async function publish(args = []) {
766
773
  }
767
774
  say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
768
775
  } catch (err) {
776
+ // The workspace this publish works in, refused — said as itself, as at every other door.
777
+ if (err instanceof WorkspaceMismatchError) {
778
+ say.err(err.message)
779
+ return { exitCode: 1 }
780
+ }
769
781
  // A typed plan refusal gets its own account (quota, or whatever else the
770
782
  // backend names); anything else falls through to the raw message. Same
771
783
  // treatment the asset plan and the site create already get — this was the
@@ -653,8 +653,10 @@ async function confirm(question) {
653
653
  }
654
654
 
655
655
  export async function pull(args = [], deps = {}) {
656
- // See utils/flag-guard.js — an unrecognized flag is invisible to a
657
- // literal scan, so it silently keeps the default (production, for --backend).
656
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
657
+ // it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
658
+ // mistyped `--no-delete` would prune. *(This named `--backend` until 2026-10-16;
659
+ // pull has taken no backend flag since 2026-09-21.)*
658
660
  const badFlag = checkFlags('pull', args)
659
661
  if (badFlag) {
660
662
  error(badFlag.message)
@@ -690,7 +692,7 @@ export async function pull(args = [], deps = {}) {
690
692
  if (captured === null) {
691
693
  error('Cannot merge: this site is not in a git repository.')
692
694
  note(
693
- 'The common ancestor a merge needs is the committed version of each file.'
695
+ 'The common ancestor a merge needs is in git history: the version of each file your last pull wrote.'
694
696
  )
695
697
  note(
696
698
  'Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.'
@@ -133,7 +133,10 @@ function flagValue(args, name) {
133
133
 
134
134
  export async function push(args = [], deps = {}) {
135
135
  // An unrecognized flag is invisible to a literal scan, so it silently keeps the
136
- // default — including for --backend, where the default can be production.
136
+ // default — a mistyped `--dry-run` would push for real. A backend flag or `--token`
137
+ // is refused too, naming `uniweb login` (`utils/flag-guard.js`). *(This named
138
+ // `--backend` as the example until 2026-10-16; push has taken no backend flag since
139
+ // 2026-09-21.)*
137
140
  //
138
141
  // `sync` validates the UNION of its two halves' flags and forwards raw argv, so a
139
142
  // legal `uniweb sync --no-git` would be rejected here. It passes `skipFlagCheck`.
@@ -17,10 +17,17 @@
17
17
  * which is the whole point of a start-of-day command. A version that also pushed
18
18
  * would be one you had to think about first, and so one people would stop running.
19
19
  *
20
- * Order is deliberate: git first, then the backend. The three-way merge's common
21
- * ancestor is the COMMITTED version of each file, so taking teammates' commits
22
- * first makes that ancestor fresher and the merge more accurate. And if git itself
23
- * conflicts, refresh stops there — one source of conflict at a time.
20
+ * Order is deliberate: git first, then the backend. Teammates' commits are then in
21
+ * the files when the backend's content arrives, and `pull --merge` merges a
22
+ * teammate's change to a file the last pull wrote just as it merges your own edits:
23
+ * against the version that pull wrote, found in the repo's history (`pull.js`,
24
+ * `captureLocalWork`). And if git itself conflicts, refresh stops there — one source
25
+ * of conflict at a time.
26
+ *
27
+ * ⛔ Until 2026-10-16 this said the merge's ancestor was the COMMITTED version of each
28
+ * file, so taking teammates' commits first made it fresher. That was HEAD — the rule
29
+ * `pull --merge` dropped on 2026-09-23, because HEAD stops being the version a pull
30
+ * wrote the moment an edit is committed on top of it.
24
31
  *
25
32
  * Exits NON-ZERO when a merge left conflicts, the way a conflicted `git merge`
26
33
  * does. That is what makes `uniweb refresh && uniweb push` correct by
@@ -91,10 +98,11 @@ function siteContentUuid(siteDir, backend) {
91
98
  */
92
99
  export async function refresh(args = [], deps = {}) {
93
100
  // An unrecognized flag is invisible to a literal scan, so it silently keeps the
94
- // default — and for `--backend` the default can be production. This verb never
95
- // sent data anywhere, which is why it went unguarded; but it DELEGATES to `pull`,
96
- // and a mistyped `--backend` is not forwarded, so the pull runs against whatever
97
- // the origin ladder resolves instead of the backend the user named.
101
+ // default — a mistyped `--no-git` would still pull from the git remote. And this is
102
+ // the only check refresh's argv gets: the pull it runs is handed `--merge` alone
103
+ // (below). A backend flag or `--token` is refused here too, naming `uniweb login`
104
+ // (`utils/flag-guard.js`). *(This explained the check by a mistyped `--backend`
105
+ // until 2026-10-16; the verb has taken no backend flag since 2026-09-21.)*
98
106
  //
99
107
  // `sync` validates the UNION of both halves and forwards raw argv, so a legal
100
108
  // `uniweb sync --force` would be rejected here. It passes `skipFlagCheck`.
@@ -221,6 +221,9 @@ export async function status(args = []) {
221
221
  site = siteState.site || null
222
222
  // In another workspace: it exists, and the refusal says where — report it.
223
223
  if (siteState.status === 409) remoteError = siteState.detail
224
+ } else if (siteState.refused) {
225
+ // The workspace this status works in, refused: say so — it is not a fact about the site.
226
+ remoteError = siteState.detail
224
227
  }
225
228
  }
226
229
  // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
@@ -321,9 +324,9 @@ export async function status(args = []) {
321
324
  `To push this as a new site: uniweb forget --server ${probeBackend}, then uniweb push.`
322
325
  )
323
326
  } else if (siteState?.state === 'unknown') {
324
- say.dim(
325
- `Could not tell whether the backend holds this site${siteState.detail ? ` (${siteState.detail})` : ''}.`
326
- )
327
+ // A refused workspace is already said above, as itself.
328
+ const detail = siteState.refused ? null : siteState.detail
329
+ say.dim(`Could not tell whether the backend holds this site${detail ? ` (${detail})` : ''}.`)
327
330
  }
328
331
  if (site) {
329
332
  if (site.draft_dirty) {
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-16T21:09:24.298Z",
3
+ "generatedAt": "2026-10-16T22:49:32.412Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.2.5"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.3.4"
9
+ "version": "1.3.5"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.3.3"
12
+ "version": "1.4.0"
13
13
  },
14
14
  "@uniweb/content-writer": {
15
15
  "version": "1.1.2"
@@ -60,7 +60,7 @@
60
60
  "version": "1.2.0"
61
61
  },
62
62
  "@uniweb/unipress": {
63
- "version": "1.1.4"
63
+ "version": "1.1.5"
64
64
  }
65
65
  },
66
66
  "templates": {
package/src/utils/git.js CHANGED
@@ -161,17 +161,23 @@ export function uncommittedUnder(dir, relPaths) {
161
161
  }
162
162
 
163
163
  /**
164
- * A file's committed content — the COMMON ANCESTOR for a three-way merge.
164
+ * A file's content at HEAD — the ancestor of a three-way merge for a file no pull
165
+ * wrote. For one a pull wrote, the ancestor is the version it wrote, found in history
166
+ * by `findCommitted` below: HEAD stops being that version the moment an edit is
167
+ * committed on top of it.
165
168
  *
166
- * This is the input a merge needs and the backend cannot supply: it keeps no
169
+ * Either way the ancestor is on this side, which is the point: the backend keeps no
167
170
  * per-version item content, and retaining some so it could feed a merge running on
168
171
  * the client would put a permanent second write on its hottest table. It doesn't
169
- * have to. `uniweb pull` writes the backend's content into these files, so the
170
- * committed version IS the state both sides diverged from — the ancestor was always
171
- * on this side, in the tool that already knows how to merge.
172
+ * have to — the tool that already knows how to merge holds the history.
172
173
  *
173
- * @returns {Buffer|null} null when the path isn't in HEAD (a file added locally and
174
- * never committed has no ancestor, so there is nothing to merge against).
174
+ * ⛔ Until 2026-10-16 this called HEAD's content THE common ancestor ("`uniweb pull`
175
+ * writes the backend's content into these files, so the committed version IS the
176
+ * state both sides diverged from") — the rule `pull --merge` dropped on 2026-09-23.
177
+ *
178
+ * @returns {Buffer|null} null when the path isn't in HEAD — a file added locally and
179
+ * never committed has no ancestor; when the backend holds it too, `pull --merge`
180
+ * merges the two versions two-way.
175
181
  */
176
182
  export function showAtHead(dir, relPath) {
177
183
  return showAt(dir, 'HEAD', relPath)
@@ -51,7 +51,7 @@
51
51
  * demonstrated.**
52
52
  */
53
53
 
54
- import { WORKSPACE_HEADER } from '../backend/client.js'
54
+ import { WORKSPACE_HEADER, WorkspaceRefusedError } from '../backend/client.js'
55
55
  import { createHash } from 'node:crypto'
56
56
  import { humanBytes } from './bytes.js'
57
57
  import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
@@ -66,6 +66,8 @@ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
66
66
  * @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
67
67
  * (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
68
68
  * one, which is self-authorizing and must carry nothing it was not signed with
69
+ * @param {string} [opts.workspaceSource] - where that workspace came from (`workspace.js`),
70
+ * so a refusal of it says how to choose another (`WorkspaceRefusedError`)
69
71
  * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
70
72
  * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
71
73
  * @param {(m: string) => void} [opts.onProgress]
@@ -77,6 +79,7 @@ export async function uploadSiteData({
77
79
  token,
78
80
  siteUuid,
79
81
  workspace = null,
82
+ workspaceSource = 'login',
80
83
  ball,
81
84
  onProgress = () => {}
82
85
  }) {
@@ -167,6 +170,11 @@ export async function uploadSiteData({
167
170
  /* not a problem document — prose refusal, or an upstream error page */
168
171
  }
169
172
  }
173
+ // ⭐ The workspace the plan named is not one the account may open — said as every other
174
+ // site request says it (`WorkspaceRefusedError`, backend/client.js), never as a raw 403.
175
+ if (planRes.status === 403 && problem?.op === 'act_as_unit' && workspace) {
176
+ throw new WorkspaceRefusedError({ named: workspace, source: workspaceSource, origin })
177
+ }
170
178
  const err = new Error(
171
179
  `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
172
180
  )