uniweb 0.32.2 → 0.33.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.32.2",
3
+ "version": "0.33.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,11 +43,11 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.13.1",
45
45
  "@uniweb/kit": "^0.14.0",
46
- "@uniweb/runtime": "^0.13.1",
47
- "@uniweb/semantic-parser": "^1.3.1"
46
+ "@uniweb/semantic-parser": "^1.3.1",
47
+ "@uniweb/runtime": "^0.13.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.28.2",
50
+ "@uniweb/build": "^0.29.1",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
52
  "@uniweb/semantic-parser": "^1.3.1"
53
53
  },
@@ -1851,7 +1851,7 @@ by the same rule — your declaration, then the host's, then neither:
1851
1851
  ```jsx
1852
1852
  import { resolveService } from '@uniweb/kit'
1853
1853
 
1854
- const { url, reason } = resolveService(website, 'assistant') // or 'search', or your own
1854
+ const { url, source } = resolveService(website, 'assistant') // or 'search', or your own
1855
1855
  ```
1856
1856
 
1857
1857
  **The name is open**: the framework ships clients for what it implements and
@@ -1870,7 +1870,9 @@ if (!url) return null // this site has no agent — render nothing, or
1870
1870
 
1871
1871
  ⛔ **Absent is the answer, not a lookup that failed.** No `url` means the site has no agent — never enabled, or this host runs none. Render for that case; don't retry it. And don't hardcode `/_agent/chat` when nothing was declared: on a static host that turns "no agent here" into a 404 your component can't tell from a broken endpoint. The path is named above so you recognize the shape, not so you can construct it.
1872
1872
 
1873
- ⛔ **And don't tell the visitor.** `resolveService` also returns a `reason`, and it is **not visitor copy** — a visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. **Absence is a rendering decision, not a message**: a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. The `reason` string is there for *you*, while you wire a site up.
1873
+ ⛔ **And don't tell the visitor.** `resolveService` returns `{ url, source }` and **deliberately nothing else** — there is no explanatory string and there was one, removed because it was a mistake. A visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. Worse, it is unfixably the wrong language: sites here are multilingual, or unilingual and not English, and a canned constant bypasses the site's whole localization pipeline. **Absence is a rendering decision, not a message**, and a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. Any text a visitor should read is *site content* authored and localized.
1874
+
1875
+ `source` is `'site'`, `'host'` or `null`, and it is a **diagnostic for you** while you wire a site up: it says which tier answered, which is the thing to check when a host's value appears not to be taking effect. `'host'` with a null `url` means the host answered and offered no address.
1874
1876
 
