@omega.js/desktop 0.50.0 → 0.51.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.
Files changed (267) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/css/tokens/_index.scss +1 -1
  3. package/dist/assets/themes/base/_includes/frontend/sections/account-section-header.html +4 -1
  4. package/dist/assets/themes/base/_includes/frontend/sections/footer.html +13 -6
  5. package/dist/assets/themes/base/_includes/frontend/sections/nav.html +10 -8
  6. package/dist/assets/themes/base/_includes/global/sections/account.html +3 -1
  7. package/dist/assets/themes/base/_includes/global/sections/app-sidebar.html +12 -8
  8. package/dist/assets/themes/base/_includes/global/sections/app-topbar.html +10 -6
  9. package/dist/assets/themes/base/_includes/global/sections/page-header.html +8 -4
  10. package/dist/assets/themes/base/_layouts/backend/pages/dashboard/index.html +24 -24
  11. package/dist/assets/themes/base/_layouts/frontend/pages/about.html +10 -10
  12. package/dist/assets/themes/base/_layouts/frontend/pages/account/index.html +35 -35
  13. package/dist/assets/themes/base/_layouts/frontend/pages/alternatives/index.html +4 -4
  14. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signin.html +1 -1
  15. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signup.html +4 -4
  16. package/dist/assets/themes/base/_layouts/frontend/pages/contact.html +3 -3
  17. package/dist/assets/themes/base/_layouts/frontend/pages/download.html +34 -33
  18. package/dist/assets/themes/base/_layouts/frontend/pages/extension/index.html +11 -11
  19. package/dist/assets/themes/base/_layouts/frontend/pages/legal/document.html +1 -1
  20. package/dist/assets/themes/base/_layouts/frontend/pages/status.html +1 -1
  21. package/dist/assets/themes/base/_layouts/frontend/pages/team/index.html +9 -7
  22. package/dist/assets/themes/base/_layouts/frontend/pages/team/member.html +5 -3
  23. package/dist/assets/themes/base/_sections/about/letter/section.html +1 -1
  24. package/dist/assets/themes/base/_sections/about/letter/section.json5 +4 -4
  25. package/dist/assets/themes/base/_sections/marketing/bento/section.html +1 -1
  26. package/dist/assets/themes/base/_sections/marketing/bento/section.json5 +15 -15
  27. package/dist/assets/themes/base/_sections/marketing/cta/section.json5 +1 -1
  28. package/dist/assets/themes/base/_sections/marketing/hero/section.html +6 -6
  29. package/dist/assets/themes/base/_sections/marketing/hero/section.json5 +10 -10
  30. package/dist/assets/themes/base/_sections/marketing/product-demo/section.html +1 -1
  31. package/dist/assets/themes/base/_sections/marketing/product-demo/section.json5 +2 -2
  32. package/dist/assets/themes/base/_sections/marketing/stats/section.html +1 -1
  33. package/dist/assets/themes/base/_sections/marketing/stats/section.json5 +5 -5
  34. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.html +1 -1
  35. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.json5 +2 -2
  36. package/dist/assets/themes/neobrutalism/_layouts/frontend/pages/index.html +10 -10
  37. package/dist/assets/themes/newsflash/_layouts/frontend/pages/index.html +10 -10
  38. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.html +2 -2
  39. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.json5 +4 -4
  40. package/dist/build.js +69 -28
  41. package/dist/cli-run.js +20 -13
  42. package/dist/cli.js +12 -7
  43. package/dist/commands/build.js +0 -2
  44. package/dist/commands/deploy.js +116 -44
  45. package/dist/commands/finalize-release.js +5 -4
  46. package/dist/commands/launch.js +12 -7
  47. package/dist/commands/lib/deploy-precheck.js +66 -34
  48. package/dist/commands/lib/ensure-target.js +71 -11
  49. package/dist/commands/package.js +0 -1
  50. package/dist/commands/publish.js +11 -4
  51. package/dist/commands/release.js +99 -252
  52. package/dist/commands/runner.js +42 -5
  53. package/dist/commands/sign-windows.js +46 -15
  54. package/dist/commands/test.js +4 -3
  55. package/dist/commands/validate-certs.js +291 -115
  56. package/dist/config/page-template.html +4 -0
  57. package/dist/defaults/.github/workflows/build.yml +144 -64
  58. package/dist/defaults/AGENTS.md +3 -2
  59. package/dist/defaults/_.gitignore +4 -2
  60. package/dist/defaults/config/certs/README.md +25 -45
  61. package/dist/defaults/config/omega.json5 +66 -32
  62. package/dist/defaults/hooks/deploy/pre.js +10 -0
  63. package/dist/defaults/src/assets/scss/main.scss +12 -2
  64. package/dist/defaults/src/integrations/tray/index.js +1 -1
  65. package/dist/gulp/main.js +13 -25
  66. package/dist/gulp/tasks/audit.js +18 -6
  67. package/dist/gulp/tasks/build-config.js +181 -60
  68. package/dist/gulp/tasks/bundle.js +108 -36
  69. package/dist/gulp/tasks/release.js +86 -4
  70. package/dist/gulp/tasks/sass.js +11 -0
  71. package/dist/hooks/lib/notarize-tools.js +137 -0
  72. package/dist/hooks/notarize-artifacts.js +57 -0
  73. package/dist/hooks/notarize.js +59 -8
  74. package/dist/lib/auth-persistence.js +25 -0
  75. package/dist/lib/client-bridge.js +11 -8
  76. package/dist/lib/deep-link.js +15 -8
  77. package/dist/lib/protocol.js +7 -1
  78. package/dist/lib/restart-manager/install.js +6 -3
  79. package/dist/lib/sign-helpers/auto-unlock.js +65 -27
  80. package/dist/lib/sign-helpers/console-lock.js +34 -0
  81. package/dist/lib/sign-helpers/exec-with-limit.js +68 -0
  82. package/dist/lib/sign-helpers/resolve-icons.js +6 -4
  83. package/dist/lib/tray.js +7 -6
  84. package/dist/main.js +16 -0
  85. package/dist/preload.js +8 -0
  86. package/dist/renderer.js +16 -4
  87. package/dist/runner/job-started.js +104 -0
  88. package/dist/test/fixtures/consumer-app/config/omega.json5 +7 -0
  89. package/dist/test/fixtures/consumer-app/package.json +1 -1
  90. package/dist/test/fixtures/consumer-app/src/assets/js/components/main/index.js +7 -1
  91. package/dist/test/harness/main-entry.js +2 -1
  92. package/dist/test/harness/renderer-preload.js +12 -3
  93. package/dist/test/runners/boot.js +101 -11
  94. package/dist/test/runners/electron.js +1 -1
  95. package/dist/test/suites/boot/consumer-app-boots.test.js +54 -0
  96. package/dist/test/suites/build/audit.test.js +45 -6
  97. package/dist/test/suites/build/auth-persistence-resolve.test.js +119 -0
  98. package/dist/test/suites/build/auto-unlock.test.js +105 -0
  99. package/dist/test/suites/build/boot-runner-timeout.test.js +253 -0
  100. package/dist/test/suites/build/brand-scss.test.js +106 -0
  101. package/dist/test/suites/build/build-config.test.js +146 -36
  102. package/dist/test/suites/build/build-json-bake.test.js +245 -0
  103. package/dist/test/suites/build/build-verbs.test.js +48 -15
  104. package/dist/test/suites/build/build-workflow-jobs.test.js +190 -0
  105. package/dist/test/suites/build/cli.test.js +32 -10
  106. package/dist/test/suites/build/config-schema.test.js +4 -4
  107. package/dist/test/suites/build/console-lock.test.js +51 -0
  108. package/dist/test/suites/build/defaults-scaffold.test.js +71 -2
  109. package/dist/test/suites/build/deploy-direct.test.js +241 -0
  110. package/dist/test/suites/build/deploy-dispatch.test.js +287 -0
  111. package/dist/test/suites/build/deploy-hook.test.js +169 -0
  112. package/dist/test/suites/build/ensure-target.test.js +14 -2
  113. package/dist/test/suites/build/env-delivery.test.js +19 -10
  114. package/dist/test/suites/build/env-watch.test.js +18 -7
  115. package/dist/test/suites/build/esm-only-dependency.test.js +127 -0
  116. package/dist/test/suites/build/exec-with-limit.test.js +53 -0
  117. package/dist/test/suites/build/finalize-release.test.js +2 -2
  118. package/dist/test/suites/build/get-config.test.js +120 -8
  119. package/dist/test/suites/build/github-utils.test.js +12 -6
  120. package/dist/test/suites/build/license-stamp.test.js +6 -4
  121. package/dist/test/suites/build/manager.test.js +88 -68
  122. package/dist/test/suites/build/manifest-deps.test.js +116 -0
  123. package/dist/test/suites/build/merge-line-files.test.js +27 -4
  124. package/dist/test/suites/build/notarize-artifacts.test.js +135 -0
  125. package/dist/test/suites/build/notarize-tools.test.js +38 -0
  126. package/dist/test/suites/build/notarize.test.js +207 -0
  127. package/dist/test/suites/build/release-pipeline.test.js +38 -60
  128. package/dist/test/suites/build/release-skipped-upload.test.js +107 -0
  129. package/dist/test/suites/build/resolve-icons.test.js +35 -35
  130. package/dist/test/suites/build/runner-job-guard.test.js +182 -0
  131. package/dist/test/suites/build/runner.test.js +25 -2
  132. package/dist/test/suites/build/sentry.test.js +8 -3
  133. package/dist/test/suites/build/setup-scripts.test.js +3 -0
  134. package/dist/test/suites/build/sign-windows.test.js +92 -8
  135. package/dist/test/suites/build/test-stealth.test.js +17 -11
  136. package/dist/test/suites/build/url-helpers.test.js +37 -17
  137. package/dist/test/suites/build/validate-certs.test.js +428 -55
  138. package/dist/test/suites/build/validate-config.test.js +3 -3
  139. package/dist/test/suites/main/auth-flow.test.js +12 -0
  140. package/dist/test/suites/main/auth-persistence.test.js +14 -17
  141. package/dist/test/suites/main/auto-updater.test.js +2 -2
  142. package/dist/test/suites/main/boot-sequence.test.js +1 -1
  143. package/dist/test/suites/main/client-bridge.integration.test.js +5 -82
  144. package/dist/test/suites/main/client-bridge.test.js +19 -6
  145. package/dist/test/suites/main/deep-link.test.js +54 -0
  146. package/dist/test/suites/main/startup-paths-and-ua.test.js +1 -1
  147. package/dist/test/suites/main/url-helpers.test.js +81 -72
  148. package/dist/test/suites/renderer/cross-context-helpers.test.js +19 -16
  149. package/dist/utils/build-pipeline.js +10 -10
  150. package/dist/utils/github.js +12 -51
  151. package/dist/utils/load-env.js +66 -0
  152. package/dist/utils/mode-helpers.js +43 -111
  153. package/dist/utils/platform.js +37 -0
  154. package/dist/utils/runner-env.js +2 -1
  155. package/dist/utils/runner-job-guard.js +149 -0
  156. package/dist/utils/ship-keys.js +52 -0
  157. package/dist/utils/test-stealth.js +5 -3
  158. package/dist/utils/url-helpers.js +33 -17
  159. package/dist/vendor/config/bundle-id.js +53 -0
  160. package/dist/vendor/config/client-config.js +141 -0
  161. package/dist/vendor/config/company.js +334 -15
  162. package/dist/vendor/config/dev-facts.js +48 -0
  163. package/dist/vendor/config/env-delivery.js +231 -9
  164. package/dist/vendor/config/env-retired.js +137 -0
  165. package/dist/vendor/config/env-rules.js +22 -3
  166. package/dist/vendor/config/env-schema.js +234 -117
  167. package/dist/vendor/config/env.js +55 -26
  168. package/dist/vendor/config/environment.js +189 -0
  169. package/dist/vendor/config/hooks.js +13 -11
  170. package/dist/vendor/config/index.js +121 -44
  171. package/dist/vendor/config/load.js +366 -115
  172. package/dist/vendor/config/merge.js +2 -2
  173. package/dist/vendor/config/order.js +3 -3
  174. package/dist/vendor/config/platforms.js +276 -0
  175. package/dist/vendor/config/repo.js +226 -104
  176. package/dist/vendor/config/retired-keys.js +232 -27
  177. package/dist/vendor/config/schema.js +525 -99
  178. package/dist/vendor/config/site-global.js +63 -53
  179. package/dist/vendor/config/targets.js +187 -0
  180. package/dist/vendor/config/validate.js +119 -59
  181. package/dist/vendor/devkit/argv.js +118 -0
  182. package/dist/vendor/devkit/attach-log-file.js +21 -13
  183. package/dist/vendor/devkit/brand-tokens.js +278 -0
  184. package/dist/vendor/devkit/brand-version.js +264 -0
  185. package/dist/vendor/devkit/build-json.js +91 -0
  186. package/dist/vendor/devkit/bundle.js +48 -0
  187. package/dist/vendor/devkit/certificate-expiry.js +108 -0
  188. package/dist/vendor/devkit/certs.js +16 -194
  189. package/dist/vendor/devkit/ci-workflows.js +124 -8
  190. package/dist/vendor/devkit/cli-router.js +3 -3
  191. package/dist/vendor/devkit/defaults-engine.js +69 -7
  192. package/dist/vendor/devkit/deploy-follow.js +297 -0
  193. package/dist/vendor/devkit/deploy-precheck.js +23 -8
  194. package/dist/vendor/devkit/deploy-record.js +11 -27
  195. package/dist/vendor/devkit/deploy-snapshot.js +661 -0
  196. package/dist/vendor/devkit/deploy.js +445 -75
  197. package/dist/vendor/devkit/git-auth.js +73 -0
  198. package/dist/vendor/devkit/git-remote.js +95 -0
  199. package/dist/vendor/devkit/github-repo.js +290 -0
  200. package/dist/vendor/devkit/local.js +47 -0
  201. package/dist/vendor/devkit/merge-line-files.js +23 -16
  202. package/dist/vendor/devkit/omega-bin.js +18 -3
  203. package/dist/vendor/devkit/pack-local.js +391 -0
  204. package/dist/vendor/devkit/preludes/index.js +120 -0
  205. package/dist/vendor/devkit/preludes/origin-heal.js +156 -0
  206. package/dist/vendor/devkit/service-account.js +43 -0
  207. package/dist/vendor/devkit/ship-plan.js +112 -0
  208. package/dist/vendor/devkit/signing-env.js +180 -0
  209. package/dist/vendor/devkit/signing-tree.js +92 -0
  210. package/dist/vendor/devkit/target-seams.js +142 -0
  211. package/dist/vendor/devkit/target-secrets.js +235 -50
  212. package/dist/vendor/devkit/test/esm-only-fixture.js +48 -0
  213. package/dist/vendor/devkit/test/fixtures/esm-only-package/browser.js +19 -0
  214. package/dist/vendor/devkit/test/fixtures/esm-only-package/index.js +20 -0
  215. package/dist/vendor/devkit/test/fixtures/esm-only-package/package.json +13 -0
  216. package/dist/vendor/monitoring/env.js +20 -10
  217. package/dist/vendor/monitoring/main.js +1 -1
  218. package/dist/vendor/monitoring/preload.js +1 -1
  219. package/dist/vendor/monitoring/renderer.js +1 -1
  220. package/docs/analytics.md +1 -1
  221. package/docs/auto-updater.md +5 -5
  222. package/docs/boot-sequence.md +1 -1
  223. package/docs/build-system.md +15 -7
  224. package/docs/client-bridge.md +9 -7
  225. package/docs/config-schema.md +4 -4
  226. package/docs/css.md +8 -2
  227. package/docs/deep-link.md +12 -4
  228. package/docs/environment-detection.md +32 -24
  229. package/docs/hooks.md +3 -1
  230. package/docs/icons.md +7 -7
  231. package/docs/index.md +61 -23
  232. package/docs/installer-options.md +24 -21
  233. package/docs/logging.md +5 -5
  234. package/docs/releasing.md +25 -17
  235. package/docs/runner.md +40 -5
  236. package/docs/shared/brands.md +12 -6
  237. package/docs/shared/breaking-changes.md +375 -21
  238. package/docs/shared/config.md +757 -199
  239. package/docs/shared/deploys.md +194 -91
  240. package/docs/shared/icons.md +18 -0
  241. package/docs/shared/local-dev.md +24 -6
  242. package/docs/shared/logging.md +9 -6
  243. package/docs/shared/monitoring.md +27 -13
  244. package/docs/shared/publishing.md +3 -3
  245. package/docs/shared/rulings.md +2 -2
  246. package/docs/shared/testing.md +1 -1
  247. package/docs/shared/theming.md +26 -1
  248. package/docs/shared/translation.md +49 -7
  249. package/docs/shared/updates.md +1 -1
  250. package/docs/signing.md +59 -33
  251. package/docs/test-framework.md +10 -5
  252. package/docs/themes.md +15 -1
  253. package/package.json +18 -12
  254. package/bin/omega-desktop +0 -2
  255. package/dist/commands/push-secrets.js +0 -141
  256. package/dist/test/suites/build/deliver-certs.test.js +0 -95
  257. package/dist/test/suites/build/derive-signing-env.test.js +0 -122
  258. package/dist/test/suites/build/push-secrets.test.js +0 -226
  259. package/dist/test/suites/build/resolve-signing-cert.test.js +0 -342
  260. package/dist/utils/deliver-certs.js +0 -69
  261. package/dist/utils/derive-signing-env.js +0 -56
  262. package/dist/utils/resolve-signing-cert.js +0 -175
  263. package/dist/vendor/config/desktop-artifacts.js +0 -110
  264. package/dist/vendor/config/instances.js +0 -208
  265. /package/dist/defaults/config/icons/{macos → mac}/dmg.png +0 -0
  266. /package/dist/defaults/config/icons/{macos → mac}/icon.png +0 -0
  267. /package/dist/defaults/config/icons/{macos → mac}/tray.png +0 -0
