@uniweb/build 0.77.0 → 0.79.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.
@@ -1,35 +1,108 @@
1
1
  /**
2
- * A site's services request — `site.yml::services`, a map from service name to
3
- * what the owner asks their host for.
2
+ * A site's services — `site.yml::services`, ONE entry per service, saying
3
+ * everything the owner says about it.
4
4
  *
5
5
  * ```yaml
6
6
  * services:
7
- * search: true # turn it on
8
- * submit: false # turn it off
9
- * api: # turn it on, with the service's own settings
10
- * grade: pro
7
+ * search: # on: the host's, or the built-in index
8
+ * exclude: { routes: [/legal] } # an option the site's runtime reads
9
+ * submit: https://forms.example.com/f/abc # an address: a provider the site brings
10
+ * tracking: { consent: required }
11
+ * backend: { grade: pro } # a setting for the host's service
11
12
  * ```
12
13
  *
13
- * ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. The rows a backend holds
14
- * for the site are kept apart from it, in that backend's entry in `sync.json` — the
15
- * CLI's record of what it last agreed with that site, which nobody edits.
14
+ * ⭐ ONE PLACE [Diego, 2026-10-06]. Until then the site's own provider and options
15
+ * lived in top-level `search:` / `submit:` / `assistant:` / `tracking:` / `api:`, and
16
+ * the request to the host in `services:` — two switches for one thing. Those keys are
17
+ * retired and refused (`refuseRetiredServiceKeys`).
16
18
  *
17
- * ⛔ NOT THE TOP-LEVEL `search:` / `submit:` / `assistant:` / `tracking:` KEYS.
18
- * Those say how the site uses a provider it brings itself; this asks the host. A
19
- * site's own `submit: https://forms.example.com/…` read as a request would turn on
20
- * the host's form handling, whose address then outranks the site's.
19
+ * Each entry goes ONE place per lane, decided here and nowhere else:
21
20
  *
22
- * Pure functions, shared by the producer (`site.js`), pull (`site-project.js`) and
23
- * the CLI's push and publish.
21
+ * - **a built site** reads it as its own config (`config.<name>` in the payload) —
22
+ * the switch, an address, the options. ⛔ For `backend` only its switch and
23
+ * address: its settings are for the host that provisions it. Never a credential.
24
+ * - **a push** sends it as the request: ONE row per entry in the `services` Section,
25
+ * `{ name, enabled?: false, config? }`, whose `config` is the whole entry — an
26
+ * address, the options, the host's settings — never a credential. What reaches a
27
+ * hosted page from a row is the host's to project.
28
+ *
29
+ * ⭐ THE ROW CARRIES THE WHOLE ENTRY [Diego, 2026-10-06: "If it's on and has
30
+ * configuration, it can all be in the same place"]. ⛔ Until that evening a push sent
31
+ * the address and the options as `settings.<name>` beside the row — the two places
32
+ * this module exists to replace, moved from `site.yml` onto the wire.
33
+ *
34
+ * ⭐ AN ADDRESS MEANS THE SITE BRINGS ITS OWN PROVIDER [Diego, 2026-10-06], so the
35
+ * host is asked to leave its own off — a host's offer outranks the site's address
36
+ * (`core/src/services.js`), and would otherwise win.
37
+ *
38
+ * ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. What a push sends is what the
39
+ * file says, plus OFF for each service this copy holds that the file no longer lists
40
+ * (`statedServices`); the services it holds are named, `{ name: $uuid }`, in that
41
+ * backend's entry in `sync.json`, which nobody edits. The backend decides per service.
42
+ *
43
+ * Pure functions, shared by the static build (`content-collector.js`), the producer
44
+ * (`site.js`), pull (`site-project.js`) and the CLI's push, publish and doctor.
45
+ *
46
+ * Spec: kb/framework/reference/site-services-request.md
24
47
  *
25
48
  * @module
26
49
  */
27
50
 
28
- /** Top-level `site.yml` keys that hold an address — named when one is misplaced here. */
29
- const ADDRESS_KEYS = new Set(['search', 'submit', 'assistant', 'tracking', 'api'])
30
-
31
51
  const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
32
52
 
