uniweb 0.31.0 → 0.32.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.31.0",
3
+ "version": "0.32.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/kit": "^0.13.7",
45
44
  "@uniweb/core": "^0.13.0",
46
- "@uniweb/semantic-parser": "^1.3.1",
47
- "@uniweb/runtime": "^0.13.0"
45
+ "@uniweb/runtime": "^0.13.0",
46
+ "@uniweb/kit": "^0.13.7",
47
+ "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "^1.3.1",
51
- "@uniweb/build": "^0.27.0",
52
- "@uniweb/content-reader": "^1.2.4"
50
+ "@uniweb/build": "^0.28.0",
51
+ "@uniweb/content-reader": "^1.2.4",
52
+ "@uniweb/semantic-parser": "^1.3.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -537,28 +537,6 @@ export class BackendClient {
537
537
  }
538
538
  }
539
539
 
540
- /**
541
- * ASSUMED endpoint (not built yet).
542
- * GET /dev/site/{uuid}/can-go-live — the pre-go-live payment gate:
543
- * { ok: true } // already paid → proceed
544
- * { payment_required: true, checkout_url, wait_token? } // open the URL, settle, retry
545
- * The framework is provider-agnostic: it only opens `checkout_url` and waits.
546
- * null on 404 / any failure → the caller PROCEEDS (degrade: publish ships
547
- * before payment lands; same posture as siteStatus on a missing route).
548
- * @param {string} uuid - the site-content uuid
549
- * @returns {Promise<object|null>}
550
- */
551
- async canGoLive(uuid) {
552
- try {
553
- const res = await this.request(
554
- `/dev/site/${encodeURIComponent(uuid)}/can-go-live`
555
- )
556
- return res.ok ? await res.json().catch(() => null) : null
557
- } catch {
558
- return null
559
- }
560
- }
561
-
562
540
  /**
563
541
  * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
564
542
  * content-addressed store. Thin pass-through to utils/asset-upload.js with this
@@ -1,124 +1,160 @@
1
1
  /**
2
- * Payment handoffthe one piece of `uniweb publish` that's three-way
3
- * (framework + backend + the `uniweb.app` web app). Design: payment-handoff-plan.md.
2
+ * Payment refusalwhat `uniweb publish` does when the backend says a site
3
+ * cannot go live until it is paid for.
4
4
  *
5
- * The intent: `uniweb publish` on an unpaid (or newly-charged) site should just
6
- * handle it open a browser to `uniweb.app`, let the user pay, and continue.
7
- * An already-paid site never opens a browser.
5
+ * THE BACKEND IS THE ONLY GATE. It evaluates on every publish, on every
6
+ * backend, whatever that deployment is configured to require a backend with
7
+ * subscriptions switched off simply never refuses. The CLI holds no opinion about whether a
8
+ * backend charges and must never form one: it attempts the publish and reads
9
+ * the answer.
8
10
  *
9
- * The framework's ENTIRE payment knowledge is "open this URL, wait for done"
10
- * PROVIDER-AGNOSTIC. The CLI opens whatever `checkout_url` the backend hands it;
11
- * the app drives the provider (Stripe or anything else) and settles with the
12
- * backend. We reuse `awaitBrowserCallback` (the same loopback `uniweb login`
13
- * uses) for the open + wait.
11
+ * THERE IS NO PRE-FLIGHT, AND ADDING ONE BACK IS A REGRESSION. A
12
+ * `GET …/can-go-live` probe used to run before go-live; it called a route no
13
+ * backend serves, and it folded every failure 404 included into
14
+ * "proceed". A check that answers "fine" when it cannot reach the server is not
15
+ * a check, and it made the CLI assume a posture it has no business assuming. A
16
+ * pre-flight also cannot be authoritative: the backend re-evaluates at publish
17
+ * time regardless, so a second asker is a second producer of one decision.
14
18
  *
15
- * DEGRADES: when the backend exposes no can-go-live route (404 / any failure),
16
- * `canGoLive` returns null and we PROCEED so publish ships before the payment
17
- * route lands (same posture as `status --remote` on a missing endpoint). Live
18
- * acceptance is the three-way test.
19
+ * WHAT THE CLI KNOWS ABOUT PAYMENT: nothing. It opens whatever settlement URL
20
+ * the backend hands it, VERBATIMprovider-agnostic, and route-agnostic. The
21
+ * app drives the provider and settles with the backend.
22
+ *
23
+ * ⛔ AND IT APPENDS NOTHING TO THAT URL. The old handoff added
24
+ * `redirect_uri=http://127.0.0.1:<port>/callback`, `state` and `wait_token`,
25
+ * then waited on a loopback for the app to redirect back. Two reasons it is
26
+ * gone: it obliged the web app to know a CLI exists and honour a callback, and
27
+ * a loopback callback requires the browser and the CLI
28
+ * on the SAME MACHINE, so over SSH or in a container it hung for its full
29
+ * timeout and then reported "payment was not completed", which was false.
19
30
  */