@@ -27,9 +27,9 @@ JSON5: comments, trailing commas, unquoted keys, single quotes all allowed.
27
27
  {
28
28
  // SHARED sections — identical spelling in every project type.
29
29
  // (`SHARED_SECTIONS` in @omega.js/config is the authoritative list.)
30
- brand: { id, name, url, description, tagline, company, type, font, color, contact: { email, phone, person: {…}, carbonCopy: […] }, address: {…}, images: {…} }, // #524: type = the schema.org type the JSON-LD stamps ('Organization' unset), font = the display face the assets service renders the wordmark from. contact.person = the human who signs "personal" email (name, firstName, image, url, urlText), ASKED for by `omega onboard` ([#770](https://github.com/Omega-JS-Stack/omega/issues/770)) since nothing can derive a human name — the wizard prompt writes name + the optional image/url, `--contactName`/`--contactImage`/`--contactUrl` answer it non-interactively, and an unanswered brand gets no key at all; contact.carbonCopy = audit BCCs; images.companyWordmark = parent wordmark in email footers
31
- cloud: { provider: 'firebase', config: { apiKey, authDomain, databaseURL, projectId, storageBucket, messagingSenderId, appId, measurementId }, messaging: { vapidKey }, shared, supportEmail, consentAudience, apiSubdomain, organizationId, billingAccount, oauthRedirectsConfigured }, // ONE cloud home (#23): the app config PLUS the provisioning fields; projectId lives only at cloud.config.projectId. vapidKey: web-push public key (console → Cloud Messaging), public by design
32
- repo: { providers: { github: { enabled, org, repo, shared, private } } },
30
+ brand: { id, name, url, description, tagline, type, font, color, contact: { email, phone, person: {…}, carbonCopy: […] }, address: {…}, images: {…} }, // #524: type = the schema.org type the JSON-LD stamps ('Organization' unset), font = the display face the assets service renders the wordmark from. contact.person = the human who signs "personal" email (name, firstName, image, url, urlText), ASKED for by `omega onboard` ([#770](https://github.com/Omega-JS-Stack/omega/issues/770)) since nothing can derive a human name: the wizard prompt writes name + the optional image/url, `--contactName`/`--contactImage`/`--contactUrl` answer it non-interactively, and an unanswered brand gets no key at all; contact.carbonCopy = audit BCCs
31
+ cloud: { provider: 'firebase', config: { apiKey, authDomain, databaseURL, projectId, storageBucket, messagingSenderId, appId, measurementId }, messaging: { vapidKey }, shared, supportEmail, consentAudience, apiSubdomain, organizationId, billingAccount, oauthRedirectsConfigured }, // ONE cloud home (#23): the app config PLUS the provisioning fields; projectId lives only at cloud.config.projectId. vapidKey: web-push public key (console → Cloud Messaging), public by design. shared: true limits the manage cycle to the per-brand operations AND skips the backend deploy whole (#882, deploys.md)
32
+ repo: { provider: 'github', org }, // ONE block (#883): where the brand hosts its SOURCE. Presence enables the repo service, `provider` defaults to `github`, and `org` is the only typed value: every repo name derives from `<brand.id>-<role>` and visibility is the brand root package.json's `private` field, so neither is configurable here
33
33
  edge: { providers: { cloudflare: { enabled, zone, dns, settings, rules, cacheRules, speedTest, workers } } }, // rules.redirect: the ORDERED dynamic-redirect ruleset — [{ name, expression, statusCode, preserveQueryString, targetUrl, enabled }], `expression`/`targetUrl` in Cloudflare's own filter language. The ONE home for a TEMPLATED redirect, whose destination is computed from the request path (#466); the manager's edge service reconciles them by `name` (docs/manager/edge.md)
34
34
  captcha: { providers: { recaptcha: { project, siteKey, domainsConfirmed: [] } } }, // domainsConfirmed: machine-written — the classic key's domain list has no API, so the captcha service records the owner's confirmation here and stops asking
35
35
  search: { providers: { searchConsole: { enabled, submitSitemap, sitemapPaths, gaLinked } } }, // #546: each `enabled` is the service's own switch, default ON — false skips that whole service. gaLinked: machine-written — the Search Console ↔ GA association has no API, so the confirmation is the record
@@ -38,43 +38,47 @@ JSON5: comments, trailing commas, unquoted keys, single quotes all allowed.
38
38
  email: { providers: { replyify: { enabled, agentId, templateAgentId, updateAgentInfo, plan, discount } } } },
39
39
  analytics: { providers: { google: { id, propertyId, accountId }, meta: { id, accountId }, tiktok: { id, accountId, appId } } }, // #524: the pixel/measurement id is the RUNTIME value; propertyId/accountId are the platform ids the manager reconciles against (never secrets — tokens stay in .env). tiktok.appId: the DEVELOPER APP the token mint authorizes through (#448/#635) — public config; the app secret is pasted once and never saved
40
40
  advertising: { providers: { adsense: { client, displaySlot, inArticleSlot, inFeedSlot, multiplexSlot }, inhouse: { source } }, fallback, tags: [] }, // C4 cp105; inhouse source: 'self' | 'company' | full URL (ads spec). #527: `client` is the ONE adsense switch — its presence drives the managed account, the ad units and the ads.txt record together (no `units`, no `enabled`). Role-level: `fallback: 'inhouse'` is the lane a provider miss falls through to (false/absent ends at the built-in promo), `tags` are the brand's contextual targeting tags
41
- payment: { providers: { stripe: { publishableKey }, paypal: { clientId }, chargebee: { site }, coinbase: { enabled } }, products: […], winback: { enabled, percent, amount, duration } }, // #642: coinbase (Coinbase Commerce, crypto, one-time purchases only) is the one provider switched by an explicit `enabled`, default OFF — its whole credential is the secret COINBASE_COMMERCE_API_KEY, so there is no public datum to gate on. winback = the cancel-flow save offer (#268), on by default at 50% off the next cycle — see below
41
+ payment: { currency, providers: { stripe: { publishableKey }, paypal: { clientId }, chargebee: { site }, coinbase: { enabled } }, products: […], winback: { enabled, percent, amount, duration } }, // currency: the ISO 4217 code every price in `products` is quoted in, default `USD` (#850). One currency per brand, named by the pricing page's JSON-LD, the checkout and the backend's order history alike; the successor to the retired `targets.web.currency`. #642: coinbase (Coinbase Commerce, crypto, one-time purchases only) is the one provider switched by an explicit `enabled`, default OFF: its whole credential is the secret COINBASE_COMMERCE_API_KEY, so there is no public datum to gate on. winback = the cancel-flow save offer (#268), on by default at 50% off the next cycle; see below
42
42
  monitoring: { enabled, providers: { sentry: { org, dsn, environment, sampleRate, tracesSampleRate, replaysSessionSampleRate, replaysOnErrorSampleRate, scrubEmail, attachScreenshot, bundlePatterns: [] } } }, // #425: the monitor is a KEY under `providers`. dsn presence IS the runtime enable signal; environment unset = the host's gate names it; scrubEmail defaults true (email OFF), attachScreenshot is desktop-only, bundlePatterns + the two replay rates browser-only (replay defaults to 0 — opt-in, #485). docs/shared/monitoring.md
43
43
  connections: { <provider>: { enabled, scope: [], name, logo, description } }, // #771/#788: per-provider USER-CONNECTION settings, keyed by provider name; public values only (the credentials are the CONNECTIONS_<PROVIDER>_CLIENT_ID/_SECRET env pair). The provider set is OPEN — a brand ships its own as `targets/backend/src/connections/<name>.js` — so the section stays free-form. See below
44
44
  theme: { id, appearance }, // project-owned; seeded at onboarding
45
- translation: { enabled, default, languages: [], providers: { claude: {} } | { chatgpt: {} }, model, exclude: [] }, // presence picks the engine; absent = claude. docs/shared/translation.md
45
+ translation: { enabled, default, languages: [], providers: { claude: {} } | { chatgpt: {} }, model, include: [] }, // presence picks the engine; absent = claude. `include` is the web route list (#858): globs with `!` negation, read in .gitignore order, default ['**', '!blog/**'], a brand list REPLACES it, and a page overrides it for itself with `translation.include: true`/`false` in frontmatter. docs/shared/translation.md
46
46
  socials: { twitter: 'somiibo', spotify: { handle, redirect } }, // platform → handle. The handle derives the profile URL every surface reads (JSON-LD sameAs, the footer row, omega_social) and @omega.js/web emits a shortlink redirect page at /<platform> per entry (#429); the object form adds a redirect target that WINS for the shortlink when it is not the profile URL. Blank handle = no entry, no page. Not a disperse-owned SHARED_SECTION — brand-level content, read by the web target
47
47
 
48
48
  // MANAGER-read brand-level sections (#277). Schema-known at the TOP level: the
49
49
  // manager loads the brand config unfolded, and a website-only brand has no
50
50
  // `targets.backend` to hold them (presence there would enable the target). A
51
51
  // `targets.backend.<same key>` block still overrides any of them.
52
- parent: 'self' | 'https://parent.example.com' | false, // webhook parent topology; false = shared webhook account owned elsewhere
52
+ company: { id: '<parent brand.id>' | 'self', webhooks: true }, // #677: the ONE key joining a brand to its company, OUTSIDE `brand`. `id` and `webhooks` are the only typed keys; the loader FILLS the same key with { id, name, url, images: { wordmark }, webhooks } from the parent's own config, and a brand with no company resolves to its own name/url under a null id, so no reader carries a fallback. `webhooks: false` says the provider ACCOUNT (SendGrid Event Webhook, Beehiiv webhook) is somebody else's and the walk never repoints its one webhook. See "The company" below
53
53
  domain: { providers: { namecheap: {} }, email: { providers: { cloudflare: {} }, forwarding: [] } }, // TWO roles (#425): the REGISTRAR is the one key under `providers` (namecheap is the one the service drives by API; every other registrar gets manual instructions), the mailbox provider the one key under `email.providers`. Presence picks; no entry = nothing chosen, and the service skips
54
54
  certificates: { enabled, providers: { apple: { bundleIdPrefix, capabilities: [], profiles: [], certificates: [] } } }, // Apple signing for desktop/mobile targets. bundleIdPrefix is the brand's own answer ('com.mycompany' + brand.id composes the bundle id); the credentials live in .env (APPLE_API_ISSUER, APPLE_API_KEY_ID, APPLE_TEAM_ID)
55
- github: { user, website }, // GitHub identity for the brand (content identity; repo.providers.github is the source-hosting home)
56
55
  reviews: { enabled, sites: [] },
57
56
  marketing: { campaigns: { enabled, providers: { sendgrid: { listId, groups: { orders, hello, account, marketing, security, newsletter, internal } } } }, newsletter: { enabled, providers: { beehiiv: { publicationId } }, content: […] }, prune: { enabled } }, // #425: each role names its vendor as a KEY under `providers`; `enabled` and the newsletter `content` PIPELINE blob stay role-level. `prune` is ON by default (Ian 2026-08-22, #478) and per-brand disableable: packages/backend/docs/marketing-campaigns.md § Contact Pruning. `groups` holds the SendGrid unsubscribe (ASM) group ids — per ACCOUNT, so the campaigns service provisions them by name and writes the ids here (#649)
58
57
  blog: { /* AI blog-content settings (Ghostii pipeline) */ },
59
58
  devlog: { enabled, providers: { ghostii: { orgs, lookbackDays, … } } }, // commit-digest devlog (#553): `enabled: true` PUBLISHES AI-written posts to the live site, so it is case 3 — the literal true is the only ON, absence is off, and no default is materialized
60
59
  seo: { github: { content: [] } }, // the manager's parasite-SEO content repos; big blocks may live in the `config/seo.json5` sidecar. The site-wide SEARCH POSTURE is NOT here: it is `targets.web.meta.index`, the same name a page writes (#564)
61
60
  dataRequest: { /* GDPR/CCPA data-request query definitions */ },
62
- directory: { enabled }, // opt in to the manager's directory PUSH — this brand's entry into `parent`'s brands collection (#246); default off, public facts only. docs/manager/directory.md
61
+ directory: { enabled }, // opt in to the manager's directory PUSH: this brand's entry into the COMPANY's brands collection (#246/#677); default off, public facts only. docs/manager/directory.md
63
62
  sponsorships: { acceptable: [], unacceptable: [], prices: { 'guest-post': 70, 'link-insertion': 50 } }, // sponsorship terms — the first directory BLOCK; `prices` is an open placement→USD map, not an enum
64
63
 
65
- // TARGET-scoped config. KEY PRESENCE = "this brand enables this target"
66
- // (replaces the legacy brand-config targets ARRAY). `extension: {}` means
67
- // enabled-with-defaults. Unknown keys are validation errors UNLESS they
68
- // declare `type: 'custom'` (see Custom targets below). A value may
69
- // also be an ARRAY of id'd instances (see Multi-instance targets below).
64
+ // TARGET-scoped config. EVERY KEY IS A TARGET NAME and key presence =
65
+ // "this brand enables a target by that name" (#886); the name is the folder
66
+ // `targets/<name>`. Every entry MUST declare its `type`: the framework that
67
+ // runs there, or `custom` (see Custom targets below). The keys below are
68
+ // named for their type, which is the common case, not a rule: a second web
69
+ // target is a sibling key with `type: 'web'` (see Targets below).
70
70
  targets: {
71
- web: { meta: { index }, imagemin, collections, client: { consent, … }, dev: { limitCollections } }, // `meta` holds SITE-WIDE defaults for page-meta values, spelled exactly as a page spells them (#564; `index` is the only key today). No `redirects` key: #466 retired it — a TEMPLATED redirect is a Cloudflare redirect rule (edge.providers.cloudflare.rules.redirect), an enumerable one is a redirect PAGE (docs/web/index.md). client: the @omega.js/client runtime blob (auth, sentry, exitPopup, …) — a settings bag the client normalizes; only the keys a BRAND authors are schema-known: `consent` (see "Consent" below), plus `auth.config.policy` ('authenticated' | 'unauthenticated' | 'disabled'; absent = no policy, and the auth/admin layouts set theirs in page frontmatter), `exitPopup.enabled` and `serviceWorker.enabled` (both default true, materialized) ([#650](https://github.com/Omega-JS-Stack/omega/issues/650)). collections: the brand's OWN content collections — name → { field, size, title, description, permalink }; documents live in `_<name>/` and the engine generates the listing + one page per category of `field` (#207). dev.limitCollections: dev-only collection sampling — collection name → max documents ({ posts: 50 }) plus `randomize: true`; development builds only, production always ships the whole site (#190)
72
- backend: { projectType, auth: { signup: { maxPerIpPerDay } } }, // projectType: 'firebase' (default — Cloud Functions) | 'custom' (the same backend as its own server on PORT, for a container host — no Functions deploy, no emulator lane; see "Backend project type" below). auth.signup.maxPerIpPerDay: signups allowed per client IP per day, positive integer, default 2. Raise it for audiences behind shared egress (NAT/CGNAT, VPNs, offices)
73
- desktop: { app, platforms: { mac, win, linux }, autoUpdate, startup,
74
- releases, remoteConfig, remoteScripts, restartManager },
75
- extension: { /* near-empty at launch */ },
76
- mobile: { /* RESERVED — MAM parked */ },
77
- api: { type: 'custom' }, // any OTHER key = a custom target (#603) — the manager drives it entirely through its own package.json scripts; see "Custom targets" below
71
+ web: { type: 'web', hosting: { provider: 'github' }, meta: { index }, imagemin, collections, client: { consent, … }, dev: { limitCollections } }, // `hosting` says who SERVES this target's built site (#883): web targets only, `github` by default, which publishes it to the target's own `<brand.id>-<name>` repo and serves it from Pages at the target's url. `meta` holds SITE-WIDE defaults for page-meta values, spelled exactly as a page spells them (#564; `index` is the only key today). No `redirects` key: #466 retired it; a TEMPLATED redirect is a Cloudflare redirect rule (edge.providers.cloudflare.rules.redirect), an enumerable one is a redirect PAGE (docs/web/index.md). client: the @omega.js/client runtime blob (auth, sentry, exitPopup, …): a settings bag the client normalizes; only the keys a BRAND authors are schema-known: `consent` (see "Consent" below), plus `auth.config.policy` ('authenticated' | 'unauthenticated' | 'disabled'; absent = no policy, and the auth/admin layouts set theirs in page frontmatter), `exitPopup.enabled` and `serviceWorker.enabled` (both default true, materialized) ([#650](https://github.com/Omega-JS-Stack/omega/issues/650)). collections: the brand's OWN content collections: name → { field, size, title, description, permalink }; documents live in `_<name>/` and the engine generates the listing + one page per category of `field` (#207). dev.limitCollections: dev-only collection sampling: collection name → max documents ({ posts: 50 }) plus `randomize: true`; development builds only, production always ships the whole site (#190)
72
+ backend: { type: 'backend', projectType, auth: { signup: { maxPerIpPerDay } } }, // projectType: 'firebase' (default: Cloud Functions) | 'custom' (the same backend as its own server on PORT, for a container host: no Functions deploy, no emulator lane; see "Backend project type" below). auth.signup.maxPerIpPerDay: signups allowed per client IP per day, positive integer, default 2. Raise it for audiences behind shared egress (NAT/CGNAT, VPNs, offices)
73
+ desktop: { type: 'desktop', omega: { authPersistence }, app: { appId, productName, copyright, category, languages, darkModeSupport },
74
+ platforms: { mac, windows, linux },
75
+ autoUpdate: { enabled, autoDownload, startupDelayMs, feedCheckIntervalMs, idleEvalIntervalMs, maxAgeMs },
76
+ startup: { mode, openAtLogin: { enabled, mode } },
77
+ releases, remoteConfig: { enabled, url }, remoteScripts, restartManager }, // platforms: what this app SHIPS plus each platform's install knobs (#867): `platforms.<mac|windows|linux>.formats.<dmg|nsis|deb|appimage|snap>`, presence = enabled, `false` drops a default, per-format settings inside the format (see "The shipping declaration" below)
78
+ extension: { type: 'extension', platforms: { chrome, firefox, edge }, categories, listings }, // platforms: the SAME shipping declaration the desktop target carries (#867): `platforms.<chrome|firefox|edge>.formats.<zip|store>`, where the zip rides the release and the store is published to. categories: the AMO slugs a FIRST Firefox publish lists the add-on under (#884), default ['alerts-updates'] and validated against AMO's own set; the Firefox lane sends them with the summary (brand.description, cut to 250) and the target package.json `license` (UNLICENSED lists as all-rights-reserved). listings: per store, the `id` the store knows this extension by (#893: chrome/firefox/edge; the firefox one IS the manifest's gecko id) plus the `url` and `state` the site's /extension page reads (see "The site global" below)
79
+ mobile: { type: 'mobile' /* RESERVED: MAM parked */ },
80
+ community: { type: 'web', url: 'https://community.acme.com' }, // a SECOND web target → targets/community
81
+ api: { type: 'custom' }, // a target no framework owns (#603): the manager drives it entirely through its own package.json scripts; see "Custom targets" below
78
82
  },
