@uniweb/build 0.76.1 → 0.78.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.
@@ -36,6 +36,7 @@ import { foldOpenGraph } from './open-graph.js'
36
36
  import { join, relative, extname, basename, dirname } from 'node:path'
37
37
  import { restoreAssetRefs } from './asset-map.js'
38
38
  import { readBackendState, updateBackendState } from './sync-store.js'
39
+ import { servicesFromDocument, heldServices } from './services-request.js'
39
40
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
40
41
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
41
42
  import { createHash } from 'node:crypto'
@@ -123,33 +124,15 @@ const INFO_TO_SITE_YML = {
123
124
  // Publish intent — verbatim both ways, dangling codes included (they carry
124
125
  // the preserved publish intent of a temporarily-undeclared language).
125
126
  favicon: 'favicon',
126
- // Safe to project back because nothing STAMPS it: `submit` is authored-only,
127
- // so a pull can never launder a deploy-derived value into authored config the
128
- // way a key carried by both would. A host-supplied destination is resolved at
129
- // render time and never enters `info`.
130
- // Authored-only, like `submit` above — a host's assistant endpoint is offered
131
- // through `config.services` and resolved at render time, so it never enters
132
- // `info` and a pull cannot launder it into authored config.
133
- //
134
- // ⚠️ Credential-shaped keys are STRIPPED ON PUSH (`stripCredentials`), so the
135
- // backend never stores one and it cannot come back. What that does to the
136
- // author's file depends on which direction they are pulling into, and the two
137
- // differ — ⛔ this comment asserted the wrong one until it was run:
138
- //
139
- // - `pull` over an EXISTING site.yml — the local `apiKey` SURVIVES.
140
- // `mergeYamlConfig` spreads one level (`{...existing[key], ...value}`), so
141
- // the projected `{endpoint, …}` merges over the local block and nothing
142
- // removes a key the document does not carry.
143
- // - `clone` into a fresh directory — no local block exists, so the file is
144
- // written from the document alone and has no `apiKey`.
145
- //
146
- // Both are correct: a pull does not silently delete something the author
147
- // typed, and the push already warned them. The security property is upheld at
148
- // the push, not by the pull. Measured end-to-end against a live uniwebd —
149
- // every unit test passed before and after, so only a real push touched it.
150
- // Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
151
- // offered through `config.services.tracking` and resolved at render, so it
152
- // never enters `info` and a pull cannot launder it into authored config.
127
+ // ⛔ `submit` / `assistant` / `tracking` are not `info` keys: they left it for the
128
+ // `settings` Section on 2026-09-09, and on 2026-10-06 left that for each service's
129
+ // row — a pull writes `site.yml::services` from the rows (`servicesFromDocument`).
130
+ // Two behaviours measured while they were here still hold, now in
131
+ // `keepCredentials`: a credential is stripped on push and a pull over an existing
132
+ // file keeps the author's (a `clone` into a fresh directory has none to keep) — a
133
+ // pull does not silently delete what the author typed, and the push already warned
134
+ // them; and a host's endpoint never enters the record, so a pull cannot launder it
135
+ // into authored config.
153
136
  // ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
154
137
  // branch below: it projects to `site.yml::query` or `site.yml::fetch`.
155
138
  template: 'template',
@@ -199,14 +182,12 @@ const SETTINGS_TO_SITE_YML = {
199
182
  paths: 'paths',
200
183
  seo: 'seo',
201
184
  layout: 'layout',
202
- // Authored-only service declarations: nothing STAMPS them, so a pull cannot
203
- // launder a host-supplied endpoint into authored config. A host's own address is
185
+ // ⛔ `search` / `submit` / `assistant` / `tracking` are not here: each service rides
186
+ // its row in the `services` Section, whole, and a pull writes it into
187
+ // `site.yml::services` (`servicesFromDocument`, below). They were top-level keys
188
+ // until 2026-10-06 and `settings` keys until that evening. A host's own address is
204
189
  // offered through `config.services` and resolved at render, never entering the
205
- // stored record.
206
- search: 'search',
207
- submit: 'submit',
208
- assistant: 'assistant',
209
- tracking: 'tracking',
190
+ // stored record, so a pull cannot launder it into authored config.
210
191
  agents: 'agents',
211
192
  }
212
193
 
@@ -309,36 +290,36 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
309
290
  : []
310
291
  if (extensions.length > 0) siteChanges.extensions = extensions
311
292
 
312
- // services[] / secrets[] → sync.json::backends.<origin>.{services,secrets}.
313
- //
314
- // ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
315
- // `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
316
- // on the bundle lane, and one key cannot mean two things.
293
+ // services → site.yml::services, one entry per service; which services this copy
294
+ // now holds → sync.json.
317
295
  //
318
- // ⭐ `$id` is DROPPED — it is derived (a service's `name`; a secret's
319
- // `service:name` pair), so writing it back would put a redundant handle in the
320
- // author's file and invite them to edit the one field that must not drift from
321
- // the fields it is derived from. Same call as `extensions` above.
296
+ // ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"]. Each
297
+ // entry is its row in the `services` Section — the whole entry in its `config`
298
+ // (`servicesFromDocument`) — except an off service with no settings, written only
299
+ // where the file already names it [Diego, 2026-10-07]. The map is written whole,
300
+ // keeping only the credentials an author typed, which a push never sends; a document
301
+ // with no Section writes nothing. The retired top-level keys go, so a pulled file
302
+ // builds.
322
303
  //
323
- // ⛔ Everything else rides VERBATIM, `config` included: it is opaque, per-service
324
- // and will grow, so projecting a known subset would quietly drop whatever the
325
- // service gained since this line was written — and the next push would then send
326
- // the truncated version back as authoritative.
327
- //
328
- // ⚠️ An EMPTY section is written as an empty list, not skipped. `[]` is a real
329
- // state — "this site has no service rows" — and it is the state a `pull` must be
330
- // able to deliver after the last one was removed. Skipping would leave a stale
331
- // `$services` on disk that the next push would resurrect.
332
- // ⭐ To `sync.json`, under the backend that provisioned them — not `site.yml`.
333
- // A service is bought on one backend, so its rows describe that backend alone.
334
- // ⛔ Needs a backend: with none there is nowhere coherent to file them, and
335
- // writing them to a shared place is exactly the single-copy bug this removed.
304
+ // ⭐ `sync.json` takes, under this backend, the services this copy now HOLDS —
305
+ // `{ name: $uuid }`, every row the site has, written to the file or not
306
+ // (`heldServices`). A push states each one the file does not list as off, so the site
307
+ // keeps what it has. `secrets` is an inventory of names whose values are set in the
308
+ // app, `$id` dropped. ⛔ *Until 2026-10-07 `services` held the rows themselves, the
309
+ // record a push compared the file against, with `servicesNamed` beside it.*
310
+ // ⛔ What is held needs a backend: with none there is nowhere coherent to file it.
311
+ if (Array.isArray(document?.services)) {
312
+ siteChanges.services = servicesFromDocument({
313
+ rows: document.services,
314
+ local: readAuthoredYaml(join(siteRoot, 'site.yml'))?.services
315
+ })
316
+ for (const retired of ['search', 'submit', 'assistant', 'tracking', 'api']) siteChanges[retired] = null
317
+ }
336
318
  if (backend) {
337
319
  const provisioned = {}
338
- for (const section of ['services', 'secrets']) {
339
- const records = document?.[section]
340
- if (!Array.isArray(records)) continue
341
- provisioned[section] = records.map(({ $id: _id, ...fields }) => fields)
320
+ if (Array.isArray(document?.services)) provisioned.services = heldServices({ written: document.services })
321
+ if (Array.isArray(document?.secrets)) {
322
+ provisioned.secrets = document.secrets.map(({ $id: _id, ...fields }) => fields)
342
323
  }
343
324
  if (Object.keys(provisioned).length) {
344
325
  updateBackendState(siteRoot, backend, provisioned)
package/src/uwx/site.js CHANGED
@@ -80,6 +80,11 @@ import { resolveLocaleList } from '../i18n/locales.js'
80
80
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
81
81
  import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
82
82
  import { updateBackendState, readBackendState } from './sync-store.js'
83
+ import {
84
+ readServicesRequest,
85
+ statedServices,
86
+ refuseRetiredServiceKeys
87
+ } from './services-request.js'
83
88
  import { upsertYamlScalar } from './yaml-upsert.js'
84
89
  import { resolveQueriesConfig } from './queries-config.js'
85
90
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
@@ -102,46 +107,6 @@ function scalarString(value) {
102
107
  return undefined
103
108
  }
104
109
 
105
- // Credential-shaped keys, mirroring the set the delivery edge strips on the
106
- // reading side. Deliberately the SAME list rather than a stricter one, so the
107
- // two guards are visibly twins and a key added to one is obviously owed to the
108
- // other.
109
- const CREDENTIAL_KEYS = ['apiKey', 'api_key', 'key', 'token', 'secret']
110
-
111
- /**
112
- * Drop credential-shaped keys out of an authored service block.
113
- *
114
- * An authored block crosses into backend storage here and is served from there
115
- * in a published payload — which is world-readable. So a credential in
116
- * `site.yml` is not merely untidy, it is disclosed. The host's secret store is
117
- * the only right home, resolved at request time.
118
- *
119
- * Warns rather than throwing: the fix belongs to the author, a failed push
120
- * helps nobody, and the block is still useful without the key. Returns the
121
- * value untouched when there is nothing to strip, so `setIf`'s absent-vs-empty
122
- * behaviour is unchanged — a block that carried ONLY a credential arrives as
123
- * `{}` rather than vanishing, keeping the mistake visible where the author
124
- * looks for it.
125
- *
126
- * @param {*} block - the authored value, any shape
127
- * @param {string} label - the site.yml key, for the warning
128
- * @returns {*} the block, minus anything credential-shaped
129
- */
130
- function stripCredentials(block, label) {
131
- if (!block || typeof block !== 'object' || Array.isArray(block)) return block
132
- const found = CREDENTIAL_KEYS.filter(key => key in block)
133
- if (found.length === 0) return block
134
-
135
- console.warn(
136
- `uwx/site: dropped ${found.join(', ')} from \`${label}:\` — authored config is published ` +
137
- 'world-readable, so a credential belongs in the host secret store, never in site.yml.'
138
- )
139
-
140
- const cleaned = { ...block }
141
- for (const key of found) delete cleaned[key]
142
- return cleaned
143
- }
144
-
145
110
  // page_sections and layout_sections share this content shape.
146
111
  // processMarkdownFile takes only type/hidden/props/fetch/query/data/id out of the
147
112
  // frontmatter (and drops preset/input), so `background:` and `theme:` stay
@@ -1037,8 +1002,8 @@ const DECL_NOT_ON_WIRE = new Set([
1037
1002
  // passed unknown keys through blindly and would have sent `route` from every site
1038
1003
  // that declared one.
1039
1004
  'route',
1040
- // Legacy predicate, translated to the canonical `where` upstream. No legacy
1041
- // fields on the wire.
1005
+ // ⛔ RETIRED 2026-10-06 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
1006
+ // ⚠️ This said it was "translated to the canonical `where` upstream"; nothing translated it.
1042
1007
  'filter',
1043
1008
  // ⛔ RETIRED 2026-09-27 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
1044
1009
  'deferred',
@@ -1088,8 +1053,6 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1088
1053
  // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
1089
1054
  if (typed && Array.isArray(queryFields) && queryFields.includes('typed_by_data_key')) data.typed_by_data_key = true
1090
1055
  setIf(data, 'sort', d.sort)
1091
- // Legacy `filter:` is not synced — it is translated to `where` upstream
1092
- // (the canonical predicate). No legacy fields on the wire.
1093
1056
  setIf(data, 'where', d.where)
1094
1057
  setIf(data, 'limit', d.limit)
1095
1058
  setIf(data, 'excerpt', d.excerpt)
@@ -1129,48 +1092,33 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1129
1092
  return out
1130
1093
  }
1131
1094
 
1132
- // ── `services` + `secrets` — a site's own service records ─────────────────────
1133
- //
1134
- // ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
1135
- // `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
1136
- // with is a fact about one backend — a service is bought on the backend that sold it.
1137
- // The `$` prefix existed to keep them off `site.yml::services`, which means something
1138
- // else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
1139
- // is gone too.
1095
+ // ── `services` + `secrets` — what a site asks of its host ─────────────────────────
1140
1096
  //
1141
- // The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
1142
- // on the other lane: the bundle lane spreads site.yml whole into the payload, so a
1143
- // `services:` block there lands at `config.services` — the HOST tier — which is the
1144
- // documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
1145
- // Reusing the name would give one key two meanings that differ per lane, which is
1146
- // the shape of bug nobody finds.
1097
+ // ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
1098
+ // map from service name to `true`, `false` or the service's settings (spec:
1099
+ // kb/framework/reference/site-services-request.md). A push STATES: every service the
1100
+ // file lists, as it says it, and OFF for every service this copy holds that the file no
1101
+ // longer lists (`statedServices`). The services it holds are named in that backend's
1102
+ // entry in `sync.json`, `{ name: $uuid }`, written by pull and by a push that succeeded;
1103
+ // one it never saw is not sent, and the site keeps it. The backend decides per service
1104
+ // from the versions the push sends [Diego, 2026-10-07 — kb/framework/plans/services-exchange.md].
1105
+ // ⛔ *Until then the Section went as a FULL LIST — the site's rows, read just before the
1106
+ // push, with the file's changed asks applied — because the backend replaced the Section
1107
+ // with what it was sent; and from 2026-09-20 to 2026-10-06 the rows lived only in
1108
+ // `sync.json`, which left a CLI user nowhere to ask.*
1147
1109
  //
1148
- // `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
1149
- // (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
1150
- // config is bound where the service is provisioned, and arrives here by `pull`.
1110
+ // `secrets` stays in `sync.json`: an inventory of names a pull writes, whose values are
1111
+ // set in the app — `#ref` marks one as set.
1151
1112
  //
1152
1113
  // ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
1153
1114
  // `assistant:`, `tracking:` — ride the `settings` Section and are untouched here.
1154
- // (They were top-level `info.*` keys until 2026-09-09; `settingsNested` below is
1155
- // where they are now written.) Those
1156
- // are authored, they resolve at the SITE tier (`config.<name>`, first choice in
1157
- // `@uniweb/core`'s `resolveService`), and moving them here would flip them to the
1158
- // host tier, where a block's mere presence declines every service it does not name.
1159
- // These Sections carry the services a site is PROVISIONED with — `api` above all,
1160
- // which has no file-authored form because it is bought, not declared.
1161
- //
1162
- // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
1163
- // REPLACED by what we send, so `[]` means "drop every stored config row" while a
1164
- // missing key means "I am not telling you about this". A project that has never
1165
- // pulled has no `services` for that backend, and its ordinary push must not read as
1166
- // a request to wipe a service the operator configured in the app. So: emit the
1167
- // Section only when that backend's entry in `sync.json` carries the key. Clearing is
1168
- // available and explicit — `services: []` there. *(Both said `site.yml::$services`
1169
- // until the move on 2026-09-20.)*
1115
+ // Those are authored addresses and land at the SITE tier, `config.<name>`, which
1116
+ // `@uniweb/core`'s `resolveService` reads for any service the host does not offer.
1117
+ // A row here is read by the service's owner and reaches neither tier, so an address
1118
+ // filed here would reach no page.
1170
1119
  //
1171
- // ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
1172
- // tokens live in a gitignored per-clone cache, so a fresh clone pushes
1173
- // unconditionally. Correctness has to sit here.
1120
+ // The Section is emitted only when there is something to state: a file that lists no
1121
+ // service, on a copy that holds none, sends no `services`.
1174
1122
 
1175
1123
  /**
1176
1124
  * A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
@@ -1223,14 +1171,33 @@ function serviceRecords(declared, identify, label) {
1223
1171
  }
1224
1172
 
1225
1173
  /** One service per `name` — the same keyspace `config.services` uses at runtime. */
1226
- function servicesNested(provisioned) {
1174
+ function servicesNested(rows) {
1227
1175
  return serviceRecords(
1228
- provisioned?.services,
1176
+ rows,
1229
1177
  (e) => (typeof e.name === 'string' && e.name ? e.name : null),
1230
- '$services'
1178
+ 'services'
1231
1179
  )
1232
1180
  }
1233
1181
 
1182
+ /**
1183
+ * The `services` Section for this push — the file's services, and the held ones it no
1184
+ * longer lists stated off (`statedServices`) — or undefined when there is nothing to state.
1185
+ *
1186
+ * @param {object} siteYml
1187
+ * @param {object|null} provisioned - the backend's entry in `sync.json`; its `services`
1188
+ * is `{ <name>: <$uuid> }`, the services this copy holds
1189
+ * @returns {object[]|undefined}
1190
+ */
1191
+ function requestedServices(siteYml, provisioned) {
1192
+ const held = provisioned?.services
1193
+ const rows = statedServices(
1194
+ readServicesRequest(siteYml.services),
1195
+ held && typeof held === 'object' && !Array.isArray(held) ? held : {}
1196
+ )
1197
+ // `$uuid` rides beside the payload-local `$id`, as on a query's row.
1198
+ return rows.length ? servicesNested(rows) : undefined
1199
+ }
1200
+
1234
1201
  /**
1235
1202
  * One secret per `(service, name)` — the pair the backend merges on. A site-level
1236
1203
  * secret belongs to no service, so `service` is optional and the handle degrades to
@@ -1342,17 +1309,11 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1342
1309
  // carries the translation collector with it.
1343
1310
  setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1344
1311
 
1345
- // Authored service declarations. ⛔ These must NOT be filed with the `$services`
1346
- // Section: authored ones resolve at the SITE tier (`config.<name>`, first choice
1347
- // in `@uniweb/core`'s `resolveService`) while `$services` is the HOST tier, where
1348
- // a block's mere PRESENCE declines every service it does not name
1349
- // (`core/src/services.js`). Moving them there would invert their precedence and
1350
- // turn a site's own search off with no error and no message.
1351
- setIf(settings, 'search', siteYml.search)
1352
- setIf(settings, 'submit', siteYml.submit)
1353
- // ⛔ Credentials are stripped, not trusted — this block is published world-readable.
1354
- setIf(settings, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
1355
- setIf(settings, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
1312
+ // ⛔ NO SERVICE IS HERE. A service's address and options ride its row in the
1313
+ // `services` Section, whole (`requestedServices`) [Diego, 2026-10-06: "If it's on and
1314
+ // has configuration, it can all be in the same place"]. *Until that evening
1315
+ // `settings.search` / `.submit` / `.assistant` / `.tracking` carried them beside the
1316
+ // row, and until that morning they were top-level `site.yml` keys.*
1356
1317
 
1357
1318
  // Projections opt-out + route exclusions. Carried because the app is a second
1358
1319
  // PUBLISHER of projections and derives them from stored content: without this it
@@ -1410,6 +1371,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1410
1371
  export async function siteProjectToDocument(siteRoot, opts = {}) {
1411
1372
  refuseOrgOption(opts, 'uwx/site')
1412
1373
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
1374
+ refuseRetiredServiceKeys(siteYml)
1413
1375
  const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
1414
1376
  if (!siteYml.name) {
1415
1377
  throw new Error('uwx/site: site.yml::name is required')
@@ -1498,67 +1460,29 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1498
1460
  // and default keywords exist for any share/SSR/crawler. `seo` rides verbatim
1499
1461
  // as authored config (round-trips like favicon); `keywords` is a localized
1500
1462
  // list (like page keywords).
1501
- // `submit` — where this site's forms send submissions. Same family as
1502
- // `fetcher`/`search`: the site declares it, the runtime reads it, and it
1503
- // round-trips verbatim. It has to be listed HERE because this lane is an
1504
- // explicit allowlist while the bundle lane spreads all of site.yml — without
1505
- // the line a `submit:` block works on a static host and vanishes silently on
1506
- // the synced lane, which is the worst shape a config bug can take.
1507
1463
  // `agents` — the projections opt-out + route exclusions. Carried because the
1508
1464
  // app is a second PUBLISHER of projections and derives them from stored
1509
1465
  // content: without this block it cannot see `agents: false` or
1510
1466
  // `agents.exclude`, so an author's opt-out is silently reversed and an
1511
1467
  // excluded branch becomes both discoverable AND summarized by the index.
1512
1468
  // (The CLI lane reads site.yml directly and honors it either way.)
1513
- // `assistant` — the site's own declaration for an AI assistant: where it
1514
- // lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
1515
- // host reads (`system` persona, model hints). Same family as
1516
- // `search`/`submit`, and here for the reason spelled out above them — the
1517
- // bundle lane spreads all of site.yml while this one is an allowlist, so
1518
- // without this line the block works on a static host and vanishes silently
1519
- // on the synced lane.
1520
1469
  //
1521
- // ⚠️ That is not hypothetical: this replaces `intelligence.yml`, a SEPARATE
1522
- // file, which needed a bespoke line in each lane and got one in only the
1523
- // bundle lane — so an authored persona never reached a hosted site at all.
1524
- // A key inside site.yml cannot repeat that, because only this lane needs a
1525
- // line.
1470
+ // The services — `site.yml::services` — ride the `services` Section: one row per
1471
+ // entry, the whole entry in its `config` (`requestedServices`). ⚠️ An entry must
1472
+ // reach this lane whole because the bundle lane spreads it into the payload, and a
1473
+ // block that works on a static host and vanishes on the synced lane is the worst
1474
+ // shape a config bug can take — `intelligence.yml`, a separate file the assistant's
1475
+ // persona once lived in, reached only the bundle lane, so a persona never reached a
1476
+ // hosted site. ⛔ A credential is dropped (`readServicesRequest`) — but only as a
1477
+ // KEYED FIELD: one embedded in an endpoint URL (`https://collector/e?key=…`) is
1478
+ // invisible there and is disclosed. The host's secret store is the only right home.
1526
1479
  //
1527
- // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
1528
- // `tracking` — where this site's usage events go (`endpoint`, read by the
1529
- // runtime through `resolveService`, plus `consent:`). Same family as
1530
- // `search`/`submit`/`assistant` and here for the same reason: the bundle lane
1531
- // spreads all of site.yml while this one is an allowlist, so without this line
1532
- // an authored `tracking:` works on a static host and vanishes silently on the
1533
- // synced lane.
1534
- //
1535
- // ⛔ Credentials stripped like `assistant`. A collector that wants a write key
1536
- // is a real shape, and this block is published world-readable — but note the
1537
- // strip only reaches a KEYED FIELD: a key embedded in the endpoint URL itself
1538
- // (`https://collector/e?key=…`) is invisible here and is disclosed. The host's
1539
- // secret store is the only right home either way.
1540
-
1541
- // ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
1542
- // above it argues the opposite — three services are on this allowlist precisely so
1543
- // an authored block cannot work on a static host and vanish on the synced one.
1544
- // Without this paragraph the next reader adds the missing fourth line and calls it
1545
- // a bug fix.
1546
- //
1547
- // ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
1548
- // provisioned, so its address is the host's to supply — it arrives as
1549
- // `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
1550
- // authored `api:` is the SITE tier: it yields to a host that offers the service,
1551
- // and is the answer wherever one does not.
1552
- //
1553
- // ⇒ Carrying it would turn a local-dev address into a production one the moment
1554
- // someone pushed: the host would store `settings.api` and serve it back as
1555
- // `config.api`, and a site with no `api` service of its own would draw sign-in
1556
- // against an address nobody answers — where the absence would have drawn
1557
- // nothing. The vanish on this lane is the correct behaviour, not the bug the
1558
- // comments above describe — there, a dropped block leaves a site with NO
1559
- // endpoint; here it leaves the site with the RIGHT answer.
1560
- //
1561
- // The provisioned record rides the `$services` section instead (see servicesNested).
1480
+ // ⛔ `api`'S ADDRESS IS THE HOST'S TO SUPPLY — `config.services.api`, read by
1481
+ // `@uniweb/api` (`resolveBase`). Asking for it is `services: { api: true }`, or a map
1482
+ // of the host's settings; an address of the site's own asks the host to leave its own
1483
+ // off, and `readServicesRequest` says so, because `api: /_api` — where a local mock
1484
+ // used to answer — reads that way. In `uniweb dev`, `$devApi` supplies the mock's
1485
+ // address (`dev/api-mount.js`).
1562
1486
  // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1563
1487
  // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1564
1488
  // renders, plus what a listing can filter on. Eighteen keys moved off it on
@@ -1722,33 +1646,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1722
1646
  opts.queryFields,
1723
1647
  Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
1724
1648
  )
1725
- // Emitted ONLY when the file declares the key — see the header above
1726
- // `serviceRecords`: on a replaced Section, absent and empty are different
1727
- // requests and one of them is destructive.
1728
- //
1729
- // ⭐ AND ONLY WHEN THE CALLER SAYS THE DECLARATION IS A REQUEST.
1730
- // `opts.declareServices === false` withholds both Sections for THIS push, which
1731
- // is not the same as the file having no key — the file still declares one; the
1732
- // caller has determined the owner is not asking for anything new by it.
1733
- //
1734
- // ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
1735
- // what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
1736
- // OVERWRITES whatever the stored request has become since — including a decision
1737
- // the owner made in the app, where the consent workflow's publish happens. But
1738
- // "has it changed since we last agreed?" needs the last agreed state, which is
1739
- // project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
1740
- // ⇒ The CLI decides; this honours the decision.
1741
- //
1742
- // ⚖️ Default is to declare, so every existing caller is unchanged and the
1743
- // withholding is opt-in.
1744
- const declare = opts.declareServices !== false
1745
- // ⭐ Provisioned per backend — a service is bought on the backend that sold it,
1746
- // so which rows exist is a question about ONE backend. They were `site.yml::$services`
1747
- // / `$secrets`, a single copy that could only ever describe one.
1649
+ // Emitted only when there is something to state — see the header above
1650
+ // `serviceRecords`. ⭐ What this copy holds is per backend — a service is bought on
1651
+ // the backend that sold it — and the request is one, in `site.yml`. Push, publish,
1652
+ // `status` and a pull's re-bank all build it alike, from the file and `sync.json`.
1748
1653
  const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1749
- const services = declare ? servicesNested(provisioned) : undefined
1654
+ const services = requestedServices(siteYml, provisioned)
1750
1655
  if (services) doc.services = services
1751
- const secrets = declare ? secretsNested(provisioned) : undefined
1656
+ const secrets = secretsNested(provisioned)
1752
1657
  if (secrets) doc.secrets = secrets
1753
1658
  return doc
1754
1659
  }
@@ -28,6 +28,7 @@ import { siteProjectToDocument } from './site.js'
28
28
  import { siteSelfScope, refuseOrgOption } from './self-scope.js'
29
29
  import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
30
30
  import { emitEntitySyncPackage } from './entity-document.js'
31
+ import { recordQueriesShown } from './shown-records.js'
31
32
  import { isLocalAssetPath } from '../site/assets.js'
32
33
 
33
34
  const SITE_MODEL_NAME = '@uniweb/site-content'
@@ -42,17 +43,28 @@ const cacheKey = (entity) => `${entity.model} ${entity.id}`
42
43
  // uuid→version map (the sync-cache); an entity the map doesn't know is left
43
44
  // unconditional rather than guessed at. See entity-document.js for what the
44
45
  // token means on the wire.
45
- const withBaseVersion = (baseVersions, itemBaseVersions) => (entity) => {
46
+ const withBaseVersion = (baseVersions, itemBaseVersions, { every = false } = {}) => (entity) => {
46
47
  const uuid = entity?.document?.$uuid
47
48
  const v = uuid ? baseVersions[uuid] : null
48
- // Per-item preconditions, narrowed to the records THIS package actually carries.
49
- // The cache spans the whole site, but a package holds only what changed; sending
50
- // tokens for absent records would bloat the manifest and assert preconditions on
51
- // items we are not touching.
49
+ // Per-item preconditions.
50
+ //
51
+ // ⭐ THE SITE-CONTENT ENTITY SENDS EVERY VERSION THIS COPY HOLDS (`every`), for items
52
+ // present AND for items the files dropped. A push deletes only the items it holds a
53
+ // version for, and only while the site left them unchanged — so a dropped item's
54
+ // version is what makes leaving it out a deletion, and without it the item is kept
55
+ // [Diego, 2026-10-07; backend's gate, kb/framework/plans/services-exchange.md]. The
56
+ // caller holds a version only for an item it has seen (`heldTokens`, cli), so none is
57
+ // ever sent for an item the backend holds and this copy does not.
58
+ // ⛔ Until then this narrowed to the units the document carried: a dropped page was
59
+ // deleted by today's rule anyway, and nothing else was sent a version.
60
+ //
61
+ // Any other entity: narrowed to the units it carries, as before — the records lane
62
+ // is gated by its entity version, and sends none.
52
63
  let items = null
53
64
  if (itemBaseVersions && Object.keys(itemBaseVersions).length) {
54
65
  items = {}
55
- for (const itemUuid of Object.values(collectUnitUuids(entity.document))) {
66
+ const uuids = every ? Object.keys(itemBaseVersions) : Object.values(collectUnitUuids(entity.document))
67
+ for (const itemUuid of uuids) {
56
68
  const t = itemBaseVersions[itemUuid]
57
69
  if (t) items[itemUuid] = t
58
70
  }
@@ -223,11 +235,12 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
223
235
  * identity of its list items, as a previous push or pull banked it
224
236
  * (`record-items.js`); stamped onto each record the backend holds, so a re-sent
225
237
  * record updates its items instead of replacing them.
226
- * @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
227
- * per-ITEM `version`. Narrowed at emit to the records the package carries and
228
- * sent as `entries[].item_base_versions`, so the backend can refuse only the
229
- * records that genuinely moved instead of the whole document. Omit to push
230
- * those records unconditionally.
238
+ * @param {Object<string,string>} [opts.itemBaseVersions] - item `$uuid` → opaque
239
+ * per-ITEM `version`, for every site-content item this copy has seen. Sent
240
+ * whole on the site-content entity as `entries[].item_base_versions` — items
241
+ * present and items the files dropped — so the backend refuses only the items
242
+ * that genuinely moved, and deletes only what this copy saw (`withBaseVersion`).
243
+ * Omit to push unconditionally.
231
244
  * @param {Object<string,string>} [opts.baseVersions] - backend-uuid → opaque
232
245
  * `version` token, the push gate's optimistic-concurrency precondition.
233
246
  * Each sent entity whose `$uuid` the map knows carries it as a top-level
@@ -290,6 +303,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
290
303
  const priorHashes = opts.priorHashes || {}
291
304
  const sendAll = !!opts.sendAll
292
305
  const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
306
+ const stampSite = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {}, { every: true })
293
307
  const exporter = opts.exporter
294
308
  const exportedAt = opts.exportedAt
295
309
  refuseOrgOption(opts, 'emitSyncPackages')
@@ -374,12 +388,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
374
388
  // and the page and `settings` keys that carry Open Graph texts per language.
375
389
  ...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
376
390
  ...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
377
- ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
378
- // Withhold the `$services`/`$secrets` Sections when the caller has
379
- // determined the file is not asking for anything new by them. Passed
380
- // through rather than decided here: the last-agreed state is project
381
- // memory the CLI owns. See site.js at `declareServices`.
382
- ...(opts.declareServices === false ? { declareServices: false } : {})
391
+ ...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {})
383
392
  })
384
393
  : null
385
394
  // Deploy-derived `info` fields are stamped here — NOT authored in site.yml, so they
@@ -581,7 +590,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
581
590
  // --- site-content lane -------------------------------------------------------
582
591
  let siteContent = null
583
592
  if (siteEntity && changed(siteEntity)) {
584
- siteContent = { ...emitLane([stamp(siteEntity)], exporter, exportedAt), index: [{ kind: 'site' }] }
593
+ siteContent = { ...emitLane([stampSite(siteEntity)], exporter, exportedAt), index: [{ kind: 'site' }] }
585
594
  }
586
595
 
587
596
  // The site's current uuid (from site.yml): the verb keys the folder push route on
@@ -633,5 +642,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
633
642
  // records this package SENDS that the backend holds with list items and no bank entry
634
643
  // (their uuids) — sent as they are, the backend refuses each one (`identity_required`).
635
644
  recordItemIdentity,
645
+ // The queries whose records the site's pages show live — what the `records` service
646
+ // delivers on a published site (`shown-records.js`). Whether it changed or not.
647
+ recordsShown: siteDoc ? recordQueriesShown(siteDoc) : [],
636
648
  }
637
649
  }