53
+ /**
54
+ * The options each service's RUNTIME reads from the site's config — the framework's
55
+ * own vocabulary, which `uniweb doctor` checks an entry against. On the wire they ride
56
+ * in the row's `config` with everything else; this list decides nothing there.
57
+ *
58
+ * ⚠️ Read off the readers, and owed to them: `Website.getSearchConfig` and kit's search
59
+ * client, and the search index `@uniweb/projections` builds (search); `readEndpoint`
60
+ * (every `endpoint`); `wireTracker` (tracking). A key one of them starts reading
61
+ * belongs here, or `doctor` calls it unknown.
62
+ */
63
+ export const RUNTIME_KEYS = Object.freeze({
64
+ search: Object.freeze(['enabled', 'provider', 'endpoint', 'include', 'exclude', 'fields', 'weight']),
65
+ submit: Object.freeze(['enabled', 'endpoint']),
66
+ assistant: Object.freeze(['enabled', 'endpoint']),
67
+ tracking: Object.freeze(['enabled', 'endpoint', 'emit', 'consent', 'scripts', 'flushIntervalMs', 'debug']),
68
+ backend: Object.freeze(['enabled', 'endpoint'])
69
+ })
70
+
71
+ /**
72
+ * Services renamed, by their old name. The old name is refused wherever a service is
73
+ * named — an entry here, and a foundation's `uniweb.supports` — naming the new one.
74
+ *
75
+ * ⛔ No alias, and the reason is the runtime: a name no host offers resolves to
76
+ * nothing, so a site or a foundation still saying `api` would simply lose the service
77
+ * on every page, with nothing anywhere saying why. Here it stops where it is written.
78
+ *
79
+ * ⭐ `api` → `backend`, 2026-10-07 [Diego: "name the actual service, and not the
80
+ * interface of the service, which is a route named /api"]. The site's own backend is
81
+ * named for what it is; `/_api` stays the route it answers on, and `@uniweb/api` is
82
+ * named for that interface.
83
+ */
84
+ export const RENAMED_SERVICES = Object.freeze({ api: 'backend' })
85
+
86
+ /** `site.yml` keys renamed with a service — refused by the old name, like the services. */
87
+ const RENAMED_SITE_KEYS = Object.freeze({ $devApi: '$devBackend' })
88
+
89
+ /**
90
+ * Services only a host provides, which a site never answers itself: no address, no
91
+ * runtime part. A site does not declare its own record provider — a third-party
92
+ * source is a foundation transport.
93
+ */
94
+ const HOST_ONLY = new Set(['records'])
95
+
96
+ /**
97
+ * Credential-shaped keys, mirroring the set the delivery edge strips on the reading
98
+ * side — deliberately the SAME list, so the two guards are visibly twins. Never sent
99
+ * and never published: a key belongs in the host's secret store, set in the app.
100
+ */
101
+ const CREDENTIAL_KEYS = Object.freeze(['apiKey', 'api_key', 'key', 'token', 'secret'])
102
+
103
+ /** The top-level keys `services:` replaced, refused by name. */
104
+ const RETIRED_SERVICE_KEYS = Object.freeze(['search', 'submit', 'assistant', 'tracking', 'api'])
105
+
33
106
  /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