79
83
  }
80
84
  ```
@@ -84,26 +88,57 @@ JSON5: comments, trailing commas, unquoted keys, single quotes all allowed.
84
88
  `loadConfig(projectDir, target, { defaults })` produces ONE resolved object per target:
85
89
 
86
90
  ```
87
- schema defaults ← framework defaults ← company ← brand shared ← brand targets[target] ← local shared ← local targets[target]
91
+ schema defaults ← framework defaults ← company ← company.<environment> ← brand shared ← brand targets[name] ← brand.<environment> ← local shared ← local targets[name] ← local.<environment>
88
92
  ```
89
93
 
90
94
  - **The bottom layer is the SCHEMA's own defaults** ([#478](https://github.com/Omega-JS-Stack/omega/issues/478)) —
91
95
  see [Defaults & self-healing](#defaults--self-healing) below. `options.defaults` sits directly
92
96
  above it and carries only what a framework does differently.
93
97
  - "shared" = the file minus its `targets` key. In a standalone repo only the local layers exist.
94
- - **The company layer** is the company workspace's own `config/omega.json5`, found through the
95
- brand's `.omega/company.json` stamp (the same marker the `.env` cascade and owner hooks read —
96
- see below). It layers exactly like the brand file (company shared ← company `targets[target]`)
97
- minus its `brands` key, which is company plumbing and never inherits. An unstamped brand has no
98
- company layer; the resolved result reports the file it used as `files.company`.
98
+ - **Every layer is TWO files**: its `config/omega.json5` and an optional
99
+ `config/omega.<environment>.json5` overlay beside it
100
+ ([#856](https://github.com/Omega-JS-Stack/omega/issues/856)), the config mirror of the
101
+ `.env` + `.env.<environment>` pair below. The overlay wins over its OWN base and still
102
+ loses to the layer above, each file keeping the shared-then-`targets[name]` split. The
103
+ three names are exactly what `envEnvironment()` returns, so the config overlay, the env
104
+ overlay and the runtime's own answer are ONE vocabulary: exactly ONE environment's
105
+ overlay composes, a missing overlay is nothing, and a file named anything else
106
+ (`omega.staging.json5`) is not a layer at all, so nothing reads it and nothing warns
107
+ about it. An overlay is a PARTIAL, holding only the overrides: the validator judges the
108
+ merged result, never an overlay on its own. Nothing scaffolds one; a brand writes the
109
+ file the day it has a public value that differs by environment (the case that named it:
110
+ a `pk_test_` Stripe publishable key for the local checkout, the live key in the base).
111
+ - **WHICH environment's overlay composes is the LANE's word, never the machine's**
112
+ ([#856](https://github.com/Omega-JS-Stack/omega/issues/856)): `loadConfig` and
113
+ `composeTargetConfig` take `options.environment`, and every lane that produces a
114
+ production artifact names `production`, the way `stageFunctions({ environment })` already
115
+ names one for the `.env` overlay beside it. So a production build never carries a
116
+ development override, even though the machine that runs it answers `development` in a
117
+ terminal: the web build and both web deploy lanes name it, the desktop and extension
118
+ bakes name it under their own `OMEGA_BUILD_MODE` word, and the backend deploy stage names
119
+ it for both halves of the upload at once. The ambient answer (`envEnvironment()`) is what
120
+ a DEV boot and a deployed runtime take, which is the whole point of it; an environment
121
+ outside the three names throws. A compose asks outright: told nothing,
122
+ `composeTargetConfig` freezes the authored base layers and no overlay at all.
123
+ - **The company layer** is the company's own `company/config/omega.json5`, found through the
124
+ brand's `company: { id }` key and the machine registry (the same ONE resolver the `.env`
125
+ cascade, owner hooks and the signing tree read, [#677](https://github.com/Omega-JS-Stack/omega/issues/677)
126
+ see "The company" below). It layers exactly like the brand file (company shared <-
127
+ company `targets[name]`). A brand naming no company has no company layer; the resolved
128
+ result reports the file it used as `files.company`.
99
129
  - **`projectDir` may be one of a target's SUBDIRS** — `functions/` (@omega.js/backend's runtime cwd)
100
130
  or `dist/` (its staged build output, the view `omega test` loads): every walk (brand root,
101
- company marker, local-layer fallback, instance id, compose, `resolveBrandRoot`) treats the target root
131
+ company marker, local-layer fallback, target name, compose, `resolveBrandRoot`) treats the target root
102
132
  as one level up, so `loadConfig(functionsDir, 'backend')`, `loadConfig(distDir, 'backend')` and
103
133
  `loadConfig(targetRoot, 'backend')` resolve identically. A staged `config/omega.json5` inside either
104
134
  subdir is the deployed runtime's own view, never an authored local layer.
135
+ - **WHICH entry is the target layer comes from the DIR**: `targets/community` resolves
136
+ `targets.community` (§ Targets). A standalone project has no such dir, so it is named by its
137
+ own file: the SINGLE declared target of the framework's type (a deployed backend staged from
138
+ `api: { type: 'backend' }` is still the `api` target, so its entry is the layer, `enabled` is
139
+ true and its url derives), and the type word when the file declares none or declares two.
105
140
  - **Target sections overlay the TOP LEVEL**: `targets.desktop.platforms` resolves to
106
- `config.platforms`; frameworks never read through `config.targets.<type>.…`.
141
+ `config.platforms`; frameworks never read through `config.targets.<name>.…`.
107
142
  - **Any shared key inside a target entry overrides it for that surface** — a desktop-only
108
143
  Sentry DSN is just `targets.desktop.monitoring.providers.sentry.dsn`; disabling any integration per-surface is
109
144
  uniformly `<key>: { enabled: false }`. One agnostic deep merge everywhere (objects merge,
@@ -113,107 +148,172 @@ schema defaults ← framework defaults ← company ← brand shared ← brand ta
113
148
  - **@omega.js/web adds one MORE layer, per page** ([#607](https://github.com/Omega-JS-Stack/omega/issues/607)):
114
149
  a page's (or layout's) `config:` frontmatter block merges over the resolved config for that page
115
150
  alone, and templates read the result as `resolved.config.*` — the WHOLE merged config, never a
116
- subset. It is the same deep merge, one layer higher — `… ← local targets[target] ← page config:`.
151
+ subset. It is the same deep merge, one layer higher: `… ← local targets[name] ← page config:`.
117
152
  Nothing else in the config chain knows about it. The membership rule runs both ways: a page
118
153
  restating a config section BARE is a build error, and a key under `config:` that no omega.json5
119
154
  section answers to is a build error too. Page machinery — `meta`, `schema`, `layout`,
120
155
  `permalink` — is not config and has no home in this file at all
121
156
  ([docs/web/frontmatter.md](../web/frontmatter.md)).
122
157
  - The merged `targets` map rides along on the resolved config so enabled-target enumeration
123
- survives (`getEnabledTargets()`); the `enabled` flag on the result says whether the
124
- requested target is listed.
158
+ survives (`getEnabledTargets()`); the `enabled` flag on the result says whether the resolved
159
+ NAME is listed, and the result carries that `name`.
160
+ - **A dir whose entry runs another framework is a loud error**: `loadConfig(targets/backend,
161
+ 'desktop')` throws `targets.backend is type backend; this project runs the desktop framework`
162
+ rather than silently merging somebody else's layer.
125
163
  - No target argument → whole-file merge (the shape omega-manager's disperse works with).
126
164
 
127
- ## Multi-instance targets
165
+ ## Targets: every key is a NAME (#886)
128
166
 
129
- One brand can run N instances of the SAME target type (the legacy `brand.subdomains` need:
130
- admin/cdn/app sites of one brand) — `targets.<type>` takes an **object OR an array of id'd
131
- instances** ([_attic/plans/multi-instance-targets.md](../../_attic/plans/multi-instance-targets.md), ratified
132
- 2026-07-20):
167
+ `targets` is an object whose EVERY key is a target NAME. The name is the folder
168
+ (`targets/<name>`), the `--target=<name>` word, and the derived-repo suffix. Every entry
169
+ declares a `type`: the framework that runs there (`web`, `backend`, `desktop`, `extension`,
170
+ `mobile`) or `custom`. A brand runs two web sites by declaring two names:
133
171
 
134
172
  ```json5
135
173
  targets: {
136
- backend: { /* single instance — today's shape, unchanged */ },
137
- web: [
138
- { id: 'main' }, // the primary — targets/website, acme.com
139
- { id: 'admin', // targets/website-admin, admin.acme.com
140
- brand: { name: 'Acme Admin' } }, // overrides brand shared for admin ONLY
141
- { id: 'store', url: 'https://shop.acme.com' }, // targets/website-store, a custom host
142
- ],
174
+ web: { type: 'web', url: 'https://somiibo.com' },
175
+ community: { type: 'web', url: 'https://community.somiibo.com' },
176
+ backend: { type: 'backend' },
177
+ desktop: { type: 'desktop' },
178
+ docs: { type: 'custom' },
143
179
  }
