uniweb 0.31.0 → 0.31.1

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.31.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
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/kit": "^0.13.7",
46
+ "@uniweb/runtime": "^0.13.0",
47
+ "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@uniweb/build": "^0.27.1",
50
51
  "@uniweb/semantic-parser": "^1.3.1",
51
- "@uniweb/build": "^0.27.0",
52
52
  "@uniweb/content-reader": "^1.2.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -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
  }
@@ -88,7 +88,10 @@ import {
88
88
  bringFoundationAlong,
89
89
  bringExtensionsAlong
90
90
  } from '../backend/foundation-bring-along.js'
91
- import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
91
+ import {
92
+ readPaymentRefusal,
93
+ reportPaymentRefusal
94
+ } from '../backend/payment-handoff.js'
92
95
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
93
96
  import { uploadSiteData } from '../utils/site-data-upload.js'
94
97
 
@@ -353,13 +356,10 @@ export async function publish(args = []) {
353
356
  cliBin: process.argv[1],
354
357
  dryRun: true
355
358
  })
356
- await settlePaymentIfNeeded({
357
- client,
358
- uuid: siteYml.$uuid || null,
359
- args,
360
- say,
361
- dryRun: true
362
- })
359
+ // No payment line on a dry run. The backend is the only gate and it
360
+ // answers at go-live, so the honest dry-run answer is silence rather than
361
+ // a guess. (The old "would check whether go-live needs payment" described
362
+ // a pre-flight probe that has been removed — payment-handoff.js.)
363
363
  return { exitCode: 0 }
364
364
  }
365
365
 
@@ -440,6 +440,8 @@ export async function publish(args = []) {
440
440
  let probe
441
441
  try {
442
442
  probe = await emitSyncPackages(siteDir, {
443
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
444
+ ...(asOrg ? { org: asOrg } : {}),
443
445
  ...(foundationDir ? { foundationDir } : {}),
444
446
  resolveModel
445
447
  })
@@ -639,6 +641,8 @@ export async function publish(args = []) {
639
641
  let pkg
640
642
  try {
641
643
  pkg = await emitSyncPackages(siteDir, {
644
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
645
+ ...(asOrg ? { org: asOrg } : {}),
642
646
  ...(foundationDir ? { foundationDir } : {}),
643
647
  resolveModel,
644
648
  priorHashes,
@@ -678,18 +682,6 @@ export async function publish(args = []) {
678
682
  return { exitCode: 1 }
679
683
  }
680
684
 
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
685
  // 7. Go live — make the just-pushed composite live (its current backend state).
694
686
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
695
687
  const languages = languagesFromContent(siteContent)
@@ -705,13 +697,29 @@ export async function publish(args = []) {
705
697
  return { exitCode: 1 }
706
698
  }
707
699
  if (!pubRes.ok) {
700
+ const body = await pubRes.text().catch(() => '')
701
+
702
+ // A 402 is the backend's payment gate — the ONLY gate, evaluated here on
703
+ // every publish against whatever posture that deployment runs. It is a
704
+ // refusal, not a fault: the content is already synced as a draft, so the
705
+ // recovery is to settle and re-run. Give it the backend's own sentence
706
+ // rather than the raw envelope.
707
+ const refusal = readPaymentRefusal({
708
+ status: pubRes.status,
709
+ contentType: pubRes.headers?.get?.('content-type') || '',
710
+ body
711
+ })
712
+ if (refusal.kind !== 'not-payment') {
713
+ await reportPaymentRefusal({ verdict: refusal, args, say })
714
+ return { exitCode: 1 }
715
+ }
716
+
708
717
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
709
718
  if (pubRes.status === 401 || pubRes.status === 403) {
710
719
  say.dim(
711
720
  "Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
712
721
  )
713
722
  }
714
- const body = await pubRes.text().catch(() => '')
715
723
  if (body) say.dim(body.slice(0, 800))
716
724
  return { exitCode: 1 }
717
725
  }
@@ -373,6 +373,8 @@ export async function push(args = [], deps = {}) {
373
373
  let mediaRefs = []
374
374
  try {
375
375
  const probe = await emitSyncPackages(siteDir, {
376
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
377
+ ...(asOrg ? { org: asOrg } : {}),
376
378
  ...(foundationDir ? { foundationDir } : {}),
377
379
  resolveModel: makeModelResolver({ client, offline: false })
378
380
  })
@@ -467,6 +469,8 @@ export async function push(args = [], deps = {}) {
467
469
  let pkg
468
470
  try {
469
471
  pkg = await emitSyncPackages(siteDir, {
472
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
473
+ ...(asOrg ? { org: asOrg } : {}),
470
474
  ...(foundationDir ? { foundationDir } : {}),
471
475
  resolveModel: makeModelResolver({
472
476
  client,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-27T04:57:15.265Z",
3
+ "generatedAt": "2026-08-27T16:19:55.310Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.27.0",
6
+ "version": "0.27.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -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 =