@uniweb/build 0.77.0 → 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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The queries whose records a site's pages SHOW live — what the `records` service is for.
3
+ *
4
+ * ⭐ `records` is the live delivery of records to a published site, nothing else
5
+ * [Diego, 2026-10-07: "syncing records is unrelated to `records` being on/off. The
6
+ * service is the live delivery of records to published site"]. So what decides whether a
7
+ * site needs it is what its published pages fetch — never whether it has records to sync.
8
+ * A query counts when a page, a section or the site fetches it, and its records have a
9
+ * data schema (the declaration names one) and come from the site's own folder (no `url:`).
10
+ * A schema-less query's records are delivered as static files, which `records` does not
11
+ * touch; an external query is fetched from its own address.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
17
+
18
+ /**
19
+ * @param {object} doc - a site-content document, as a push sends it
20
+ * @returns {string[]} the query names, sorted
21
+ */
22
+ export function recordQueriesShown(doc) {
23
+ const fetched = new Set()
24
+ const take = (fetch) => {
25
+ for (const one of [fetch].flat()) if (isMap(one) && typeof one.query === 'string' && one.query) fetched.add(one.query)
26
+ }
27
+ const walkSections = (sections) => {
28
+ for (const section of Array.isArray(sections) ? sections : []) {
29
+ take(section?.params?.fetch)
30
+ take(section?.fetch)
31
+ walkSections(section?.$children)
32
+ }
33
+ }
34
+ const walkPages = (pages) => {
35
+ for (const page of Array.isArray(pages) ? pages : []) {
36
+ take(page?.fetch)
37
+ walkSections(page?.page_sections)
38
+ walkPages(page?.$children)
39
+ }
40
+ }
41
+ take(doc?.settings?.fetch)
42
+ walkPages(doc?.pages)
43
+ walkSections(doc?.layout_sections)
44
+ const live = new Set()
45
+ for (const query of Array.isArray(doc?.queries) ? doc.queries : []) {
46
+ if (!isMap(query) || typeof query.name !== 'string') continue
47
+ if (typeof query.schema !== 'string' || !query.schema) continue
48
+ if (isMap(query.source) && query.source.url !== undefined) continue
49
+ live.add(query.name)
50
+ }
51
+ return [...fetched].filter((name) => live.has(name)).sort()
52
+ }
@@ -321,3 +321,50 @@ export function collectQueryUuids(doc) {
321
321
  }
322
322
  return out
323
323
  }
324
+
325
+ // The Sections whose items are matched by a field of their own, and that field — how
326
+ // the backend matches them too. `extensions` has no declared key yet; its `ref` or
327
+ // `url` is what the file names it by.
328
+ const KEYED_SECTIONS = {
329
+ queries: (item) => item?.name,
330
+ services: (item) => item?.name,
331
+ extensions: (item) => item?.ref || item?.url
332
+ }
333
+
334
+ /**
335
+ * Every item of a site-content document, under a key that names it the same way in OUR
336
+ * document and in the BACKEND's, so the two can be paired item by item.
337
+ *
338
+ * - units — `site.yml` (the `info` item), pages, page sections and layout sections —
339
+ * by the path the projector writes them to (`unit:<path>`), each its OWN content, as
340
+ * `collectSiteUnits` keys them;
341
+ * - `settings`, one item, by its Section;
342
+ * - `queries`, `services` and `extensions` by the field each is matched by
343
+ * (`queries:<name>`, …).
344
+ *
345
+ * ⛔ `secrets` is left out on purpose: a push never deletes a secret by leaving it out,
346
+ * and its values are set in the app, so no version of one is ever kept or sent.
347
+ * A Section this does not know is left out too — an item that cannot be paired is
348
+ * never taken as seen.
349
+ *
350
+ * @returns {Map<string, { uuid: string|null, record: object }>}
351
+ */
352
+ export function siteItemsByKey(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
353
+ const out = new Map()
354
+ const uuidOf = (record) => (typeof record?.$uuid === 'string' && record.$uuid ? record.$uuid : null)
355
+ const units = collectSiteUnits(doc, sourceLocale)
356
+ walkSiteUnits(doc, (path, record) => {
357
+ if (!out.has(`unit:${path}`)) out.set(`unit:${path}`, { uuid: uuidOf(record), record: units.get(path) })
358
+ }, sourceLocale)
359
+ if (doc?.settings && typeof doc.settings === 'object' && !Array.isArray(doc.settings)) {
360
+ out.set('settings', { uuid: uuidOf(doc.settings), record: doc.settings })
361
+ }
362
+ for (const [section, keyOf] of Object.entries(KEYED_SECTIONS)) {
363
+ for (const item of Array.isArray(doc?.[section]) ? doc[section] : []) {
364
+ const key = keyOf(item)
365
+ if (typeof key !== 'string' || !key || out.has(`${section}:${key}`)) continue
366
+ out.set(`${section}:${key}`, { uuid: uuidOf(item), record: item })
367
+ }
368
+ }
369
+ return out
370
+ }
@@ -36,7 +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 { servicesRequestFromRows } from './services-request.js'
39
+ import { servicesFromDocument, heldServices } from './services-request.js'
40
40
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
41
41
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
42
42
  import { createHash } from 'node:crypto'