144
180
  ```
145
181
 
146
- - **Normalization is the whole mechanism** (`normalizeTargetInstances`): a single object is
147
- `[{ id: 'main', ...entry }]` internally — every consumer iterates instances and the
148
- single-instance world is just length 1. Zero breaking change for existing brands.
149
- - **Target-dir mapping**: `main` → `targets/<canonical dir>` (unchanged); any other id →
150
- `targets/<canonical dir>-<id>`. The inverse walk names the instance from the dir
151
- (`website-admin` → web/admin), and `loadConfig`/`composeTargetConfig` slot THAT instance's
152
- entry into the merge chain: `defaults ← brand shared ← instance entry ← local shared ← local
153
- targets.<type>`. The instance `id` key is bookkeeping — stripped, never config. The result
154
- carries `instance` (the resolved id).
155
- - **Validator rules**: array entries MUST carry a dir-safe `id`, unique per type; an empty
156
- array is an error; **>1 backend instance is a WARNING** (`warnings` on the result) — backend
157
- stays single-instance in practice (one Cloud Functions surface per brand).
158
- - **Scoping rules**: the single-object form applies to EVERY target of the type (today's
159
- behavior, suffixed dirs included); the array form is exact-id — a target dir with no matching
160
- id rides shared config alone. The workspace structure op expects every instance's exact dir
161
- (missing = the same create-this-dir error as today).
162
- - **The instance id IS the subdomain** ([#588](https://github.com/Omega-JS-Stack/omega/issues/588),
163
- Ian 2026-09-01). An entry with no `url` of its own resolves to `https://<id>.<host of
164
- brand.url>` for every id but `main`, and `main` keeps `brand.url`, so `web: [{ id: 'main' },
165
- { id: 'admin' }]` is a COMPLETE declaration. An explicit `url` overrides it for a custom host
166
- (`{ id: 'store', url: 'https://shop.acme.com' }`), and an instance-scoped `brand.url` overrides
167
- it too. The host is taken EXACTLY as `brand.url` states it: a `www.` brand derives
168
- `admin.www.acme.com`, and no usable `brand.url` derives nothing at all (null, never a
169
- half-built `https://admin.`).
170
- - **One shared `api.<domain>`**: every instance talks to the same backend, so the manager's cloud
171
- hosting op ensures exactly one API domain no matter how many instances a brand runs
182
+ - **No id, no folder key, no name table.** The key IS the answer, so the dir walk and the path
183
+ derivation are each one line: `targetPath(config, 'community')` → `targets/community`, and
184
+ `targetNameFromDir(projectDir)` → the target root's basename inside a brand (null for a
185
+ standalone project, whose dir name is arbitrary). `targetEntries(config)` is the ONE
186
+ enumeration, `{ name, type, ...entry }` in config order; `targetsOfType(config, 'web')`
187
+ filters it. Nothing derives a folder any other way.
188
+ - **The merge chain is by NAME**: `defaults ← company ← brand shared ← brand targets.<name> ←
189
+ local shared ← local targets.<name>`. A shared key inside an entry still overrides the shared
190
+ value for that surface, exactly as before.
191
+ - **The NAME is the subdomain** ([#588](https://github.com/Omega-JS-Stack/omega/issues/588),
192
+ Ian 2026-09-01). `targetUrl(config, name)` is the one resolver: the entry's own `url`, else a
193
+ target-scoped `brand.url`, else `brand.url` when the name IS its type (`web: { type: 'web' }`
194
+ is the brand itself), else `https://<name>.<host of brand.url>`, so `community: { type:
195
+ 'web' }` is a COMPLETE declaration. An explicit `url` overrides it for a custom host. The host
196
+ is taken EXACTLY as `brand.url` states it (a `www.` brand derives `admin.www.acme.com`), and
197
+ no usable `brand.url` derives nothing at all (null, never a half-built `https://admin.`).
198
+ - **Validator rules**: a key must be a dir-safe slug (`/^[a-z][a-z0-9-]*$/`, because it is a
199
+ folder name), an entry must be a plain object, and its `type` must be one of the six. An ARRAY
200
+ is the retired multi-instance form and names its replacement, a sibling key
201
+ ([breaking-changes.md](breaking-changes.md)). **More than one `backend` target is a WARNING**
202
+ (`warnings` on the result): backend stays single in practice, one Cloud Functions surface per
203
+ brand.
204
+ - **Dev ports offset by the target's position among its OWN type**
205
+ (`targetPortOffset(config, name)`): the first web target takes the classic base, the second
206
+ takes base + 1, and a backend declared between them shifts nothing
207
+ ([docs/shared/local-dev.md](local-dev.md)). The N7 bump-if-taken allocator still guarantees a
208
+ free port.
209
+ - **An override AT the target IS its url**, never a base to stack the name on: the entry's own
210
+ `brand.url`, a `targets/<name>/config/omega.json5` naming `https://shop.acme.test`, or a dev
211
+ layer naming `http://localhost:4000` are each the answer as written (no `shop.shop.acme.test`,
212
+ no `https://admin.localhost:4000`). The derivation only runs while the resolved `brand.url` is
213
+ still the brand layer's. A CUSTOM host belongs on the entry's `url`, never on a target-scoped
214
+ `brand.url`: the authDomain check reads `brand.url`, so overriding it there makes that check
215
+ compare against the custom host and fail.
216
+ - **Brand-level facts stay brand-level.** `cloud.config.authDomain` compares against `brand.url`
217
+ for every target (one Firebase project, one backend, one authDomain), and so does the persona
218
+ domain the test lanes seed. Only a target's PUBLIC surface is per target: `site.url`, the
219
+ gh-pages CNAME `omega deploy`/`omega build` write, and the deploy path prefix.
220
+ - **One shared `api.<domain>`**: every web target talks to the same backend, so the manager's
221
+ cloud hosting op ensures exactly one API domain no matter how many targets a brand runs
172
222
  ([docs/manager/cloud.md](../manager/cloud.md)).
173
- - **Brand-level facts stay brand-level.** `cloud.config.authDomain` compares against
174
- `brand.url` for every instance (one Firebase project, one backend, one authDomain), and so
175
- does the persona domain the test lanes seed. Only the instance's PUBLIC surface is per
176
- instance: `site.url`, the gh-pages CNAME `omega deploy`/`omega build` write, and the deploy
177
- path prefix.
178
- - **An override AT the instance IS the instance url**, never a base to stack the id on: an
179
- instance entry's own `brand.url`, a `targets/website-<id>/config/omega.json5` naming
180
- `https://shop.acme.test`, or a dev layer naming `http://localhost:4000` are each the answer
181
- as written (no `shop.shop.acme.test`, no `https://admin.localhost:4000`). The derivation only
182
- runs while the resolved `brand.url` is still the brand layer's.
183
- A CUSTOM host belongs on the entry's `url` (`{ id: 'store', url: 'https://shop.acme.test' }`),
184
- never on an instance `brand.url`: the authDomain check reads `brand.url`, so overriding it
185
- at the instance makes that check compare against the custom host and fail.
186
- - **The resolved `url` is a declared key** (`packages/config/src/schema.js`), so an instance load
223
+ - **The resolved `url` is a declared key** (`packages/config/src/schema.js`), so a target load
187
224
  raises no undeclared-key warning for the url it just derived.
188
- - **Per-instance surfaces**: dev ports offset by array position (docs/shared/local-dev.md), deploy
189
- records key per target (docs/shared/deploys.md), and every reader of an instance's public URL
190
- (the manager's live-URL checks, the resolved config's top-level `url`, `site.url` in templates)
191
- goes through the one resolver (`resolveInstanceUrl`: instance `url` → instance `brand.url` →
192
- the derived `<id>.<host>` → brand shared `brand.url`).
193
- - **Legacy `brand.subdomains` conversion rule**: each subdomain becomes a web instance,
194
- `["admin", "cdn"]` → `web: [{ id: 'main' }, { id: 'admin' }, { id: 'cdn' }]`; the ids carry the
195
- subdomains, so nothing else is written. The key itself is a retired path (below), so a config
196
- still carrying it fails validation with that recipe.
197
- - Non-goals (v1): no cross-instance shared builds, no per-instance Firebase projects.
225
+ - **Legacy `brand.subdomains` conversion rule**: each subdomain becomes its own web target,
226
+ `["admin", "cdn"]` → `admin: { type: 'web' }, cdn: { type: 'web' }` beside the main site; the
227
+ names carry the subdomains, so nothing else is written. The key itself is a retired path
228
+ (below), so a config still carrying it fails validation with that recipe.
229
+ - Non-goals: no cross-target shared builds, no per-target Firebase projects.
230
+
231
+ ## The shipping declaration (`platforms`) and the format table (#867)
232
+
233
+ Desktop and extension declare what they SHIP in the SAME shape, in the ONE vocabulary
234
+ (@omega.js/client's `getPlatform()` / `getBrowser()`): platforms `mac`, `windows`,
235
+ `linux`, `chrome`, `firefox`, `edge`; formats `dmg`, `nsis`, `deb`, `appimage`, `snap`,
236
+ `zip`, `store`. It is `mac`, never `macos`, and `windows`, never `win`.
237
+
238
+ ```json5
239
+ targets: {
240
+ desktop: {
241
+ type: 'desktop',
242
+ platforms: {
243
+ mac: { formats: { dmg: {} }, arch: ['universal'] },
244
+ windows: { formats: { nsis: {} }, oneClick: true, signing: { strategy: 'self-hosted' } },
245
+ linux: { formats: { deb: {}, appimage: {}, snap: { channels: ['stable'] } } },
246
+ },
247
+ },
248
+ extension: {
249
+ type: 'extension',
250
+ platforms: {
251
+ chrome: { formats: { zip: {}, store: {} } },
252
+ firefox: { formats: { zip: {}, store: { channel: 'listed' } } },
253
+ edge: false, // this brand does not ship to Edge
254
+ },
255
+ },
256
+ }
257
+ ```
258
+
259
+ - **Presence is the switch, and every platform and format defaults ON.** A brand drops one
260
+ with the literal `false` (the `certificates: false` idiom), never by omitting it: a
261
+ config that says nothing ships everything the framework can build.
262
+ - **Per-format settings live INSIDE the format** (`linux.formats.snap.channels`,
263
+ `firefox.formats.store.channel`), which is why the declaration is an object and not a
264
+ list. The keys BESIDE `formats` are that platform's install knobs (arch, the NSIS
265
+ installer flags, mac entitlements, the Windows signing strategy).
266
+ - **The format table is [`platforms.js`](../../packages/config/src/platforms.js)**, the
267
+ successor of `desktop-artifacts.js`: per target, platform and format it says what the
268
+ format IS (`kind: 'asset'`, a file on the brand's releases repo with an `ext`; or
269
+ `kind: 'store'`, a publish), the env keys it cannot ship without (`requires`) and the
270
+ per-listing config paths a store needs before it can accept an upload (`listing`, the
271
+ `listings.<browser>.id` of [#893](https://github.com/Omega-JS-Stack/omega/issues/893)),
272
+ and, for a store, the `label` and `console` page a human creates that listing on.
273
+ Everything derives from it: the electron-builder target lists, the site's
274
+ `/download/<platform>/<format>` links and their versionless asset names, the manage
275
+ walk's per-brand list of ship credentials and its Enter-to-open ask, and the manual step
276
+ a publish prints for a listing that does not exist yet.
277
+ - **A `listing` says what a store needs BEFORE it can accept an upload**, which is why the
278
+ firefox store declares none: AMO takes the manifest's gecko id, so that publish always
279
+ goes through, and the id is the brand's own derived value, pinned into the brand config as
280
+ `listings.firefox.id` by the extension's local scaffold (never by a publish, which runs on
281
+ a throwaway checkout). Chrome and Edge assign theirs in a dashboard, so those two are
282
+ the ids the walk asks for, with a "not yet" skip.
283
+ - **`requires` names ENV keys, and the env schema owns them.** A key with a `requiredWhen`
284
+ applies only when that rule holds, so the Windows nsis format asks for the credentials of
285
+ the strategy the brand actually picked and no other's. The schema is also where each key's
286
+ `label`, `url` (the page that mints it) and `hint` live: one home for what a key is and
287
+ where it comes from (see "The env schema" below).
288
+ - **The one translation points.** Node's `darwin`/`win32` becomes OMEGA's word in
289
+ @omega.js/desktop's `utils/platform.js`; electron-builder's `mac`/`win`/`AppImage`/`nsis`
290
+ is spelled only in its `gulp/tasks/build-config.js`; GitHub's runner labels only in the
291
+ generated workflow. `APPLE_*` and `SNAPCRAFT_*` env names stay as they are: they name
292
+ vendors, not platforms.
293
+ - **Retired, with a migration**: `platforms.win` and `platforms.linux.snap` are registered
294
+ retired paths, so a brand still carrying either fails validation naming its replacement.
295
+ `npx omega manage --migration=platform-names --execute` performs the rewrite at the brand
296
+ root (every omega.json5 it owns, brand file and target files alike) and renames
297
+ `config/icons/macos/` to `config/icons/mac/` with it. Run it BEFORE `omega migrate`, which
298
+ DELETES a retired key rather than moving it. `snap.enabled: false` becomes
299
+ `formats.snap: false`, because presence is the switch now.
198
300
 
199
301
  ## Custom targets (#603)
200
302
 
201
303
  A brand also runs targets no framework owns — a Render API, a worker, a script. They are
202
- declared under **any key that is not a framework name**, and the entry must say what it is:
304
+ declared like any other target, with `type: 'custom'`:
203
305
 
204
306
  ```json5
205
307
  targets: {
206
- web: {},
207
- api: { type: 'custom' }, // → targets/api
208
- jobs: [{ id: 'main', type: 'custom' }, { id: 'nightly', type: 'custom' }], // → targets/jobs, targets/jobs-nightly
308
+ web: { type: 'web' },
309
+ api: { type: 'custom' }, // → targets/api
310
+ jobs: { type: 'custom' }, // → targets/jobs
311
+ nightly: { type: 'custom' }, // → targets/nightly
209
312
  }
210
313
  ```
211
314
 
212
- - **The type is the declaration.** An unknown key WITHOUT `type: 'custom'` is still a
213
- validation error (it is a typo'd framework name), and a framework key WITH it is an error
214
- too — a framework's verbs come from its framework, never from package scripts.
215
- - **The array form works the same way**, and every instance must carry the type; the
216
- target-dir mapping is the shared one (`main` → the bare dir, any other id → `<name>-<id>`).
315
+ - **The type is the declaration**, and it is the ONLY thing checked: the key is a name, so a
316
+ name no framework owns is a deliberate brand choice rather than a typo to guess at.
217
317
  - **Its verbs are its own package.json scripts**: `start`, `build`, `test`, `deploy`, `clean`.
218
318
  The manager runs each through `npm run <verb>` when the script is present and skips it
219
319
  loudly when it is absent — nothing is inferred or defaulted.
@@ -246,7 +346,7 @@ targets: {
246
346
  server that reads Firestore or verifies an ID token works exactly as before.
247
347
  - **Not to be confused with a custom TARGET** (above): that is a target no framework owns.
248
348
  This one IS the `@omega.js/backend` target, with a different artifact. Same-type duplicates
249
- are still the array (multi-instance) form.
349
+ are sibling keys (§ Targets).
250
350
  - The brand-root behavior — deploy through the target's own `deploy` script, `omega dev` booting
251
351
  the server instead of the emulator: [docs/manager/index.md](../manager/index.md).
252
352
 
@@ -256,10 +356,120 @@ targets: {
256
356
  key matching `/(secret|privateKey|apiSecret)$/i` in any section of a raw file, before any
257
357
  merge. Public credentials (`publishableKey`, `clientId`, `cloud.config.apiKey`) pass by
258
358
  design.
259
- - **The legacy `targets` ARRAY form throws** — `targets` is an object keyed by target name.
359
+ - **The legacy `targets` ARRAY form throws**: `targets` is an object keyed by target name,
360
+ and a per-target ARRAY (the retired multi-instance form) is a validation error naming its
361
+ replacement, a sibling key.
260
362
  - Schema findings (required/type/min/match/enum) come back as `errors`, not throws — build-time
261
363
  audit throws on them, boot warns/fails per framework policy.
262
364
 
365
+ ## The company (`company: { id }`), #677
366
+
367
+ ONE key joins a brand to its company, OUTSIDE `brand` (Ian 2026-09-12: "brand key is for
368
+ things about this brand, and the parent/company/organization key is OUTSIDE of that"):
369
+
370
+ ```json5
371
+ company: { id: 'itw-creative-works' }, // a sub-brand: the parent's brand.id
372
+ company: { id: 'self' }, // the company brand itself, kept visible on purpose
373
+ ```
374
+
375
+ - **The `id` is the only key a consumer types.** The loader FILLS the same object at load,
376
+ from the parent's own config: `company: { id, name, url, images: { wordmark } }`. One name,
377
+ one shape, at every level.
378
+ - **A brand with no `company` key resolves to `{ id: null, name: brand.name, url: brand.url,
379
+ images: {} }`**, so no reader anywhere needs a fallback: the footer credit, the email
380
+ wordmark and the in-house ads api all read one shape. `company.name` / `company.url` /
381
+ `company.images` are RESOLVED, never typed: an authored one fails the load naming the key.
382
+ - **The company's shared files live in `company/` inside the company brand's own repo**
383
+ (`company/config/omega.json5`, `company/.env`, `company/.omega/certificates/apple/`), and
384
+ inheritance is ONE rule: a brand-level file the child lacks resolves from there at the same
385
+ relative path (`resolveCompany(brandRoot).file(relPath)`). A new kind of inherited file
386
+ costs zero code. The manage walk's preflight SENDS an operator there
387
+ ([#910](https://github.com/Omega-JS-Stack/omega/issues/910)): once a company resolves,
388
+ every fix line it prints for a missing key names that `company/.env` by path, with the
389
+ brand `.env` as the override, so a credential every sibling brand needs is pasted once.
390
+ - **The doctrine line: a brand with a company reuses the company tree first, always.**
391
+ - **WHERE that repo is on this machine is a cache, never configuration**: `~/.omega/brands.json`
392
+ (`OMEGA_HOME` moves it), keyed by `brand.id`, which every `loadConfig()` refreshes for its
393
+ own brand, and nobody maintains it. A company that has never been loaded here prints ONE line
394
+ per run (`Company <id> is not on this machine: inheritance off…`) and the run continues with
395
+ no company layer.
396
+ - **Off-laptop, nothing ever reads `company/`**: the machine that dispatches a deploy resolves
397
+ it and writes `config/company-resolved.json5` beside the brand config (`{ config, company }`),
398
+ which the mirror push carries and then removes; the loader reads that generated layer when the
399
+ registry has no line. The env half needs nothing: the deploy precheck's secrets push sends the COMPOSED target
400
+ env (company ← brand ← target) as that repo's own secrets.
401
+
402
+ Full contract, and the `omega company init` half: [../manager/company.md](../manager/company.md).
403
+
404
+ ## Config or env? (#893)
405
+
406
+ One value, one home, and the line between the two files is what it IS, not who
407
+ reads it:
408
+
409
+ - **config/omega.json5 holds what is PUBLIC by design**: a value a store URL
410
+ carries, a browser bundle ships, or a binary prints. A store item id, a
411
+ reCAPTCHA site key, a PayPal client id, a Chargebee site name, `cloud.config.apiKey`,
412
+ a Stripe `publishableKey`: anyone can read them off the shipped product, so
413
+ hiding them buys nothing and splitting them across two files costs a drift.
414
+ - **`.env` holds secrets, the login coordinates that only travel WITH a secret,
415
+ and machine paths**: an API key or token; the username, OAuth client id,
416
+ Apple issuer / key id / team id that are useless without the secret beside
417
+ them and are asked for in the same breath (Ian 2026-09-12: the Apple triple
418
+ and the Namecheap username stay in env); and a path that is true of one
419
+ laptop (`OMEGA_FONTAWESOME_ROOT`, `machineLocal`).
420
+ - The validator enforces the first half mechanically: a secret-shaped key in
421
+ omega.json5 hard-fails the load (Hard rules above). The second half is this
422
+ rule plus the retired-key register below.
423
+ - **A public value in config needs no CI delivery.** It rides the snapshot a
424
+ deploy pushes like every other config value, so it is never a repo secret and
425
+ never a `${{ secrets.KEY }}` line.
426
+
427
+ ### Retired env keys (`src/env-retired.js`)
428
+
429
+ Six public identifiers moved out of `.env` with #893. There is no dual-read, so
430
+ a stale line is a value nothing consults: every `.env` layer read
431
+ (`parseEnvFile`, which both the process cascade and the artifact composer go
432
+ through) FAILS on one, naming the move.
433
+
434
+ | Retired `.env` key | Its config home |
435
+ |---|---|
436
+ | `CHROME_EXTENSION_ID` | `targets.<name>.listings.chrome.id` |
437
+ | `FIREFOX_EXTENSION_ID` | `targets.<name>.listings.firefox.id` |
438
+ | `EDGE_PRODUCT_ID` | `targets.<name>.listings.edge.id` |
439
+ | `RECAPTCHA_SITE_KEY` | `captcha.providers.recaptcha.siteKey` |
440
+ | `PAYPAL_CLIENT_ID` | `payment.providers.paypal.clientId` |
441
+ | `CHARGEBEE_SITE` | `payment.providers.chargebee.site` |
442
+
443
+ The by-hand move for a brand that still carries one:
444
+ [breaking-changes.md](breaking-changes.md). The SECRET halves are untouched:
445
+ `RECAPTCHA_SECRET_KEY`, `PAYPAL_CLIENT_SECRET`, `CHARGEBEE_API_KEY` and every
446
+ store API credential stay in `.env`.
447
+
448
+ The same register carries the keys that stayed secrets and only changed NAME
449
+ ([#845](https://github.com/Omega-JS-Stack/omega/issues/845)). The refusal is
450
+ identical, and so is the silence it prevents: the env schema does not know the
451
+ old name, so the value reaches nothing. The fix is renaming the line, not
452
+ deleting it. Rows are exact names, one per provider.
453
+
454
+ | Renamed `.env` key | Its new `.env` name |
455
+ |---|---|
456
+ | `OAUTH2_GOOGLE_CLIENT_ID` | `CONNECTIONS_GOOGLE_CLIENT_ID` |
457
+ | `OAUTH2_GOOGLE_CLIENT_SECRET` | `CONNECTIONS_GOOGLE_CLIENT_SECRET` |
458
+
459
+ And it carries the keys retired OUTRIGHT
460
+ ([#819](https://github.com/Omega-JS-Stack/omega/issues/819), Ian 2026-09-13): no config
461
+ path and no new env name, because a MECHANISM took the key's place rather than another
462
+ value. Such a row declares `replacement: null` and its refusal reads as a deletion with
463
+ nowhere to move the value to. The test-lane pair is the whole set today: web, desktop and
464
+ extension each test their own sign-in against a persona the backend emulator seeds
465
+ ([#904](https://github.com/Omega-JS-Stack/omega/issues/904)), so no suite holds a
466
+ credential of its own and the `testing` schema group is gone with them.
467
+
468
+ | Retired `.env` key | What replaced it |
469
+ |---|---|
470
+ | `OMEGA_TEST_FIREBASE_ADMIN_KEY` | Nothing to move: delete the line. The seeded persona (#904) replaces the minted custom token |
471
+ | `OMEGA_TEST_USER_UID` | Nothing to move: delete the line. The emulator's roster names the persona (#904) |
472
+
263
473
  ## The .env cascade (secrets) — D15
264
474
 
265
475
  Secrets mirror the config hierarchy (`src/env.js`), weakest → strongest:
@@ -276,9 +486,9 @@ over it.
276
486
  ```
277
487
 
278
488
  - **Same walk as the config cascade**: `{brand}/targets/{target}` layers the brand root's `.env`
279
- under the target's; a brand stamped with `.omega/company.json` (written idempotently by
280
- company manage runs) layers its company root's `.env` underneath that. `findBrandRoot`
281
- in `load.js` is the ONE definition of the walk — both cascades use it.
489
+ under the target's; a brand naming a company (`company: { id }`) layers that company's
490
+ `company/.env` underneath that (#677). `findBrandRoot` in `load.js` is the ONE definition
491
+ of the walk; both cascades use it.
282
492
  - **`.env.<environment>` overlays the `.env` beside it**
283
493
  ([#586](https://github.com/Omega-JS-Stack/omega/issues/586)) — the widespread standard
284
494
  (Next.js, Vite, Rails dotenv, dotenv-flow). The three names are exactly what
@@ -289,6 +499,12 @@ over it.
289
499
  deploy composes base + production, the emulator base + development, a test lane base +
290
500
  testing, and no other environment's file ever rides along. Onboard scaffolds all three
291
501
  beside the brand `.env`, empty but for a header comment (`.env.*` is gitignored).
502
+ - **A key of your own in the brand root `.env` reaches every target**
503
+ ([#835](https://github.com/Omega-JS-Stack/omega/issues/835)). The schema filters the
504
+ company and brand layers by TARGET, and a key it never declared names no target, so it
505
+ composes everywhere (base `.env` and `.env.production` alike) and rides the same pipeline
506
+ to GitHub Actions as every declared one. Want it on one target only? Put it in that
507
+ target's own `.env`, the per-key override that was always unfiltered.
292
508
  - **Precedence via dotenv's no-override semantics**: files load strongest-first and never
293
509
  overwrite keys already set, so the shell always wins and local beats brand beats company.
294
510
  - **A RELOAD honors edits, because ownership is remembered**
@@ -354,10 +570,25 @@ own list derives from it, so a new key is **one entry**, never four edits.
354
570
  requiredWhen: 'captcha.providers…', // non-empty when this config path is truthy
355
571
  publicAtRest: true, // sanctions a 'bake' (readable in the artifact)
356
572
  machineLocal: true, // this machine's fact — never published to CI
573
+ label: 'Snap Store credentials', // the human name an ask opens with
574
+ url: 'https://snapcraft.io/account', // the page that MINTS it
575
+ hint: 'Run `snapcraft export-login -`', // how to get it there
357
576
  description: 'What the key drives.',
358
577
  }
359
578
  ```
360
579
 
580
+ - **`label`, `url` and `hint` are the human half, and this schema is their one home**
581
+ ([#867](https://github.com/Omega-JS-Stack/omega/issues/867)). The manager's REQUIRES
582
+ registry used to carry a second copy per service, which is how every desktop signing key
583
+ and every extension store key ended up with a mint page in NEITHER place: no service
584
+ declared them, so nobody could be asked for them. `serviceInputSpec` fills each ask from
585
+ here now, and the registry keeps only its own fields (which service asks, whether the ask
586
+ is interactive, where Disable writes `false`). `url: null` is a deliberate statement that
587
+ no page mints the value, and such an entry owes a `hint` saying where it does come from.
588
+ The sweep test (`packages/manager/test/service-input.test.js`) holds the line: no
589
+ label/url/hint in the registry, a label on every pasted key, and a url (or an explicit
590
+ null plus a hint) on every one that is not machine-local.
591
+
361
592
  - **`generated:` is the mint switch.** Those keys have no dashboard behind them, so the
362
593
  manager writes them into a brand `.env` — at onboard, and on every manage that finds
363
594
  one missing. Everything else is a credential a human provides.
@@ -366,10 +597,14 @@ own list derives from it, so a new key is **one entry**, never four edits.
366
597
  - **`targets:` is the composition domain.** Every verb composes its target's RUNTIME env
367
598
  (the backend's staged `dist/.env` — the only artifact that ships and so cannot walk up to
368
599
  the brand layer) from the file layers, taking the entries whose `targets` name that
369
- target. The schema is the only filter: no hand list, and PATTERN entries
600
+ target. The schema is the only filter on DECLARED keys: no hand list, and PATTERN entries
370
601
  (`match:`, e.g. the `CONNECTIONS_*` family) compose exactly like named ones. A key the schema
371
- does not name for a target never reaches it — a desktop signing key stays out of the
372
- functions upload. Other targets read brand values through the cascade above at runtime,
602
+ names for other targets never reaches this one, so a desktop signing key stays out of the
603
+ functions upload. A key the schema does not know AT ALL is the consumer's own
604
+ ([#835](https://github.com/Omega-JS-Stack/omega/issues/835)): it names no target to be
605
+ filtered by, so a custom line in the brand root `.env` (or its `.env.production` overlay)
606
+ composes for EVERY target, and a consumer who wants one on a single target puts it in
607
+ that target's own `.env`, which passes unfiltered anyway. Other targets read brand values through the cascade above at runtime,
373
608
  so nothing is written for them.
374
609
  - **`deliverAs:` renames on delivery**: the entry's brand-level name is what the cascade
375
610
  carries (`GOOGLE_ANALYTICS_SECRET_BACKEND`), and the target receives it under the name
@@ -383,6 +618,49 @@ own list derives from it, so a new key is **one entry**, never four edits.
383
618
  `@omega.js/config/env-delivery` derives everything from these declarations: each
384
619
  target's workflow secrets block, its bake list, and its publish-step secret set. No
385
620
  hand-kept `${{ secrets.KEY }}` list survives anywhere.
621
+ - **The delivered set is the schema PLUS what only the composed env can name**
622
+ ([#835](https://github.com/Omega-JS-Stack/omega/issues/835),
623
+ [#876](https://github.com/Omega-JS-Stack/omega/issues/876)). `deliveredKeys(target, modes,
624
+ { values })` is the ONE primitive every list derives from, and the composed production
625
+ values it takes add two kinds of key the schema cannot name: a `match` FAMILY member (the
626
+ schema knows `CONNECTIONS_<PROVIDER>_CLIENT_ID` as a shape, so only a brand's own `.env`
627
+ says which providers exist), and a CUSTOM key the schema never declared. A custom key
628
+ travels in its target's FILE mode: on the backend it is written into the `.env` the runner
629
+ builds, like an `env` delivery; on web, desktop and the extension it reaches the runner env
630
+ only, like a `ci` one. `machineLocal` keys and declared keys this target does not deliver
631
+ stay home exactly as before, and neither a `WORKFLOW_OWNED_KEYS` name (the template
632
+ declares those itself) nor a `GITHUB_`-prefixed one (GitHub refuses that prefix as a
633
+ secret name) is ever taken from a composed set. Both halves of the backend workflow (the
634
+ injected block and the `.env` writer's key list) render from that one set, so they cannot
635
+ disagree inside a run.
636
+ - **`env` on a NON-backend target declares the laptop's composed `.env` as the channel**
637
+ ([#819](https://github.com/Omega-JS-Stack/omega/issues/819)). The workflow renderer walks
638
+ `ci` and `bake` only, so such a key renders no workflow line, reaches no runner and is
639
+ published as no repo secret: it is read where the developer runs the build.
640
+ `OPENAI_API_KEY` is the case that made it explicit (`{ web: 'env', backend: 'env',
641
+ extension: 'env' }`), because translation runs locally and CI reads the committed cache
642
+ ([#905](https://github.com/Omega-JS-Stack/omega/issues/905)).
643
+ - **A workflow block carries `ci` + `bake`, except the backend's, which carries `env` too**
644
+ ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)). Everywhere else the artifact holds its values inside itself and reads
645
+ no env file, so the CI half is the whole block. The backend's deployed artifact ships a
646
+ COMPOSED `.env`, and its deploy runs on a runner with no brand checkout to compose one
647
+ from, so its workflow writes that file out of the runner env: every `env` delivery has to
648
+ be up there to be written. `WORKFLOW_MODES` in `env-delivery.js` is the one home of that
649
+ exception; `DEFAULT_WORKFLOW_MODES` is every other target. Riding with it is
650
+ **`OMEGA_SERVICE_ACCOUNT_JSON`** (`delivery: { backend: 'ci' }`), the deploy credential as
651
+ the key file's own CONTENTS: it is a FILE on every other lane (minted into the brand's
652
+ `.omega/secrets/`, staged into `dist/`), so it renders no brand `.env` line, and the
653
+ backend's precheck values it from that authored chain instead of from a composed env.
654
+ Riding beside it: **`OMEGA_LICENSE_KEY`** (`ci` on all four targets now), because the
655
+ license check runs inside the deploy and the backend's deploy runs on a runner too.
656
+ - **`ci` is what keeps a key out of an artifact's `.env`.** The two lanes that write a
657
+ backend `.env` read that one declaration: `envFileKeys('backend')` (the generated key
658
+ list the workflow's writer reads out of the runner env) takes the `env` deliveries only,
659
+ and `artifactEnvValues('backend', values)` strips every `ci` key out of the COMPOSED
660
+ values before the stage serializes `dist/.env`. The composer itself resolves a value for
661
+ everything the target claims, `ci` included, because the secrets publisher has to value
662
+ what it publishes from the same cascade as everything else: the artifact is the narrower
663
+ half, and those two functions are where that is said.
386
664
  - **A baked key is public at rest.** Anyone who unpacks the app can read it, so a
387
665
  `secret: true` entry may only bake when it also declares `publicAtRest: true` — the
388
666
  renderer THROWS otherwise, on every lane, so a real credential can never reach an
@@ -462,13 +740,56 @@ Who derives from it:
462
740
  |---|---|
463
741
  | `@omega.js/manager` workspace `env-keys` + the onboard `.env` stub | `generatedEnvKeys()` — name → the function that mints a value |
464
742
  | `@omega.js/manager` `lib/env-order.js` (canonical .env order) | `envFileGroups()` + `envKeysByGroup()` — the sections, their comments, their keys |
465
- | `@omega.js/config` `composeTargetEnv()` (the delivery composition every verb runs) | `ENV_SCHEMA` + `envFileGroups()` — a brand key rides down when some entry claims it (by `name` or by `match`), its `targets` include the target, and its group renders into a file; `deliverAs` is applied on arrival, and each layer's `.env.<environment>` overlay composes above its own base (#586) |
743
+ | `@omega.js/config` `composeTargetEnv()` (the delivery composition every verb runs) | `ENV_SCHEMA` + `envFileGroups()`: a DECLARED brand key rides down when its entry claims it (by `name` or by `match`), its `targets` include the target, and its group renders into a file; a key no entry knows at all is the consumer's own and rides down to every target ([#835](https://github.com/Omega-JS-Stack/omega/issues/835)); `deliverAs` is applied on arrival, and each layer's `.env.<environment>` overlay composes above its own base (#586) |
466
744
  | `@omega.js/config` `envKeysForTarget(target)` (the rendering lane's list) | `ENV_SCHEMA` + `envFileGroups()` — the NAMED keys a target reads, which placeholders a brand `.env` carries |
467
- | `@omega.js/backend` `libraries/env.js` (the one reader) | `envSchemaEntry()` for every read, `requiredEnvKeys('backend')` for the boot guard, and `envEnvironment()` re-exported as `env.environment()` ([docs/backend/index.md](../backend/index.md)) |
745
+ | `@omega.js/backend` `libraries/env.js` (the one reader) | `envSchemaEntry()` for every read, `requiredEnvKeys('backend')` for the boot guard, and `getEnvironment()` re-exported under its own name ([docs/backend/index.md](../backend/index.md)) |
468
746
  | `@omega.js/manager` `lib/scaffold.js` (the onboard stub) + `lib/gitignore.js` (the heal) | `ENV_ENVIRONMENTS` — one empty `.env.<environment>` per name, and the `.env.*` ignore |
469
- | `@omega.js/config` `env-delivery.js` (the one delivery renderer) | `delivery` + `deliverAs` + `machineLocal` + `publicAtRest` — each target's workflow secrets block, bake list, and publish-step secret set; web and extension render their workflow token from it, desktop's ensure-target template pass does the same, and all secret publishers send exactly its set |
747
+ | `@omega.js/config` `env-delivery.js` (the one delivery renderer) | `delivery` + `deliverAs` + `machineLocal` + `publicAtRest`, plus the target's COMPOSED production values for the keys the schema cannot name (`match` families and custom keys, #835/#876): each target's workflow secrets block, bake list, and publish-step secret set; web and extension render their workflow token from it, desktop's ensure-target template pass does the same, backend's composed `deploy.yml` renders both its secrets block and the KEY LIST its node `.env` writer reads out of the runner env ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)), and all secret publishers send exactly its set |
470
748
  | `@omega.js/config` `env-rules.js` (the one presence checker) | `required` + `requiredWhen` — the violations the backend boot, the desktop/extension bakes, and the manager's manage walk act on, each at its own severity |
471
749
 
750
+ ## The environment (`src/environment.js`), #817
751
+
752
+ `src/environment.js` is the ONE environment module every OMEGA target answers
753
+ from ([#817](https://github.com/Omega-JS-Stack/omega/issues/817)). Four calls,
754
+ one implementation, one call form everywhere: `getEnvironment()` plus
755
+ `isDevelopment()` / `isProduction()` / `isTesting()`, mixed into a framework's
756
+ Manager with `attachTo()` (exported from the package index as
757
+ `attachEnvironment`). It requires NOTHING, so it is bundled into browser
758
+ artifacts (the desktop renderer, every extension bundle) and vendored into
759
+ `@omega.js/client`, which reaches the same four through its own methods.
760
+
761
+ **ONE INPUT.** On Node it is `process.env.OMEGA_ENVIRONMENT`. In a browser
762
+ context, which can read neither env nor files, it is `config.environment`, the
763
+ build fact every surface already bakes into `OMEGA_BUILD_JSON.config`
764
+ ([#896](https://github.com/Omega-JS-Stack/omega/issues/896)), reached as
765
+ `this.config.environment` off the Manager the call is made on. Nothing else is
766
+ consulted: no `app.isPackaged`, no `manifest.update_url`, no `NODE_ENV`, no
767
+ terminal sniffing.
768
+
769
+ **NO DEFAULT.** A context with no input throws and names the variable. The four
770
+ framework copies this replaced each had their own default and they DISAGREED:
771
+ desktop answered `production` with no signal while the extension answered
772
+ `development`, so a desktop `npm start` bundled itself as a production artifact.
773
+ @omega.js/client seeded `environment: 'production'` into its own config defaults,
774
+ and web's browser Manager defaulted to `development`.
775
+
776
+ **`setEnvironment(value)` is the only writer**, so a fourth word can never reach
777
+ the variable. Who calls it, and with what:
778
+
779
+ | Lane | Names |
780
+ |---|---|
781
+ | `@omega.js/backend`'s boot (`Manager.init`, right after the `.env` cascade loads) | `envEnvironment()`, the AMBIENT answer (below) |
782
+ | `@omega.js/desktop` / `@omega.js/extension` `src/build.js`, at load | `buildLaneEnvironment(Manager.isBuildMode())`: `production` under `OMEGA_BUILD_MODE` (which WINS over an inherited value, so a production build spawned from a test run still bakes production), else an already-named value, else `development`. That rule is ONE exported helper beside `setEnvironment`, not a copy of the expression per framework, and those two build lanes are its only callers |
783
+ | `@omega.js/desktop`'s main process, at `initialize()` | the `environment` its baked config carries, for a packaged app that has no parent lane |
784
+ | `@omega.js/web`'s verbs | `production` for `omega build`, `development` for `omega dev`, `testing` for `omega test` |
785
+ | the desktop test runners, the extension `test` verb | `testing` |
786
+
787
+ `envEnvironment()` (in `src/env.js`) is the PRODUCER of that input, never a
788
+ second reader of it: it is the sniff a lane runs ONCE to decide what to set, and
789
+ the default for the two overlay selections (`.env.<environment>` and
790
+ `config/omega.<environment>.json5`). An already-set `OMEGA_ENVIRONMENT` wins
791
+ inside it too, so the producer and the reader can never disagree in one process.
792
+
472
793
  ## Owner hooks (`config/hooks/`) — cp91
473
794
 
474
795
  `src/hooks.js` — owner-supplied code the frameworks call at named hook points, so
@@ -484,8 +805,9 @@ step loads `config/hooks/account/password.js`; a future onboarding hook would li
484
805
  rotating). Secrets still belong in `.env` — a hook that needs one reads `process.env`;
485
806
  to keep a hook out of git anyway, add your own `config/hooks/` ignore line.
486
807
  - **Resolution order**: the brand root's own `config/hooks/<point>.js`, else the company
487
- root's (via the `.omega/company.json` stamp) — a company-wide hook covers every brand,
488
- a single brand can still override it.
808
+ tree's `company/config/hooks/<point>.js` (via `company: { id }`, #677): a company-wide
809
+ hook covers every brand, a single brand can still override it. It is the ONE inheritance
810
+ rule applied to one more relative path, and costs the hook loader no code of its own.
489
811
  - **Contract**: plain CJS, `module.exports = ({ … }) => …` (async fine). Each call site
490
812
  documents its hook's signature/return. Absent hook → `loadHook` returns null and the
491
813
  caller uses its default behavior; a hook that EXISTS but is broken (unloadable,
@@ -503,7 +825,21 @@ byte-identical to the pre-N7 behavior (no bumping, no artifacts).
503
825
 
504
826
  - **`CLASSIC_PORTS`** — the historical defaults (functions 5001, hosting 5002, firestore
505
827
  8080, auth 9099, database 9000, storage 9199, pubsub 8085, ui 4050, website 4000,
506
- livereload 35729, cdp 9222).
828
+ livereload 35729, cdp 9222). **This map is the ONLY source of those numbers**
829
+ ([#834](https://github.com/Omega-JS-Stack/omega/issues/834)): @omega.js/client
830
+ carried four of them plus a classic dev origin, @omega.js/desktop's url-helpers
831
+ and client-bridge three more, and @omega.js/extension's url-helpers and
832
+ background worker two, each as a last-resort fallback "for a build made with no
833
+ stack up". Every one of those is gone. The numbers reach a browser exactly one
834
+ way: each surface bakes the map into its build json with the classics as the
835
+ FLOOR and the live stack's resolved (possibly bumped) numbers over them, so a
836
+ dev artifact always carries a COMPLETE map and the classic dev ORIGIN rides it
837
+ the same way. A read that finds no map throws `devFactMissing()`
838
+ (`src/dev-facts.js`, the one home of that error), naming the fact it wanted and
839
+ the build step that writes it: a missing map is a broken build, and nothing
840
+ anywhere assumes a port again. Nothing identity-checks what answers on a
841
+ classic port, which is why an assumed one failed as an auth mystery or a silent
842
+ connection refusal rather than as a port problem.
507
843
  - **`resolvePorts({ wanted, pins, claimed })`** — each wanted port keeps its value when
508
844
  free, bumps +1 until free when taken (shared `claimed` set prevents two names landing
509
845
  on one port). `pins` (the config `ports` section) never bump — a busy pin throws.
@@ -535,7 +871,8 @@ byte-identical to the pre-N7 behavior (no bumping, no artifacts).
535
871
  URL getters resolve env → classic default.
536
872
  - **Browser channel (cp89, [#300](https://github.com/Omega-JS-Stack/omega/issues/300))** —
537
873
  browser code can read neither env nor files, so a surface BAKES the map into its
538
- client config: `omega dev` writes `dev: { ports }` into the Configuration chrome
874
+ client config, under the same key on all three (`OMEGA_BUILD_JSON.config.dev`, #894):
875
+ `omega dev` writes that line into the page chrome
539
876
  PER RENDER (its resolved website port with the sibling backend's map merged over it),
540
877
  and its auth-emulator proxy resolves the target port per REQUEST. The render-time
541
878
  bake is ADVISORY ([#346](https://github.com/Omega-JS-Stack/omega/issues/346)): the
@@ -544,9 +881,9 @@ byte-identical to the pre-N7 behavior (no bumping, no artifacts).
544
881
  under a second while the emulator suite seeds for minutes — nothing under `src/`
545
882
  changes when it lands, so no page would ever re-render onto it. Mid-session emulator
546
883
  restarts onto bumped numbers ride the same lane. Built `dist/` output is untouched on
547
- disk. Desktop (`OMEGA_BUILD_JSON.config.dev`) and
548
- extension (`OMEGA_BUILD_JSON.config.dev`, baked into every bundle) bake the same map at build time, from
549
- the sibling file plus the env channel; production builds bake none. Drivers serving a
884
+ disk. Desktop (its renderer bundle) and extension (every bundle) bake the same map at
885
+ the same key at build time, from the sibling file plus the env channel; production
886
+ builds bake none. Drivers serving a
550
887
  STATIC build set `window.__OMEGA_DEV_PORTS__` (the devkit e2e harness — the site
551
888
  builds before the emulator boots), which is a FALLBACK: it fills only what a page's
552
889
  chrome omits, so a side channel no real browser has can never hide a broken real one.
@@ -554,16 +891,21 @@ byte-identical to the pre-N7 behavior (no bumping, no artifacts).
554
891
  ([#262](https://github.com/Omega-JS-Stack/omega/issues/262)): `omega dev` publishes
555
892
  `dev.origin` (protocol AND port — the mkcert proxy fronts the public port by default,
556
893
  so a port number alone cannot say the scheme) into the chrome and into its ports file,
557
- and desktop/extension bake it from that file on their existing lanes. The extension
558
- manifest's `externally_connectable` dev entry resolves from it at package time —
559
- nothing hardcodes a dev origin any more. `@omega.js/client`'s `getDevWebsiteOrigin()`
560
- is the one getter that answers it, falling back to the classic `https://localhost:4000`
561
- with the same out-loud warning the ports take.
562
- `@omega.js/client` resolves chrome `dev.ports` → runtime global → classic defaults,
563
- and warns loudly (dev only) naming every port it had to assume; its dev `getApiUrl`
564
- speaks plain http to a mapped `hosting` (the emulator serves http), https to a mapped
565
- `https` (`mgr serve`'s mkcert proxy), and keeps the classic
566
- `https://localhost:5002` serve assumption when no map was provided. Dev mode
894
+ and desktop/extension bake it from that file on their existing lanes, with
895
+ `CLASSIC_DEV_ORIGIN` as the floor under it (#834) so the key is always present.
896
+ The extension manifest's `externally_connectable` dev entry resolves from it at
897
+ package time; nothing hardcodes a dev origin any more.
898
+ `@omega.js/client`'s `getDevWebsiteOrigin()` is the one getter that answers it,
899
+ and it THROWS when the artifact carries none rather than assuming
900
+ `https://localhost:4000`, because a wrong dev origin fails as a silent
901
+ connection refusal.
902
+ `@omega.js/client` resolves chrome `dev.ports` → runtime global, and THROWS when
903
+ neither answers ([#834](https://github.com/Omega-JS-Stack/omega/issues/834)):
904
+ there is no classic-defaults step under them any more, and no
905
+ "assuming the classic …" warning, because nothing is assumed. Its dev
906
+ `getApiUrl` speaks plain http to a mapped `hosting` (the emulator serves http)
907
+ and https to a mapped `https` (`mgr serve`'s mkcert proxy); neither mapped is
908
+ the same loud error. Dev mode
567
909
  resolves the LOCAL stack for every source, including `source: 'company'` —
568
910
  `company.url` is a production concept, and dev deliberately makes no live server
569
911
  hits (ratified, Ian 2026-08-03, [#34](https://github.com/Omega-JS-Stack/omega/issues/34)).
@@ -616,10 +958,10 @@ a framework, or to a custom target. A finding is a hole to fill — either the k
616
958
  or the schema owes it a rule.
617
959
 
618
960
  **authDomain is the brand's own host** (cp268): when `cloud.config.authDomain` is set it
619
- must equal the BRAND host, `brand.url`, for every instance a brand runs
961
+ must equal the BRAND host, `brand.url`, for every target a brand runs
620
962
  ([#588](https://github.com/Omega-JS-Stack/omega/issues/588)): one Firebase project, one
621
- backend, one authDomain. The instance's own `url` is not read here (a
622
- `targets/website-admin` load would otherwise fail its own brand's authDomain), and the
963
+ backend, one authDomain. A target's own `url` is not read here (a
964
+ `targets/admin` load would otherwise fail its own brand's authDomain), and the
623
965
  top-level `url` is only the fallback for a config carrying no `brand.url` at all. A
624
966
  `*.firebaseapp.com` value hard-fails (self-hosted `/__/auth/*` on the brand
625
967
  host is what keeps redirect sign-in working under browser storage partitioning; the web
@@ -649,12 +991,12 @@ definition can be written:
649
991
  features: {
650
992
  saves: {
651
993
  name: 'Saves',
652
- icon: 'feather',
994
+ icon: 'fa-solid fa-feather',
653
995
  definition: 'Notes, clips, and pages you can save per month.',
654
996
  usage: { pace: 'daily', mirror: ['teams'] },
655
997
  },
656
- templates: { name: 'Page templates', icon: 'palette', usage: { pace: false } },
657
- support: { name: 'Priority support', icon: 'headset', definition: 'Your tickets jump the queue.' },
998
+ templates: { name: 'Page templates', icon: 'fa-solid fa-palette', usage: { pace: false } },
999
+ support: { name: 'Priority support', icon: 'fa-solid fa-headset', definition: 'Your tickets jump the queue.' },
658
1000
  },
659
1001
 
660
1002
  payment: {
@@ -869,7 +1211,7 @@ site. Three cases:
869
1211
  | Case | The switch | The read | Examples |
870
1212
  |------|-----------|----------|----------|
871
1213
  | **1. Data-bearing** — the feature cannot run without a value only the brand can supply | that DATA's presence | `if (value)` | `advertising.providers.adsense.client`, `monitoring.providers.sentry.dsn`, `analytics.providers.*.id`, `edge.providers.cloudflare.zone`, `advertising.providers.inhouse.source` |
872
- | **2. Zero-data** — the framework can run it for every brand with no input | `enabled`, default ON | `value !== false` | `forms.providers.slapform.enabled`, `inbound.chat.providers.chatsy.enabled`, `repo.providers.github.enabled`, `search.providers.searchConsole.enabled`, `edge.providers.cloudflare.enabled`, `targets.web.meta.index` |
1214
+ | **2. Zero-data**: the framework can run it for every brand with no input | `enabled`, default ON | `value !== false` | `forms.providers.slapform.enabled`, `inbound.chat.providers.chatsy.enabled`, `search.providers.searchConsole.enabled`, `edge.providers.cloudflare.enabled`, `targets.web.meta.index` |
873
1215
  | **3. Consequential** — it costs money, publishes to the world, or is irreversible | `enabled`, default OFF | `value === true` | `directory.enabled`, `devlog.enabled`, `targets.desktop.platforms.mac.mas.enabled` |
874
1216
 
875
1217
  - **A block by itself NEVER enables.** Authoring `providers: { adsense: {} }` is an opt-IN
@@ -935,31 +1277,39 @@ validation naming the block it derives from, and `omega migrate` drops it with a
935
1277
  - **Desktop releases URL**: `https://github.com/<owner>/<name>/releases/latest`, where
936
1278
  owner and name come from `releasesRepo(config)` in
937
1279
  [repo.js](../../packages/config/src/repo.js): the brand's ONE public releases repo
938
- ([#799](https://github.com/Omega-JS-Stack/omega/issues/799)). `targets.desktop.releases.repo`
939
- names it, else it is `<brand.id>-releases`; `releases.owner` owns it, else the brand
940
- repo's own owner (`brandRepoOwner`). Nothing addressable, no URL. That helper is the ONE
1280
+ ([#799](https://github.com/Omega-JS-Stack/omega/issues/799),
1281
+ [#883](https://github.com/Omega-JS-Stack/omega/issues/883)). It is always
1282
+ `<brand.id>-releases` under `repo.org`, with no override key left anywhere;
1283
+ `targets.desktop.releases` presence is only the opt-in. Nothing addressable, no URL
1284
+ (no org, no brand id). That helper is the ONE
941
1285
  home of the address: @omega.js/desktop's electron-builder publish block, its releases-repo
942
1286
  provisioning and its `finalize-release` uploads read the same call, so the feed a shipped
943
1287
  app polls and the link a download button carries cannot disagree.
944
- - **Desktop direct downloads ([#620](https://github.com/Omega-JS-Stack/omega/issues/620))**:
945
- `downloads.<platform>.<artifact>` = `<releasesUrl>/download/<asset>`, one per published
946
- artifact (`mac.universal`, `windows.universal`, `linux.debian`, `linux.appimage`, in
947
- offer order). The asset names are `desktop-artifacts.js`'s — the SAME rule
1288
+ - **Desktop direct downloads ([#620](https://github.com/Omega-JS-Stack/omega/issues/620),
1289
+ [#867](https://github.com/Omega-JS-Stack/omega/issues/867))**:
1290
+ `downloads.<platform>.<format>` = `<releasesUrl>/download/<asset>`, one per format the
1291
+ brand SHIPS (`mac.dmg`, `windows.nsis`, `linux.deb`, `linux.appimage`, in offer order),
1292
+ plus `linux.snap`, which is the Snap Store listing because the store holds that file.
1293
+ The format keys and the asset names are `platforms.js`'s: the SAME table
948
1294
  @omega.js/desktop's `build-config` writes into `electron-builder.yml`, so a button
949
1295
  hands over the file and never lands on a GitHub page. They carry no version, which is
950
1296
  what keeps `/releases/latest/download/<asset>` pointing at the newest build forever:
951
1297
  releasing a desktop version never touches the website. Derived from
952
1298
  `targets.desktop.app.productName` → `brand.name`; no product name, no `downloads` (a
953
- guessed filename is a dead button).
1299
+ guessed filename is a dead button). A platform or format the brand dropped is absent
1300
+ here too, so the site never renders a button for something nobody builds.
954
1301
  - **Extension listings**: `targets.extension.listings.<store>.{url,state}` for the six
955
1302
  stores the theme renders (chrome, firefox, edge, opera, safari, brave) —
956
1303
  schema-declared; url must be http(s). Entries with neither url nor state stay absent.
1304
+ The `id` beside them (#893) is the publish lane's, not the page's: it never
1305
+ reaches the curated view, because a listing with no url has nothing to link.
957
1306
  - **Idempotent by contract**: the web build applies `toSiteGlobal` twice (loadSiteData,
958
1307
  then configureOmega) — a curated `releasesUrl` and its `downloads` survive the second
959
1308
  pass unchanged.
960
- - **Array-form (multi-instance) targets derive nothing** — presence only: which
961
- instance's facts belong on the site is ambiguous, so instance-form brands get an
962
- `enabled: true` entry and nothing else. Those pages stay on their empty state.
1309
+ - **The curated view is keyed by target NAME, and every fact derives from the entry's TYPE**
1310
+ ([#886](https://github.com/Omega-JS-Stack/omega/issues/886)): a brand that names its desktop
1311
+ target `app` gets `site.targets.app.releasesUrl` and `site.targets.app.downloads`. The name is
1312
+ never the test. A target whose type derives nothing is a presence-only entry.
963
1313
 
964
1314
  Three consumers read the curated view and nothing else: the `/download` page (every
965
1315
  desktop button → `site.targets.desktop.downloads[platform][artifact]`), the `/extension` page
@@ -968,6 +1318,77 @@ desktop button → `site.targets.desktop.downloads[platform][artifact]`), the `/
968
1318
  and `/extension/<store>` off the same facts. Mobile derives nothing while MAM is
969
1319
  parked, so the mobile band stays on its notify form.
970
1320
 
1321
+ ## The browser subset: one `client` flag, one `OMEGA_BUILD_JSON`, one `build.js` (#894, #743)
1322
+
1323
+ Three surfaces hand a config to a browser: web's pages and its service worker, desktop's
1324
+ renderer windows, and every extension page plus its service worker. What may go in is ONE
1325
+ decision, declared once and made once, and it is DELIVERED one way:
1326
+
1327
+ - **The declaration** is `CLIENT_SECTIONS` in [src/schema.js](../../packages/config/src/schema.js):
1328
+ a per-SECTION `client` flag. `true` ships the whole section (its keys are public by
1329
+ design, and a secret-shaped key can never be in the file at all); an ARRAY of dot-paths
1330
+ ships only that section's public half, for a section whose other keys are
1331
+ provisioning-only. No row means the browser never sees it. Today: `advertising`,
1332
+ `analytics`, `app`, `brand`, `captcha`, `client`, `company`, `connections`, `features`,
1333
+ `listings`, `monitoring`, `payment`, `theme` ride whole; `cloud` rides
1334
+ `['config', 'messaging.vapidKey']` (the Firebase WEB config is public, the GCP account
1335
+ facts beside it are not); `forms` rides `['providers.slapform.formId']` and `inbound`
1336
+ the three chatsy leaves the widget reads. A NEW section defaults to invisible, which
1337
+ fails as a missing feature rather than as a leak.
1338
+ - **The function** is `clientConfig(resolved)`
1339
+ ([src/client-config.js](../../packages/config/src/client-config.js)): the flagged
1340
+ sections, plus the build FACTS a surface composes onto the config before baking it
1341
+ (`runtime`, `environment`, `version`, `buildTime`, `target`, `url`, `dev`). `runtime` is
1342
+ baked on every surface, including desktop (#896): a packaged Electron renderer is a
1343
+ browser with no Electron globals of its own, so nothing can sniff it from inside.
1344
+ `payment` comes out with its winback offer RESOLVED, so the dialog a customer reads and
1345
+ the coupon the backend creates name the same numbers. A secret-shaped key inside a
1346
+ section about to ship THROWS: that config should never have loaded, and this is the last
1347
+ place before it is published. A value a build is sanctioned to bake (`publicAtRest`,
1348
+ "Config or env?" above) is added by that build AFTER this gate, never carried through it.
1349
+ - **The wrapper** is the same name and the same five keys on every surface:
1350
+ `OMEGA_BUILD_JSON = { config, package, mode, license, builtAt }`, reachable as
1351
+ `self.OMEGA_BUILD_JSON` in a window and in a worker alike. `config` is the subset; the
1352
+ rest are facts about the BUILD and never part of the client contract. `mode` is the same
1353
+ THREE keys everywhere, `{ environment, build, publish }`: the build's verdict, whether it
1354
+ was a build rather than a dev/watch run, and whether it publishes. A surface's own extra
1355
+ verdicts (desktop's `server`) stay inside that surface's Manager.
1356
+ - **The delivery** is ONE FILE, `build.js` at the artifact's web root
1357
+ ([#743](https://github.com/Omega-JS-Stack/omega/issues/743), Ian 2026-09-12: "make it
1358
+ same shape and consumption everywhere as much as possible"). Two plain statements, written
1359
+ by `composeBuildJson()` + `writeBuildJs()` in
1360
+ [@omega.js/devkit/build-json](../../packages/devkit/src/build-json.js):
1361
+
1362
+ ```js
1363
+ self.OMEGA_BUILD_JSON = { config, package, mode, license, builtAt };
1364
+ self.OMEGA_BUILD_JSON.config.dev = { … } | null;
1365
+ ```
1366
+
1367
+ Every HTML shell loads it with one script tag, FIRST (web's `core/_includes/core/head.html`
1368
+ at `/build.js`, desktop's page template at `../../build.js`, the extension's at
1369
+ `/build.js`), and every worker with one `importScripts('/build.js')` line (web's
1370
+ `sw/manager.js`, the extension's `background.js`). No bundle carries a copy: the esbuild
1371
+ banners and defines the browser bundles grew are retired, and so is web's inline foot
1372
+ script. The `dev` map is its own statement because `omega dev` REWRITES that one line per
1373
+ request (#346), so a site built before the emulator came up still serves the map of the
1374
+ stack running right now.
1375
+
1376
+ Desktop's MAIN and PRELOAD bundles are the one exception, and not to the file: they are
1377
+ Node, they boot from the WHOLE resolved config inside the asar, and they keep carrying it
1378
+ as a define plus a banner.
1379
+
1380
+ A web page whose own `config:` block (#607) changes what a runtime reader sees emits ONE
1381
+ more line after the loader tag, `Object.assign(self.OMEGA_BUILD_JSON.config, <delta>)`,
1382
+ naming only the sections that actually differ.
1383
+ - **`@omega.js/client` is handed `OMEGA_BUILD_JSON.config`** by the same call on all three
1384
+ surfaces, and it owns the mapping onto its own contract: the `client` blob IS its top
1385
+ level, `cloud.config` is the Firebase home it boots from, and
1386
+ `monitoring.providers.sentry` is the one error-reporting switch. No surface composes a
1387
+ second home for any of those.
1388
+
1389
+ The backend is deliberately outside this: it stages the WHOLE resolved config to
1390
+ `dist/config/omega.json5` and reads it with the loader, which is right for a server.
1391
+
971
1392
  ## Consumer access
972
1393
 
973
1394
  Each framework exposes the vendored loader — desktop: `require('@omega.js/desktop/config')`,
@@ -979,22 +1400,57 @@ always applies.
979
1400
  ([#290](https://github.com/Omega-JS-Stack/omega/issues/290)). A brand target cannot require
980
1401
  this private package at runtime, so a framework that owns a derivation publishes its
981
1402
  ANSWER on the runtime config object the target already holds, under `resolved.*`: the
982
- backend's `Manager.config.resolved.github` carries `{ owner, name, repo }` — the brand
983
- repo derivation (`repo.providers.github` overlaid by `targets.backend.github`, slug or
984
- bare name) as one finished value, `repo` being the `owner/name` slug. The derivations
985
- themselves stay here (`brandRepo()` in `src/repo.js`): one implementation, called by the
986
- framework, so no brand re-implements the merge rules and drifts from them. New derived
987
- values join a framework's `resolved` group as real brand needs surface.
988
-
989
- **The repo NAME itself derives from the `<brand.id>-<role>` rule** (Ian 2026-09-07,
990
- [#809](https://github.com/Omega-JS-Stack/omega/issues/809)). Every repo a brand owns is
991
- its id plus the role that repo plays, so nobody types a repo name to get the right one:
992
- `brandRepoName()` answers a typed `repo.providers.github.repo` first (a bare name, or an
993
- `owner/name` slug whose owner slot also wins the owner half), else the default
994
- `<brand.id>-omega`, the SOURCE monorepo's role, beside `releases` for the one public
995
- desktop releases repo (`releasesRepo()`, `<brand.id>-releases`). The typed slug stays the
996
- override for a brand whose repo is named something else, which is the only way a brand
997
- keeps a pre-rule name.
1403
+ backend's `Manager.config.resolved.sourceRepo` carries `{ owner, name, slug }`, the brand's
1404
+ SOURCE repo as one finished value. The derivations themselves stay here (`src/repo.js`):
1405
+ one implementation, called by the framework, so no brand re-implements the rules and
1406
+ drifts from them. New derived values join a framework's `resolved` group as real brand
1407
+ needs surface.
1408
+
1409
+ ## The repo block and the repos it derives (#883)
1410
+
1411
+ ONE block says where a brand hosts its code, and it is two keys:
1412
+
1413
+ ```json5
1414
+ repo: { provider: 'github', org: 'Acme-Org' }
1415
+ ```
1416
+
1417
+ - **Presence is the switch.** A brand with no `repo` block hosts its source somewhere the
1418
+ manager does not touch, and the repo service skips, exactly as a missing target key
1419
+ means that target is not enabled. There is no `enabled` key.
1420
+ - **`provider`** defaults to `github` and must be one of `REPO_PROVIDERS` (`['github']`).
1421
+ A second provider is one table row plus its own helper, never a reshape of the block.
1422
+ - **`org`** is the only typed value, and it is required whenever the block is present: it
1423
+ owns every repo the brand's names derive under.
1424
+
1425
+ **Every repo NAME derives from the `<brand.id>-<role>` rule** (Ian 2026-09-07,
1426
+ [#809](https://github.com/Omega-JS-Stack/omega/issues/809)), one function per role in
1427
+ [repo.js](../../packages/config/src/repo.js), which every package reads. No override key
1428
+ of any kind exists: a repo name that must differ is a brand id that must differ.
1429
+
1430
+ | Role | Derivation | Visibility | Holds |
1431
+ |---|---|---|---|
1432
+ | Source monorepo | `sourceRepo(config)` → `<repo.org>/<brand.id>-omega` | the brand root package.json `private` field | the brand's code, its scaffolded workflows and their Actions secrets, and the CMS's content commits. Every dispatch addresses this one |
1433
+ | Releases | `releasesRepo(config)` → `<repo.org>/<brand.id>-releases` | always public (the desktop updater polls it with no token) | every target's built artifacts: desktop installers, the extension's zips, tagged per target |
1434
+ | Website, one per GitHub-hosted web target | `websiteRepo(config, name)` → `<repo.org>/<brand.id>-<target name>` | private only when the brand is private AND the org's plan allows Pages from a private repo, else public, and the manage walk says which | the BUILT site only, one force-orphan commit on `gh-pages`, served by Pages at the target's url |
1435
+
1436
+ **Visibility lives in the brand root's `package.json`**, never in omega.json5
1437
+ (`brandVisibility(brandRoot)`): `private: true` or the field ABSENT is a private brand
1438
+ (every brand monorepo is private by default, Ian 2026-09-11), and only a literal `false`
1439
+ is a public one. The manage walk reconciles the repo to it in both directions.
1440
+
1441
+ **`targets.<name>.hosting.provider`** says who SERVES a web target's built site, on web
1442
+ targets only (a non-web target carrying `hosting` is a validation error). It defaults to
1443
+ `github`, must be one of `HOSTING_PROVIDERS` (`['github']`), and is what `websiteRepo`
1444
+ reads: another provider means there is no GitHub repo to address at all.
1445
+
1446
+ **The Pages custom domain is ONE derivation**, `pagesHost(config, name)`: the bare host of
1447
+ that target's `targetUrl`, and an empty string when the url names a default Pages address
1448
+ (`*.github.io` is an ADDRESS, never a domain, [#366](https://github.com/Omega-JS-Stack/omega/issues/366)).
1449
+ The manage walk sets the domain on the repo from it and the web deploy writes its `CNAME`
1450
+ file from it, so the two can never claim different domains for one site.
1451
+
1452
+ The by-hand steps for a brand still on the old shape are the register's
1453
+ [2026-09-11 section](breaking-changes.md#2026-09-11-one-repo-block-and-the-website-repo-883).
998
1454
 
999
1455
  ## Defaults & self-healing
1000
1456
 
@@ -1013,8 +1469,11 @@ means monitoring: that service reads `monitoring.providers.sentry` presence as t
1013
1469
  Sentry, so its SDK knobs (`sampleRate`, `scrubEmail`, …) carry no default and keep their home in
1014
1470
  the package that reads them — and `advertising`, whose adsense `client` id is the whole switch
1015
1471
  ([#527](https://github.com/Omega-JS-Stack/omega/issues/527)), so a materialized block would turn
1016
- the web build's automatic ad placements on for a brand that configured none. Blocks whose services
1017
- gate on `enabled` or provisioned ids rather than presence (github, cloudflare, searchConsole,
1472
+ the web build's automatic ad placements on for a brand that configured none. `repo` is the third
1473
+ ([#883](https://github.com/Omega-JS-Stack/omega/issues/883)): the BLOCK's presence enables the
1474
+ repo service, so `repo.provider` carries no default either and the value a missing provider reads
1475
+ lives beside the derivation, in `src/repo.js`. Blocks whose services
1476
+ gate on `enabled` or provisioned ids rather than presence (cloudflare, searchConsole,
1018
1477
  slapform, chatsy, replyify) DO carry defaults — see the polarity doctrine above.
1019
1478
  Role-level switches beside any providers block
1020
1479
  (`monitoring.enabled`, `marketing.campaigns.enabled`) are nobody's pick and always may.
@@ -1102,11 +1561,48 @@ No framework reads the legacy files anymore. Convert once, delete the old file.
1102
1561
  recipe: shared-looking sections move to the TOP LEVEL (brand, analytics, payment, theme;
1103
1562
  `oauth2` lands as `connections` (#788); `firebaseConfig` becomes `cloud: { provider: 'firebase', config: {…} }` and
1104
1563
  `sentry` becomes `monitoring: { providers: { sentry: {…} } }`); everything framework-specific
1105
- moves under `targets.<type>`.
1564
+ moves under `targets.<name>`.
1565
+
1566
+ ### The targets shape rename (#886)
1567
+
1568
+ An OMEGA-era brand converts once: every entry declares its `type`, and the KEY is the folder.
1569
+ The by-hand steps (and the rest of the register) are in
1570
+ [breaking-changes.md](breaking-changes.md#2026-09-11-targets-keyed-by-name-886).
1571
+
1572
+ | Legacy | New |
1573
+ |---|---|
1574
+ | `targets/website` (the canonical web folder) | **`targets/web`**: the folder is the key, and the key is `web` |
1575
+ | `targets/website-admin` (an instance's suffixed folder) | **`targets/admin`**: the sibling key names its own folder |
1576
+ | `web: [{ id: 'main' }, { id: 'admin' }]` (the instance ARRAY) | **sibling keys**: `web: { type: 'web' }, admin: { type: 'web' }` |
1577
+ | `--target=website` | **`--target=web`**: the flag takes the NAME |
1578
+
1579
+ ### The one repo block (#883)
1580
+
1581
+ The brand's repo hosting used to be spelled in four places: `repo.providers.github`, a
1582
+ separate top-level `github` identity, a `targets.<name>.github.repo` override, and the
1583
+ desktop releases owner/repo. One block says it now (`repo: { provider, org }`), every repo
1584
+ name derives from `<brand.id>-<role>`, and visibility is the brand root package.json's
1585
+ `private` field. Each retired key is a registered PATH, so a brand still carrying one
1586
+ fails validation instead of silently addressing a repo nothing publishes to. The by-hand
1587
+ steps are the register's
1588
+ [2026-09-11 section](breaking-changes.md#2026-09-11-one-repo-block-and-the-website-repo-883).
1589
+
1590
+ | Retired path | New home |
1591
+ |---|---|
1592
+ | `repo.providers.github.org` | **`repo.org`** |
1593
+ | `repo.providers.github.repo` | nothing: the source repo IS `<brand.id>-omega`. A name that must differ is a brand id that must differ |
1594
+ | `repo.providers.github.private` | the brand root `package.json` `private` field (absent = private) |
1595
+ | `repo.providers.github.shared` | nothing: an org may host many brands, and no brand rewrites an org profile |
1596
+ | `repo.providers.github.enabled` | the PRESENCE of the `repo` block |
1597
+ | `github.user` | nothing: the org is `repo.org` |
1598
+ | `github.website` | nothing: a web target's site repo is `<brand.id>-<target name>` |
1599
+ | `targets.<name>.github.repo` (every type) | nothing: the CMS commits to the SOURCE repo `<brand.id>-omega` |
1600
+ | `targets.desktop.releases.owner` | nothing: `<brand.id>-releases` under `repo.org` |
1601
+ | `targets.desktop.releases.repo` | nothing: `<brand.id>-releases` under `repo.org` (`releases: {}` stays the presence switch) |
1106
1602
 
1107
1603
  **Retired keys fail loudly** ([#142](https://github.com/Omega-JS-Stack/omega/issues/142)):
1108
1604
  a name that was renamed OUTRIGHT is a validation error wherever it sits — shared level,
1109
- inside a `targets.<type>` entry, inside an instance array — naming its replacement and
1605
+ inside a `targets.<name>` entry, naming its replacement and
1110
1606
  pointing back here. Today that is `web_manager` → `client`, `firebaseConfig` → `cloud`,
1111
1607
  `cookieConsent` → `client.consent`, `subdomains` → `targets.web` and `oauth2` → `connections` (`src/retired-keys.js` is the list). Without the guard the old key validated clean and
1112
1608
  everything under it vanished, since nothing dual-reads it. Names that live on as legitimate
@@ -1120,19 +1616,22 @@ list, from the file as AUTHORED, through the comment-preserving editor (`removeC
1120
1616
  the key, its subtree, and the comment documenting it, with every other byte untouched. One line
1121
1617
  per key naming its replacement, `--dry-run` for the plan, idempotent (a converged brand's rerun
1122
1618
  is byte-identical). It removes the dead key; moving the setting into the home named in the tables
1123
- below is still by hand.
1619
+ below is still by hand, EXCEPT where a row carries a converter
1620
+ ([#858](https://github.com/Omega-JS-Stack/omega/issues/858)): then the new value is written
1621
+ first, through the same comment-preserving editor, and the old key is deleted in the same
1622
+ run, so a brand never sits between the two shapes. A dry run prints both halves.
1124
1623
 
1125
1624
  `subdomains` is the newest name ([#588](https://github.com/Omega-JS-Stack/omega/issues/588),
1126
1625
  Ian 2026-09-01). The key was READ by exactly one thing, the cloud hosting op, which ensured an
1127
1626
  `api.{sub}.{domain}` per entry, and DECLARED by nothing: no schema rule, no default, never
1128
- materialized. The fact it was reaching for is a web instance, so the instance is its home now:
1129
- the id is the subdomain, and every subdomain shares one `api.<domain>`. It is a NAME test by the
1627
+ materialized. The fact it was reaching for is a web target, so a target is its home now:
1628
+ the NAME is the subdomain, and every subdomain shares one `api.<domain>`. It is a NAME test by the
1130
1629
  rule above (`subdomains` exists nowhere else in the schema), so it fires wherever a brand wrote
1131
- it, including down inside an instance entry's own `brand` block.
1630
+ it, including down inside a target entry's own `brand` block.
1132
1631
 
1133
1632
  | Retired key | New home |
1134
1633
  |---|---|
1135
- | `subdomains` | **`targets.web`** as an array of instances: `["admin", "cdn"]` becomes `web: [{ id: 'main' }, { id: 'admin' }, { id: 'cdn' }]` (§ Multi-instance targets). The id IS the subdomain (`https://admin.<brand host>`), an entry's own `url` overrides it for a custom host, and the instances share ONE `api.<domain>` |
1634
+ | `subdomains` | **`targets`** as sibling web targets: `["admin", "cdn"]` becomes `admin: { type: 'web' }, cdn: { type: 'web' }` beside the main site (§ Targets). The NAME is the subdomain (`https://admin.<brand host>`), an entry's own `url` overrides it for a custom host, and the targets share ONE `api.<domain>` |
1136
1635
 
1137
1636
  `oauth2` is the newest name ([#788](https://github.com/Omega-JS-Stack/omega/issues/788),
1138
1637
  Ian 2026-09-03). The product concept is a CONNECTION, and a connection will not always be an
@@ -1150,9 +1649,13 @@ are in [breaking-changes.md](breaking-changes.md#the-user-connection-feature-is-
1150
1649
  The de-branding rekey ([#23](https://github.com/Omega-JS-Stack/omega/issues/23)) adds a
1151
1650
  second, PATH-based half in the same file (`RETIRED_PATHS`): keys whose provider keeps its
1152
1651
  own name one level down inside the new home, so a name test would false-positive. Each
1153
- entry matches ONE exact path from the root, array positions ignored — a `targets.<type>` row
1154
- fires inside an instance array too ([#732](https://github.com/Omega-JS-Stack/omega/issues/732)),
1155
- and the error names the real path, index and all:
1652
+ entry matches ONE exact path from the root, array positions ignored, and the error names the
1653
+ real path, index and all
1654
+ ([#732](https://github.com/Omega-JS-Stack/omega/issues/732)). A `targets.<name>` row names the
1655
+ TYPE, and the key is a NAME, so the row fires on EVERY target of that type: with `community:
1656
+ { type: 'web' }` declared, `targets.community.meta.title` bounces against the
1657
+ `targets.web.meta.title` row and the error names the real path
1658
+ ([#886](https://github.com/Omega-JS-Stack/omega/issues/886)):
1156
1659
 
1157
1660
  | Retired path | New home |
1158
1661
  |---|---|
@@ -1165,7 +1668,7 @@ and the error names the real path, index and all:
1165
1668
  | `gcp` | **`cloud`** (`cloud.organizationId`, `cloud.billingAccount`) |
1166
1669
  | `firebase` | **`cloud`** (`cloud.shared`, `cloud.supportEmail`, `cloud.apiSubdomain`; projectId only at `cloud.config.projectId`) |
1167
1670
  | `advertising.providers.google-adsense` | **`advertising.providers.adsense`** + camelCase slots |
1168
- | `github` | **`repo.providers.github`** — the ONE row with no guard: the brand's own `github` (content identity, unchanged; a shared key since [#277](https://github.com/Omega-JS-Stack/omega/issues/277)) lives at the top level, so a name test would false-positive. This table is its only guide |
1671
+ | `github` | **`repo`** ([#883](https://github.com/Omega-JS-Stack/omega/issues/883)): the separate identity block is gone, and `github.user` / `github.website` are registered retired PATHS now, so a carrying brand hears it from the validator instead of this table |
1169
1672
 
1170
1673
  The one-provider-shape normalization ([#425](https://github.com/Omega-JS-Stack/omega/issues/425))
1171
1674
  adds its own rows to the same `RETIRED_PATHS` half — every role names its vendors
@@ -1183,7 +1686,7 @@ ratified exception. Full rationale + the by-hand step per row:
1183
1686
  | `domain.email.provider` | **`domain.email.providers.<provider>`** — `domain.email.forwarding` stays role-level (provider-agnostic) |
1184
1687
  | `translation.provider` | **`translation.providers.<name>`** — `{ claude: {} }` / `{ chatgpt: {} }`; an absent block still means claude. `translation.model` stays role-level |
1185
1688
  | `devlog.provider` + `devlog.{lookbackDays,orgs,excludeRepos,excludeCommits,excludeTopics,includePrivate,postPath,destinations,overrides}` | **`devlog.providers.ghostii.<same key>`** — the writer is the KEY, its settings live inside it. `devlog.enabled` stays role-level |
1186
- | `monitoring.provider` + `monitoring.{org,dsn,environment,sampleRate,tracesSampleRate,scrubEmail,attachScreenshot,bundlePatterns}` | **`monitoring.providers.sentry.<same key>`** — the monitor is the KEY, every SDK-facing knob lives inside it. `monitoring.enabled` stays role-level, and per-surface DSNs are `targets.<type>.monitoring.providers.sentry.dsn` |
1689
+ | `monitoring.provider` + `monitoring.{org,dsn,environment,sampleRate,tracesSampleRate,scrubEmail,attachScreenshot,bundlePatterns}` | **`monitoring.providers.sentry.<same key>`**: the monitor is the KEY, every SDK-facing knob lives inside it. `monitoring.enabled` stays role-level, and per-surface DSNs are `targets.<name>.monitoring.providers.sentry.dsn` |
1187
1690
  | `marketing.campaigns.provider` + `marketing.campaigns.listId` | **`marketing.campaigns.providers.sendgrid.listId`** — `marketing.campaigns.enabled` stays role-level |
1188
1691
  | `marketing.newsletter.provider` + `marketing.newsletter.publicationId` | **`marketing.newsletter.providers.beehiiv.publicationId`** — `marketing.newsletter.enabled` AND `marketing.newsletter.content` stay role-level: content configures @omega.js/backend's newsletter generator, not Beehiiv |
1189
1692
 
@@ -1214,6 +1717,21 @@ authored path AND at the `targets.web` overlay — never by key NAME, because
1214
1717
  | `meta.title`, `meta.description` (and the same two under `targets.web.meta`) | **`brand.name` / `brand.description`** for the site-wide default, and that page's own `meta:` frontmatter for anything per-page ([docs/web/frontmatter.md](../web/frontmatter.md)) |
1215
1718
  | `seo.index` | **`targets.web.meta.index`** ([#564](https://github.com/Omega-JS-Stack/omega/issues/564), Ian's same-name ruling 2026-09-09): the site-wide default and the page override are ONE name at both levels, so `meta.index` is what a page writes and `targets.web.meta.index` is what the site writes. `targets.web.meta` itself is LIVE for that key; only `title` and `description` are retired under it |
1216
1719
 
1720
+ `translation.exclude` is the same ruling's second fold ([#858](https://github.com/Omega-JS-Stack/omega/issues/858),
1721
+ Ian 2026-09-13). The key named what NOT to translate and defaulted to nothing, so a brand
1722
+ that never thought about it paid a provider for every post it had, and a page had no way to
1723
+ say anything at all. The list says what to TRANSLATE now: globs on page routes with `!`
1724
+ negation, read in `.gitignore` order, defaulting to `['**', '!blog/**']` in the DEFAULTS
1725
+ layer, and a page overrides it for itself with the SAME key name one level down
1726
+ (`translation.include: true`/`false` in its own frontmatter). Registered at its authored
1727
+ path AND at the `targets.web` overlay, the two places a brand can write it, never by key
1728
+ NAME: `exclude` is a legitimate key elsewhere. This row carries a CONVERTER, so
1729
+ `omega migrate` MOVES the setting instead of only deleting it.
1730
+
1731
+ | Retired path | New home |
1732
+ |---|---|
1733
+ | `translation.exclude` (and the same key under `targets.web.translation`) | **`translation.include`**: the route list says what to translate, so `exclude: ['docs']` becomes `include: ['**', '!docs']` and `omega migrate` writes exactly that before deleting the old key ([translation.md](translation.md), [docs/web/frontmatter.md](../web/frontmatter.md)) |
1734
+
1217
1735
  `targets.web.redirects` is the newest registered path ([#466](https://github.com/Omega-JS-Stack/omega/issues/466)).
1218
1736
  It shipped in 0.45.0 and was withdrawn: static hosting has no server, so the map could only
1219
1737
  ever be answered CLIENT-side off the built 404 page, and a search engine saw a 404 that
@@ -1225,6 +1743,24 @@ path, the one place a carrying brand has it.
1225
1743
  |---|---|
1226
1744
  | `targets.web.redirects` | **`edge.providers.cloudflare.rules.redirect`** for a templated redirect (`/c/:id` → `/code?id=:id`, the DashQR pattern) — the manager's edge service reconciles the ruleset ([docs/manager/edge.md](../manager/edge.md)). A redirect whose URLs can be ENUMERATED is a redirect PAGE instead: `redirect.url` in frontmatter on the `modules/utilities/redirect` layout ([docs/web/index.md](../web/index.md)) |
1227
1745
 
1746
+ The four schema-less web sections are registered paths too
1747
+ ([#850](https://github.com/Omega-JS-Stack/omega/issues/850), Ian 2026-09-09:
1748
+ everything the build processes has a schema home). They were legacy UJM
1749
+ presentation blocks the converter wrote under `targets.web` with no schema rule
1750
+ anywhere, and @omega.js/web carried a PRIVATE list (`WEB_ONLY_SECTIONS`) purely
1751
+ so its own `config:` guard would pass them. That list is gone: each key is a
1752
+ registered path now, so a brand still carrying one hears it from the validator
1753
+ instead of authoring a value nothing reads. Registered at their authored path,
1754
+ the one place a carrying brand has them, and `omega migrate` drops the three
1755
+ with a note and moves the currency.
1756
+
1757
+ | Retired path | New home |
1758
+ |---|---|
1759
+ | `targets.web.favicon` | nothing for the path: the favicon set is MINTED from the brand images (manager assets service) and bridged to `/assets/images/favicon`, so an override pointed the whole site at an unminted folder. Change the source image, **`brand.images.favicon`**. The `theme-color` half is **`brand.color`**, the one place a brand states its hex |
1760
+ | `targets.web.manifest` | nothing: no reader anywhere in @omega.js/web. The web app manifest that ships is the minted set's own `site.webmanifest` |
1761
+ | `targets.web.icons` | **the `icon` on the link itself**, as Font Awesome classes (`icon: 'fa-brands fa-github'`): one icon mechanism ([#619](https://github.com/Omega-JS-Stack/omega/issues/619), [icons.md](icons.md)), so there is no name-to-markup map |
1762
+ | `targets.web.currency` | **`payment.currency`**: the price currency belongs to the payment section every other surface reads it from, and the pricing JSON-LD reads it there |
1763
+
1228
1764
  `targets.desktop.downloads.*` are the newest registered paths ([#799](https://github.com/Omega-JS-Stack/omega/issues/799)).
1229
1765
  The `download-server` mirror gave marketing a fixed filename, which the versionless artifact
1230
1766
  names ([#620](https://github.com/Omega-JS-Stack/omega/issues/620)) made free: the site links
@@ -1237,10 +1773,26 @@ Registered per KEY at its authored path, never by name: the curated
1237
1773
  | Retired path | New home |
1238
1774
  |---|---|
1239
1775
  | `targets.desktop.downloads.enabled` | **`targets.desktop.releases`**: one public releases repo per brand, and its versionless assets ARE the permanent download links |
1240
- | `targets.desktop.downloads.owner` | **`targets.desktop.releases.owner`**: there is no second repo to own, and it defaults to the brand repo's owner |
1241
- | `targets.desktop.downloads.repo` | **`targets.desktop.releases.repo`**, defaulting to `<brand.id>-releases` |
1776
+ | `targets.desktop.downloads.owner` | nothing: there is no second repo to own, and the releases repo derives as `<brand.id>-releases` under `repo.org` (#883) |
1777
+ | `targets.desktop.downloads.repo` | nothing: the releases repo derives as `<brand.id>-releases` (#883) |
1242
1778
  | `targets.desktop.downloads.tag` | nothing: `/releases/latest/download/<asset>` is what the stable mirror tag was for |
1243
1779
 
1780
+ The company is ONE key now ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)),
1781
+ outside `brand`, and everything else about the company is RESOLVED at load. The typed
1782
+ display name and the typed parent wordmark are registered retired PATHS; the typed
1783
+ `company.url` (and any `company.name` / `company.images`) fails the LOAD instead, naming
1784
+ the file, because the resolver fills those same keys and a retired-key sweep over a
1785
+ resolved config would fire on its own answer. The by-hand step, and the `parent` /
1786
+ `.omega/company.json` half, are in
1787
+ [breaking-changes.md](breaking-changes.md#2026-09-12-one-company-key-and-a-company-tree-inside-the-parent-677).
1788
+
1789
+ | Retired path | New home |
1790
+ |---|---|
1791
+ | `brand.company` | **`company.name`**, resolved from `company: { id: '<parent brand.id>' }`: the parent's name is the parent's to state |
1792
+ | `brand.images.companyWordmark` | **`company.images.wordmark`**, resolved from the parent's own `brand.images.wordmark` |
1793
+ | `company.url` (typed) | **`company.url`** (resolved): type `company: { id }` and the loader fills it; an authored one fails the load |
1794
+ | `parent` (every value) | The key is RETIRED outright ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)): the topology is **`company: { id: 'self' }`** / **`company: { id: '<parent brand.id>' }`**, and the opt-out `parent: false` is **`company: { webhooks: false }`**. A config still carrying `parent` fails the load naming `company.webhooks` |
1795
+
1244
1796
  ### electron-manager (`config/electron-manager.json` → `config/omega.json5`) — DONE (checkpoint 18)
1245
1797
 
1246
1798
  | Legacy | New |
@@ -1249,7 +1801,7 @@ Registered per KEY at its authored path, never by name: the curated
1249
1801
  | `firebaseConfig` | **`cloud: { provider: 'firebase', config: {…} }`** (D12) |
1250
1802
  | `sentry` | **`monitoring: { providers: { sentry: { dsn } } }`** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)) |
1251
1803
  | `app` | `targets.desktop.app` |
1252
- | `targets.mac` / `targets.win` / `targets.linux` (per-OS) | `targets.desktop.platforms.mac` / `.win` / `.linux` |
1804
+ | `targets.mac` / `targets.win` / `targets.linux` (per-OS) | `targets.desktop.platforms.mac` / `.windows` / `.linux` ([#867](https://github.com/Omega-JS-Stack/omega/issues/867): the vocabulary is `mac`, `windows`, `linux`, and what each ships is `platforms.<platform>.formats.<format>`; `platforms.linux.snap` moved inside as `platforms.linux.formats.snap`, its `enabled` flag replaced by presence) |
1253
1805
  | `autoUpdate`, `startup`, `releases`, `remoteConfig`, `restartManager` | `targets.desktop.<same key>` |
1254
1806
  | `electronBuilder` overrides | `targets.desktop.electronBuilder` |
1255
1807
  | `cdp` | `targets.desktop.cdp` |
@@ -1265,7 +1817,8 @@ Registered per KEY at its authored path, never by name: the curated
1265
1817
  | `firebaseConfig` | **`cloud: { provider: 'firebase', config: {…} }`** (D12) |
1266
1818
  | `sentry` | **`monitoring: { providers: { sentry: { dsn } } }`** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)) |
1267
1819
  | custom keys (`omega`, `mcp`, …) | top level, unchanged |
1268
- | `parent`, `github`, `reviews`, `marketing`, `blog`, `dataRequest` | top level, unchanged ([#277](https://github.com/Omega-JS-Stack/omega/issues/277)): shared keys the manager reads brand-level, so a website-only brand has a home for them; `targets.backend.<same key>` still overrides |
1820
+ | `github`, `reviews`, `marketing`, `blog`, `dataRequest` | top level, unchanged ([#277](https://github.com/Omega-JS-Stack/omega/issues/277)): shared keys the manager reads brand-level, so a website-only brand has a home for them; `targets.backend.<same key>` still overrides |
1821
+ | `parent` | RETIRED outright ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)): a URL or `'self'` becomes **`company: { id }`**, and `parent: false` becomes **`company: { webhooks: false }`**. The key itself fails the load now, naming its new home |
1269
1822
 
1270
1823
  Notes: @omega.js/backend's framework-defaults layer is `templates/config/omega.json5` resolved through
1271
1824
  the same loader and passed as `options.defaults`; `Manager.init()`'s
@@ -1287,7 +1840,7 @@ the backend target's local file carries only `targets.backend`.
1287
1840
 
1288
1841
  Notes: `Manager.getConfig()` returns the RESOLVED config (missing file → `{}`; schema
1289
1842
  findings warn once per process — BXM has no separate audit surface). The build snapshot
1290
- (`OMEGA_BUILD_JSON`, baked into every bundle) bakes `GOOGLE_ANALYTICS_SECRET` from the environment at
1843
+ (`OMEGA_BUILD_JSON`, the artifact's one `build.js`) bakes `GOOGLE_ANALYTICS_SECRET` from the environment at
1291
1844
  build time, same value flow as before. `bxm setup` scaffolds + merges `config/omega.json5`
1292
1845
  (the defaults merge now preserves consumer-only keys at every level — it previously
1293
1846
  dropped them).
@@ -1311,9 +1864,9 @@ before the report prints.
1311
1864
  | `web_manager.cookieConsent` (incl. `palette`, `theme`, `type`, `content.dismiss`) | **`targets.web.client.consent`** — the block that became a real gate (#383). `palette`/`theme` are gone (the panel paints from the `--omega-*` tokens); `type` is gone (the visitor's region picks opt-in vs opt-out); `content.dismiss` is now `content.accept`, beside `content.customize`, `content.panelIntro`, `content.acceptAll` and `content.acceptNone` (#391 retired `content.save` with the Save button) |
1312
1865
  | `web_manager.chatsy` (agentId + widget settings) | **`inbound.chat.providers.chatsy`** — the chat widget left the client blob for the one chat home the manager also provisions (#23) |
1313
1866
  | `socials` | **`socials`** (top level) — a SHARED_SCHEMA key, so the root is its home and the scaffold emits it there ([#483](https://github.com/Omega-JS-Stack/omega/issues/483)). A web load resolves `targets.web.socials` identically, which is why the converter used to leave it in the target; the handles are brand identity, read past the website too |
1314
- | `translation` | **`translation`** (top level) — a SHARED_SECTIONS key, so the root is its home and the scaffold emits it there ([#526](https://github.com/Omega-JS-Stack/omega/issues/526)). A web load resolves `targets.web.translation` identically, which is why the converter used to leave it in the target; but disperse copies the SHARED sections, so a target-scoped engine config is invisible to every other target that translates (the extension's `_locales`). `translation.exclude` stays a web-only key at that shared home |
1867
+ | `translation` | **`translation`** (top level): a SHARED_SECTIONS key, so the root is its home and the scaffold emits it there ([#526](https://github.com/Omega-JS-Stack/omega/issues/526)). A web load resolves `targets.web.translation` identically, which is why the converter used to leave it in the target; but disperse copies the SHARED sections, so a target-scoped engine config is invisible to every other target that translates (the extension's `_locales`). `translation.include` stays a web-only key at that shared home, and the legacy `exclude` list CONVERTS to it on the way through ([#858](https://github.com/Omega-JS-Stack/omega/issues/858)): `exclude: ['docs']` becomes `include: ['**', '!docs']`, one note, so the brand keeps translating exactly what it was |
1315
1868
  | `meta` | **`targets.web.meta.index`, and nothing else**: the title/description half is DROPPED ([#607](https://github.com/Omega-JS-Stack/omega/issues/607), Ian 2026-08-26: meta never exists in two places): the site-wide defaults are `brand.name` / `brand.description` (what @omega.js/web's head falls back to) and everything per-page is that page's own `meta:` frontmatter ([docs/web/frontmatter.md](../web/frontmatter.md)). The `index` half lives on under the SAME name a page writes ([#564](https://github.com/Omega-JS-Stack/omega/issues/564), Ian 2026-09-09). `omega migrate` drops the legacy title/description with a note, and a config still carrying `meta.title` / `meta.description` is a retired-key error |
1316
- | `download`, `extension`, `favicon`, `manifest`, `icons` | `targets.web.<same key>` (target overlay puts them back at the top level for web loads) |
1869
+ | `download`, `extension`, `favicon`, `manifest`, `icons`, `currency` | **DROPPED with a note, every one** (nothing lands under `targets.web`): `download` / `extension` derive from the desktop and extension targets ([#610](https://github.com/Omega-JS-Stack/omega/issues/610)); `favicon`, `manifest` and `icons` retire with [#850](https://github.com/Omega-JS-Stack/omega/issues/850) (the favicon set is minted and `theme-color` comes from `brand.color`, nothing reads a manifest block, and a link carries its own `fa-*` classes). `currency` MOVES to **`payment.currency`**. Each one is a registered retired path, so carrying it forward would write a config the validator refuses |
1317
1870
  | `recaptcha` (incl. `site-key`) | **`captcha.providers.recaptcha`** (`siteKey` — every key is camelCase, #23) |
1318
1871
  | `cloudflare` (the purge `zone`) | **`edge.providers.cloudflare`** — one cloudflare home, shared with the manager's zone reconciliation (#23) |
1319
1872
  | `advertising.google-adsense` (flat or under `providers`) | **`advertising.providers.adsense`** with camelCase slots (`displaySlot`, `inArticleSlot`, `inFeedSlot`, `multiplexSlot`) — provider ids drop the vendor prefix (#23). An `enabled` or `units` gate beside them is DROPPED with a note ([#628](https://github.com/Omega-JS-Stack/omega/issues/628)): `client` presence is the one switch, so carrying the gate forward would validate as retired while the id turned the account and the units back on |
@@ -1334,13 +1887,13 @@ configs are removed.
1334
1887
  The brand-config `targets` ARRAY's role is absorbed by key presence in the omega.json5
1335
1888
  `targets` object. omega-manager's disperse writes omega.json5 from brand config + state at
1336
1889
  its Phase-3 cutover (enumerating `SHARED_SECTIONS`, per-surface values into
1337
- `targets.<type>` overrides).
1890
+ `targets.<name>` overrides).
1338
1891
 
1339
1892
  ## Package API (quick reference)
1340
1893
 
1341
1894
  ```js
1342
1895
  const {
1343
- loadConfig, // (projectDir, target?, { defaults }?) → { config, errors, warnings, enabled, instance, files }
1896
+ loadConfig, // (projectDir, target?, { defaults }?) → { config, errors, warnings, enabled, name, files }
1344
1897
  composeTargetConfig, // (projectDir, target) → { config, files } — company+brand+local frozen into ONE self-contained file (deploy upload boundary, #31)
1345
1898
  hasOmegaConfig, // (projectDir) → boolean — "is this project migrated?"
1346
1899
  resolveConfigPath, // (projectDir) → abs path | null
@@ -1352,8 +1905,10 @@ const {
1352
1905
  reloadEnv, // (startDir, options?) → same — drops the FILE-owned keys, then loads again, so an EDITED value lands and the shell still wins (#724)
1353
1906
  resolveEnvChain, // (startDir) → { local, brand, company } .env paths (no loading)
1354
1907
  loadEnvChain, // (paths) → loaded[] — dotenv strongest-first, nulls/missing skip
1355
- readCompanyRoot, // (brandRoot) → company root | null (.omega/company.json)
1356
- COMPANY_MARKER, // '.omega/company.json'
1908
+ resolveCompany, // (brandRoot) → { id, name, url, images, root, dir, file(relPath), config }: the ONE company resolver (#677)
1909
+ recordBrand, // ({ id, root, name, url }) → wrote?: the machine registry line every loadConfig refreshes
1910
+ readRegistry, // () → { <brand.id>: { root, name, url, updatedAt } }: ~/.omega/brands.json (OMEGA_HOME moves it)
1911
+ COMPANY_RESOLVED_FILE, // 'config/company-resolved.json5': the generated layer a deploy hands a runner
1357
1912
  resolveHook, // (startRoot, 'account/password') → hook file | null (brand → company)
1358
1913
  loadHook, // (startRoot, hookPath) → { fn, file } | null — broken hooks THROW
1359
1914
  validateConfig, // (config, { target }?) → { errors, warnings }
@@ -1373,15 +1928,18 @@ const {
1373
1928
  missingDefaults, // (rawConfig, target?) → [{ path, value }] — the blocks a brand file lacks (the manage heal's list)
1374
1929
  defaultComments, // (target?) → { 'dot.path': description } — the guiding comments a materialized block carries
1375
1930
  deepMerge, // agnostic layer merge
1376
- // Multi-instance targets (instances.js — the ONE iteration mechanism)
1377
- normalizeTargetInstances, // (targets.<type> value) → [{ id, … }] (object form = [{ id: 'main', …entry }])
1378
- instanceIdFromDirName, // ('website-admin', 'web') → 'admin'; canonical/unconventional dirs → 'main'
1379
- instanceTargetDir, // ('web', 'admin') → 'website-admin'; main → the canonical dir
1380
- targetInstance, // (projectDir, target) → this target dir's instance id (brand targets only; standalone → 'main')
1381
- resolveInstanceEntry, // (entry, id) → the instance's merge layer (id stripped) | null
1382
- instancePortOffset, // (entry, id) → position in the instances array (dev-port offsets)
1383
- resolveInstanceUrl, // (entry, id, config) → instance url → instance brand.url → https://<id>.<brand host> (non-main) → brand.url | null
1384
- DIR_TARGETS, TARGET_DIRS, MAIN_INSTANCE, // the target-dir mapping SSOT (manager re-exports)
1385
- TARGETS, SHARED_SECTIONS, SHARED_SCHEMA, TARGET_SCHEMAS,
1931
+ // The targets map (targets.js: every key is a NAME, #886)
1932
+ targetEntries, // (config) → [{ name, type, …entry }] in config order; throws on a missing/unknown type
1933
+ targetsOfType, // (config, 'web') → the same entries, that type only
1934
+ targetPath, // (config, 'community') → 'targets/community'; an undeclared name throws with the declared list
1935
+ targetNameFromDir, // (projectDir) → the target root's basename inside a brand (functions//dist/ normalize up); null standalone
1936
+ targetUrl, // (config, name) → entry url → entry brand.url → brand.url when name IS the type → https://<name>.<brand host> | null
1937
+ targetPortOffset, // (config, name) → position among the SAME-type targets (dev-port offsets)
1938
+ brandHost, // (url) → the host exactly as brand.url states it (www kept, path dropped, port kept)
1939
+ TARGET_NAME_PATTERN, TARGET_TYPES, // the name slug rule and [web, backend, desktop, extension, mobile, custom]
1940
+ // The browser subset (#894): what a browser surface may see, and the one wrapper it is baked in
1941
+ clientConfig, // (resolved + this build's facts) → the blob every browser surface bakes as OMEGA_BUILD_JSON.config
1942
+ CLIENT_FACT_KEYS, // the build facts that ride beside the client sections (runtime, environment, version, buildTime, target, url, dev)
1943
+ TARGETS, SHARED_SECTIONS, CLIENT_SECTIONS, SHARED_SCHEMA, TARGET_SCHEMAS,
1386
1944
  } = require('@omega.js/config');
1387
1945
  ```