uniweb 0.59.1 → 0.60.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.59.1",
3
+ "version": "0.60.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,13 +43,13 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
45
  "@uniweb/core": "^0.29.6",
46
- "@uniweb/runtime": "^0.27.1",
47
46
  "@uniweb/kit": "^0.19.3",
47
+ "@uniweb/runtime": "^0.27.2",
48
48
  "@uniweb/schemas": "^0.3.3",
49
49
  "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.55.0",
52
+ "@uniweb/build": "^0.56.0",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
@@ -2526,7 +2526,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
2526
2526
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2527
2527
  uniweb push --org @acme # First push/publish of a site: who owns it (see below)
2528
2528
  uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
2529
- uniweb login / logout # Start or clear the backend session the verbs above reuse
2529
+ uniweb login / logout # One backend at a time: log in (--backend <url>) or out
2530
2530
  uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
2531
2531
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2532
2532
 
@@ -2560,7 +2560,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2560
2560
  | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2561
2561
  | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2562
2562
 
2563
- `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `lastDeploy:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2563
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2564
2564
 
2565
2565
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2566
2566
 
@@ -2582,7 +2582,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2582
2582
  > uniweb publish --personal # your personal account, deliberately
2583
2583
  > ```
2584
2584
  >
2585
- > The answer is recorded in `site.yml::$org` and committed, so it is a one-time choice per site,
2585
+ > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2586
2586
  > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2587
2587
  > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2588
2588
  > ownership is settled, so nothing is asked.
@@ -2598,11 +2598,13 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2598
2598
 
2599
2599
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2600
2600
 
2601
+ **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2602
+
2601
2603
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2602
2604
 
2603
2605
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2604
2606
 
2605
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login`, `--token`, or `UNIWEB_TOKEN`; preview with `--dry-run`.
2607
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2606
2608
 
2607
2609
  ### Staying current
2608
2610
 
@@ -35,8 +35,9 @@ import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
35
35
  import { dirname, join } from 'node:path'
36
36
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
37
37
  import {
38
- readAssetMap,
39
- updateAssetMap,
38
+ readBackendState,
39
+ updateBackendMap,
40
+ carryServed,
40
41
  servedFingerprint
41
42
  } from '@uniweb/build/uwx'
42
43
 