@@ -124,33 +124,15 @@ const INFO_TO_SITE_YML = {
124
124
  // Publish intent — verbatim both ways, dangling codes included (they carry
125
125
  // the preserved publish intent of a temporarily-undeclared language).
126
126
  favicon: 'favicon',
127
- // Safe to project back because nothing STAMPS it: `submit` is authored-only,
128
- // so a pull can never launder a deploy-derived value into authored config the
129
- // way a key carried by both would. A host-supplied destination is resolved at
130
- // render time and never enters `info`.
131
- // Authored-only, like `submit` above — a host's assistant endpoint is offered
132
- // through `config.services` and resolved at render time, so it never enters
133
- // `info` and a pull cannot launder it into authored config.
134
- //
135
- // ⚠️ Credential-shaped keys are STRIPPED ON PUSH (`stripCredentials`), so the
136
- // backend never stores one and it cannot come back. What that does to the
137
- // author's file depends on which direction they are pulling into, and the two
138
- // differ — ⛔ this comment asserted the wrong one until it was run:
139
- //
140
- // - `pull` over an EXISTING site.yml — the local `apiKey` SURVIVES.
141
- // `mergeYamlConfig` spreads one level (`{...existing[key], ...value}`), so
142
- // the projected `{endpoint, …}` merges over the local block and nothing
143
- // removes a key the document does not carry.
144
- // - `clone` into a fresh directory — no local block exists, so the file is
145
- // written from the document alone and has no `apiKey`.
146
- //
147
- // Both are correct: a pull does not silently delete something the author
148
- // typed, and the push already warned them. The security property is upheld at
149
- // the push, not by the pull. Measured end-to-end against a live uniwebd —
150
- // every unit test passed before and after, so only a real push touched it.
151
- // Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
152
- // offered through `config.services.tracking` and resolved at render, so it
153
- // 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.
154
136
  // ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
155
137
  // branch below: it projects to `site.yml::query` or `site.yml::fetch`.
156
138
  template: 'template',
@@ -200,14 +182,12 @@ const SETTINGS_TO_SITE_YML = {
200
182
  paths: 'paths',
201
183
  seo: 'seo',
202
184
  layout: 'layout',
203
- // Authored-only service declarations: nothing STAMPS them, so a pull cannot
204
- // 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
205
189
  // offered through `config.services` and resolved at render, never entering the
206
- // stored record.
207
- search: 'search',
208
- submit: 'submit',
209
- assistant: 'assistant',
210
- tracking: 'tracking',
190
+ // stored record, so a pull cannot launder it into authored config.
211
191
  agents: 'agents',
212
192
  }
213
193
 
@@ -310,30 +290,36 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
310
290
  : []
311
291
  if (extensions.length > 0) siteChanges.extensions = extensions
312
292
 
313
- // services[] → site.yml::services, and the same rows → sync.json as the record.
293
+ // services → site.yml::services, one entry per service; which services this copy
294
+ // now holds → sync.json.
314
295
  //
315
- // ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"]. The
316
- // stored list is the site's settled request, so the author's map becomes it, whole: a
317
- // row is `true`, `false`, or its settings (`servicesRequestFromRows`). An empty list
318
- // removes the key; a document with no `services` Section writes nothing.
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.
319
303
  //
320
- // ⭐ The record goes to `sync.json`, under this backend: the rows as the site holds
321
- // them — the last state both sides agreed on, which push and publish compare the file
322
- // against (`reconcileServices`). `$id` is dropped, being derived; everything else rides
323
- // VERBATIM, `config` included — it is opaque, per-service and will grow, and the next
324
- // push sends the stored rows as its base. `secrets` likewise: an inventory of names
325
- // whose values are set in the app. `[]` is written as `[]`: "the site holds no rows" is
326
- // a state a pull must be able to deliver.
327
- // ⛔ The record needs a backend: with none there is nowhere coherent to file it.
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.
328
311
  if (Array.isArray(document?.services)) {
329
- siteChanges.services = servicesRequestFromRows(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
330
317
  }
331
318
  if (backend) {
332
319
  const provisioned = {}
333
- for (const section of ['services', 'secrets']) {
334
- const records = document?.[section]
335
- if (!Array.isArray(records)) continue
336
- 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)
337
323
  }
338
324
  if (Object.keys(provisioned).length) {
339
325
  updateBackendState(siteRoot, backend, provisioned)
package/src/uwx/site.js CHANGED
@@ -80,7 +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 { readServicesRequest, mergeServiceRows } from './services-request.js'
83
+ import {
84
+ readServicesRequest,
85
+ statedServices,
86
+ refuseRetiredServiceKeys
87
+ } from './services-request.js'
84
88
  import { upsertYamlScalar } from './yaml-upsert.js'
85
89
  import { resolveQueriesConfig } from './queries-config.js'
86
90
  import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
@@ -103,46 +107,6 @@ function scalarString(value) {
103
107
  return undefined
104
108
  }
105
109
 
106
- // Credential-shaped keys, mirroring the set the delivery edge strips on the
107
- // reading side. Deliberately the SAME list rather than a stricter one, so the
108
- // two guards are visibly twins and a key added to one is obviously owed to the
109
- // other.
110
- const CREDENTIAL_KEYS = ['apiKey', 'api_key', 'key', 'token', 'secret']
111
-
112
- /**
113
- * Drop credential-shaped keys out of an authored service block.
114
- *
115
- * An authored block crosses into backend storage here and is served from there
116
- * in a published payload — which is world-readable. So a credential in
117
- * `site.yml` is not merely untidy, it is disclosed. The host's secret store is
118
- * the only right home, resolved at request time.
119
- *
120
- * Warns rather than throwing: the fix belongs to the author, a failed push
121
- * helps nobody, and the block is still useful without the key. Returns the
122
- * value untouched when there is nothing to strip, so `setIf`'s absent-vs-empty
123
- * behaviour is unchanged — a block that carried ONLY a credential arrives as
124
- * `{}` rather than vanishing, keeping the mistake visible where the author
125
- * looks for it.
126
- *
127
- * @param {*} block - the authored value, any shape
128
- * @param {string} label - the site.yml key, for the warning
129
- * @returns {*} the block, minus anything credential-shaped
130
- */
131
- function stripCredentials(block, label) {
132
- if (!block || typeof block !== 'object' || Array.isArray(block)) return block
133
- const found = CREDENTIAL_KEYS.filter(key => key in block)
134
- if (found.length === 0) return block
135
-
136
- console.warn(
137
- `uwx/site: dropped ${found.join(', ')} from \`${label}:\` — authored config is published ` +
138
- 'world-readable, so a credential belongs in the host secret store, never in site.yml.'
139
- )
140
-
141
- const cleaned = { ...block }
142
- for (const key of found) delete cleaned[key]
143
- return cleaned
144
- }
145
-
146
110
  // page_sections and layout_sections share this content shape.
147
111
  // processMarkdownFile takes only type/hidden/props/fetch/query/data/id out of the
148
112
  // frontmatter (and drops preset/input), so `background:` and `theme:` stay
@@ -1038,8 +1002,8 @@ const DECL_NOT_ON_WIRE = new Set([
1038
1002
  // passed unknown keys through blindly and would have sent `route` from every site
1039
1003
  // that declared one.
1040
1004
  'route',
1041
- // Legacy predicate, translated to the canonical `where` upstream. No legacy
1042
- // 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.
1043
1007
  'filter',
1044
1008
  // ⛔ RETIRED 2026-09-27 — the build refuses it (`refuseQueryDeclaration`), so it is never sent.
1045
1009
  'deferred',
@@ -1089,8 +1053,6 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1089
1053
  // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
1090
1054
  if (typed && Array.isArray(queryFields) && queryFields.includes('typed_by_data_key')) data.typed_by_data_key = true
1091
1055
  setIf(data, 'sort', d.sort)
1092
- // Legacy `filter:` is not synced — it is translated to `where` upstream
1093
- // (the canonical predicate). No legacy fields on the wire.
1094
1056
  setIf(data, 'where', d.where)
1095
1057
  setIf(data, 'limit', d.limit)
1096
1058
  setIf(data, 'excerpt', d.excerpt)
@@ -1134,14 +1096,16 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1134
1096
  //
1135
1097
  // ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
1136
1098
  // map from service name to `true`, `false` or the service's settings (spec:
1137
- // kb/framework/reference/site-services-request.md). It is sent as a FULL LIST: the
1138
- // site's rows, with the owner's asks applied by name (`mergeServiceRows`), because the
1139
- // backend REPLACES the Section with what it is sent and a row left out would lose its
1140
- // stored settings. The site's rows come from the caller when it has just read them
1141
- // (`opts.serviceRows`, already merged), else from the record of the last agreement —
1142
- // that backend's entry in `sync.json`, written by pull and by a push that succeeded.
1143
- // ⛔ *The rows lived only in `sync.json` from 2026-09-20 to 2026-10-06, which left a
1144
- // CLI user nowhere to ask; `site.yml::$services` / `$secrets` before that.*
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.*
1145
1109
  //
1146
1110
  // `secrets` stays in `sync.json`: an inventory of names a pull writes, whose values are
1147
1111
  // set in the app — `#ref` marks one as set.
@@ -1153,12 +1117,8 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1153
1117
  // A row here is read by the service's owner and reaches neither tier, so an address
1154
1118
  // filed here would reach no page.
1155
1119
  //
1156
- // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is REPLACED
1157
- // by what we send, so `[]` means "drop every stored row" while a missing key means "I
1158
- // am not telling you about this". So `services` is emitted only when `site.yml` asks
1159
- // for something and there is a list to apply it to — never for a file that says
1160
- // nothing, and never as a partial list for a site whose rows this project does not
1161
- // know. A site with no entry in `sync.json` has nothing stored yet.
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`.
1162
1122
 
1163
1123
  /**
1164
1124
  * A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
@@ -1220,25 +1180,22 @@ function servicesNested(rows) {
1220
1180
  }
1221
1181
 
1222
1182
  /**
1223
- * The `services` Section for this push, or undefined when there is nothing to send:
1224
- * the caller's rows when it passes them (the site's, read just now, with the asks it
1225
- * decided to send applied), else the file's asks over the record of the site's rows.
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.
1226
1185
  *
1227
1186
  * @param {object} siteYml
1228
- * @param {object|null} provisioned - the backend's entry in `sync.json`
1229
- * @param {object[]} [serviceRows]
1187
+ * @param {object|null} provisioned - the backend's entry in `sync.json`; its `services`
1188
+ * is `{ <name>: <$uuid> }`, the services this copy holds
1230
1189
  * @returns {object[]|undefined}
1231
1190
  */
1232
- function requestedServices(siteYml, provisioned, serviceRows) {
1233
- if (Array.isArray(serviceRows)) return servicesNested(serviceRows)
1234
- const asks = readServicesRequest(siteYml.services)
1235
- if (!asks) return undefined
1236
- const base = Array.isArray(provisioned?.services)
1237
- ? provisioned.services
1238
- : provisioned?.site?.uuid
1239
- ? null
1240
- : []
1241
- return base ? servicesNested(mergeServiceRows(base, asks)) : undefined
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
1242
1199
  }
1243
1200
 
1244
1201
  /**
@@ -1352,19 +1309,11 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1352
1309
  // carries the translation collector with it.
1353
1310
  setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1354
1311
 
1355
- // Authored service declarations. ⛔ These must NOT be filed with the `services`
1356
- // Section. Here they are the SITE tier, `config.<name>` — the address
1357
- // `@uniweb/core`'s `resolveService` uses wherever the host offers none. That
1358
- // Section is a provisioning request whose rows reach neither tier, so filed there
1359
- // a site's own search endpoint would reach no page, and on a host whose services
1360
- // block does not name search the site would lose search with no error and no
1361
- // message (`core/src/services.js`). *(Until 2026-10-06 this called that Section
1362
- // "the HOST tier"; it is a request about what the host provides.)*
1363
- setIf(settings, 'search', siteYml.search)
1364
- setIf(settings, 'submit', siteYml.submit)
1365
- // ⛔ Credentials are stripped, not trusted — this block is published world-readable.
1366
- setIf(settings, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
1367
- 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.*
1368
1317
 
1369
1318
  // Projections opt-out + route exclusions. Carried because the app is a second
1370
1319
  // PUBLISHER of projections and derives them from stored content: without this it
@@ -1415,10 +1364,6 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1415
1364
  * known, `@/x` ships as written.
1416
1365
  * @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
1417
1366
  * (the records lane's `schemaless`): their declarations name no `schema`.
1418
- * @param {object[]} [opts.serviceRows] - the `services` Section to send, as the caller
1419
- * decided it (the site's rows with the owner's asks applied). Default: the
1420
- * file's asks over the record in `sync.json`.
1421
- * @param {boolean} [opts.declareServices] - `false` withholds `services` and `secrets`.
1422
1367
  * @returns {Promise<object>} the section-keyed `$`-document:
1423
1368
  * `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
1424
1369
  * queries, services?, secrets? }`
@@ -1426,6 +1371,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1426
1371
  export async function siteProjectToDocument(siteRoot, opts = {}) {
1427
1372
  refuseOrgOption(opts, 'uwx/site')
1428
1373
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
1374
+ refuseRetiredServiceKeys(siteYml)
1429
1375
  const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
1430
1376
  if (!siteYml.name) {
1431
1377
  throw new Error('uwx/site: site.yml::name is required')
@@ -1514,68 +1460,29 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1514
1460
  // and default keywords exist for any share/SSR/crawler. `seo` rides verbatim
1515
1461
  // as authored config (round-trips like favicon); `keywords` is a localized
1516
1462
  // list (like page keywords).
1517
- // `submit` — where this site's forms send submissions. Same family as
1518
- // `fetcher`/`search`: the site declares it, the runtime reads it, and it
1519
- // round-trips verbatim. It has to be listed HERE because this lane is an
1520
- // explicit allowlist while the bundle lane spreads all of site.yml — without
1521
- // the line a `submit:` block works on a static host and vanishes silently on
1522
- // the synced lane, which is the worst shape a config bug can take.
1523
1463
  // `agents` — the projections opt-out + route exclusions. Carried because the
1524
1464
  // app is a second PUBLISHER of projections and derives them from stored
1525
1465
  // content: without this block it cannot see `agents: false` or
1526
1466
  // `agents.exclude`, so an author's opt-out is silently reversed and an
1527
1467
  // excluded branch becomes both discoverable AND summarized by the index.
1528
1468
  // (The CLI lane reads site.yml directly and honors it either way.)
1529
- // `assistant` — the site's own declaration for an AI assistant: where it
1530
- // lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
1531
- // host reads (`system` persona, model hints). Same family as
1532
- // `search`/`submit`, and here for the reason spelled out above them — the
1533
- // bundle lane spreads all of site.yml while this one is an allowlist, so
1534
- // without this line the block works on a static host and vanishes silently
1535
- // on the synced lane.
1536
- //
1537
- // ⚠️ That is not hypothetical: this replaces `intelligence.yml`, a SEPARATE
1538
- // file, which needed a bespoke line in each lane and got one in only the
1539
- // bundle lane — so an authored persona never reached a hosted site at all.
1540
- // A key inside site.yml cannot repeat that, because only this lane needs a
1541
- // line.
1542
1469
  //
1543
- // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
1544
- // `tracking` — where this site's usage events go (`endpoint`, read by the
1545
- // runtime through `resolveService`, plus `consent:`). Same family as
1546
- // `search`/`submit`/`assistant` and here for the same reason: the bundle lane
1547
- // spreads all of site.yml while this one is an allowlist, so without this line
1548
- // an authored `tracking:` works on a static host and vanishes silently on the
1549
- // synced lane.
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.
1550
1479
  //
1551
- // ⛔ Credentials stripped like `assistant`. A collector that wants a write key
1552
- // is a real shape, and this block is published world-readable — but note the
1553
- // strip only reaches a KEYED FIELD: a key embedded in the endpoint URL itself
1554
- // (`https://collector/e?key=…`) is invisible here and is disclosed. The host's
1555
- // secret store is the only right home either way.
1556
-
1557
- // ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
1558
- // above it argues the opposite — three services are on this allowlist precisely so
1559
- // an authored block cannot work on a static host and vanish on the synced one.
1560
- // Without this paragraph the next reader adds the missing fourth line and calls it
1561
- // a bug fix.
1562
- //
1563
- // ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
1564
- // provisioned, so its address is the host's to supply — it arrives as
1565
- // `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
1566
- // authored `api:` is the SITE tier: it yields to a host that offers the service,
1567
- // and is the answer wherever one does not.
1568
- //
1569
- // ⇒ Carrying it would turn a local-dev address into a production one the moment
1570
- // someone pushed: the host would store `settings.api` and serve it back as
1571
- // `config.api`, and a site with no `api` service of its own would draw sign-in
1572
- // against an address nobody answers — where the absence would have drawn
1573
- // nothing. The vanish on this lane is the correct behaviour, not the bug the
1574
- // comments above describe — there, a dropped block leaves a site with NO
1575
- // endpoint; here it leaves the site with the RIGHT answer.
1576
- //
1577
- // Asking for the `api` service is `site.yml::services` → the `services` Section (see
1578
- // requestedServices); its address still comes from the host.
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`).
1579
1486
  // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1580
1487
  // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1581
1488
  // renders, plus what a listing can filter on. Eighteen keys moved off it on
@@ -1739,24 +1646,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1739
1646
  opts.queryFields,
1740
1647
  Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
1741
1648
  )
1742
- // Emitted ONLY when there is something to say — see the header above
1743
- // `serviceRecords`: on a replaced Section, absent and empty are different
1744
- // requests and one of them is destructive.
1745
- //
1746
- // ⭐ WHICH ASKS GO IS THE CALLER'S. Push and publish read the site's rows and the
1747
- // record of the last agreement, apply only what the owner changed, and pass the
1748
- // result as `opts.serviceRows` — so an ask the site has since decided otherwise is
1749
- // never re-sent over that decision (`reconcileServices`). Without them, the file's
1750
- // asks apply over the record, which is what `status` and a pull's re-bank compare.
1751
- //
1752
- // `opts.declareServices === false` withholds both Sections for THIS push.
1753
- const declare = opts.declareServices !== false
1754
- // ⭐ The rows are per backend — a service is bought on the backend that sold it —
1755
- // and the request is one, in `site.yml`.
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`.
1756
1653
  const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
1757
- const services = declare ? requestedServices(siteYml, provisioned, opts.serviceRows) : undefined
1654
+ const services = requestedServices(siteYml, provisioned)
1758
1655
  if (services) doc.services = services
1759
- const secrets = declare ? secretsNested(provisioned) : undefined
1656
+ const secrets = secretsNested(provisioned)
1760
1657
  if (secrets) doc.secrets = secrets
1761
1658
  return doc
1762
1659
  }