@uniweb/build 0.18.4 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.18.4",
3
+ "version": "0.19.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,16 +59,16 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/schemas": "^0.2.10",
62
+ "@uniweb/content-writer": "^0.3.3",
63
+ "@uniweb/projections": "^0.2.8",
63
64
  "@uniweb/theming": "^0.1.15",
64
- "@uniweb/projections": "^0.2.7",
65
- "@uniweb/content-writer": "^0.3.3"
65
+ "@uniweb/schemas": "^0.2.10"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/content-reader": "^1.2.2",
69
+ "@uniweb/runtime": "^0.11.7",
69
70
  "@uniweb/schemas": "^0.2.10",
70
- "@uniweb/runtime": "^0.11.4",
71
- "@uniweb/semantic-parser": "^1.2.1"
71
+ "@uniweb/semantic-parser": "^1.2.2"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -77,7 +77,7 @@
77
77
  "@tailwindcss/vite": "^4.0.0",
78
78
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
79
79
  "vite-plugin-svgr": "^4.0.0",
80
- "@uniweb/core": "^0.8.4"
80
+ "@uniweb/core": "^0.8.5"
81
81
  },
82
82
  "peerDependenciesMeta": {
83
83
  "vite": {
@@ -110,6 +110,29 @@ function validate(doc, path) {
110
110
  if (doc.targets !== undefined && (typeof doc.targets !== 'object' || Array.isArray(doc.targets))) {
111
111
  throw new Error(`${path}: \`targets\` must be a map.`)
112
112
  }
113
+ // A site has exactly ONE Uniweb identity — `site.yml::$uuid`, a single scalar,
114
+ // set by the create and never moved. So two targets naming `host: uniweb` cannot
115
+ // both be coherent: whichever one `default:` selects is the site, and the other
116
+ // silently describes a site that does not exist. The per-target `backend` key
117
+ // makes that look expressible, which is exactly why it is worth rejecting rather
118
+ // than leaving to be discovered.
119
+ //
120
+ // *(Diego, 2026-08-12: multiple deploys are not expected in general, and are not
121
+ // allowed for Uniweb Cloud specifically — the only host that reads `$uuid` at
122
+ // all. Every other host is stateless from the CLI's side, so any number of those
123
+ // targets is fine.)*
124
+ if (doc.targets && typeof doc.targets === 'object' && !Array.isArray(doc.targets)) {
125
+ const uniwebTargets = Object.entries(doc.targets)
126
+ .filter(([, t]) => t && typeof t === 'object' && t.host === 'uniweb')
127
+ .map(([name]) => name)
128
+ if (uniwebTargets.length > 1) {
129
+ throw new Error(
130
+ `${path}: only one target may use \`host: uniweb\` — found ${uniwebTargets.length} (${uniwebTargets.join(', ')}). ` +
131
+ 'A site has a single Uniweb identity (site.yml::$uuid), so a second Uniweb target cannot describe a different site. ' +
132
+ 'Keep one, or point the others at a third-party host.'
133
+ )
134
+ }
135
+ }
113
136
  if (doc.autoSave !== undefined && !VALID_AUTOSAVE.has(doc.autoSave)) {
114
137
  throw new Error(
115
138
  `${path}: \`autoSave\` must be one of: ${[...VALID_AUTOSAVE].join(', ')}.`
package/src/uwx/index.js CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  siteProjectToDocument,
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
+ writeSiteOrg,
46
47
  extensionDeclaration,
47
48
  isExtensionUrl,
48
49
  isSiteRelativeExtensionUrl,
@@ -106,6 +106,16 @@ const INFO_TO_SITE_YML = {
106
106
  // render time and never enters `info`.
107
107
  submit: 'submit',
108
108
  agents: 'agents',
109
+ // Authored-only, like `submit` above — a host's assistant endpoint is offered
110
+ // through `config.services` and resolved at render time, so it never enters
111
+ // `info` and a pull cannot launder it into authored config.
112
+ //
113
+ // ⚠️ One asymmetry worth knowing: push STRIPS credential-shaped keys, so a
114
+ // block that carried an `apiKey` comes back without it and the pull rewrites
115
+ // the author's file minus that line. That is intended — the key must not be
116
+ // there and the push already warned — but it is the one case where a pull
117
+ // removes something the author typed.
118
+ assistant: 'assistant',
109
119
  paths: 'paths',
110
120
  data: 'data',
111
121
  template: 'template',
package/src/uwx/site.js CHANGED
@@ -68,6 +68,46 @@ function setIf(obj, key, value) {
68
68
  if (value !== undefined) obj[key] = value
69
69
  }
70
70
 
71
+ // Credential-shaped keys, mirroring the set the delivery edge strips on the
72
+ // reading side. Deliberately the SAME list rather than a stricter one, so the
73
+ // two guards are visibly twins and a key added to one is obviously owed to the
74
+ // other.
75
+ const CREDENTIAL_KEYS = ['apiKey', 'api_key', 'key', 'token', 'secret']
76
+
77
+ /**
78
+ * Drop credential-shaped keys out of an authored service block.
79
+ *
80
+ * An authored block crosses into backend storage here and is served from there
81
+ * in a published payload — which is world-readable. So a credential in
82
+ * `site.yml` is not merely untidy, it is disclosed. The host's secret store is
83
+ * the only right home, resolved at request time.
84
+ *
85
+ * Warns rather than throwing: the fix belongs to the author, a failed push
86
+ * helps nobody, and the block is still useful without the key. Returns the
87
+ * value untouched when there is nothing to strip, so `setIf`'s absent-vs-empty
88
+ * behaviour is unchanged — a block that carried ONLY a credential arrives as
89
+ * `{}` rather than vanishing, keeping the mistake visible where the author
90
+ * looks for it.
91
+ *
92
+ * @param {*} block - the authored value, any shape
93
+ * @param {string} label - the site.yml key, for the warning
94
+ * @returns {*} the block, minus anything credential-shaped
95
+ */
96
+ function stripCredentials(block, label) {
97
+ if (!block || typeof block !== 'object' || Array.isArray(block)) return block
98
+ const found = CREDENTIAL_KEYS.filter(key => key in block)
99
+ if (found.length === 0) return block
100
+
101
+ console.warn(
102
+ `uwx/site: dropped ${found.join(', ')} from \`${label}:\` — authored config is published ` +
103
+ 'world-readable, so a credential belongs in the host secret store, never in site.yml.'
104
+ )
105
+
106
+ const cleaned = { ...block }
107
+ for (const key of found) delete cleaned[key]
108
+ return cleaned
109
+ }
110
+
71
111
  // page_sections and layout_sections share this content shape.
72
112
  // processMarkdownFile only destructures type/component/preset/input/props/
73
113
  // fetch/data/id out of frontmatter, so `background:` and `theme:` stay
@@ -700,6 +740,22 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
700
740
  // excluded branch becomes both discoverable AND summarized by the index.
701
741
  // (The CLI lane reads site.yml directly and honors it either way.)
702
742
  setIf(info, 'agents', siteYml.agents)
743
+ // `assistant` — the site's own declaration for an AI assistant: where it
744
+ // lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
745
+ // host reads (`system` persona, model hints). Same family as
746
+ // `search`/`submit`, and here for the reason spelled out above them — the
747
+ // bundle lane spreads all of site.yml while this one is an allowlist, so
748
+ // without this line the block works on a static host and vanishes silently
749
+ // on the synced lane.
750
+ //
751
+ // ⚠️ That is not hypothetical: this replaces `intelligence.yml`, a SEPARATE
752
+ // file, which needed a bespoke line in each lane and got one in only the
753
+ // bundle lane — so an authored persona never reached a hosted site at all.
754
+ // A key inside site.yml cannot repeat that, because only this lane needs a
755
+ // line. (kb/framework/architecture/assistant-config.md)
756
+ //
757
+ // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
758
+ setIf(info, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
703
759
  setIf(info, 'paths', siteYml.paths)
704
760
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
705
761
  // `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
@@ -804,3 +860,53 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
804
860
  export function writeSiteEntityUuid(siteRoot, uuid) {
805
861
  return upsertYamlScalar(join(siteRoot, 'site.yml'), '$uuid', uuid)
806
862
  }
863
+
864
+ /**
865
+ * Record the org the site was CREATED under (`site.yml::$org`), beside `$uuid`.
866
+ *
867
+ * The org is consumed at exactly one moment — the `as_org` on the create that
868
+ * mints `$uuid` — and after that the uuid carries the ownership binding. So this
869
+ * is not a knob the backend re-reads; it is the answer to *"whose workspace is
870
+ * this site's storage charged to?"*, which `$uuid` alone cannot answer and which
871
+ * otherwise costs a backend round-trip (or is simply unknowable from the repo).
872
+ *
873
+ * Stored as the BARE handle, never `@handle`: `upsertYamlScalar` writes the value
874
+ * verbatim, and `@` is a reserved YAML indicator, so a plain scalar may not start
875
+ * with one — `$org: @acme` is a parse error. The bare form is also the canonical
876
+ * one everywhere else (`deriveScope` returns it, `createOrg` echoes it as
877
+ * `org.handle`, `validateHandle` validates it); the `@` is display sugar the
878
+ * reader re-adds.
879
+ *
880
+ * Safe to add to `site.yml` because the sync lane is an explicit allowlist
881
+ * (`info.*` above is built key by key), so this never rides the wire.
882
+ *
883
+ * ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
884
+ * two look like the same class of fact. Four reasons, and the first is the one that
885
+ * settles it:
886
+ *
887
+ * 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
888
+ * so its shape says "this may vary per target." An org may not: one site has one
889
+ * owning org, fixed at create and preserved on replace. `$org` is a property of
890
+ * `$uuid`, which is singular and lives here — filing it under a target would
891
+ * encode a freedom that does not exist.
892
+ * 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
893
+ * content-lane create, and `clone` seeding an existing one) and **none of them
894
+ * write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
895
+ * record would exist or not depending on which verb the developer reached for.
896
+ * 3. SUPPRESSIBLE. `recordLastDeploy` is a no-op under `autoSave: off` / `--no-save`.
897
+ * Turning off deploy *receipts* would silently drop an *ownership* record.
898
+ * 4. SEMANTICS. `deploy.yml` describes the act of shipping (`lastDeploy` is a
899
+ * receipt) and is optional entirely. But `push` creates a site and never
900
+ * publishes — a site can exist, be owned, and accrue storage charges without
901
+ * ever being deployed. Ownership does not belong in a record of a deploy that
902
+ * may not have happened.
903
+ *
904
+ * `backend` answers *where this ships*; `$org` answers *whose this is*.
905
+ *
906
+ * @param {string} siteRoot
907
+ * @param {string} handle - the bare org handle (no leading `@`)
908
+ * @returns {boolean} true if site.yml changed
909
+ */
910
+ export function writeSiteOrg(siteRoot, handle) {
911
+ return upsertYamlScalar(join(siteRoot, 'site.yml'), '$org', handle)
912
+ }