34
107
  function stable(value) {
35
108
  return JSON.stringify(value ?? null, (_k, v) =>
@@ -38,243 +111,443 @@ function stable(value) {
38
111
  }
39
112
 
40
113
  /**
41
- * A row as it is stored and compared: no payload-local `$id`, `enabled` only when
42
- * it is `false` (absent means on), no empty `config`.
114
+ * YAML 1.1's words for a switch, which js-yaml — YAML 1.2 — reads as TEXT, and `true` /
115
+ * `false` written in quotes. ⛔ As text, `search: yes` was an address: the site's own
116
+ * search provider at `yes`, with the host's search asked OFF, while the CLI said
117
+ * "site.yml turns on search" (measured on 0.85.0, 2026-10-07 — F14).
43
118
  */
44
- function canonicalRow(row) {
45
- const { $id: _id, enabled, ...rest } = row
46
- const out = { ...rest }
47
- if (enabled === false) out.enabled = false
48
- if (isMap(out.config) && Object.keys(out.config).length === 0) delete out.config
49
- return out
119
+ const SWITCH_WORDS = /^(y|yes|on|true|n|no|off|false)$/i
120
+ const ON_WORDS = /^(y|yes|on|true)$/i
121
+
122
+ /** What a value is, in words — for a message that says what was found. */
123
+ function described(value) {
124
+ if (value === null || value === undefined) return 'empty'
125
+ if (Array.isArray(value)) return 'a list'
126
+ if (typeof value === 'string') return value.trim() ? `the text \`${value}\`` : 'empty'
127
+ return `\`${JSON.stringify(value)}\``
50
128
  }
51
129
 
52
- const rowNamed = (rows, name) =>
53
- Array.isArray(rows) ? rows.find((r) => isMap(r) && r.name === name) : undefined
130
+ /** A switch written as a word: what it says, and what to write instead. */
131
+ function switchWord(path, key, value) {
132
+ const on = ON_WORDS.test(value.trim())
133
+ return (
134
+ `\`${path}\` is the text \`${value}\`, not a switch — YAML reads yes, no, on and off as words. ` +
135
+ `Write \`${key}: ${on}\` to turn it ${on ? 'on' : 'off'}.`
136
+ )
137
+ }
54
138
 
55
139
  /**
56
- * `site.yml::services` → the asks, as rows in the `services` Section's shape:
57
- * `{ name, enabled?: false, config? }`.
58
- *
59
- * @param {*} declared - the raw `services:` value
60
- * @param {{ warn?: (message: string) => void }} [opts] - told about every value it
61
- * ignores, with the fix
62
- * @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
140
+ * What is wrong with one entry, or null when it is `true`, `false`, an address or a map —
141
+ * the one judgement both `readServicesRequest` and `refuseUnreadableServices` make.
63
142
  */
64
- export function readServicesRequest(declared, { warn = () => {} } = {}) {
65
- if (declared === undefined || declared === null) return null
66
- if (!isMap(declared)) {
67
- warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
143
+ function entryProblem(name, value) {
144
+ if (RENAMED_SERVICES[name]) {
145
+ const now = RENAMED_SERVICES[name]
146
+ return (
147
+ `\`services.${name}\` is now \`services.${now}\` — the site's own backend is the \`${now}\` service. ` +
148
+ `Rename the entry; what it says stays. On a site you push, \`uniweb pull\` brings it renamed.`
149
+ )
150
+ }
151
+ if (typeof value === 'boolean') return null
152
+ if (typeof value === 'string') {
153
+ if (SWITCH_WORDS.test(value.trim())) return switchWord(`services.${name}`, name, value)
154
+ if (!value.trim()) return `\`services.${name}\` is empty — write true, false, an address, or a map.`
155
+ if (HOST_ONLY.has(name)) return `\`services.${name}\` has no address of its own — your host provides it. Write \`${name}: true\`.`
68
156
  return null
69
157
  }
70
- const asks = []
71
- for (const [name, value] of Object.entries(declared)) {
72
- if (value === true) asks.push({ name })
73
- else if (value === false) asks.push({ name, enabled: false })
74
- else if (isMap(value)) {
75
- const { enabled, ...settings } = value
76
- if (enabled !== undefined && typeof enabled !== 'boolean') {
77
- warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
78
- continue
79
- }
80
- asks.push({
81
- name,
82
- ...(enabled === false ? { enabled: false } : {}),
83
- ...(Object.keys(settings).length ? { config: settings } : {})
84
- })
85
- } else {
86
- const address =
87
- typeof value === 'string' && ADDRESS_KEYS.has(name)
88
- ? ` An address for the site's own ${name} goes in the top-level \`${name}:\` key.`
89
- : ''
90
- warn(`\`services.${name}\` is true, false, or a map of its settings. Ignoring it.${address}`)
91
- }
158
+ if (!isMap(value)) return `\`services.${name}\` is true, false, an address, or a map — not ${described(value)}.`
159
+ if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
160
+ return typeof value.enabled === 'string' && SWITCH_WORDS.test(value.enabled.trim())
161
+ ? switchWord(`services.${name}.enabled`, 'enabled', value.enabled)
162
+ : `\`services.${name}.enabled\` is true or false — not ${described(value.enabled)}.`
92
163
  }
93
- return asks.length ? asks : null
164
+ if (typeof value.endpoint === 'string' && SWITCH_WORDS.test(value.endpoint.trim())) {
165
+ return `\`services.${name}.endpoint\` is the text \`${value.endpoint}\`, not an address — an address is a URL or a path. To turn the service on, leave \`endpoint\` out.`
166
+ }
167
+ if (HOST_ONLY.has(name) && addressOf(value)) {
168
+ return `\`services.${name}\` has no address of its own — your host provides it. Remove \`endpoint\`.`
169
+ }
170
+ return null
94
171
  }
95
172
 
96
173
  /**
97
- * The site's stored rows → the `site.yml::services` map — what pull writes.
174
+ * Every entry of `site.yml::services` that neither a build nor a push can read, each said
175
+ * with its fix — [] when there is none.
98
176
  *
99
- * A row becomes `true` or `false`, or a map of its settings with `enabled: false`
100
- * when it is off.
177
+ * @param {*} declared - the raw `services:` value
178
+ * @returns {string[]}
179
+ */
180
+ export function unreadableServices(declared) {
181
+ if (declared === undefined || declared === null) return []
182
+ if (!isMap(declared)) return ['`services:` is a map of service names — `services: { search: true }`.']
183
+ return Object.entries(declared)
184
+ .map(([name, value]) => entryProblem(name, value))
185
+ .filter(Boolean)
186
+ }
187
+
188
+ /**
189
+ * Stop on a `services:` entry neither lane can read — beside `refuseRetiredServiceKeys`,
190
+ * in the static build and in the push.
101
191
  *
102
- * @param {object[]} rows
103
- * @returns {object|null} null when there is nothing to write
192
+ * ⛔ NOT "warn and ignore it". A push states every service the file lists and OFF for each
193
+ * one this copy holds that it does not (`statedServices`), so an entry it skipped turned
194
+ * that service off — `search: 7`, or `records: /_query`, switched off on the site under a
195
+ * warning that said "Ignoring it" (until 2026-10-07). And a word for a switch was not
196
+ * skipped but read as an address, which asks the host to leave its own off (F14).
197
+ *
198
+ * @param {object} siteYml
199
+ * @param {string} [where]
200
+ * @throws {Error} naming each entry and what to write
104
201
  */
105
- export function servicesRequestFromRows(rows) {
106
- if (!Array.isArray(rows)) return null
107
- const out = {}
108
- for (const row of rows) {
109
- if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
110
- out[row.name] = requestValue(row)
111
- }
112
- return Object.keys(out).length ? out : null
202
+ export function refuseUnreadableServices(siteYml, where = 'site.yml') {
203
+ if (!isMap(siteYml)) return
204
+ const found = unreadableServices(siteYml.services)
205
+ if (!found.length) return
206
+ throw new Error(
207
+ found.length === 1
208
+ ? `[uniweb] ${where}: ${found[0]}`
209
+ : `[uniweb] ${where}: \`services:\` has entries that cannot be read:\n${found.map((f) => ` - ${f}`).join('\n')}`
210
+ )
113
211
  }
114
212
 
115
- /** One stored row → its value in the `site.yml::services` map. */
116
- function requestValue(row) {
117
- const off = row.enabled === false
118
- const settings = isMap(row.config) && Object.keys(row.config).length ? row.config : null
119
- if (!settings) return !off
120
- return off ? { enabled: false, ...settings } : { ...settings }
213
+ /** The address an entry names, or null — a string, or a map's `endpoint`. */
214
+ function addressOf(value) {
215
+ if (typeof value === 'string') return value.trim() || null
216
+ if (isMap(value) && typeof value.endpoint === 'string' && value.endpoint.trim()) return value.endpoint.trim()
217
+ return null
218
+ }
219
+
220
+ /** The keys of a map the runtime reads for this service. */
221
+ function isRuntimeKey(name, key) {
222
+ const keys = RUNTIME_KEYS[name]
223
+ return keys ? keys.includes(key) : true
224
+ }
225
+
226
+ /** A map without its credential-shaped keys, and which ones it had. */
227
+ function withoutCredentials(value) {
228
+ const found = CREDENTIAL_KEYS.filter((k) => k in value)
229
+ if (!found.length) return { value, found }
230
+ const out = { ...value }
231
+ for (const k of found) delete out[k]
232
+ return { value: out, found }
233
+ }
234
+
235
+ function sayCredentials(name, found, warn) {
236
+ if (found.length) {
237
+ warn(
238
+ `\`services.${name}\` carries ${found.map((k) => `\`${k}\``).join(', ')} — a credential is never sent ` +
239
+ 'or published. Set it in the app, where secrets are kept.'
240
+ )
241
+ }
121
242
  }
122
243
 
123
244
  /**
124
- * The `site.yml::services` map with these services taken from the site's rows — a
125
- * service the site holds no row for leaves the map.
245
+ * Refuse the top-level keys `services:` replaced.
126
246
  *
127
- * @param {*} declared - the current `services:` value
128
- * @param {object[]} stored - the site's rows now
129
- * @param {string[]} names
130
- * @returns {object|null} the new map, or null when nothing is left in it
247
+ * @param {object} siteYml
248
+ * @param {string} [where]
249
+ * @throws {Error} naming each key and how to move it
131
250
  */
132
- export function takeServices(declared, stored, names) {
133
- const out = isMap(declared) ? { ...declared } : {}
134
- for (const name of names) {
135
- const row = rowNamed(stored, name)
136
- if (row) out[name] = requestValue(row)
137
- else delete out[name]
251
+ export function refuseRetiredServiceKeys(siteYml, where = 'site.yml') {
252
+ if (!isMap(siteYml)) return
253
+ const renamed = Object.keys(RENAMED_SITE_KEYS).filter((k) => siteYml[k] !== undefined)
254
+ if (renamed.length) {
255
+ throw new Error(
256
+ `[uniweb] ${where}: ${renamed.map((k) => `\`${k}:\` is now \`${RENAMED_SITE_KEYS[k]}:\``).join(', ')} — ` +
257
+ "it names what answers the site's `backend` service in `uniweb dev`. Rename the key; its value stays."
258
+ )
138
259
  }
139
- return Object.keys(out).length ? out : null
260
+ const found = RETIRED_SERVICE_KEYS.filter((k) => siteYml[k] !== undefined)
261
+ if (!found.length) return
262
+ // The block to move them into names each service as `services:` does now.
263
+ const lines = found.map((k) => ` ${RENAMED_SERVICES[k] || k}: …`).join('\n')
264
+ const api = found.includes('api')
265
+ ? '\n `api:` named the address a local mock answered on — in `uniweb dev`, `$devBackend` now ' +
266
+ "supplies it. Asking your host for the site's own backend is `backend: true` under `services:`."
267
+ : ''
268
+ throw new Error(
269
+ `[uniweb] ${where}: ${found.map((k) => `\`${k}:\``).join(', ')} ` +
270
+ `${found.length === 1 ? 'is' : 'are'} retired — a service lives under \`services:\`, ` +
271
+ `one entry per service:\n\nservices:\n${lines}${api}`
272
+ )
140
273
  }
141
274
 
142
275
  /**
143
- * Does this stored row already give what the ask asks — the same switch, and every
144
- * setting the ask names equal?
276
+ * Services whose settings are never published: only the switch and the address reach
277
+ * the site's config. `backend`'s settings are for the host that provisions it (a grade,
278
+ * sign-in providers, billing) and no page reads them.
279
+ */
280
+ const UNPUBLISHED_SETTINGS = new Set(['backend'])
281
+
282
+ /**
283
+ * One entry → what the site's config carries: `false`, an address, or the entry's
284
+ * options — or undefined when there is nothing for it (`true` is the default).
145
285
  *
146
- * @param {object|undefined} row
147
- * @param {object} ask
148
- * @returns {boolean}
286
+ * @param {string} name
287
+ * @param {*} value - the entry
288
+ * @returns {false|string|object|undefined}
149
289
  */
150
- export function satisfies(row, ask) {
151
- if (!isMap(row)) return false
152
- if ((row.enabled === false) !== (ask.enabled === false)) return false
153
- if (!ask.config) return true
154
- const stored = isMap(row.config) ? row.config : {}
155
- return Object.entries(ask.config).every(([key, value]) => stable(stored[key]) === stable(value))
290
+ export function runtimeServiceConfig(name, value) {
291
+ if (HOST_ONLY.has(name)) return undefined
292
+ if (value === false) return false
293
+ if (typeof value === 'string') return value.trim() || undefined
294
+ if (!isMap(value)) return undefined
295
+ const out = {}
296
+ for (const [key, v] of Object.entries(withoutCredentials(value).value)) {
297
+ if (!UNPUBLISHED_SETTINGS.has(name) || isRuntimeKey(name, key)) out[key] = v
298
+ }
299
+ if (out.enabled === true) delete out.enabled
300
+ return Object.keys(out).length ? out : undefined
156
301
  }
157
302
 
158
- /** Two rows that are the same as stored — both absent included. */
159
- function sameRow(a, b) {
160
- return stable(isMap(a) ? canonicalRow(a) : null) === stable(isMap(b) ? canonicalRow(b) : null)
303
+ /**
304
+ * `site.yml::services` → `{ <name>: <runtime value> }` for every entry the runtime
305
+ * has something for — what a static payload's `config.<name>` carries.
306
+ *
307
+ * @param {*} declared
308
+ * @param {{ warn?: (message: string) => void }} [opts]
309
+ * @returns {object}
310
+ */
311
+ export function runtimeServicesConfig(declared, { warn = () => {} } = {}) {
312
+ const out = {}
313
+ if (!isMap(declared)) return out
314
+ for (const [name, value] of Object.entries(declared)) {
315
+ if (isMap(value)) sayCredentials(name, withoutCredentials(value).found, warn)
316
+ const runtime = runtimeServiceConfig(name, value)
317
+ if (runtime !== undefined) out[name] = runtime
318
+ }
319
+ return out
161
320
  }
162
321
 
163
322
  /**
164
- * The full list to send: the stored rows, with these asks applied by name.
323
+ * `site.yml::services` → the asks to the host, ONE row per entry, in the `services`
324
+ * Section's shape: `{ name, enabled?: false, config? }`.
165
325
  *
166
- * ⭐ A FULL LIST, because the backend replaces the `services` Section with what it is
167
- * sent: a row left out would lose its stored settings. A row no ask names is sent as
168
- * stored. A row an ask names takes the ask's switch, and its settings over the
169
- * stored ones, key by key. Every field is kept — a row is opaque past `name`,
170
- * `enabled` and `config`.
326
+ * `true` asks on; `false` asks off; a map asks on — or off, with `enabled: false` — and
327
+ * its keys are the row's `config`. An address — a string, or `endpoint:` in a map — is
328
+ * the site's own provider: it rides as `config.endpoint`, and the host is asked to
329
+ * leave its own off, since a host's offer outranks the site's address
330
+ * (`core/src/services.js`) and would otherwise win. `enabled` is the row's switch and
331
+ * never in `config`; a credential never leaves at all.
171
332
  *
172
- * @param {object[]|null|undefined} base - the site's rows (or the record of them)
173
- * @param {object[]} asks - from `readServicesRequest`
174
- * @returns {object[]}
333
+ * @param {*} declared - the raw `services:` value
334
+ * @param {{ warn?: (message: string) => void }} [opts] - told about every value it
335
+ * ignores or trims, with the fix
336
+ * @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
175
337
  */
176
- export function mergeServiceRows(base, asks) {
177
- const out = []
178
- const at = new Map()
179
- for (const row of Array.isArray(base) ? base : []) {
180
- if (!isMap(row) || typeof row.name !== 'string' || !row.name || at.has(row.name)) continue
181
- at.set(row.name, out.length)
182
- out.push(canonicalRow(row))
338
+ export function readServicesRequest(declared, { warn = () => {} } = {}) {
339
+ if (declared === undefined || declared === null) return null
340
+ if (!isMap(declared)) {
341
+ warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
342
+ return null
183
343
  }
184
- for (const ask of asks || []) {
185
- const index = at.get(ask.name)
186
- const next = index === undefined ? { name: ask.name } : { ...out[index] }
187
- if (ask.enabled === false) next.enabled = false
188
- else delete next.enabled
189
- if (ask.config) next.config = { ...(isMap(next.config) ? next.config : {}), ...ask.config }
190
- if (index === undefined) {
191
- at.set(ask.name, out.length)
192
- out.push(next)
193
- } else {
194
- out[index] = next
344
+ const asks = []
345
+ for (const [name, value] of Object.entries(declared)) {
346
+ // ⚠️ Skipped here for a caller that only reads; the build and the push stop on it
347
+ // first (`refuseUnreadableServices`), since a push would state a skipped one off.
348
+ // First, so a renamed service is skipped whatever its value — `true` included.
349
+ const problem = entryProblem(name, value)
350
+ if (problem) {
351
+ warn(`${problem} Ignoring \`${name}\`.`)
352
+ continue
353
+ }
354
+ if (value === true) {
355
+ asks.push({ name })
356
+ continue
357
+ }
358
+ if (value === false) {
359
+ asks.push({ name, enabled: false })
360
+ continue
195
361
  }
362
+ const address = addressOf(value)
363
+ const { enabled, ...entry } = isMap(value) ? value : { endpoint: address }
364
+ const { value: config, found } = withoutCredentials(entry)
365
+ sayCredentials(name, found, warn)
366
+ if (address) {
367
+ config.endpoint = address
368
+ // ⚠️ Said for `backend` because its old spelling invites it: `api: /_api` was where
369
+ // a local mock answered, and under `services:` an address turns the host's off.
370
+ if (name === 'backend') {
371
+ warn(
372
+ "`services.backend` is an address: it asks your host to leave its own `backend` off, so the site uses yours. " +
373
+ "For your host's, write `backend: true`; in `uniweb dev`, `$devBackend` answers it."
374
+ )
375
+ }
376
+ }
377
+ asks.push({
378
+ name,
379
+ ...(address || enabled === false ? { enabled: false } : {}),
380
+ ...(Object.keys(config).length ? { config } : {})
381
+ })
196
382
  }
197
- return out
383
+ return asks.length ? asks : null
198
384
  }
199
385
 
200
386
  /**
201
- * Per service the file names: who moved since the last agreement, and so what to do.
387
+ * One row → the entry `site.yml::services` writes for it.
202
388
  *
203
- * Three states — the file's asks, the site's rows now (`stored`), and the record of
204
- * the last agreement (`record`, from `sync.json`):
389
+ * An address with the host's own off is the site's own provider, written as the entry
390
+ * was — a bare address when that is all it says. With the host's own on, the address
391
+ * goes: the host's offer is what answers now, and it was turned on elsewhere.
392
+ */
393
+ function entryFromRow(row) {
394
+ const off = row.enabled === false
395
+ const config = isMap(row.config) ? { ...row.config } : {}
396
+ const address = addressOf(config)
397
+ if (address && off) return Object.keys(config).length === 1 ? address : config
398
+ if (address) delete config.endpoint
399
+ if (!Object.keys(config).length) return !off
400
+ return off ? { enabled: false, ...config } : config
401
+ }
402
+
403
+ /**
404
+ * The credentials an author typed into an entry, kept on the one a pull writes over it.
405
+ * A push never sends one — it is stripped, with a warning naming the app — so the site's
406
+ * row cannot hold it, and writing the row whole would delete what the author typed.
407
+ * ⛔ *Until 2026-10-06 they sat in top-level keys the writer merged one level deep,
408
+ * which kept them; `services` is written whole.*
409
+ */
410
+ function keepCredentials(entry, local) {
411
+ if (!isMap(local)) return entry
412
+ const found = CREDENTIAL_KEYS.filter((k) => k in local)
413
+ if (!found.length) return entry
414
+ const kept = Object.fromEntries(found.map((k) => [k, local[k]]))
415
+ if (entry === true) return kept
416
+ if (entry === false) return { enabled: false, ...kept }
417
+ if (typeof entry === 'string') return { endpoint: entry, ...kept }
418
+ return isMap(entry) ? { ...entry, ...kept } : entry
419
+ }
420
+
421
+ /**
422
+ * The site's rows → the `site.yml::services` map — what a pull writes.
205
423
  *
206
- * | the file vs the record | the site vs the record | outcome |
207
- * |---|---|---|
208
- * | unchanged | unchanged | nothing |
209
- * | changed | unchanged | `send` |
210
- * | unchanged | changed | `adopt` — the site's is kept, and may be taken into the file |
211
- * | changed | changed, differently | `conflict` — only the owner can rank the two |
424
+ * ⭐ AN OFF SERVICE WITH NO SETTINGS IS WRITTEN ONLY WHERE `site.yml` ALREADY NAMES IT
425
+ * [Diego, 2026-10-07]. Every service is on or off, and off by default, so a `false` line
426
+ * adds nothing — and writing one for every service switched off in the app, or for each
427
+ * line the file dropped, would fill the file with them. So a pull writes a service that is
428
+ * on, or that has settings (an own provider's address); and one that is off, as `false`,
429
+ * where the file names it — a file listing a service the site has since switched off must
430
+ * say so, or its next push switches it back on. The rows it leaves out are still held
431
+ * (`sync.json`), and a push states them off, as the site has them (`statedServices`).
432
+ * ⛔ *Until then every row was written, an off one as `false`.*
212
433
  *
213
- * A service the site already gives as asked is nothing, whoever moved. With no record
214
- * the two cannot be told apart: a service the site holds nothing for is sent, one it
215
- * holds differently is a conflict. With the site unreadable, what changed against the
216
- * record is sent over the record; with neither, nothing can be merged onto for a site
217
- * that exists — `unreadable` — while a site not yet created has nothing stored.
434
+ * ⚖️ What it gives up: a `false` an author wrote does not reach a fresh clone, whose
435
+ * `site.yml` names nothing yet. It still means off there.
218
436
  *
219
437
  * @param {object} p
220
- * @param {object[]} p.asks - from `readServicesRequest`
221
- * @param {object[]} [p.record] - the rows last agreed, when the project has them
222
- * @param {object[]} [p.stored] - the site's rows now, when they could be read
223
- * @param {boolean} [p.siteKnown=true] - whether the site exists on this backend
224
- * @returns {{ send: string[], adopt: string[], conflict: string[], unreadable: boolean }}
438
+ * @param {object[]} [p.rows] - the `services` Section: the site's rows
439
+ * @param {object} [p.local] - the `services:` the pull writes over — which services it
440
+ * names, and the credentials a push never sends (`keepCredentials`)
441
+ * @returns {object|null} null when there is nothing to write
225
442
  */
226
- export function reconcileServices({ asks, record, stored, siteKnown = true }) {
227
- const send = []
228
- const adopt = []
229
- const conflict = []
230
- const haveRecord = Array.isArray(record)
231
- const haveStored = Array.isArray(stored)
232
- if (!haveRecord && !haveStored) {
233
- return siteKnown
234
- ? { send, adopt, conflict, unreadable: true }
235
- : { send: asks.map((a) => a.name), adopt, conflict, unreadable: false }
443
+ export function servicesFromDocument({ rows, local } = {}) {
444
+ const before = isMap(local) ? local : {}
445
+ const out = {}
446
+ for (const row of Array.isArray(rows) ? rows : []) {
447
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name || row.name in out) continue
448
+ const entry = entryFromRow(row)
449
+ if (entry === false && !(row.name in before)) continue
450
+ out[row.name] = keepCredentials(entry, before[row.name])
236
451
  }
237
- for (const ask of asks) {
238
- const now = rowNamed(stored, ask.name)
239
- const then = rowNamed(record, ask.name)
240
- if (!haveStored) {
241
- if (!satisfies(then, ask)) send.push(ask.name)
242
- continue
243
- }
244
- if (satisfies(now, ask)) continue
245
- if (!haveRecord) {
246
- ;(now === undefined ? send : conflict).push(ask.name)
247
- continue
248
- }
249
- const fileMoved = !satisfies(then, ask)
250
- const siteMoved = !sameRow(now, then)
251
- if (fileMoved && !siteMoved) send.push(ask.name)
252
- else if (!fileMoved && siteMoved) adopt.push(ask.name)
253
- else conflict.push(ask.name)
452
+ return Object.keys(out).length ? out : null
453
+ }
454
+
455
+ /**
456
+ * The `services` Section a push sends: every service `site.yml` lists, as it says it, and
457
+ * OFF for every service this copy holds that the file does not list.
458
+ *
459
+ * ⭐ A PUSH STATES, AND THE BACKEND DECIDES PER SERVICE [Diego, 2026-10-07 —
460
+ * kb/framework/plans/services-exchange.md]. Every service is on or off, off by default.
461
+ *
462
+ * - A service the file lists goes WHOLE — its switch, and the entry as its `config` — so
463
+ * a setting removed from the file is removed.
464
+ * - A service this copy holds — a pull returned it, or a push sent it — and the file
465
+ * does not list is stated `{ name, enabled: false }`, with no settings. Deleting the
466
+ * line turns it off and removes its settings, which would otherwise stay live on the
467
+ * page, since a row's `config` reaches it whether the row is on or off, and come back
468
+ * with the next pull.
469
+ * - A service this copy never saw is not sent, and the site keeps it.
470
+ *
471
+ * A held service carries its `$uuid`, and the push sends the version of every item it
472
+ * holds (`withBaseVersion`), so a service changed on the site since is kept when the file
473
+ * left it as it was, and refused when the file changed it too.
474
+ *
475
+ * ⛔ *Until then a push sent the site's whole list, read just before it, with the file's
476
+ * changed asks applied — a service left out was deleted — and the CLI kept its own copy
477
+ * of the list in `sync.json` to tell who had changed what.*
478
+ *
479
+ * @param {object[]|null} asks - from `readServicesRequest`
480
+ * @param {object} [held] - `{ <name>: <$uuid> }`, the services this copy holds (`sync.json`)
481
+ * @returns {object[]} the rows; `[]` when there is nothing to state
482
+ */
483
+ export function statedServices(asks, held = {}) {
484
+ const ids = isMap(held) ? held : {}
485
+ const uuidOf = (name) => (typeof ids[name] === 'string' && ids[name] ? ids[name] : null)
486
+ const rows = []
487
+ const listed = new Set()
488
+ for (const ask of asks || []) {
489
+ listed.add(ask.name)
490
+ const row = { name: ask.name }
491
+ if (ask.enabled === false) row.enabled = false
492
+ if (ask.config) row.config = { ...ask.config }
493
+ if (uuidOf(ask.name)) row.$uuid = uuidOf(ask.name)
494
+ rows.push(row)
254
495
  }
255
- return { send, adopt, conflict, unreadable: false }
496
+ for (const name of Object.keys(ids).sort()) {
497
+ if (listed.has(name) || !uuidOf(name)) continue
498
+ rows.push({ name, enabled: false, $uuid: uuidOf(name) })
499
+ }
500
+ return rows
256
501
  }
257
502
 
258
503
  /**
259
- * The record after a push: what this project now agrees with the site on.
504
+ * The services this copy holds after a push or a pull — `{ <name>: <$uuid> }`, read from
505
+ * the document the backend returned.
260
506
  *
261
- * The rows sent, or — when nothing was — the site's rows now. ⛔ For a service left
262
- * OPEN (an adopt not taken, a conflict not resolved) the earlier agreement stands,
263
- * or the next run would read the site's change as the file's and send the stale ask.
507
+ * A pull returns every service the site has, all seen (`sent` absent). A push returns the
508
+ * site's rows after the write, which may include services added since this copy's last
509
+ * pull: those were neither sent nor held before, so they are left out — held, a later
510
+ * push would state them off, switching off a service nobody here has seen.
264
511
  *
265
512
  * @param {object} p
266
- * @param {object[]} [p.record] - the rows last agreed
267
- * @param {object[]} [p.agreed] - the rows sent, or the site's rows now
268
- * @param {string[]} [p.open] - services whose decision is still open
269
- * @returns {object[]|undefined} undefined when there is nothing to record
513
+ * @param {object[]} [p.written] - the `services` rows the backend returned
514
+ * @param {object[]} [p.sent] - the rows the push sent; absent for a pull
515
+ * @param {object} [p.prior] - the services held before
516
+ * @returns {object} the services now held
270
517
  */
271
- export function recordAfter({ record, agreed, open = [] }) {
272
- if (!Array.isArray(agreed)) return undefined
273
- const keep = new Set(open)
274
- const out = agreed.filter((r) => isMap(r) && !keep.has(r.name)).map(canonicalRow)
275
- for (const name of open) {
276
- const then = rowNamed(record, name)
277
- if (then) out.push(canonicalRow(then))
518
+ export function heldServices({ written, sent, prior } = {}) {
519
+ const before = isMap(prior) ? prior : {}
520
+ const stated = Array.isArray(sent) ? new Set(sent.filter(isMap).map((r) => r.name)) : null
521
+ const out = {}
522
+ for (const row of Array.isArray(written) ? written : []) {
523
+ if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
524
+ if (typeof row.$uuid !== 'string' || !row.$uuid) continue
525
+ if (stated && !stated.has(row.name) && !(row.name in before)) continue
526
+ out[row.name] = row.$uuid
278
527
  }
528
+ // Sorted: it is written to `sync.json`, a committed file that must not reorder itself.
529
+ return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]))
530
+ }
531
+
532
+ /** What a row SAYS — its switch and its `config` — not the fields a backend adds to it. */
533
+ function said(row) {
534
+ if (!isMap(row)) return null
535
+ const out = {}
536
+ if (row.enabled === false) out.enabled = false
537
+ if (isMap(row.config) && Object.keys(row.config).length) out.config = row.config
279
538
  return out
280
539
  }
540
+
541
+ /**
542
+ * Whether the site holds a service as a push sent it: the switch and the WHOLE
543
+ * `config`. ⛔ Not "equal on the keys we sent", which units use: an `on` row sends no
544
+ * `enabled` and an entry with no settings no `config`, so a row the app switched off,
545
+ * or gave settings, would read as ours.
546
+ *
547
+ * @param {object} sent - the row a push sent
548
+ * @param {object} written - the site's row after it
549
+ * @returns {boolean}
550
+ */
551
+ export function sameServiceRow(sent, written) {
552
+ return isMap(sent) && isMap(written) && stable(said(sent)) === stable(said(written))
553
+ }