@@ -116,7 +117,7 @@ export async function downloadMissingAssets({
116
117
  }) {
117
118
  const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
118
119
  // Read first: a bare-string reference is only recognizable through the map.
119
- const map = readAssetMap(siteDir)
120
+ const map = readBackendState(siteDir, origin).assets || {}
120
121
  const refs = collectAssetRefs(document, map)
121
122
  const out = { downloaded: [], present: [], failed: [], skipped: [] }
122
123
  if (!refs.length) return out
@@ -166,6 +167,7 @@ export async function downloadMissingAssets({
166
167
 
167
168
  // Newly-landed assets become known ones, so the next pull restores them to
168
169
  // this path rather than fetching them again to a second location.
169
- if (Object.keys(learned).length) updateAssetMap(siteDir, learned)
170
+ if (Object.keys(learned).length)
171
+ updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
170
172
  return out
171
173
  }
@@ -42,65 +42,31 @@ import { uploadFoundationCode } from '../utils/code-upload.js'
42
42
  import { uploadSiteAssets } from '../utils/asset-upload.js'
43
43
 
44
44
  /**
45
- * Resolve the backend origin via the resolution ladder (highest precedence
46
- * first). A full URL is reduced to its origin, so callers may pass a whole
47
- * endpoint URL; an unparseable value falls through to the next tier.
45
+ * Resolve the backend a command talks to:
48
46
  *
49
- * 1. `flag` — the raw --backend value (this command)
50
- * 2. UNIWEB_REGISTER_URL env — session-wide override (CI / local dev)
51
- * 3. `siteScope` — the project's `site.yml::$backend` (site verbs)
52
- * 4. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
53
- * 5–7. the logged-in session origin > ~/.uniweb/config.json > the default
54
- * (uniweb.app) — all via getRegistryApiBaseUrl()
47
+ * 1. UNIWEB_REGISTER_URL env — the override for automation (CI, scripts), one process
48
+ * 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
49
+ * 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
50
+ * where the command's first request then asks the user to log in
55
51
  *
56
- * ⭐ **`siteScope` outranks the SESSION, and that is the whole reason it is a tier.** The
57
- * session is a machine-wide default; `$backend` is this project's statement about where
58
- * its stored identity is meaningful. Without the tier, a teammate who clones a project
59
- * bound to a non-default backend resolves to whatever they last logged into, and is then
60
- * REFUSED by `assertSiteBackendScope` — nagged instead of routed, on a question the
61
- * project already answered.
52
+ * ⭐ **That is the whole ladder, for every command** *[Diego, 2026-09-21: "push and pull
53
+ * are also meant to go to the backend you are logged into" · "We do not allow any
54
+ * communication with backend if the user is not logged into a backend. The default
55
+ * backend for login, if not specified, is uniweb.app" · switching "via login to another
56
+ * backend is good, and the only way to switch"]*. Logging in is how a backend is chosen.
62
57
  *
63
- * ⭐ **And it outranks `siteBackend`, which looks like the same fact and is not.**
64
- * `deploy.yml`'s target `backend` records *where this ships*; `$backend` records *whose
65
- * namespace the uuid is in*. They agree in the normal case. When they disagree the
66
- * identity wins, because resolving to the other one only reaches the guard and stops.
58
+ * ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
59
+ * `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
60
+ * slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
61
+ * a script aims with UNIWEB_REGISTER_URL without touching the machine's login. A
62
+ * project's sync.json and deploy.yml routed commands too, until the same day.
63
+ * `--backend` survives only where it SELECTS rather than routes: `login` (where to log
64
+ * in) and `forget` (which backend's records to remove). `logout` needs none: there is
65
+ * one session.
67
66
  *
68
- * ⚠️ The explicit overrides stay ON TOP deliberately. `--backend` and the env var are how
69
- * you deliberately aim elsewhere — at a staging mirror, say — and a project file must not
70
- * be able to veto a flag the user just typed. A wrong aim is then caught by the guard
71
- * rather than silently redirected.
72
- *
73
- * @param {string} [flag] - the raw value of --backend, if supplied
74
- * @param {object} [opts]
75
- * @param {string} [opts.siteScope] - the project's `site.yml::$backend`
76
- * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
77
67
  * @returns {string} a bare origin with no trailing slash
78
68
  */
79
- export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
80
- const norm = (v) => {
81
- try {
82
- return new URL(v).origin
83
- } catch {
84
- return null
85
- }
86
- }
87
- if (flag) {
88
- const o = norm(flag)
89
- if (o) return o
90
- }
91
- const env = process.env.UNIWEB_REGISTER_URL
92
- if (env) {
93
- const o = norm(env)
94
- if (o) return o
95
- }
96
- if (siteScope) {
97
- const o = norm(siteScope)
98
- if (o) return o
99
- }
100
- if (siteBackend) {
101
- const o = norm(siteBackend)
102
- if (o) return o
103
- }
69
+ export function resolveBackendOrigin() {
104
70
  return getRegistryApiBaseUrl()
105
71
  }
106
72
 
@@ -142,10 +108,8 @@ export const DISCOVERY_DEFAULTS = {}
142
108
  export class BackendClient {
143
109
  /**
144
110
  * @param {object} [opts]
145
- * @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
146
- * @param {string} [opts.originFlag] - raw --backend value to resolve
147
- * @param {string} [opts.siteScope] - the project's `site.yml::$backend` (site verbs)
148
- * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
111
+ * @param {string} [opts.origin] - explicit origin (wins over the ladder; tests and
112
+ * internal callers — no command takes one from the user)
149
113
  * @param {string} [opts.token] - explicit bearer (wins over env + stored session)
150
114
  * @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
151
115
  * callers with their own auth); used when no explicit token/env is present
@@ -155,9 +119,6 @@ export class BackendClient {
155
119
  */
156
120
  constructor({
157
121
  origin,
158
- originFlag,
159
- siteScope,
160
- siteBackend,
161
122
  token,
162
123
  getToken,
163
124
  args = [],
@@ -165,7 +126,7 @@ export class BackendClient {
165
126
  fetchImpl
166
127
  } = {}) {
167
128
  this.origin = (
168
- origin || resolveBackendOrigin(originFlag, { siteScope, siteBackend })
129
+ origin || resolveBackendOrigin()
169
130
  ).replace(/\/+$/, '')
170
131
  this._token = token || process.env.UNIWEB_TOKEN || null
171
132
  this._getToken = getToken || null
@@ -176,9 +137,9 @@ export class BackendClient {
176
137
  }
177
138
 
178
139
  /**
179
- * The session bearer, resolved lazily and memoized. Order (matching the
180
- * standalone verbs): explicit --token / UNIWEB_TOKEN (constructor) > stored
181
- * session > interactive login (ensureRegistryAuth).
140
+ * The session bearer, resolved lazily and memoized. Order: an explicit `token`
141
+ * (constructor — tests and internal callers; no command takes one from the user) /
142
+ * UNIWEB_TOKEN > the stored session > interactive login (ensureRegistryAuth).
182
143
  * @returns {Promise<string>}
183
144
  */
184
145
  async token() {
@@ -187,27 +148,13 @@ export class BackendClient {
187
148
  this._token = await this._getToken()
188
149
  return this._token
189
150
  }
190
- // Origin-mismatch guard: a usable session authed against a DIFFERENT origin
191
- // than this command targets means the bearer will be rejected — warn once
192
- // and point at the fix (the stored token IS for `stored.origin`, not here).
193
- if (!this._warnedOriginMismatch) {
194
- this._warnedOriginMismatch = true
195
- try {
196
- const stored = await readRegistryAuth()
197
- if (
198
- stored?.token &&
199
- !isExpired(stored) &&
200
- stored.origin &&
201
- stored.origin.replace(/\/+$/, '') !== this.origin
202
- ) {
203
- console.error(
204
- `\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`
205
- )
206
- }
207
- } catch {
208
- /* advisory only */
209
- }
210
- }
151
+ // ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
152
+ // It existed because the session store held ONE record: a session for another
153
+ // backend was the only session, so it was about to be sent here and rejected, and a
154
+ // warning was the best available move. Sessions are keyed by origin now
155
+ // (utils/registry-auth.js), so being logged into another backend is just a fact
156
+ // about another backend — `ensureRegistryAuth` below finds this origin's session or
157
+ // asks for one. Warning about the other would be noise about nothing.
211
158
  this._token = await ensureRegistryAuth({
212
159
  apiBase: this.origin,
213
160
  command: this._command,
@@ -255,8 +202,8 @@ export class BackendClient {
255
202
  // ── Discovery ─────────────────────────────────────────────────────────────────
256
203
 
257
204
  /**
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
205
+ * The session bearer IF one can be had without asking — an explicit `token`, the
206
+ * UNIWEB_TOKEN env var, or a stored unexpired session. Never prompts, never logs in, returns null
260
207
  * instead. `token()` is the one that may block; this is for calls that want to be
261
208
  * authenticated when possible but must not *cause* an authentication.
262
209
  * @returns {Promise<string|null>}
@@ -276,7 +223,7 @@ export class BackendClient {
276
223
  }
277
224
  }
278
225
  try {
279
- const stored = await readRegistryAuth()
226
+ const stored = await readRegistryAuth(this.origin)
280
227
  if (stored?.token && !isExpired(stored)) return stored.token
281
228
  } catch {
282
229
  /* advisory — a missing or unreadable session is simply "no token" */
@@ -455,8 +402,8 @@ export class BackendClient {
455
402
  * the field itself is required and cannot be blank.
456
403
  *
457
404
  * NOT idempotent: two calls mint two sites (a name is not a unique key, by
458
- * design). Callers must guard on `site.yml::$uuid` and write the result back
459
- * immediately — see `ensureSiteExists`.
405
+ * design). Callers must guard on the site's uuid for this backend (sync.json) and
406
+ * write the result back immediately — see `ensureSiteExists`.
460
407
  *
461
408
  * @param {{ name: string, foundation: string, asOrg?: string|null }} opts
462
409
  * @returns {Promise<Response>} `{ site_content_uuid }`
@@ -545,7 +492,7 @@ export class BackendClient {
545
492
  /**
546
493
  * POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
547
494
  * backend state live; it does NOT push local files). `{uuid}` is the site-content
548
- * uuid (`site.yml::$uuid`); a never-synced site 404s (sync first, or use deploy).
495
+ * uuid on this backend (sync.json); a never-synced site 404s (sync first, or use deploy).
549
496
  * Languages, when present, go in the body; absent → no body. Returns the raw
550
497
  * Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
551
498
  *
@@ -38,7 +38,6 @@ import { execFileSync } from 'node:child_process'
38
38
 
39
39
  import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
- import { readFlagValue } from '../utils/args.js'
42
41
  import { isNonInteractive } from '../utils/interactive.js'
43
42
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
44
43
 
@@ -151,14 +150,12 @@ function readPkgField(dir, field) {
151
150
  }
152
151
  }
153
152
 
154
- // Forward the origin + auth flags so the spawned `uniweb register` / `build`
155
- // hits the SAME backend with the SAME session as the publish that called it.
153
+ // What travels to the spawned `uniweb register` / `build` on the command line: only
154
+ // --non-interactive. The BACKEND travels in the child's environment
155
+ // (releaseFoundation), and the SESSION is the shared session file — or UNIWEB_TOKEN,
156
+ // which the child inherits. The backend commands take no `--backend` or `--token`.
156
157
  function forwardedFlags(args) {
157
158
  const out = []
158
- for (const name of ['--backend', '--token']) {
159
- const v = readFlagValue(args, name)
160
- if (v) out.push(name, v)
161
- }
162
159
  if (isNonInteractive(args)) out.push('--non-interactive')
163
160
  return out
164
161
  }
@@ -322,7 +319,7 @@ async function bringLocalCodeAlong({
322
319
  }
323
320
  say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
324
321
  return {
325
- released: releaseFoundation(local, args, cliBin, say),
322
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
326
323
  proceed: true,
327
324
  ref: pinnedRef()
328
325
  }
@@ -365,7 +362,7 @@ async function bringLocalCodeAlong({
365
362
  : `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
366
363
  )
367
364
  return {
368
- released: releaseFoundation(local, args, cliBin, say),
365
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
369
366
  proceed: true,
370
367
  ref: pinnedRef()
371
368
  }
@@ -396,7 +393,7 @@ async function bringLocalCodeAlong({
396
393
  )
397
394
  if (reRelease)
398
395
  return {
399
- released: releaseFoundation(local, args, cliBin, say),
396
+ released: releaseFoundation(local, args, cliBin, say, client?.origin),
400
397
  proceed: true,
401
398
  ref: pinnedRef()
402
399
  }
@@ -490,12 +487,15 @@ function buildFoundation(local, cliBin) {
490
487
  // Release = `uniweb register` in the foundation directory (the one foundation
491
488
  // release path). Returns true on success; throws to the caller on failure so
492
489
  // publish stops before going live with missing code.
493
- function releaseFoundation(local, args, cliBin, say) {
490
+ function releaseFoundation(local, args, cliBin, say, origin) {
494
491
  console.log('')
492
+ // ⭐ Pinned to the parent's backend through the environment, so the release lands
493
+ // where the publish goes by construction — not because two processes happen to read
494
+ // the same session file the same way.
495
495
  execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
496
496
  cwd: local.dir,
497
497
  stdio: 'inherit',
498
- env: process.env
498
+ env: origin ? { ...process.env, UNIWEB_REGISTER_URL: origin } : process.env
499
499
  })
500
500
  console.log('')
501
501
  return true
@@ -38,7 +38,8 @@
38
38
  *
39
39
  * ## ⛔ A HASH, never the block
40
40
  *
41
- * `$secrets` carries secret material, and `$services[].config` is opaque and
41
+ * `$secrets` names every secret the site has (values are the `#ref` marker, never
42
+ * secret material — the old wording here said otherwise), and `$services[].config` is opaque and
42
43
  * per-service — anything may be in it. `deploy.yml` is committed, so recording
43
44
  * either verbatim would write them into git. Equality is all this gate needs;
44
45
  * *what* differs is a question for the backend's own copy of the request, not for
@@ -91,11 +92,14 @@ function stableString(value) {
91
92
  * @param {object} siteYml
92
93
  * @returns {{servicesRequest?: string, secretsRequest?: string}}
93
94
  */
94
- export function fingerprintRequest(siteYml) {
95
+ export function fingerprintRequest(siteYml, provisioned = {}) {
95
96
  const out = {}
96
- const services = fingerprintDeclaration(siteYml?.$services)
97
+ // ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
98
+ // backend being published to (they were `site.yml::$services` / `$secrets` until
99
+ // 2026-09-20). The language selection below is still authored, so still site.yml.
100
+ const services = fingerprintDeclaration(provisioned?.services)
97
101
  if (services) out.servicesRequest = services
98
- const secrets = fingerprintDeclaration(siteYml?.$secrets)
102
+ const secrets = fingerprintDeclaration(provisioned?.secrets)
99
103
  if (secrets) out.secretsRequest = secrets
100
104
  // ⭐ The language selection is a request too, and it is the one that moves a
101
105
  // PRICE — the line is billed on how many languages go out. Banking it is what
@@ -124,8 +128,8 @@ export function fingerprintRequest(siteYml) {
124
128
  * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
125
129
  * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
126
130
  */
127
- export function decideDeclaration(siteYml, lastDeploy) {
128
- const now = fingerprintRequest(siteYml)
131
+ export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
132
+ const now = fingerprintRequest(siteYml, provisioned)
129
133
  if (!now.servicesRequest && !now.secretsRequest) {
130
134
  // Nothing in the file to send. The gate is moot; say so rather than
131
135
  // reporting "unchanged", which would imply a comparison happened.
@@ -178,8 +182,8 @@ export function decideDeclaration(siteYml, lastDeploy) {
178
182
  * @param {object|null} lastDeploy - the banked base
179
183
  * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
180
184
  */
181
- export function reconcileRequest(siteYml, remoteServices, lastDeploy) {
182
- return reconcile(siteYml?.$services, remoteServices, lastDeploy?.servicesRequest)
185
+ export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
186
+ return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
183
187
  }
184
188
 
185
189
  /**