@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.
- package/package.json +7 -7
- package/src/content/index.js +0 -1
- package/src/dev/api-mount.js +27 -19
- package/src/site/content-collector.js +58 -18
- package/src/site/data-fetcher.js +19 -0
- package/src/site/index.js +1 -2
- package/src/site/plugin.js +18 -5
- package/src/site/queries-config.js +2 -1
- package/src/site/query-processor.js +4 -40
- package/src/uwx/emit-surface.json +3 -15
- package/src/uwx/index.js +13 -0
- package/src/uwx/project-writer.js +6 -2
- package/src/uwx/services-request.js +432 -0
- package/src/uwx/shown-records.js +52 -0
- package/src/uwx/site-diff.js +47 -0
- package/src/uwx/site-project.js +41 -60
- package/src/uwx/site.js +77 -172
- package/src/uwx/sync-package.js +30 -18
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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
|
-
//
|
|
203
|
-
//
|
|
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
|
|
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
|
-
// ⭐
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
// the
|
|
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
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
//
|
|
1041
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
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
|
-
//
|
|
1149
|
-
//
|
|
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
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
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
|
-
//
|
|
1172
|
-
//
|
|
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(
|
|
1174
|
+
function servicesNested(rows) {
|
|
1227
1175
|
return serviceRecords(
|
|
1228
|
-
|
|
1176
|
+
rows,
|
|
1229
1177
|
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
1230
|
-
'
|
|
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
|
-
//
|
|
1346
|
-
// Section
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
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
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
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
|
-
// ⛔
|
|
1528
|
-
// `
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
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
|
|
1726
|
-
// `serviceRecords
|
|
1727
|
-
//
|
|
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 =
|
|
1654
|
+
const services = requestedServices(siteYml, provisioned)
|
|
1750
1655
|
if (services) doc.services = services
|
|
1751
|
-
const secrets =
|
|
1656
|
+
const secrets = secretsNested(provisioned)
|
|
1752
1657
|
if (secrets) doc.secrets = secrets
|
|
1753
1658
|
return doc
|
|
1754
1659
|
}
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// items
|
|
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
|
-
|
|
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] -
|
|
227
|
-
* per-ITEM `version
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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([
|
|
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
|
}
|