uniweb 0.34.0 → 0.34.2

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.34.0",
3
+ "version": "0.34.2",
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.15.0",
45
- "@uniweb/runtime": "^0.13.2",
46
- "@uniweb/core": "^0.14.0",
47
- "@uniweb/semantic-parser": "^1.3.1"
44
+ "@uniweb/core": "^0.14.1",
45
+ "@uniweb/semantic-parser": "^1.3.1",
46
+ "@uniweb/runtime": "^0.13.3",
47
+ "@uniweb/kit": "^0.15.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.30.0",
51
50
  "@uniweb/semantic-parser": "^1.3.1",
52
- "@uniweb/content-reader": "^1.2.4"
51
+ "@uniweb/content-reader": "^1.2.4",
52
+ "@uniweb/build": "^0.30.2"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2234,6 +2234,112 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
2234
2234
 
2235
2235
  ---
2236
2236
 
2237
+ ## Part 4b — When the site is also an app
2238
+
2239
+ Everything above is a site: content the author writes, built into pages. Some sites
2240
+ also have **their own backend** — accounts, per-visitor data, records their members
2241
+ create and edit. That is `@uniweb/api`.
2242
+
2243
+ ⛔ **Only reach for this when the site actually has one.** A site with no backend is
2244
+ the normal case, and a foundation that assumes one breaks on every other site it is
2245
+ used with.
2246
+
2247
+ ```bash
2248
+ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
2249
+ ```
2250
+
2251
+ ### Ask before you draw
2252
+
2253
+ ```jsx
2254
+ import { isEnabled, useSession, SignedIn, SignedOut } from '@uniweb/api'
2255
+
2256
+ if (!isEnabled(website)) return <StaticVersion /> // synchronous — nothing to await
2257
+ ```
2258
+
2259
+ ⛔ **When there is no backend, draw nothing** — not a disabled control, and not an
2260
+ explanation. Same rule as `services` in Part 4: which capabilities a site's operator
2261
+ set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
2262
+ when it is simply a feature this site does not have. Render the version of your
2263
+ component that never needed one.
2264
+
2265
+ ### Reading
2266
+
2267
+ ```jsx
2268
+ const { status, records } = useRecords({ schema: '@/session' })
2269
+ ```
2270
+
2271
+ ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2272
+ mistake to avoid.** `absent` = there is no live source (no backend, or nobody signed
2273
+ in) → render the site's authored content. `ready` with `records: []` = the backend
2274
+ answered and there is nothing there → render your empty state. Showing "nothing yet"
2275
+ for the first tells a visitor their content is gone when it was never requested.
2276
+
2277
+ ### Writing
2278
+
2279
+ ```jsx
2280
+ const writer = useEntityWriter({ schema: '@/track', uuid: track.uuid })
2281
+
2282
+ await writer.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
2283
+ await writer.update(itemId, { ...item.data, room: 'Hall A' }) // whole-data replace
2284
+ await writer.move(itemId, { after: previousItemId }) // never an index
2285
+ await writer.remove(itemId)
2286
+ ```
2287
+
2288
+ - **`section` is required on `create`.** An entity has several, and a rule declared on
2289
+ one — insert-only, say — does not reach an item that landed in another. Getting it
2290
+ wrong stores the item happily and quietly voids the rule.
2291
+ - **`update` replaces the item's data whole.** Spread what you are not editing.
2292
+ - **Ordering is the server's.** Say `'first'`, `'last'` or `{ after }` — never compute
2293
+ an order number, or two people arranging one list will produce an order neither
2294
+ chose.
2295
+ - ⛔ **`writer.conflict` is reported, not retried.** Someone else changed the item
2296
+ first; a retry would succeed by overwriting a change nobody looked at. Tell the
2297
+ person.
2298
+
2299
+ ### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
2300
+
2301
+ Gate your controls on what the viewer may do — `viewer.actingUnitId`, `viewer.roles` —
2302
+ but **never rely on that for safety**. A foundation runs with exactly the viewer's
2303
+ own authority, so hiding a button hides a button. The rule belongs in the data
2304
+ schema, where the store enforces it:
2305
+
2306
+ ```yaml
2307
+ # foundation/schemas/session.yml
2308
+ creatable_by: unit_members # only members of the owning unit may create these
2309
+
2310
+ sections:
2311
+ checkins:
2312
+ many: true
2313
+ append_only: true # may be added; never edited or removed, by anyone
2314
+ ```
2315
+
2316
+ `append_only` holds against the item's own author. That is the difference between a
2317
+ permission model and a CSS one.
2318
+
2319
+ ### A backend on your machine
2320
+
2321
+ You do not need a live backend to build against one. In `site.yml`:
2322
+
2323
+ ```yaml
2324
+ api: /_api # where it answers — the same value in production
2325
+ $devApi: ./mock/api.js # what answers it locally; `$` keys are never published
2326
+ ```
2327
+
2328
+ ```js
2329
+ // site/mock/api.js
2330
+ import { createMockBackend } from '@uniweb/api/mock'
2331
+ export default createMockBackend({ seed }).fetch
2332
+ ```
2333
+
2334
+ `uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
2335
+ site's configuration behave exactly as they will in production. It **enforces**
2336
+ `creatable_by` and `append_only`, so a permission you are relying on fails on your
2337
+ machine rather than in front of a user. State is in memory; restart to reset.
2338
+
2339
+ `uniweb create my-event --template conference` is a worked example of all of this.
2340
+
2341
+ ---
2342
+
2237
2343
  ## Part 5 — Commands, shipping, and migration
2238
2344
 
2239
2345
  ```bash