20
31
 
21
- import { randomBytes } from 'node:crypto'
22
-
23
- import { awaitBrowserCallback } from '../utils/registry-auth.js'
24
- import { isNonInteractive } from '../utils/interactive.js'
25
-
26
- // Append query params to a backend-supplied URL without disturbing its own.
27
- function withParams(url, params) {
28
- const u = new URL(url)
29
- for (const [k, v] of Object.entries(params)) {
30
- if (v != null) u.searchParams.set(k, String(v))
31
- }
32
- return u.toString()
33
- }
32
+ /** Reasons the CLI knows how to act on. An ALLOWLIST, never an inventory. */
33
+ const ACTIONABLE_REASONS = new Set(['no_subscription'])
34
34
 
35
35
  /**
36
- * Settle payment for a site before go-live, if the backend says it's needed.
36
+ * Read a `402` from the publish call and decide what the CLI does. Pure — no
37
+ * network, no browser, no process exit — so the decision is testable on its own.
38
+ *
39
+ * ⛔ THE RULE, and it is a property of the wire rather than a CLI preference:
40
+ * NEVER route to a purchase surface from the ABSENCE of a recognised token. A
41
+ * purchase surface is opened by a `reason` that NAMES one; everything else
42
+ * surfaces the backend's own sentence and stops. Degrading that way means an
43
+ * older CLI shows you the message — annoying, honest, recoverable. Degrading
44
+ * the other way sends someone to a checkout for something they cannot buy.
45
+ *
46
+ * ⛔ Do NOT turn `ACTIONABLE_REASONS` into "every reason the backend has". The
47
+ * set is open by design and a stale copy here fails in the worst direction; an
48
+ * allowlist is safe precisely because what it misses lands on `stop`.
49
+ * ⛔ Do NOT parse `detail` — it is deliberately not asserted word-for-word, and
50
+ * `reason` exists to do the job parsing it would.
51
+ *
52
+ * NOTE ON `reason`'s PRESENCE: it is NOT guaranteed by the content type. A 402
53
+ * naming a condition the caller can act on carries one; nothing guarantees it
54
+ * in general — a declined card is `problem+json` and deliberately carries none,
55
+ * because there is no machine decision for a client to make about it. This reads `reason` when it is there and needs
56
+ * no invariant, which is why that correction cost this branch nothing.
37
57
  *
38
58
  * @param {object} o
39
- * @param {import('./client.js').BackendClient} o.client
40
- * @param {string|null} o.uuid - the site-content uuid (null only on a dry run; a
41
- * real publish creates the site before anything uploads, so it is set by here)
42
- * @param {string[]} o.args
43
- * @param {object} o.say - { ok, info, warn, err, dim } reporters
44
- * @param {boolean} [o.dryRun]
45
- * @returns {Promise<{ proceed: boolean }>} proceed:false → the caller aborts go-live.
59
+ * @param {number} o.status - the HTTP status
60
+ * @param {string} [o.contentType] - the response's content-type header
61
+ * @param {string} [o.body] - the raw response body
62
+ * @returns {{ kind: 'not-payment' }
63
+ * | { kind: 'settle', url: string, handle: string|null, reason: string, message: string|null }
64
+ * | { kind: 'stop', reason: string|null, message: string|null }}
46
65
  */