1875
1877
  *(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
1876
1878
 
@@ -27,6 +27,7 @@ import {
27
27
  computeUnitHashes,
28
28
  collectUnitUuids,
29
29
  collectFolderItemUuids,
30
+ collectCollectionUuids,
30
31
  readAssetMap
31
32
  } from '@uniweb/build/uwx'
32
33
 
@@ -241,6 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
241
242
  const prior = readSyncCacheFile(siteDir)
242
243
  const dropped = [
243
244
  'itemUuids',
245
+ 'collectionUuids',
244
246
  'hashes',
245
247
  'baseVersions',
246
248
  'unitBases',
@@ -248,6 +250,9 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
248
250
  ].filter((k) => prior[k] && Object.keys(prior[k]).length)
249
251
  updateSyncCache(siteDir, {
250
252
  itemUuids: {},
253
+ // Remote-derived exactly like itemUuids — it holds the OLD site's collection
254
+ // ids, and surviving the drop it would offer them for the new site's sections.
255
+ collectionUuids: {},
251
256
  hashes: {},
252
257
  baseVersions: {},
253
258
  unitBases: {},
@@ -468,6 +473,32 @@ export function readItemUuids(siteDir) {
468
473
  export function readFolderItemUuids(siteDir) {
469
474
  return readMap(siteDir, 'folderItemUuids')
470
475
  }
476
+
477
+ /**
478
+ * Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
479
+ *
480
+ * ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
481
+ * the file an item projects to. Two kinds of item have no file of their own — a
482
+ * folder's placements, and a collection DECLARATION (they all live in one
483
+ * `collections/collections.yml`) — so a path-keyed map has no shape either could
484
+ * occupy, and a push re-sends that whole section uuid-less.
485
+ *
486
+ * The backend refuses an all-blank section over stored items rather than applying it,
487
+ * because applying it would insert every record fresh and delete every stored row —
488
+ * content survives, identity does not. So `push` worked once and every push after was
489
+ * refused. Measured 2026-08-29; collab framework-backend-812b.
490
+ *
491
+ * ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
492
+ * its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
493
+ * handle the backend skips on parse and never stores.
494
+ */
495
+ export function readCollectionUuids(siteDir) {
496
+ return readMap(siteDir, 'collectionUuids')
497
+ }
498
+ export function writeCollectionUuids(siteDir, map) {
499
+ if (!map || !Object.keys(map).length) return
500
+ updateSyncCache(siteDir, { collectionUuids: map })
501
+ }
471
502
  export function writeFolderItemUuids(siteDir, map) {
472
503
  if (!map || !Object.keys(map).length) return
473
504
  updateSyncCache(siteDir, { folderItemUuids: map })
@@ -932,18 +963,88 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
932
963
  // wrote. Reading the live file rather than a snapshot is what makes a moved
933
964
  // map (a teammate's push, a pull) read as changed instead of matching a copy
934
965
  // of itself.
966
+ // · RECORDED — the site's own org, from `site.yml::$org`, written by the push
967
+ // that banked these hashes.
968
+ //
969
+ // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
970
+ // a foundation-relative `@/member` into the `@org/member` the push shipped and
971
+ // keyed its hashes by. Without it the emit does not fail — `buildCollectionEntities`
972
+ // WARNS and ships the model unresolved, deliberately, so an org-less export still
973
+ // works — so every record of a `@/`-scoped collection is emitted under a key that
974
+ // can never match its banked one, and reads as changed forever.
975
+ //
976
+ // ⚠️ It hides in plain sight because `@std/…` collections are unaffected: their
977
+ // scope is already absolute, so they match. A site mixing both — the marketing
978
+ // fixture has `@std/person` AND `@proximify/member` — shows some records settling
979
+ // and others never settling, which reads like a content problem rather than a
980
+ // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
981
+ // immediately after a successful push; passing the org took it to 1.
982
+ const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
983
+ const changed =
984
+ (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
985
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
986
+ }
987
+
988
+ /**
989
+ * The emit whose hashes are comparable with the banked ones — ONE definition.
990
+ *
991
+ * ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
992
+ * WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
993
+ * have a single home. Assembled from three sources, each for a stated reason:
994
+ *
995
+ * · BANKED the injections the push applied before hashing — serve URLs and
996
+ * the pinned foundation ref, which only a round trip produces.
997
+ * · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
998
+ * reads as changed rather than matching a copy of itself.
999
+ * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1000
+ * the `@org/x` the push keyed its hashes by, and the collection
1001
+ * identity a push stamps — so `status` hashes the same document a
1002
+ * push would send rather than one missing a section's `$uuid`s.
1003
+ *
1004
+ * Offline by design — measured at zero HTTP requests, a property the cross-client
1005
+ * flows rely on.
1006
+ */
1007
+ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
935
1008
  const applied = readAppliedInjections(siteDir)
936
1009
  const assetIds = readAssetMap(siteDir)
937
- const pkg = await emitSyncPackages(siteDir, {
1010
+ const org = readSiteOrg(siteDir)
1011
+ const collectionUuids = readCollectionUuids(siteDir)
1012
+ return emitSyncPackages(siteDir, {
938
1013
  resolveModel: makeModelResolver({ client: null, offline: true }),
939
1014
  priorHashes,
940
1015
  sendAll,
941
1016
  ...applied,
942
- ...(Object.keys(assetIds).length ? { assetIds } : {})
1017
+ ...(Object.keys(collectionUuids).length ? { collectionUuids } : {}),
1018
+ ...(Object.keys(assetIds).length ? { assetIds } : {}),
1019
+ ...(org ? { org } : {})
943
1020
  })
944
- const changed =
945
- (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
946
- return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
1021
+ }
1022
+
1023
+ /**
1024
+ * Re-bank the send-only-changed hashes over the files as they NOW stand.
1025
+ *
1026
+ * ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
1027
+ *
1028
+ * A pull projects the backend's document into source files, and that projection is
1029
+ * canonical rather than byte-identical to what was there: it moves section ordering
1030
+ * out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
1031
+ * list) and stamps each section's `id`. Lossless, and a different document.
1032
+ *
1033
+ * ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
1034
+ * because "what we would emit from them is not byte-identical to it, so the old
1035
+ * local base no longer describes anything". That reasoning was never carried to the
1036
+ * hashes, which were left STALE rather than unknown: the next `uniweb status`
1037
+ * reported the site as having unpushed content immediately after a pull, forever.
1038
+ * Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
1039
+ *
1040
+ * ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
1041
+ * on-disk state IS the agreed state: it came from the backend. A push with no edits
1042
+ * in between should send nothing, and clearing would make it send everything.
1043
+ */
1044
+ export async function rebankSyncHashes(siteDir) {
1045
+ const pkg = await comparisonEmit(siteDir, { sendAll: true })
1046
+ writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
1047
+ return Object.keys(pkg.hashes || {}).length
947
1048
  }
948
1049
 
949
1050
  /**
@@ -1045,18 +1146,47 @@ export async function pushSyncPackages({
1045
1146
  return null
1046
1147
  }
1047
1148
  if (problem?.reason === 'identity_required') {
1149
+ // ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
1150
+ //
1151
+ // The backend asks, of each section independently: is this section's ENTIRE
1152
+ // incoming record set uuid-less while the stored entity already has items in
1153
+ // it? So a push can carry correct identity for most of the document and still
1154
+ // be refused over one section it stamps none of — and the old wording ("this
1155
+ // copy has no record of the site's item identity", "the recovery … could not
1156
+ // reach the backend", "`uniweb pull` also restores it") was wrong on all
1157
+ // three counts in exactly that case: the copy has identity, no recovery was
1158
+ // attempted, and a pull changes nothing.
1159
+ //
1160
+ // ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
1161
+ // `records_without_uuid`, `stored_items` — and this branch discarded every
1162
+ // one of them, so every refusal anyone collected was missing the only fields
1163
+ // that say WHERE. (Named by backend in collab `framework-backend-812b`,
1164
+ // 2026-08-28: "the offending section and both counts have been in the body of
1165
+ // every refusal you have collected".)
1166
+ const n = problem.records_without_uuid
1167
+ const stored = problem.stored_items
1168
+ // `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
1169
+ // i64 row id we could not resolve. The fallback stays for an older backend,
1170
+ // not because the swap is pending — it is done.
1171
+ const where = problem.section_name ?? problem.section_id
1048
1172
  error(
1049
- `${label} push refused — this copy has no record of the site's item identity.`
1050
- )
1051
- note(
1052
- 'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
1053
- )
1054
- note(
1055
- 'The recovery normally runs automatically, so it likely could not reach the backend.'
1173
+ `${label} push refused — one section carries no item identity` +
1174
+ (where === undefined ? '.' : ` (section ${where}).`)
1056
1175
  )
1176
+ if (Number.isInteger(n) && Number.isInteger(stored)) {
1177
+ note(
1178
+ `That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
1179
+ )
1180
+ }
1057
1181
  note(
1058
- 'Check your connection and re-run; `uniweb pull` also restores it.'
1182
+ 'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
1059
1183
  )
1184
+ // ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
1185
+ // the backend's own document, so it fixes a LOST cache — and does nothing at
1186
+ // all when the cache is intact and one section simply has no entry in it,
1187
+ // which is the case this message now names. Suggesting it there sends the
1188
+ // user to re-fetch a map they already have.
1189
+ if (problem.detail) note(String(problem.detail))
1060
1190
  return null
1061
1191
  }
1062
1192
  if (problem?.reason === 'stale_base') {
@@ -1246,6 +1376,16 @@ export async function pushSyncPackages({
1246
1376
  }
1247
1377
  harvest(finalized)
1248
1378
  siteFinalizedDoc = finalized[0]?.document || null
1379
+ // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1380
+ // placements below. These items have no file to back-fill into — they all
1381
+ // come from one `collections/collections.yml` — so the only place their
1382
+ // `$uuid` can live is the cache, keyed by the name the backend enforces
1383
+ // unique. Without it every push after the first re-sends the whole
1384
+ // `collections` section uuid-less and is refused.
1385
+ if (siteFinalizedDoc) {
1386
+ const collectionIds = collectCollectionUuids(siteFinalizedDoc)
1387
+ if (Object.keys(collectionIds).length) writeCollectionUuids(siteDir, collectionIds)
1388
+ }
1249
1389
  finalizedTotal += finalized.length
1250
1390
  } else {
1251
1391
  const payload = await postLane('site-content', () =>
@@ -75,7 +75,8 @@ import {
75
75
  resolveCollectionsConfig,
76
76
  readZip,
77
77
  computeUnitHashes,
78
- collectUnitUuids
78
+ collectUnitUuids,
79
+ collectCollectionUuids
79
80
  } from '@uniweb/build/uwx'
80
81
  import {
81
82
  readWritten,
@@ -84,6 +85,8 @@ import {
84
85
  } from '../utils/pull-written.js'
85
86
  import {
86
87
  makeModelResolver,
88
+ rebankSyncHashes,
89
+ writeCollectionUuids,
87
90
  mergeBaseVersions,
88
91
  mergeItemBaseVersions,
89
92
  writeUnitBases,
@@ -686,6 +689,10 @@ export async function pull(args = [], deps = {}) {
686
689
  // Per-item identity for the next push. Without it the backend reads our
687
690
  // records as new and re-mints every page and section row.
688
691
  writeItemUuids(siteDir, collectUnitUuids(siteDoc))
692
+ // The collections section's identity has no file to live in either — same
693
+ // reason, same remedy, keyed by name. A pull is the other route by which a
694
+ // copy can recover it (see readCollectionUuids).
695
+ writeCollectionUuids(siteDir, collectCollectionUuids(siteDoc))
689
696
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
690
697
  // entry, and the projection reads that map to put authored paths back. Run
691
698
  // after, and this pull's new assets would project as URLs and only restore
@@ -840,6 +847,33 @@ export async function pull(args = [], deps = {}) {
840
847
  removed
841
848
  )
842
849
 
850
+ // ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT WE JUST WROTE.
851
+ //
852
+ // The projection above is canonical, not byte-identical to what was on disk: it
853
+ // moves section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
854
+ // explicit `sections:` list) and stamps each section's `id`. Lossless, and a
855
+ // different document — which is exactly why the `local` unit base is cleared above.
856
+ //
857
+ // ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
858
+ // rather than unknown, and `uniweb status` reported unpushed content immediately
859
+ // after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
860
+ // 1 changed of 8, with nothing edited in between.
861
+ //
862
+ // Re-banking rather than clearing, because after a pull the on-disk state IS the
863
+ // agreed state — it came from the backend, so a push with no edits should send
864
+ // nothing. Clearing would make it send everything.
865
+ //
866
+ // Best-effort: a failure here costs an unnecessary re-send on the next push, never
867
+ // wrong content, and must not fail a pull whose files are already written.
868
+ if (!dryRun) {
869
+ try {
870
+ await rebankSyncHashes(siteDir)
871
+ } catch (err) {
872
+ note(`! could not re-bank the sync cache: ${err.message}`)
873
+ note(' The next push will re-send content that is already current.')
874
+ }
875
+ }
876
+
843
877
  success(
844
878
  `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
845
879
  (deleted ? `, ${deleted} deleted` : '')
@@ -85,6 +85,7 @@ import {
85
85
  readItemBaseVersions,
86
86
  readItemUuids,
87
87
  readFolderItemUuids,
88
+ readCollectionUuids,
88
89
  ensureItemUuids,
89
90
  ensureSiteExists,
90
91
  clearRemoteSyncStateIfUnbound,
@@ -474,6 +475,9 @@ export async function push(args = [], deps = {}) {
474
475
  pkg = await emitSyncPackages(siteDir, {
475
476
  // Placement identity for the folder — see writeFolderItemUuids.
476
477
  folderItemUuids: readFolderItemUuids(siteDir),
478
+ // Identity for the `collections` section — see readCollectionUuids. Keyed by
479
+ // name, because a declaration has no file for a path-keyed map to hold.
480
+ collectionUuids: readCollectionUuids(siteDir),
477
481
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
478
482
  ...(asOrg ? { org: asOrg } : {}),
479
483
  ...(foundationDir ? { foundationDir } : {}),
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-28T13:37:15.824Z",
3
+ "generatedAt": "2026-08-28T23:49:23.885Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.28.2",
6
+ "version": "0.29.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -102,7 +102,7 @@
102
102
  "deps": []
103
103
  },
104
104
  "@uniweb/templates": {
105
- "version": "0.9.6",
105
+ "version": "0.10.0",
106
106
  "path": "framework/templates",
107
107
  "deps": []
108
108
  },
@@ -112,7 +112,7 @@
112
112
  "deps": []
113
113
  },
114
114
  "@uniweb/unipress": {
115
- "version": "0.8.15",
115
+ "version": "0.8.16",
116
116
  "path": "framework/unipress",
117
117
  "deps": [
118
118
  "@uniweb/build",
@@ -225,11 +225,6 @@ export async function ensureRegistryAuth({
225
225
  return record.token
226
226
  }
227
227
 
228
- // The browser/OAuth flow is wired below (loginViaBrowser: a loopback redirect
229
- // against the backend's /dev/auth/cli/authorize, token-in-redirect). Kept
230
- // gated until that endpoint is live on the backend — flip to true then, and the
231
- // picker offers Browser/social as the default (and `--browser` works).
232
- const BROWSER_AVAILABLE = false
233
228
 
234
229
  /**
235
230
  * GET /dev/auth/me with a bearer → the account object ({ uuid, username,
@@ -423,45 +418,90 @@ export async function awaitBrowserCallback({
423
418
  return result.value
424
419
  }
425
420
 
426
- // Browser / social — loopback OAuth against the backend's /dev/auth/authorize.
427
- // The CLI hosts a one-shot 127.0.0.1 server (awaitBrowserCallback), opens the
428
- // browser to authorize, and the backend (after the Google dance) 302s back to
429
- // the loopback with the token (or an error). state is a CSRF nonce echoed back
430
- // and verified. The token never leaves browser→localhost. Gated by
431
- // BROWSER_AVAILABLE until the endpoint is live.
421
+ // Browser / social — the backend's CLI delegation flow. The CLI never speaks to
422
+ // an identity provider and holds no client id, secret or provider knowledge: it
423
+ // opens ONE url (the backend's), catches a one-time code on a loopback, and
424
+ // trades that code for a bearer. Whatever methods the backend's own sign-in page
425
+ // offers password, Google, Microsoft, anything added later — the CLI gains with
426
+ // no change here, because it never learns which one was used.
427
+ //
428
+ // Three legs, all verified against a live backend rather than read:
429
+ //
430
+ // GET {base}/dev/auth/authorize?callback=<loopback>&state=<nonce>
431
+ // no session → 302 {hub}/login?returnTo=… (the ordinary sign-in page)
432
+ // session → 302 <callback>?state=<ours>&code=<one-time>
433
+ // POST {base}/dev/auth/token {code} → { token, expires_at, account }
434
+ //
435
+ // ⛔ `callback` IS THE PARAMETER NAME, not `redirect_uri` — the backend serves
436
+ // this route and rejects the other spelling with a 400. ⛔ AND THE CALLBACK
437
+ // CARRIES A `code`, NEVER A TOKEN: the bearer is born on the POST, server-to-CLI,
438
+ // so it never touches the browser, the URL bar, history or a proxy log. Both were
439
+ // wrong here for three months — this code was written against an anticipated
440
+ // shape five days before the backend shipped, and being gated meant nothing could
441
+ // contradict it.
442
+ //
443
+ // ⛔ The loopback MUST be a v4 literal. `awaitBrowserCallback` binds 127.0.0.1
444
+ // explicitly and composes `http://127.0.0.1:<port>/callback`; the backend's
445
+ // validator accepts `http://` only, host exactly `127.0.0.1` or `localhost`, and
446
+ // refuses `[::1]` and any `user@host` (that last guard stops
447
+ // `http://127.0.0.1:1@evil.com/cb` from walking off with the code). Do not
448
+ // "modernise" the bind to `::`.
432
449
  async function loginViaBrowser({ apiBase }) {
433
- if (!BROWSER_AVAILABLE) {
434
- throw new Error(
435
- 'browser/social login for the new backend isn’t available yet — use --password or --token-paste.'
436
- )
437
- }
438
450
  const base = apiBase.replace(/\/$/, '')
439
451
  const state = randomBytes(16).toString('hex')
440
452
 
441
- const token = await awaitBrowserCallback({
442
- buildUrl: (redirectUri) =>
443
- `${base}/dev/auth/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`,
453
+ // Leg 1+2 open the backend's authorize url, catch the one-time code.
454
+ const code = await awaitBrowserCallback({
455
+ buildUrl: (callback) =>
456
+ `${base}/dev/auth/authorize?callback=${encodeURIComponent(callback)}&state=${state}`,
444
457
  validate: (params) => {
445
458
  if (params.get('error')) return { error: params.get('error') }
459
+ // Check `state` BEFORE reading anything else: it is the only thing that
460
+ // ties this callback to the request we made.
446
461
  if (params.get('state') !== state)
447
462
  return { error: 'state mismatch — please try again.' }
448
- const tok = params.get('token')
449
- if (!tok) return { error: 'no token returned by the callback.' }
450
- return { value: tok }
463
+ const code = params.get('code')
464
+ if (!code) return { error: 'no code returned by the callback.' }
465
+ return { value: code }
451
466
  },
452
467
  openingLabel: 'Opening your browser to sign in…',
453
- waitingLabel: 'Waiting for sign-in to complete (120s)…',
468
+ waitingLabel: 'Waiting for sign-in to complete (5 min)…',
469
+ // A person is signing in at an identity provider, not clicking one button:
470
+ // a first-time Google or Microsoft login can carry a consent screen, an
471
+ // account chooser and 2FA. The default 120s expires mid-flow and reports a
472
+ // TIMEOUT, which reads as "the CLI is broken" rather than "you were slow".
473
+ // Same reasoning, and the same value, as the publish payment handoff.
474
+ timeoutMs: 5 * 60 * 1000,
454
475
  okTitle: 'Login successful',
455
476
  errTitle: 'Login failed'
456
477
  })
457
478
 
458
- let account = null
459
- try {
460
- account = await fetchMe({ apiBase, token })
461
- } catch {
462
- /* identity optional; token is valid */
479
+ // Leg 3 — trade the code for a bearer. Anonymous: the code IS the credential,
480
+ // and it is single-use, so a replay gets a 400 rather than a second session.
481
+ const res = await fetch(`${base}/dev/auth/token`, {
482
+ method: 'POST',
483
+ headers: { 'Content-Type': 'application/json' },
484
+ body: JSON.stringify({ code })
485
+ })
486
+ const payload = await res.json().catch(() => null)
487
+ if (!res.ok || !payload?.token) {
488
+ const detail = payload?.detail || payload?.title || `HTTP ${res.status}`
489
+ throw new Error(`could not complete sign-in: ${detail}`)
463
490
  }
464
- const record = { token, origin: normOrigin(apiBase) }
491
+
492
+ // The token response already carries the account, so no second round trip.
493
+ // `fetchMe` remains the fallback for a backend that answers without one.
494
+ let account = payload.account || null
495
+ if (!account) {
496
+ try {
497
+ account = await fetchMe({ apiBase, token: payload.token })
498
+ } catch {
499
+ /* identity is optional; the bearer is valid either way */
500
+ }
501
+ }
502
+
503
+ const record = { token: payload.token, origin: normOrigin(apiBase) }
504
+ if (payload.expires_at) record.expiresAt = payload.expires_at
465
505
  if (account?.uuid) record.uuid = account.uuid
466
506
  if (account?.username) record.username = account.username
467
507
  if (account?.handle) record.handle = account.handle
@@ -549,11 +589,10 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
549
589
  } else {
550
590
  const prompts = (await import('prompts')).default
551
591
  const choices = []
552
- if (BROWSER_AVAILABLE)
553
- choices.push({
554
- title: 'Browser / social (Google, etc.)',
555
- value: 'browser'
556
- })
592
+ choices.push({
593
+ title: 'Browser / social (Google, Microsoft, …)',
594
+ value: 'browser'
595
+ })
557
596
  choices.push({ title: 'Username and password', value: 'password' })
558
597
  choices.push({ title: 'Paste a token', value: 'token-paste' })
559
598
  const { picked } = await prompts(