@@ -105,34 +105,39 @@ export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
105
105
  }
106
106
 
107
107
  /**
108
- * The fallback capability doc when `GET /dev/config` is absent or unreachable
109
- * (an older backend, or no backend at all). Keeps the client non-breaking: the
110
- * bases mirror a self-serve dev backend.
108
+ * The fallback capability doc for when `GET /dev/config` was not asked for (no
109
+ * credential in hand) or did not answer. Keeps the client non-breaking.
110
+ *
111
+ * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
112
+ * that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
113
+ * unknown reads falsy, and the caller stays silent rather than claiming a deployment
114
+ * does or does not charge. Every other key that used to sit here had no reader.
115
+ *
116
+ * ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
117
+ * a reader waiting to happen, and it is how this file came to describe a client that
118
+ * discovered its backend's gateway base, asset base and login path — none of which was
119
+ * ever true. The removals, and why each was not merely unused but wrong:
120
+ *
121
+ * `gatewayBase` sat here UNREAD until 2026-07-29. A serve location is read from the
122
+ * response that carries it (an upload plan's `serve_base`, an asset
123
+ * entry's `serve_url`, a payload's `config.base`) — never from a
124
+ * handshake, which cannot know a per-response answer.
125
+ * `assetBase` until 2026-08-17: one production host, hardcoded, applied to every
126
+ * deployment the CLI can be pointed at. Read only to compose an asset
127
+ * URL the plan already returns verbatim. Reader and composer both gone.
128
+ * `runtime` until 2026-08-22. A backend does not hold runtimes — a version comes
129
+ * from a CDN — so there is no installed set to report. What a site gets
130
+ * follows from its foundation's floor (`info.runtime`, at register).
131
+ * `auth` `loginPath` was never read: the login path is a constant in
132
+ * `utils/registry-auth.js`, and the ORIGIN comes from the resolution
133
+ * ladder, so the CLI is never told where to log in — it is born knowing.
134
+ * `delivery` `deploy` and `broker` had no reader. `publish` had one, but it could
135
+ * never refuse: the backend sent a literal true for every deployment,
136
+ * so the gate read a constant. Removed on both sides 2026-08-30.
137
+ * `assets` `supported` had no reader; the asset lane reports its own capability
138
+ * through the upload plan it returns.
111
139
  */
112
- export const DISCOVERY_DEFAULTS = {
113
- // ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
114
- // discovery or from a per-response field (an upload plan's `serve_base`, an
115
- // asset entry's `serve_url`); nothing here reconstructs one, so a default is a
116
- // route name with no consumer. (A `gatewayBase` entry lived here unread until
117
- // 2026-07-29.)
118
- //
119
- // `assetBase: 'https://assets.uniweb.app/'` sat here until 2026-08-17 — one
120
- // production host, hardcoded, applied to EVERY deployment the CLI can be
121
- // pointed at. It was only ever read to compose an asset URL, which the plan
122
- // already returns as `serve_url`; both the reader and the composer are gone.
123
- //
124
- // No `runtime` entry, and there must not be one. A backend does not hold
125
- // runtimes: a version is acquired from a CDN — the official mirror, the
126
- // distribution channel, or a local server — so there is no installed set for
127
- // it to report and nothing here to default. `runtime.installed` lived here
128
- // until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
129
- // builds to a backend; both are gone. The runtime a site gets follows from
130
- // its foundation's floor (`info.runtime`, stated at register), not from
131
- // anything the CLI asks a backend about.
132
- auth: { loginPath: '/dev/auth/login', required: true },
133
- delivery: { deploy: true, publish: true, broker: 'self-serve' },
134
- assets: { supported: false }
135
- }
140
+ export const DISCOVERY_DEFAULTS = {}
136
141
 