47
- export async function settlePaymentIfNeeded({
48
- client,
49
- uuid,
50
- args,
51
- say,
52
- dryRun = false
53
- }) {
54
- // No uuid yet (a first publish mints it on push) → nothing to check here; the
55
- // post-push go-live is the moment the backend gates on payment.
56
- if (!uuid) return { proceed: true }
66
+ export function readPaymentRefusal({ status, contentType = '', body = '' } = {}) {
67
+ if (status !== 402) return { kind: 'not-payment' }
68
+
69
+ let problem = null
70
+ try {
71
+ problem = JSON.parse(body)
72
+ } catch {
73
+ /* a non-JSON 402 is simply unrecognised it falls to `stop` below */
74
+ }
75
+ if (!problem || typeof problem !== 'object') {
76
+ return { kind: 'stop', reason: null, message: null }
77
+ }
78
+
79
+ // The human sentence, in the backend's own words. `detail` is the 7807
80
+ // member; `title` is the fallback when a body carries no detail.
81
+ const message =
82
+ (typeof problem.detail === 'string' && problem.detail) ||
83
+ (typeof problem.title === 'string' && problem.title) ||
84
+ null
85
+
86
+ // `status` is NOT a discriminator: the backend's problem bodies carry it as
87
+ // the NUMBER 402 while at least one hand-built 402 elsewhere on their wire
88
+ // carries a STRING. Same key, two types, neither failing loudly — so this
89
+ // reads `reason` and the content type instead, and never `body.status`.
90
+ const isProblem = String(contentType).includes('application/problem+json')
91
+ const reason =
92
+ isProblem && typeof problem.reason === 'string' && problem.reason
93
+ ? problem.reason
94
+ : null
57
95
 
58
- // Dry-run reports the intent WITHOUT touching the network — the can-go-live
59
- // read is auth-gated and must not force a login on a dry-run.
60
- if (dryRun) {
61
- say.dim(
62
- `Payment : would check whether go-live needs payment for ${uuid}`
63
- )
64
- return { proceed: true }
96
+ if (!reason || !ACTIONABLE_REASONS.has(reason)) {
97
+ return { kind: 'stop', reason, message }
65
98
  }
66
99
 
67
- const verdict = await client.canGoLive(uuid)
68
- // Degrade (no route) or already-paid proceed.
69
- if (!verdict || verdict.ok || !verdict.payment_required)
70
- return { proceed: true }
100
+ // Actionable but only if the backend actually handed over somewhere to go.
101
+ // A recognised reason with no settlement block is a backend that has not
102
+ // built that half yet: still a stop, and still with its own sentence.
103
+ const s = problem.settlement
104
+ const url = s && typeof s.url === 'string' && s.url ? s.url : null
105
+ if (!url) return { kind: 'stop', reason, message }
71
106
 
72
- const checkoutUrl = verdict.checkout_url
73
- if (!checkoutUrl) {
74
- say.warn(
75
- 'The backend reports payment is required but returned no checkout URL proceeding.'
76
- )
77
- return { proceed: true }
107
+ return {
108
+ kind: 'settle',
109
+ url,
110
+ handle: s && typeof s.handle === 'string' && s.handle ? s.handle : null,
111
+ reason,
112
+ message
78
113
  }
114
+ }
115
+
116
+ /**
117
+ * Report a payment refusal to the user, and open the settlement page when the
118
+ * backend supplied one.
119
+ *
120
+ * ⛔ Returns rather than exits — the caller owns the exit code, and a refusal
121
+ * is not a crash: the content is already synced as a draft, so re-running
122
+ * after paying is the recovery.
123
+ *
124
+ * @param {object} o
125
+ * @param {ReturnType<typeof readPaymentRefusal>} o.verdict
126
+ * @param {string[]} o.args - argv slice (for --non-interactive detection)
127
+ * @param {object} o.say - { ok, info, warn, err, dim } reporters
128
+ * @param {(url: string) => Promise<boolean>} [o.open] - injected for tests
129
+ * @returns {Promise<{ opened: boolean }>}
130
+ */
131
+ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
132
+ // The backend's own sentence is the HEADLINE when there is one. A generic
133
+ // lead would be wrong as often as right — "payment is required" does not
134
+ // describe a declined card — and `detail` is written for this reader.
135
+ say.err(verdict.message || 'This site cannot go live until it is paid for.')
79
136
 
80
- if (dryRun) {
81
- say.dim(`Payment : required would open ${checkoutUrl}`)
82
- return { proceed: true }
137
+ if (verdict.kind !== 'settle') {
138
+ // The push completed before go-live, so the content is safely stored.
139
+ say.dim('The site is synced as a draft; nothing was made live.')
140
+ return { opened: false }
83
141
  }
84
142
 
143
+ const { isNonInteractive } = await import('../utils/interactive.js')
85
144
  if (isNonInteractive(args)) {
86
- say.err(
87
- 'Payment is required to publish this site, and the CLI is non-interactive.'
88
- )
89
- say.dim(`Complete it in a browser, then re-run: ${checkoutUrl}`)
90
- return { proceed: false }
145
+ say.dim(`Complete it in a browser, then re-run \`uniweb publish\`:`)
146
+ say.dim(` ${verdict.url}`)
147
+ return { opened: false }
91
148
  }
92
149
 
93
- // The CSRF nonce the app echoes back on the done-signal redirect. The
94
- // wait_token (when present) lets the app correlate the session backend-side.
95
- const state = randomBytes(16).toString('hex')
96
- say.info('Payment required completing it in your browser…')
97
- try {
98
- await awaitBrowserCallback({
99
- buildUrl: (redirectUri) =>
100
- withParams(checkoutUrl, {
101
- redirect_uri: redirectUri,
102
- state,
103
- wait_token: verdict.wait_token
104
- }),
105
- validate: (params) => {
106
- if (params.get('error')) return { error: params.get('error') }
107
- if (params.get('state') !== state)
108
- return { error: 'state mismatch — please retry.' }
109
- return { value: true } // ok=1 / any non-error return = the app settled with the backend
110
- },
111
- openingLabel: 'Opening uniweb.app to complete payment…',
112
- waitingLabel: 'Waiting for payment to complete (5 min)…',
113
- timeoutMs: 5 * 60 * 1000,
114
- okTitle: 'Payment complete',
115
- errTitle: 'Payment failed'
116
- })
117
- } catch (err) {
118
- say.err(`Payment was not completed: ${err.message}`)
119
- say.dim('Re-run `uniweb publish` once payment is done.')
120
- return { proceed: false }
150
+ const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
151
+ say.info('Opening your browser to complete it…')
152
+ say.dim(` ${verdict.url}`)
153
+ // VERBATIM. Nothing is appended see the header.
154
+ const opened = await openBrowser(verdict.url)
155
+ if (!opened) {
156
+ say.warn('Could not open a browser automatically — open the URL above.')
121
157
  }
122
- say.ok('Payment complete.')
123
- return { proceed: true }
158
+ say.dim('Once payment is complete, re-run `uniweb publish`.')
159
+ return { opened }
124
160
  }
@@ -26,6 +26,7 @@ import {
26
26
  describeSiteDiff,
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
+ collectFolderItemUuids,
29
30
  readAssetMap
30
31
  } from '@uniweb/build/uwx'
31
32
 
@@ -457,6 +458,20 @@ export function mergeBaseVersions(siteDir, versions) {
457
458
  export function readItemUuids(siteDir) {
458
459
  return readMap(siteDir, 'itemUuids')
459
460
  }
461
+ /**
462
+ * Placement identity for the site's `@uniweb/folder` — path chain → `$uuid`.
463
+ *
464
+ * Kept separate from `itemUuids` (which is site-content units) because they key
465
+ * different trees: `pages/…/page.yml` there, `members/alice` here. One map with
466
+ * two key languages is a map nobody can validate.
467
+ */
468
+ export function readFolderItemUuids(siteDir) {
469
+ return readMap(siteDir, 'folderItemUuids')
470
+ }
471
+ export function writeFolderItemUuids(siteDir, map) {
472
+ if (!map || !Object.keys(map).length) return
473
+ updateSyncCache(siteDir, { folderItemUuids: map })
474
+ }
460
475
  export function writeItemUuids(siteDir, map) {
461
476
  if (!map || !Object.keys(map).length) return
462
477
  updateSyncCache(siteDir, { itemUuids: map })
@@ -1297,6 +1312,21 @@ export async function pushSyncPackages({
1297
1312
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1298
1313
  if (bf.updated.length)
1299
1314
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
1315
+ // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records back-fill their own
1316
+ // `$uuid` into their source files (above); the folder's ITEMS have nowhere to
1317
+ // be written, so they are banked here from the document the backend just
1318
+ // returned.
1319
+ //
1320
+ // ⛔ Without this a `publish` after a `push` is refused: send-only-changed
1321
+ // skips the unchanged records and re-sends the folder ALONE, its `contents`
1322
+ // items carry no `$uuid`, and a `multi` item without one reads as new — so
1323
+ // the backend refuses rather than replacing every placement. Reproduced on a
1324
+ // live manor 2026-08-27; the identities were on the wire all along.
1325
+ const folderDoc = finalized.find((f) => f?.document?.contents)?.document
1326
+ if (folderDoc) {
1327
+ const placements = collectFolderItemUuids(folderDoc)
1328
+ if (Object.keys(placements).length) writeFolderItemUuids(siteDir, placements)
1329
+ }
1300
1330
  finalizedTotal += finalized.length
1301
1331
  }
1302
1332
 
@@ -1323,6 +1353,26 @@ export async function pushSyncPackages({
1323
1353
  // Replaced wholesale: an item that no longer exists must not keep a uuid that
1324
1354
  // would re-target something else.
1325
1355
  if (theirs) writeItemUuids(siteDir, collectUnitUuids(theirs))
1356
+ else {
1357
+ // ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
1358
+ // because the cost lands two commands away and names something else.
1359
+ //
1360
+ // Without `finalized[0].document` (carrying `pages`) this push banks NO item
1361
+ // identity. The push still reports success. The next `push`/`publish` then
1362
+ // emits with no `$uuid` per item, and the backend refuses — correctly, since
1363
+ // silently re-identifying every stored row is far worse. But that refusal
1364
+ // reads as a stale-token or a producer bug, with nothing pointing back at the
1365
+ // push that failed to bank.
1366
+ //
1367
+ // ⚠️ We cannot tell WHY it is absent from here — a response shape that
1368
+ // changed, a lane that shipped nothing, a backend that does not echo the
1369
+ // document. Report the observable fact and let the operator carry it.
1370
+ note(
1371
+ 'identity not banked: this push returned no post-write document, so no per-item ' +
1372
+ '$uuid was stored. The next push or publish will be identity-blind and the backend ' +
1373
+ 'may refuse it. `uniweb pull` re-arms identity if that happens.'
1374
+ )
1375
+ }
1326
1376
  }
1327
1377
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
1328
1378
  }
@@ -77,6 +77,7 @@ import {
77
77
  readBaseVersions,
78
78
  readItemBaseVersions,
79
79
  ensureItemUuids,
80
+ readFolderItemUuids,
80
81
  ensureSiteExists,
81
82
  clearRemoteSyncStateIfUnbound,
82
83
  pushSyncPackages,
@@ -88,7 +89,10 @@ import {
88
89
  bringFoundationAlong,
89
90
  bringExtensionsAlong
90
91
  } from '../backend/foundation-bring-along.js'
91
- import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
92
+ import {
93
+ readPaymentRefusal,
94
+ reportPaymentRefusal
95
+ } from '../backend/payment-handoff.js'
92
96
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
93
97
  import { uploadSiteData } from '../utils/site-data-upload.js'
94
98
 
@@ -353,13 +357,10 @@ export async function publish(args = []) {
353
357
  cliBin: process.argv[1],
354
358
  dryRun: true
355
359
  })
356
- await settlePaymentIfNeeded({
357
- client,
358
- uuid: siteYml.$uuid || null,
359
- args,
360
- say,
361
- dryRun: true
362
- })
360
+ // No payment line on a dry run. The backend is the only gate and it
361
+ // answers at go-live, so the honest dry-run answer is silence rather than
362
+ // a guess. (The old "would check whether go-live needs payment" described
363
+ // a pre-flight probe that has been removed — payment-handoff.js.)
363
364
  return { exitCode: 0 }
364
365
  }
365
366
 
@@ -440,6 +441,10 @@ export async function publish(args = []) {
440
441
  let probe
441
442
  try {
442
443
  probe = await emitSyncPackages(siteDir, {
444
+ // Placement identity for the folder — see writeFolderItemUuids.
445
+ folderItemUuids: readFolderItemUuids(siteDir),
446
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
447
+ ...(asOrg ? { org: asOrg } : {}),
443
448
  ...(foundationDir ? { foundationDir } : {}),
444
449
  resolveModel
445
450
  })
@@ -639,6 +644,10 @@ export async function publish(args = []) {
639
644
  let pkg
640
645
  try {
641
646
  pkg = await emitSyncPackages(siteDir, {
647
+ // Placement identity for the folder — see writeFolderItemUuids.
648
+ folderItemUuids: readFolderItemUuids(siteDir),
649
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
650
+ ...(asOrg ? { org: asOrg } : {}),
642
651
  ...(foundationDir ? { foundationDir } : {}),
643
652
  resolveModel,
644
653
  priorHashes,
@@ -678,18 +687,6 @@ export async function publish(args = []) {
678
687
  return { exitCode: 1 }
679
688
  }
680
689
 
681
- // 6. Payment gate — the backend says whether go-live needs payment. Settles
682
- // via a browser handoff to uniweb.app; degrades to "proceed" when the
683
- // backend exposes no payment route. The draft is already synced, so a
684
- // decline leaves a recoverable state (re-run after paying).
685
- const pay = await settlePaymentIfNeeded({ client, uuid: siteUuid, args, say })
686
- if (!pay.proceed) {
687
- say.info(
688
- 'Site synced as a draft but not made live. Re-run `uniweb publish` once payment is complete.'
689
- )
690
- return { exitCode: 0 }
691
- }
692
-
693
690
  // 7. Go live — make the just-pushed composite live (its current backend state).
694
691
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
695
692
  const languages = languagesFromContent(siteContent)
@@ -705,13 +702,29 @@ export async function publish(args = []) {
705
702
  return { exitCode: 1 }
706
703
  }
707
704
  if (!pubRes.ok) {
705
+ const body = await pubRes.text().catch(() => '')
706
+
707
+ // A 402 is the backend's payment gate — the ONLY gate, evaluated here on
708
+ // every publish against whatever posture that deployment runs. It is a
709
+ // refusal, not a fault: the content is already synced as a draft, so the
710
+ // recovery is to settle and re-run. Give it the backend's own sentence
711
+ // rather than the raw envelope.
712
+ const refusal = readPaymentRefusal({
713
+ status: pubRes.status,
714
+ contentType: pubRes.headers?.get?.('content-type') || '',
715
+ body
716
+ })
717
+ if (refusal.kind !== 'not-payment') {
718
+ await reportPaymentRefusal({ verdict: refusal, args, say })
719
+ return { exitCode: 1 }
720
+ }
721
+
708
722
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
709
723
  if (pubRes.status === 401 || pubRes.status === 403) {
710
724
  say.dim(
711
725
  "Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
712
726
  )
713
727
  }
714
- const body = await pubRes.text().catch(() => '')
715
728
  if (body) say.dim(body.slice(0, 800))
716
729
  return { exitCode: 1 }
717
730
  }
@@ -84,6 +84,7 @@ import {
84
84
  readBaseVersions,
85
85
  readItemBaseVersions,
86
86
  readItemUuids,
87
+ readFolderItemUuids,
87
88
  ensureItemUuids,
88
89
  ensureSiteExists,
89
90
  clearRemoteSyncStateIfUnbound,
@@ -373,6 +374,10 @@ export async function push(args = [], deps = {}) {
373
374
  let mediaRefs = []
374
375
  try {
375
376
  const probe = await emitSyncPackages(siteDir, {
377
+ // Placement identity for the folder — see writeFolderItemUuids.
378
+ folderItemUuids: readFolderItemUuids(siteDir),
379
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
380
+ ...(asOrg ? { org: asOrg } : {}),
376
381
  ...(foundationDir ? { foundationDir } : {}),
377
382
  resolveModel: makeModelResolver({ client, offline: false })
378
383
  })
@@ -467,6 +472,10 @@ export async function push(args = [], deps = {}) {
467
472
  let pkg
468
473
  try {
469
474
  pkg = await emitSyncPackages(siteDir, {
475
+ // Placement identity for the folder — see writeFolderItemUuids.
476
+ folderItemUuids: readFolderItemUuids(siteDir),
477
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
478
+ ...(asOrg ? { org: asOrg } : {}),
470
479
  ...(foundationDir ? { foundationDir } : {}),
471
480
  resolveModel: makeModelResolver({
472
481
  client,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-27T04:57:15.265Z",
3
+ "generatedAt": "2026-08-27T21:10:42.589Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.27.0",
6
+ "version": "0.28.0",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -68,7 +68,7 @@
68
68
  "deps": []
69
69
  },
70
70
  "@uniweb/projections": {
71
- "version": "0.3.7",
71
+ "version": "0.4.1",
72
72
  "path": "framework/projections",
73
73
  "deps": [
74
74
  "@uniweb/content-writer",
@@ -114,7 +114,7 @@
114
114
  "deps": []
115
115
  },
116
116
  "@uniweb/unipress": {
117
- "version": "0.8.13",
117
+ "version": "0.8.14",
118
118
  "path": "framework/unipress",
119
119
  "deps": [
120
120
  "@uniweb/build",
@@ -321,7 +321,9 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
321
321
  }
322
322
 
323
323
  // Open a URL in the default browser. Returns whether it launched.
324
- async function openBrowser(url) {
324
+ // Exported for the publish payment refusal, which opens the backend's
325
+ // settlement URL VERBATIM and needs no loopback (backend/payment-handoff.js).
326
+ export async function openBrowser(url) {
325
327
  try {
326
328
  const { exec } = await import('node:child_process')
327
329
  const cmd =