137
142
  export class BackendClient {
138
143
  /**
@@ -250,18 +255,78 @@ export class BackendClient {
250
255
  // ── Discovery ─────────────────────────────────────────────────────────────────
251
256
 
252
257
  /**
253
- * GET /dev/config the anonymous capability/handshake document. The one route
254
- * that answers before login (`auth: false`). Lazy + cached for the client's
255
- * lifetime; a missing route or any transport/parse error falls back to
256
- * DISCOVERY_DEFAULTS (non-breaking an older backend still works). Lets the
257
- * CLI hardcode nothing about a backend but its origin and discover the rest:
258
- * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?).
258
+ * The session bearer IF one can be had without asking — an explicit `--token`, an
259
+ * env var, or a stored unexpired session. Never prompts, never logs in, returns null
260
+ * instead. `token()` is the one that may block; this is for calls that want to be
261
+ * authenticated when possible but must not *cause* an authentication.
262
+ * @returns {Promise<string|null>}
263
+ */
264
+ async _tokenIfAvailable() {
265
+ if (this._token) return this._token
266
+ // ⛔ The injected resolver must be honoured here too, not just in `token()`.
267
+ // `pull` and `clone` pass one (`deps.getToken`), so skipping it would treat a caller
268
+ // that supplies its own auth as unauthenticated — and, worse, fall through to the
269
+ // machine's stored session, quietly using a DIFFERENT credential than the caller
270
+ // asked for. A throwing resolver is "no token", never a failed command.
271
+ if (this._getToken) {
272
+ try {
273
+ return (await this._getToken()) || null
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+ try {
279
+ const stored = await readRegistryAuth()
280
+ if (stored?.token && !isExpired(stored)) return stored.token
281
+ } catch {
282
+ /* advisory — a missing or unreadable session is simply "no token" */
283
+ }
284
+ return null
285
+ }
286
+
287
+ /**
288
+ * GET /dev/config — the capability/handshake document. Lazy + cached for the client's
289
+ * lifetime; a missing route, a 401, or any transport/parse error falls back to
290
+ * DISCOVERY_DEFAULTS, so this can never be the reason a command fails.
291
+ *
292
+ * ⭐ **Authenticated, or not sent at all.** `/dev/*` is the CLI's lane and the CLI is
293
+ * an authenticated client, so this attaches the bearer when it has one and **makes no
294
+ * request when it does not** — which keeps that true by construction rather than by
295
+ * ordering luck, and makes the route moving behind auth a no-op here.
296
+ *
297
+ * ⚠️ It uses `_tokenIfAvailable()` and never `token()`, deliberately: **discovery must
298
+ * never be the thing that triggers a login.** A capability probe that opens a password
299
+ * prompt would be a worse defect than the anonymous call it replaced.
300
+ *
301
+ * ⛔ **Most of this document is deliberately not read.** `gatewayBase` and `assetBase`
302
+ * were dropped because a serve location is read from the response that carries it
303
+ * (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
304
+ * is not read either — the login path is a hardcoded constant
305
+ * (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
306
+ * `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
307
+ * rest: each one would be a second place a backend's layout is pinned.
308
+ *
259
309
  * @returns {Promise<object>}
260
310
  */
261
311
  async discover() {
262
312
  if (this._discovery) return this._discovery
313
+ const bearer = await this._tokenIfAvailable()
314
+
315
+ // ⛔ NO CREDENTIAL ⇒ NO REQUEST. This is the rule made structural rather than
316
+ // incidental: apart from the login routes themselves, the CLI does not touch
317
+ // `/dev/*` without a bearer. The defaults are the honest answer here — we do not
318
+ // know this backend's capabilities and are not entitled to ask yet — and every
319
+ // caller already treats them as non-breaking, so nothing downstream changes.
320
+ if (!bearer) {
321
+ this._discovery = { ...DISCOVERY_DEFAULTS }
322
+ return this._discovery
323
+ }
324
+
263
325
  try {
264
- const res = await this.request('/dev/config', { auth: false })
326
+ const res = await this.request('/dev/config', {
327
+ auth: false,
328
+ headers: { Authorization: `Bearer ${bearer}` }
329
+ })
265
330
  this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
266
331
  } catch {
267
332
  this._discovery = { ...DISCOVERY_DEFAULTS }
@@ -519,7 +584,29 @@ export class BackendClient {
519
584
  * shipped backend-side — collab backend-framework-b220):
520
585
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
521
586
  * `draft_dirty` = never-published, or the synced draft changed since the last
522
- * publish ("pushed but not published"). The path is VERB-FIRST (`status/{uuid}`)
587
+ * publish ("pushed but not published").
588
+ *
589
+ * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
590
+ * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
591
+ *
592
+ * last_published_url · last_published_foundation · last_published_extensions
593
+ * last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
594
+ *
595
+ * Three things about it that a reader will otherwise get wrong:
596
+ *
597
+ * ⛔ `runtime_resolution` is `resolved` or `pinned:<reason>` (`operator` / `unknown_floor` /
598
+ * `no_foundations`). **A pin is a first-class answer, not a failure** — most sites are pinned
599
+ * at any moment, and an "old" runtime still satisfies the site's floor. Never surface
600
+ * `pinned:*` as an error state.
601
+ * ⛔ `extensions` is there because a site's code surface is the primary foundation **plus N
602
+ * extensions**; reading the primary alone describes a site nobody has.
603
+ * ⛔ `last_*` is deliberate on every one. `unpublish` LEAVES the URL populated (the static site
604
+ * may still be reachable), so beside `published: bool` a bare `published_url` would read as a
605
+ * liveness claim and be wrong exactly when it matters. And **nothing back-fills** — a site
606
+ * published before this reports them absent, which means "published before we recorded it",
607
+ * never "has no foundation".
608
+ *
609
+ * The path is VERB-FIRST (`status/{uuid}`)
523
610
  * to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
524
611
  * not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
525
612
  * 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from 'node:fs'
17
17
  import { basename } from 'node:path'
18
18
  import { resolveAssetPath } from '@uniweb/build/site'
19
19
  import { contentTypeFor } from '../utils/code-upload.js'
20
+ import { humanBytes } from '../utils/bytes.js'
20
21
 
21
22
  /**
22
23
  * @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
@@ -123,20 +124,6 @@ export async function uploadSiteMedia(
123
124
  // rewording). Same house style as the push-staleness `reason: "stale_base"` in
124
125
  // site-sync.js.
125
126
 
126
- const KIB = 1024
127
- function humanBytes(n) {
128
- if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
129
- if (n < KIB) return `${n} B`
130
- const units = ['KiB', 'MiB', 'GiB', 'TiB']
131
- let v = n / KIB
132
- let i = 0
133
- while (v >= KIB && i < units.length - 1) {
134
- v /= KIB
135
- i++
136
- }
137
- return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
138
- }
139
-
140
127
  // Append `label: <bytes>` when the value is a usable number. A refusal that omits an
141
128
  // extra still produces a useful message — never print `undefined` at a user.
142
129
  function pushBytes(lines, label, n) {
@@ -146,9 +133,17 @@ function pushBytes(lines, label, n) {
146
133
 
147
134
  /**
148
135
  * Turn an asset-plan refusal into user-facing lines, or null when this is not a
149
- * refusal we recognise (including every refusal shipped today, which is still
150
- * prose the typed `reason` values are agreed but NOT YET EMITTED). Null means
151
- * "fall through to the generic error", so this degrades rather than swallowing.
136
+ * refusal we recognise. Null means "fall through to the generic error", so this
137
+ * degrades rather than swallowing.
138
+ *
139
+ * ⛔ This comment used to assert that the typed `reason` values were "agreed but
140
+ * NOT YET EMITTED" — an unverified claim about ANOTHER LANE's deployed state,
141
+ * sitting in our source where it read as fact and nobody would think to re-check
142
+ * it. That is the precise shape that cost the backend lane a metering hole (a
143
+ * comment claiming what the app sent, wrong the whole time). What we can say is
144
+ * ours: we branch on `reason` and fall through cleanly when there is none, so
145
+ * this function is correct whether or not any given backend emits one, and no
146
+ * sentence here needs to track what a deployment is running.
152
147
  *
153
148
  * Two wording rules are load-bearing and come from the ratified accounting model,
154
149
  * not from taste:
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { recordSiteBackend } from '../utils/site-identity.js'
19
+ import { humanBytes } from '../utils/bytes.js'
19
20
  import {
20
21
  backfillEntityUuids,
21
22
  writeSiteEntityUuid,
@@ -647,9 +648,8 @@ export async function resolveSiteOrgForCreate({
647
648
  // serves the foundation lane, where every answer is an org and 0-orgs means
648
649
  // "claim your personal org". Here "personal" must stay reachable as *no org*,
649
650
  // and reusing deriveScope would quietly turn it into an org creation.
650
- const { fetchOrgs, createOrg, validateHandle, bareHandle } = await import(
651
- '../utils/registry-orgs.js'
652
- )
651
+ const { fetchOrgs, createOrg, validateHandle, bareHandle } =
652
+ await import('../utils/registry-orgs.js')
653
653
  let envelope
654
654
  try {
655
655
  envelope = await fetchOrgs({
@@ -719,6 +719,59 @@ export async function resolveSiteOrgForCreate({
719
719
  }
720
720
  }
721
721
 
722
+ /**
723
+ * Turn a typed site-create refusal into a sentence, or null to fall through to the
724
+ * generic HTTP line.
725
+ *
726
+ * ⭐ Why this exists separately from `describeAssetRefusal`. The asset lane grew a
727
+ * typed describer because a raw status dump is useless to the person who has to act
728
+ * on it; the create lane had the same hole and nobody had met it, because until the
729
+ * backend shipped account capacity a create could not be refused for space at all.
730
+ * So this is the same fix on the second door, written before anyone hits it rather
731
+ * than after.
732
+ *
733
+ * ⛔ Branch on `reason`, never on the status. `507` alone cannot be told from any
734
+ * other 507 and carries none of the numbers a user needs, and `detail` is prose the
735
+ * backend may reword.
736
+ *
737
+ * ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
738
+ * allowance belongs to the site's owner, who may not be the person pushing. Here
739
+ * there is no site yet, so the only workspace in play is the one the site would be
740
+ * created in — `--as <org>` included. Saying "the site owner's workspace" would be
741
+ * incoherent for a site that does not exist.
742
+ *
743
+ * @param {string} body - the raw response body
744
+ * @returns {string|null}
745
+ */
746
+ function describeCreateRefusal(body) {
747
+ if (!body) return null
748
+ let p
749
+ try {
750
+ p = JSON.parse(body)
751
+ } catch {
752
+ return null // prose refusal, or an upstream error page
753
+ }
754
+ if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
755
+
756
+ if (p.reason === 'storage_quota_exceeded') {
757
+ const parts = []
758
+ const used = humanBytes(p.used_bytes)
759
+ const limit = humanBytes(p.limit_bytes)
760
+ const needed = humanBytes(p.needed_bytes)
761
+ if (used && limit) parts.push(`${used} of ${limit} used`)
762
+ if (needed) parts.push(`a new site needs ${needed}`)
763
+ return (
764
+ 'storage quota reached — the workspace this site would belong to has no room for it' +
765
+ (parts.length ? ` (${parts.join('; ')})` : '') +
766
+ '. Quota is returned by deleting a site or entity, not by editing content.'
767
+ )
768
+ }
769
+ // An unrecognised typed reason still beats a status dump: name it, and let the
770
+ // backend's own prose follow when it sent any.
771
+ const detail = typeof p.detail === 'string' ? p.detail : ''
772
+ return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
773
+ }
774
+
722
775
  /**
723
776
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
724
777
  *
@@ -797,7 +850,8 @@ export async function ensureSiteExists({
797
850
  reason:
798
851
  res?.status === 404
799
852
  ? 'this backend has no /dev/site route (it predates the empty-site create)'
800
- : `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`
853
+ : (describeCreateRefusal(body) ??
854
+ `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`)
801
855
  }
802
856
  }
803
857
  const payload = await res.json().catch(() => null)
@@ -837,7 +891,13 @@ export async function ensureSiteExists({
837
891
  *
838
892
  * @returns {Promise<string|null>} the display handle recorded, or null for personal
839
893
  */
840
- async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note }) {
894
+ async function recordAndDescribeOwner({
895
+ client,
896
+ siteDir,
897
+ payload,
898
+ asOrg,
899
+ note
900
+ }) {
841
901
  const echoed = payload && 'org' in payload ? payload.org : undefined
842
902
  const owner =
843
903
  echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
@@ -851,7 +911,8 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
851
911
  //
852
912
  // A no-op on the default backend, so the common case writes nothing.
853
913
  const scope = await recordSiteBackend(siteDir, client.origin)
854
- if (scope) note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
914
+ if (scope)
915
+ note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
855
916
 
856
917
  note?.(
857
918
  org
@@ -859,27 +920,28 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
859
920
  : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
860
921
  )
861
922
 
862
- // The billing line needs the JOIN of two independent facts, and either alone
863
- // gives a wrong answer:
864
- // hosts_free a property of the SCOPE (is this owner exempt?)
865
- // siteSubscriptionRequired a property of the DEPLOYMENT (does it charge at all?)
866
- // Keyed on the scope alone, this fires on every local publish where nothing
867
- // enforces until the warning is trained away. Keyed on the deployment alone it
868
- // fires at exempt owners. An older backend supplies neither, so both read falsy
869
- // and nothing is said: silence beats a claim we cannot justify.
870
- const hostsFree = payload?.hosts_free === true
871
- let enforces = false
872
- try {
873
- const cfg = await client.discover()
874
- enforces = cfg?.delivery?.siteSubscriptionRequired === true
875
- } catch {
876
- /* discovery is advisory here never fail a create over a message */
877
- }
878
- if (hostsFree) {
879
- note?.('This owner is hosted free publishing will not require a subscription.')
880
- } else if (enforces) {
923
+ // What the create echoed about this site's OWNER, and nothing beyond it.
924
+ //
925
+ // `hosts_free` is a property of the SCOPE is this owner exempt? — and it is the only
926
+ // billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
927
+ // from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
928
+ // every deployment charges, so it read true everywhere and the join was testing a
929
+ // constant.
930
+ //
931
+ // AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
932
+ // alone, which is the exact thing the join existed to prevent. Whether a given publish
933
+ // is charged is derived per-site at publish time, on a side the CLI cannot see, so any
934
+ // prediction made here can only be approximately right and would go stale silently.
935
+ // The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
936
+ // arrives when it matters; `backend/payment-handoff.js` already turns it into a
937
+ // checkout. Deciding whether payment is due is not the CLI's job.
938
+ //
939
+ // What survives is the reassuring direction only, and only when it was stated:
940
+ // `false` is an answer we deliberately do not speak to, and missing is not an answer
941
+ // at all. Do not reintroduce a "you will be charged" line here.
942
+ if (payload?.hosts_free === true) {
881
943
  note?.(
882
- 'Publishing this site live will require a hosting subscription on this backend.'
944
+ 'This owner is hosted free — publishing will not require a subscription.'
883
945
  )
884
946
  }
885
947
  return org
@@ -1004,7 +1066,10 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
1004
1066
  * Offline by design — measured at zero HTTP requests, a property the cross-client
1005
1067
  * flows rely on.
1006
1068
  */
1007
- async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
1069
+ async function comparisonEmit(
1070
+ siteDir,
1071
+ { priorHashes = {}, sendAll = false } = {}
1072
+ ) {
1008
1073
  const applied = readAppliedInjections(siteDir)
1009
1074
  const assetIds = readAssetMap(siteDir)
1010
1075
  const org = readSiteOrg(siteDir)
@@ -1265,9 +1330,7 @@ export async function pushSyncPackages({
1265
1330
  note(
1266
1331
  ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1267
1332
  )
1268
- note(
1269
- 'Deleting this folder removes only your local copy, either way.'
1270
- )
1333
+ note('Deleting this folder removes only your local copy, either way.')
1271
1334
  } else if (res.status === 409) {
1272
1335
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1273
1336
  // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
@@ -1417,7 +1480,8 @@ export async function pushSyncPackages({
1417
1480
  asOrg,
1418
1481
  note
1419
1482
  })
1420
- if (createdOrg) wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1483
+ if (createdOrg)
1484
+ wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
1421
1485
  const createdFinalized = extractFinalized(payload)
1422
1486
  harvest(createdFinalized)
1423
1487
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -1465,7 +1529,8 @@ export async function pushSyncPackages({
1465
1529
  const folderDoc = finalized.find((f) => f?.document?.contents)?.document
1466
1530
  if (folderDoc) {
1467
1531
  const placements = collectFolderItemUuids(folderDoc)
1468
- if (Object.keys(placements).length) writeFolderItemUuids(siteDir, placements)
1532
+ if (Object.keys(placements).length)
1533
+ writeFolderItemUuids(siteDir, placements)
1469
1534
  }
1470
1535
  finalizedTotal += finalized.length
1471
1536
  }
@@ -248,15 +248,17 @@ export async function publish(args = []) {
248
248
  }
249
249
  const asOrg = org.asOrg
250
250
 
251
- // Capability handshake (cached). Publish ends in a go-live, so the publish
252
- // lane must be offered.
253
- const config = await client.discover()
254
- if (config?.delivery && config.delivery.publish === false) {
255
- say.err(
256
- `Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`
257
- )
258
- return { exitCode: 1 }
259
- }
251
+ // There is no capability gate here any more, deliberately.
252
+ //
253
+ // This used to read `delivery.publish` from the discovery document and refuse when it
254
+ // was false. It could never fire: the backend sent a literal true for every deployment,
255
+ // so the gate compared a constant against false. The key is now gone on both sides
256
+ // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
257
+ // while implying a capability that was never negotiable.
258
+ //
259
+ // Discovery is not consulted on this path at all. The one leaf the CLI still reads
260
+ // (`delivery.siteSubscriptionRequired`) is read after the site create, where a
261
+ // credential is already in hand.
260
262
 
261
263
  // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
262
264
  // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-29T19:59:22.061Z",
3
+ "generatedAt": "2026-09-01T22:08:19.670Z",
4
4
  "packages": {
5
+ "@uniweb/api": {
6
+ "version": "0.1.0",
7
+ "path": "framework/api",
8
+ "deps": [
9
+ "@uniweb/core"
10
+ ]
11
+ },
5
12
  "@uniweb/build": {
6
- "version": "0.30.0",
13
+ "version": "0.30.2",
7
14
  "path": "framework/build",
8
15
  "deps": [
9
16
  "@uniweb/content-reader",
@@ -27,7 +34,7 @@
27
34
  "deps": []
28
35
  },
29
36
  "@uniweb/core": {
30
- "version": "0.14.0",
37
+ "version": "0.14.1",
31
38
  "path": "framework/core",
32
39
  "deps": [
33
40
  "@uniweb/semantic-parser",
@@ -74,7 +81,7 @@
74
81
  ]
75
82
  },
76
83
  "@uniweb/runtime": {
77
- "version": "0.13.2",
84
+ "version": "0.13.3",
78
85
  "path": "framework/runtime",
79
86
  "deps": [
80
87
  "@uniweb/core",
@@ -87,7 +94,7 @@
87
94
  "deps": []
88
95
  },
89
96
  "@uniweb/schemas": {
90
- "version": "0.2.11",
97
+ "version": "0.2.12",
91
98
  "path": "framework/schemas",
92
99
  "deps": []
93
100
  },
@@ -102,7 +109,7 @@
102
109
  "deps": []
103
110
  },
104
111
  "@uniweb/templates": {
105
- "version": "0.10.0",
112
+ "version": "0.11.0",
106
113
  "path": "framework/templates",
107
114
  "deps": []
108
115
  },
@@ -112,7 +119,7 @@
112
119
  "deps": []
113
120
  },
114
121
  "@uniweb/unipress": {
115
- "version": "0.8.16",
122
+ "version": "0.8.17",
116
123
  "path": "framework/unipress",
117
124
  "deps": [
118
125
  "@uniweb/build",
@@ -291,6 +298,18 @@
291
298
  "quote",
292
299
  "trades"
293
300
  ]
301
+ },
302
+ "conference": {
303
+ "name": "Conference",
304
+ "description": "A site that is also an app — sign-in, per-viewer data, and an editable programme. Ships a local backend so it runs with nothing installed.",
305
+ "tags": [
306
+ "app",
307
+ "api",
308
+ "backend",
309
+ "auth",
310
+ "permissions",
311
+ "conference"
312
+ ]
294
313
  }
295
314
  }
296
315
  }
package/src/index.js CHANGED
@@ -814,10 +814,45 @@ async function main() {
814
814
  const originFlag =
815
815
  readFlagValue(loginArgs, '--backend') ||
816
816
  readFlagValue(loginArgs, '--registry')
817
- await runRegistryLogin({
818
- apiBase: resolveBackendOrigin(originFlag),
819
- args: loginArgs
820
- })
817
+ const apiBase = resolveBackendOrigin(originFlag)
818
+
819
+ // ⭐ The project says where it belongs — say so BEFORE authenticating elsewhere.
820
+ //
821
+ // `login` is deliberately NOT given the `site.yml::$backend` tier the site verbs
822
+ // get (see resolveBackendOrigin): the session it writes is machine-wide, so letting
823
+ // cwd pick the account you log into would be a silent surprise. But staying silent
824
+ // does not remove the failure, it MOVES it — you log into the default, and the next
825
+ // push/pull/publish resolves to `$backend` and warns about the mismatch. That is the
826
+ // routed-not-nagged case `$backend` was added for, missing at the one command a
827
+ // teammate runs FIRST after cloning.
828
+ //
829
+ // ⛔ Silent when the origin was named explicitly (--backend / --registry /
830
+ // UNIWEB_REGISTER_URL). A deliberate aim is not a mistake to warn about; a genuinely
831
+ // wrong one is still caught by the session-mismatch guard in BackendClient.token().
832
+ if (!originFlag && !process.env.UNIWEB_REGISTER_URL) {
833
+ try {
834
+ const { findNearbySiteBackend } = await import(
835
+ './utils/site-identity.js'
836
+ )
837
+ const nearby = findNearbySiteBackend(process.cwd())
838
+ if (nearby && nearby.backend !== apiBase) {
839
+ console.error(
840
+ `\x1b[33m⚠\x1b[0m This project syncs with ${nearby.backend} (site.yml::$backend), but login is targeting ${apiBase}.`
841
+ )
842
+ console.error(
843
+ ` Log in where the project belongs: uniweb login --backend ${nearby.backend}`
844
+ )
845
+ console.error(
846
+ ` Continuing with ${apiBase} — a session is machine-wide, so this is a heads-up, not a block.\n`
847
+ )
848
+ }
849
+ } catch {
850
+ // Advisory only. A malformed site.yml, an unreadable directory or anything else
851
+ // here must never be the reason someone cannot log in.
852
+ }
853
+ }
854
+
855
+ await runRegistryLogin({ apiBase, args: loginArgs })
821
856
  return
822
857
  }
823
858
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Byte formatting for refusal messages.
3
+ *
4
+ * Lives on its own because TWO lanes now print a storage refusal — the asset plan
5
+ * (`backend/site-media.js`) and the site create (`backend/site-sync.js`) — and the
6
+ * numbers must read identically in both. A second copy would drift in units or
7
+ * rounding, and a user comparing "you have 1.1 GiB" against "this needs 100 MB"
8
+ * cannot tell a real gap from a formatting difference.
9
+ */
10
+
11
+ const KIB = 1024
12
+
13
+ /**
14
+ * `1073741824` → `1 GiB`. Returns null for anything that is not a usable byte
15
+ * count, so a refusal that omits an extra prints nothing rather than `undefined`.
16
+ * @param {unknown} n
17
+ * @returns {string|null}
18
+ */
19
+ export function humanBytes(n) {
20
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
21
+ if (n < KIB) return `${n} B`
22
+ const units = ['KiB', 'MiB', 'GiB', 'TiB']
23
+ let v = n / KIB
24
+ let i = 0
25
+ while (v >= KIB && i < units.length - 1) {
26
+ v /= KIB
27
+ i++
28
+ }
29
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
30
+ }
@@ -28,8 +28,8 @@
28
28
  * and it has to move the readers and the writers together or it makes the split worse.
29
29
  */
30
30
 
31
- import { existsSync, readFileSync } from 'node:fs'
32
- import { join } from 'node:path'
31
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
32
+ import { join, dirname } from 'node:path'
33
33
  import yaml from 'js-yaml'
34
34
  import { DEFAULT_BACKEND_ORIGIN } from './config.js'
35
35
 
@@ -232,3 +232,66 @@ export function assertSiteBackendScope(siteDir, origin) {
232
232
  hint
233
233
  }
234
234
  }
235
+
236
+ /**
237
+ * The `$backend` of the site project `startDir` sits in — for verbs that are NOT site
238
+ * verbs and so never resolve a site directory of their own.
239
+ *
240
+ * ⛔ **This does NOT feed the origin ladder, deliberately.** `login` writes a
241
+ * MACHINE-WIDE session (`~/.uniweb/registry-auth.json`), not a per-project one, so
242
+ * letting whichever directory you happen to stand in decide which backend you
243
+ * authenticate against would be a silent surprise — the same class of surprise
244
+ * `$backend` exists to remove. What this enables is a **notice**: the project says
245
+ * where it belongs, so we say so before authenticating somewhere else.
246
+ *
247
+ * ⭐ **Conservative by construction — it answers only when there is exactly ONE
248
+ * candidate.** A workspace of several sites has no single answer, and a confident
249
+ * *"did you mean localhost?"* aimed at the wrong one of three sites is worse than
250
+ * saying nothing. Ambiguity returns null and the caller stays quiet.
251
+ *
252
+ * ⚠️ Build-free, like everything else in this file — `login` is a STANDALONE command
253
+ * that must work outside a project, where `@uniweb/build` is not installed. That is why
254
+ * this cannot reuse `resolveSiteDir` (`commands/deploy.js`), which pulls build in.
255
+ *
256
+ * @param {string} startDir
257
+ * @returns {{ siteDir: string, backend: string }|null}
258
+ */
259
+ export function findNearbySiteBackend(startDir) {
260
+ // 1. Walk UP for the site we are standing in or under. Bounded: a `site.yml` more
261
+ // than a few levels above is not "the project you are in", it is a coincidence,
262
+ // and at the filesystem root it would be someone else's entirely.
263
+ let dir = startDir
264
+ for (let i = 0; i < 4; i++) {
265
+ if (existsSync(join(dir, 'site.yml'))) {
266
+ const { backend } = readSiteIdentity(dir)
267
+ return backend ? { siteDir: dir, backend } : null
268
+ }
269
+ const up = dirname(dir)
270
+ if (up === dir) break
271
+ dir = up
272
+ }
273
+
274
+ // 2. Standing AT a project root, the site is one level down — `site/` in the default
275
+ // layout, or a lone entry under `sites/`. Two or more candidates is a workspace,
276
+ // which is exactly the ambiguity above.
277
+ const candidates = []
278
+ if (existsSync(join(startDir, 'site', 'site.yml')))
279
+ candidates.push(join(startDir, 'site'))
280
+ const sitesDir = join(startDir, 'sites')
281
+ if (existsSync(sitesDir)) {
282
+ let entries = []
283
+ try {
284
+ entries = readdirSync(sitesDir, { withFileTypes: true })
285
+ } catch {
286
+ entries = []
287
+ }
288
+ for (const e of entries) {
289
+ if (!e.isDirectory()) continue
290
+ const d = join(sitesDir, e.name)
291
+ if (existsSync(join(d, 'site.yml'))) candidates.push(d)
292
+ }
293
+ }
294
+ if (candidates.length !== 1) return null
295
+ const { backend } = readSiteIdentity(candidates[0])
296
+ return backend ? { siteDir: candidates[0], backend } : null
297
+ }