@omega.js/desktop 0.53.0 → 0.54.1

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 (161) hide show
  1. package/README.md +38 -38
  2. package/dist/cli-run.js +4 -1
  3. package/dist/cli.js +2 -2
  4. package/dist/commands/cdp/client.js +1 -1
  5. package/dist/commands/cdp.js +1 -1
  6. package/dist/commands/clean.js +2 -3
  7. package/dist/commands/dev.js +25 -0
  8. package/dist/commands/lib/ensure-target.js +12 -17
  9. package/dist/commands/lib/migrate.js +17 -0
  10. package/dist/commands/logs.js +1 -1
  11. package/dist/commands/release.js +1 -1
  12. package/dist/commands/test.js +4 -4
  13. package/dist/commands/update.js +5 -4
  14. package/dist/defaults/.github/workflows/build.yml +18 -18
  15. package/dist/defaults/_.gitignore +0 -2
  16. package/dist/defaults/_mas/README.md +3 -3
  17. package/dist/defaults/config/certs/README.md +1 -1
  18. package/dist/defaults/config/omega.json5 +36 -36
  19. package/dist/defaults/docs/README.md +3 -3
  20. package/dist/defaults/gulpfile.js +1 -1
  21. package/dist/defaults/hooks/build/post.js +1 -1
  22. package/dist/defaults/hooks/build/pre.js +1 -1
  23. package/dist/defaults/hooks/notarize/post.js +2 -2
  24. package/dist/defaults/hooks/release/post.js +1 -1
  25. package/dist/defaults/hooks/release/pre.js +1 -1
  26. package/dist/defaults/src/assets/scss/pages/about.scss +1 -1
  27. package/dist/defaults/src/assets/scss/pages/main.scss +1 -1
  28. package/dist/defaults/src/assets/scss/pages/settings.scss +1 -1
  29. package/dist/defaults/src/integrations/context-menu/index.js +11 -11
  30. package/dist/defaults/src/integrations/menu/index.js +5 -5
  31. package/dist/defaults/src/integrations/tray/index.js +9 -9
  32. package/dist/defaults/src/main.js +2 -2
  33. package/dist/defaults/src/preload.js +1 -1
  34. package/dist/defaults/test/README.md +3 -3
  35. package/dist/defaults/test/_init.js +1 -1
  36. package/dist/gulp/tasks/audit.js +5 -8
  37. package/dist/lib/restart-manager/index.js +1 -1
  38. package/dist/lib/restart-manager/install.js +1 -1
  39. package/dist/lib/restart-manager/protocol.js +1 -1
  40. package/dist/main.js +4 -3
  41. package/dist/preload.js +1 -1
  42. package/dist/test/suites/build/audit.test.js +20 -7
  43. package/dist/test/suites/build/build-workflow-jobs.test.js +2 -2
  44. package/dist/test/suites/build/cli.test.js +28 -0
  45. package/dist/test/suites/build/defaults-em-dash.test.js +22 -0
  46. package/dist/test/suites/build/defaults-scaffold.test.js +19 -5
  47. package/dist/test/suites/build/deploy-direct.test.js +7 -5
  48. package/dist/test/suites/build/deploy-dispatch.test.js +2 -1
  49. package/dist/test/suites/build/deploy-hook.test.js +4 -2
  50. package/dist/test/suites/build/dev-verb.test.js +67 -0
  51. package/dist/test/suites/build/ensure-target.test.js +11 -3
  52. package/dist/test/suites/build/merge-line-files.test.js +6 -6
  53. package/dist/test/suites/build/migrate.test.js +29 -0
  54. package/dist/test/suites/build/project-scripts-deps.test.js +6 -10
  55. package/dist/test/suites/build/runner-env-write.test.js +73 -0
  56. package/dist/test/suites/build/runner.test.js +9 -8
  57. package/dist/test/suites/build/setup-scripts.test.js +27 -0
  58. package/dist/test/suites/build/validate-config.test.js +13 -2
  59. package/dist/test/suites/build/verb-logs.test.js +20 -0
  60. package/dist/test/suites/renderer/window-desktop-surface.test.js +1 -1
  61. package/dist/utils/build-pipeline.js +4 -4
  62. package/dist/utils/runner-env.js +13 -28
  63. package/dist/vendor/config/company.js +46 -14
  64. package/dist/vendor/config/defaults.js +30 -7
  65. package/dist/vendor/config/edit.js +25 -3
  66. package/dist/vendor/config/env-delivery.js +1 -1
  67. package/dist/vendor/config/env-schema.js +3 -6
  68. package/dist/vendor/config/env.js +34 -22
  69. package/dist/vendor/config/index.js +13 -17
  70. package/dist/vendor/config/load.js +15 -7
  71. package/dist/vendor/config/repo.js +10 -27
  72. package/dist/vendor/config/schema-client.js +64 -0
  73. package/dist/vendor/config/schema-cloud.js +38 -0
  74. package/dist/vendor/config/schema-manager.js +118 -0
  75. package/dist/vendor/config/schema-overrides.js +68 -0
  76. package/dist/vendor/config/schema.js +99 -152
  77. package/dist/vendor/config/validate.js +97 -77
  78. package/dist/vendor/devkit/agents-md.js +233 -0
  79. package/dist/vendor/devkit/attach-log-file.js +15 -1
  80. package/dist/vendor/devkit/ci-workflows.js +30 -30
  81. package/dist/vendor/devkit/cli-router.js +13 -7
  82. package/dist/vendor/devkit/defaults-engine.js +9 -43
  83. package/dist/vendor/devkit/deploy-snapshot.js +44 -9
  84. package/dist/vendor/devkit/env-lines.js +183 -0
  85. package/dist/vendor/devkit/local.js +62 -10
  86. package/dist/vendor/devkit/lockfile.js +32 -13
  87. package/dist/vendor/devkit/logger.js +7 -2
  88. package/dist/vendor/devkit/merge-line-files.js +219 -176
  89. package/dist/vendor/devkit/omega-bin.js +208 -111
  90. package/dist/vendor/devkit/preludes/docs-sync.js +52 -0
  91. package/dist/vendor/devkit/preludes/index.js +1 -0
  92. package/dist/vendor/devkit/target-picker.js +45 -0
  93. package/dist/vendor/devkit/test/dashed-files.js +37 -0
  94. package/dist/vendor/devkit/test/run-verb-under-tee.js +71 -0
  95. package/dist/vendor/devkit/update.js +15 -15
  96. package/dist/vendor/devkit/verb-scripts.js +40 -0
  97. package/dist/vendor/devkit/verbs.js +170 -0
  98. package/package.json +18 -24
  99. package/dist/commands/install.js +0 -37
  100. package/dist/defaults/AGENTS.md +0 -119
  101. package/dist/defaults/CLAUDE.md +0 -1
  102. package/dist/vendor/config/env-retired.js +0 -137
  103. package/dist/vendor/config/retired-keys.js +0 -635
  104. package/docs/analytics.md +0 -140
  105. package/docs/app-state.md +0 -92
  106. package/docs/audit.md +0 -69
  107. package/docs/auth.md +0 -284
  108. package/docs/auto-updater.md +0 -243
  109. package/docs/boot-sequence.md +0 -44
  110. package/docs/build-system.md +0 -169
  111. package/docs/cdp-debugging.md +0 -169
  112. package/docs/common-mistakes.md +0 -21
  113. package/docs/config-schema.md +0 -120
  114. package/docs/context-menu.md +0 -112
  115. package/docs/context.md +0 -81
  116. package/docs/css.md +0 -84
  117. package/docs/deep-link.md +0 -186
  118. package/docs/environment-detection.md +0 -112
  119. package/docs/fontawesome.md +0 -109
  120. package/docs/hooks.md +0 -89
  121. package/docs/icons.md +0 -79
  122. package/docs/index.md +0 -328
  123. package/docs/installer-options.md +0 -165
  124. package/docs/ipc.md +0 -61
  125. package/docs/lib-modules.md +0 -53
  126. package/docs/logging.md +0 -227
  127. package/docs/menu.md +0 -160
  128. package/docs/releasing.md +0 -239
  129. package/docs/remote-config.md +0 -118
  130. package/docs/remote-scripts.md +0 -144
  131. package/docs/restart-manager.md +0 -144
  132. package/docs/runner.md +0 -290
  133. package/docs/sentry.md +0 -97
  134. package/docs/shared/agent-docs.md +0 -89
  135. package/docs/shared/analytics.md +0 -612
  136. package/docs/shared/brands.md +0 -57
  137. package/docs/shared/breaking-changes.md +0 -917
  138. package/docs/shared/config.md +0 -1948
  139. package/docs/shared/deploys.md +0 -341
  140. package/docs/shared/icons.md +0 -219
  141. package/docs/shared/local-dev.md +0 -167
  142. package/docs/shared/logging.md +0 -205
  143. package/docs/shared/monitoring.md +0 -167
  144. package/docs/shared/publishing.md +0 -187
  145. package/docs/shared/rulings.md +0 -34
  146. package/docs/shared/testing.md +0 -147
  147. package/docs/shared/theming.md +0 -629
  148. package/docs/shared/translation.md +0 -342
  149. package/docs/shared/updates.md +0 -61
  150. package/docs/signing.md +0 -293
  151. package/docs/startup.md +0 -142
  152. package/docs/storage.md +0 -59
  153. package/docs/templating.md +0 -101
  154. package/docs/test-boot-layer.md +0 -157
  155. package/docs/test-framework.md +0 -362
  156. package/docs/themes.md +0 -149
  157. package/docs/tooltips.md +0 -99
  158. package/docs/tray.md +0 -164
  159. package/docs/usage.md +0 -58
  160. package/docs/verts.md +0 -62
  161. package/docs/windows.md +0 -149
@@ -1,1948 +0,0 @@
1
- # omega.json5 — the single OMEGA config
2
-
3
- One config file, identical shape, for every OMEGA project type. Owned by `@omega.js/config`
4
- (`packages/config`); frameworks vendor it at prepare time and read **only** this format —
5
- there is no dual-read of legacy files. Legacy brands migrate by converting their old config
6
- once (mapping tables below) and deleting the old file.
7
-
8
- Consistency between this file and what pages actually say — brand facts read from config instead of typed,
9
- one brand hex, the merge chain, secrets out of config — is the plugin's `omega:brandcheck` skill,
10
- [agent-plugins/claude/skills/brandcheck/SKILL.md](../../agent-plugins/claude/skills/brandcheck/SKILL.md);
11
- its quality hook fires on every `omega.json5` edit.
12
-
13
- ## Location
14
-
15
- | Project | File |
16
- |---|---|
17
- | Every project type (default) | `config/omega.json5` |
18
- | Standalone backend repo | `functions/config/omega.json5` |
19
- | Brand monorepo — brand level | `{brand}/config/omega.json5` |
20
- | Brand monorepo — local level | `{brand}/targets/{target}/config/omega.json5` |
21
-
22
- JSON5: comments, trailing commas, unquoted keys, single quotes all allowed.
23
-
24
- ## Shape
25
-
26
- ```json5
27
- {
28
- // SHARED sections — identical spelling in every project type.
29
- // (`SHARED_SECTIONS` in @omega.js/config is the authoritative list.)
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
- 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
- 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
- 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
36
- forms: { providers: { slapform: { enabled, formId, templateFormId, updateFormInfo, plan } } },
37
- inbound: { chat: { providers: { chatsy: { enabled, agentId, accountId, templateAgentId, updateAgentInfo, plan, sponsorshipsUrl, settings } } },
38
- email: { providers: { replyify: { enabled, agentId, templateAgentId, updateAgentInfo, plan, discount } } } },
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
- 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: { 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
- 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
- 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
- theme: { id, appearance }, // project-owned; seeded at onboarding
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
- 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
-
48
- // MANAGER-read brand-level sections (#277). Schema-known at the TOP level: the
49
- // manager loads the brand config unfolded, and a website-only brand has no
50
- // `targets.backend` to hold them (presence there would enable the target). A
51
- // `targets.backend.<same key>` block still overrides any of them.
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
- 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
- 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
- reviews: { enabled, sites: [] },
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)
57
- blog: { /* AI blog-content settings (Ghostii pipeline) */ },
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
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)
60
- dataRequest: { /* GDPR/CCPA data-request query definitions */ },
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
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
63
-
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
- targets: {
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
82
- },
83
- }
84
- ```
85
-
86
- ## Resolution
87
-
88
- `loadConfig(projectDir, target, { defaults })` produces ONE resolved object per target:
89
-
90
- ```
91
- schema defaults ← framework defaults ← company ← company.<environment> ← brand shared ← brand targets[name] ← brand.<environment> ← local shared ← local targets[name] ← local.<environment>
92
- ```
93
-
94
- - **The bottom layer is the SCHEMA's own defaults** ([#478](https://github.com/Omega-JS-Stack/omega/issues/478)) —
95
- see [Defaults & self-healing](#defaults--self-healing) below. `options.defaults` sits directly
96
- above it and carries only what a framework does differently.
97
- - "shared" = the file minus its `targets` key. In a standalone repo only the local layers exist.
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`.
129
- - **`projectDir` may be one of a target's SUBDIRS** — `functions/` (@omega.js/backend's runtime cwd)
130
- or `dist/` (its staged build output, the view `omega test` loads): every walk (brand root,
131
- company marker, local-layer fallback, target name, compose, `resolveBrandRoot`) treats the target root
132
- as one level up, so `loadConfig(functionsDir, 'backend')`, `loadConfig(distDir, 'backend')` and
133
- `loadConfig(targetRoot, 'backend')` resolve identically. A staged `config/omega.json5` inside either
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.
140
- - **Target sections overlay the TOP LEVEL**: `targets.desktop.platforms` resolves to
141
- `config.platforms`; frameworks never read through `config.targets.<name>.…`.
142
- - **Any shared key inside a target entry overrides it for that surface** — a desktop-only
143
- Sentry DSN is just `targets.desktop.monitoring.providers.sentry.dsn`; disabling any integration per-surface is
144
- uniformly `<key>: { enabled: false }`. One agnostic deep merge everywhere (objects merge,
145
- arrays/scalars replace, `null` replaces, `undefined` is skipped).
146
- - **A global value and its specific override share ONE name**: the standing rule is recorded in
147
- [docs/shared/rulings.md](rulings.md) (Ian 2026-09-09).
148
- - **@omega.js/web adds one MORE layer, per page** ([#607](https://github.com/Omega-JS-Stack/omega/issues/607)):
149
- a page's (or layout's) `config:` frontmatter block merges over the resolved config for that page
150
- alone, and templates read the result as `resolved.config.*` — the WHOLE merged config, never a
151
- subset. It is the same deep merge, one layer higher: `… ← local targets[name] ← page config:`.
152
- Nothing else in the config chain knows about it. The membership rule runs both ways: a page
153
- restating a config section BARE is a build error, and a key under `config:` that no omega.json5
154
- section answers to is a build error too. Page machinery — `meta`, `schema`, `layout`,
155
- `permalink` — is not config and has no home in this file at all
156
- ([docs/web/frontmatter.md](../web/frontmatter.md)).
157
- - The merged `targets` map rides along on the resolved config so enabled-target enumeration
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.
163
- - No target argument → whole-file merge (the shape omega-manager's disperse works with).
164
-
165
- ## Targets: every key is a NAME (#886)
166
-
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:
171
-
172
- ```json5
173
- targets: {
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' },
179
- }
180
- ```
181
-
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
222
- ([docs/manager/cloud.md](../manager/cloud.md)).
223
- - **The resolved `url` is a declared key** (`packages/config/src/schema.js`), so a target load
224
- raises no undeclared-key warning for the url it just derived.
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.
300
-
301
- ## Custom targets (#603)
302
-
303
- A brand also runs targets no framework owns — a Render API, a worker, a script. They are
304
- declared like any other target, with `type: 'custom'`:
305
-
306
- ```json5
307
- targets: {
308
- web: { type: 'web' },
309
- api: { type: 'custom' }, // → targets/api
310
- jobs: { type: 'custom' }, // → targets/jobs
311
- nightly: { type: 'custom' }, // → targets/nightly
312
- }
313
- ```
314
-
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.
317
- - **Its verbs are its own package.json scripts**: `start`, `build`, `test`, `deploy`, `clean`.
318
- The manager runs each through `npm run <verb>` when the script is present and skips it
319
- loudly when it is absent — nothing is inferred or defaulted.
320
- - **No framework service reconciles it.** The only manage op that sees a custom target is the
321
- workspace service (structure, agent docs, settings). Nothing is composed into a `.env` of its
322
- own; it INHERITS the brand keys — the manager loads the env chain into `process.env` before it
323
- spawns anything, so a custom target started by `omega dev`/`omega deploy` has them. A standalone
324
- run inside the target dir does not (there is no `@omega.js/config` in there to walk the cascade).
325
- Full contract: [docs/manager/index.md](../manager/index.md) § Custom targets.
326
-
327
- ## Backend project type (#584)
328
-
329
- `targets.backend.projectType` says how the backend RUNS, and it is the only switch:
330
-
331
- ```json5
332
- targets: {
333
- backend: { projectType: 'custom' }, // default is 'firebase'
334
- }
335
- ```
336
-
337
- - **`'firebase'` (default)** — the backend exports Cloud Functions, deploys with `firebase deploy`,
338
- and runs locally on the emulator suite. Everything OMEGA does today.
339
- - **`'custom'`** — the SAME backend (same routes, same schemas, same auth middleware, same
340
- helpers, same `.env`) served by its Express app on `process.env.PORT`, for a container host
341
- (Render & co). `initialize()` reads the mode off this key, so a brand's `src/index.js` is
342
- unchanged; an explicit `projectType` option still wins.
343
- - **What custom mode removes is the Firebase LANE, not Firebase**: no Functions deploy, no
344
- emulator, no emulator test run — those four verbs refuse loudly and name their replacement
345
- ([docs/backend/index.md](../backend/index.md)). `firebase-admin` still loads, so a custom
346
- server that reads Firestore or verifies an ID token works exactly as before.
347
- - **Not to be confused with a custom TARGET** (above): that is a target no framework owns.
348
- This one IS the `@omega.js/backend` target, with a different artifact. Same-type duplicates
349
- are sibling keys (§ Targets).
350
- - The brand-root behavior — deploy through the target's own `deploy` script, `omega dev` booting
351
- the server instead of the emulator: [docs/manager/index.md](../manager/index.md).
352
-
353
- ## Hard rules
354
-
355
- - **Secrets NEVER live in omega.json5** — they live in `.env`. `loadConfig` throws on any
356
- key matching `/(secret|privateKey|apiSecret)$/i` in any section of a raw file, before any
357
- merge. Public credentials (`publishableKey`, `clientId`, `cloud.config.apiKey`) pass by
358
- design.
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.
362
- - Schema findings (required/type/min/match/enum) come back as `errors`, not throws — build-time
363
- audit throws on them, boot warns/fails per framework policy.
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
-
473
- ## The .env cascade (secrets) — D15
474
-
475
- Secrets mirror the config hierarchy (`src/env.js`), weakest → strongest:
476
-
477
- ```
478
- company .env ← brand .env ← local .env ← shell env
479
- ```
480
-
481
- Every layer is TWO files: its `.env`, and the `.env.<environment>` overlay that wins
482
- over it.
483
-
484
- ```
485
- .env ← .env.development | .env.testing | .env.production
486
- ```
487
-
488
- - **Same walk as the config cascade**: `{brand}/targets/{target}` layers the brand root's `.env`
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.
492
- - **`.env.<environment>` overlays the `.env` beside it**
493
- ([#586](https://github.com/Omega-JS-Stack/omega/issues/586)) — the widespread standard
494
- (Next.js, Vite, Rails dotenv, dotenv-flow). The three names are exactly what
495
- `envEnvironment()` returns (`development` | `testing` | `production`), so the file name
496
- and the runtime's own answer are ONE vocabulary; only the RUNNING environment's overlay
497
- is read, and every key in it is equal — whatever it holds wins, values are TRUSTED, and
498
- no key gets special treatment. The composed artifact is flat and single-environment: a
499
- deploy composes base + production, the emulator base + development, a test lane base +
500
- testing, and no other environment's file ever rides along. Onboard scaffolds all three
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.
508
- - **Precedence via dotenv's no-override semantics**: files load strongest-first and never
509
- overwrite keys already set, so the shell always wins and local beats brand beats company.
510
- - **A RELOAD honors edits, because ownership is remembered**
511
- ([#724](https://github.com/Omega-JS-Stack/omega/issues/724)). After a boot load every key
512
- is "already set", so presence can no longer tell a shell value from a file value — which
513
- is why `loadEnv` alone can never deliver an edit. The first chain load in a process
514
- snapshots what `process.env` carried before any file was read (shell-owned, forever) and
515
- records what each file layer delivers (file-owned). `reloadEnv(startDir, options?)` drops
516
- the file-owned keys, then loads again: a NEW key and an EDITED value both land, a key
517
- dropped from the file is dropped from the process, and a shell-set value is never touched.
518
- The dev lanes' `.env` watchers ([#681](https://github.com/Omega-JS-Stack/omega/issues/681))
519
- are its one caller.
520
- - **Empty file values never claim a key (cp95a, friction #20)**: `KEY=` / `KEY=""` in any
521
- `.env` FILE means "documented here, value supplied by another layer" — a scaffolded local
522
- file full of placeholders can't shadow the brand root's real values. Only the shell can
523
- deliberately set a key to empty. The brand root's `.env` stub ships `# KEY=` commented
524
- placeholders, rendered from the env schema (the merge protocol keeps set values on their
525
- line, converges empties to the placeholder); no framework scaffolds a target `.env` at all.
526
- - **Defined at the source, resolved at runtime/build**: a brand-wide `GH_TOKEN` lives once
527
- in the brand `.env`; every framework CLI/build resolves the chain at boot
528
- (`loadEnv(process.cwd())` in the web/desktop/extension CLIs + gulp pipelines,
529
- `loadEnv(functionsDir)` in the @omega.js/backend CLI and runtime). Nothing is copied
530
- between `.env` files just to be visible.
531
- - **The brand root's `.env` is the ONE file humans and the manager edit**
532
- ([#678](https://github.com/Omega-JS-Stack/omega/issues/678)). A target's own `.env` is
533
- optional and overrides PER KEY, by hand; no machine ever writes one.
534
- - **Backend's local layer is the target-root `.env`** (`targets/backend/.env`) — the layer a
535
- human uses to override one key for that surface. What physically ships is the STAGED
536
- `dist/.env`: it rides the Firebase deploy artifact (the cloud can't walk up), so every verb
537
- that produces one (`omega build`, `dev`, `test`, `deploy`) composes it from the file layers,
538
- filtered by the env schema. In the cloud the walk finds no brand/company and behavior
539
- is identical to plain dotenv.
540
- - **The brand-generated keys are the manager's to mint** — `OMEGA_ADMIN_KEY`,
541
- `OMEGA_WEBHOOK_KEY`, `OMEGA_NAMESPACE` and `UNSUBSCRIBE_HMAC_KEY` have no dashboard
542
- behind them, so the onboard stub writes them for a fresh brand and the workspace
543
- service's `env-keys` step mints any the cascade doesn't serve on every manage
544
- ([#569](https://github.com/Omega-JS-Stack/omega/issues/569)). The env schema below is
545
- the one list feeding both, a company-served value is never shadowed by a brand-level
546
- one, and nothing prints a minted value.
547
- - Missing files and unreadable/stale markers skip silently — `loadEnv` never throws for
548
- an absent layer.
549
-
550
- ## The env schema (`src/env-schema.js`) — #581
551
-
552
- The omega.json5 schema's sibling: ONE inventory of the env keys OMEGA needs — who owns
553
- each, which targets read it, whether OMEGA mints it or a human pastes it from a third
554
- party, whether it is required, and what it does. Everything that used to hand-keep its
555
- own list derives from it, so a new key is **one entry**, never four edits.
556
-
557
- ```js
558
- {
559
- name: 'OMEGA_ADMIN_KEY', // the env var (SCREAMING_SNAKE)
560
- match: /^CONNECTIONS_.+$/, // …or a pattern, for dynamic families
561
- owner: 'workspace', // the manager service that owns it
562
- // ('backend' = the framework itself)
563
- targets: ['backend'], // the targets whose runtime READS it
564
- group: 'omega', // its ENV_GROUPS bucket (.env file order)
565
- generated: () => randomBytes(32)…, // the function that MINTS a value
566
- default: 'value', // …or a static default, where one applies
567
- secret: true, // never printed, never in omega.json5
568
- required: true, // absent = the backend refuses to boot
569
- delivery: { backend: 'env' }, // per target, HOW the value gets there
570
- requiredWhen: 'captcha.providers…', // non-empty when this config path is truthy
571
- publicAtRest: true, // sanctions a 'bake' (readable in the artifact)
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
576
- description: 'What the key drives.',
577
- }
578
- ```
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
-
592
- - **`generated:` is the mint switch.** Those keys have no dashboard behind them, so the
593
- manager writes them into a brand `.env` — at onboard, and on every manage that finds
594
- one missing. Everything else is a credential a human provides.
595
- - **Only a key OMEGA can produce may be `required`.** Refusing every boot over a secret
596
- nobody can mint would be a hostage note, not a guard — the config test pins it.
597
- - **`targets:` is the composition domain.** Every verb composes its target's RUNTIME env
598
- (the backend's staged `dist/.env` — the only artifact that ships and so cannot walk up to
599
- the brand layer) from the file layers, taking the entries whose `targets` name that
600
- target. The schema is the only filter on DECLARED keys: no hand list, and PATTERN entries
601
- (`match:`, e.g. the `CONNECTIONS_*` family) compose exactly like named ones. A key the schema
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,
608
- so nothing is written for them.
609
- - **`deliverAs:` renames on delivery**: the entry's brand-level name is what the cascade
610
- carries (`GOOGLE_ANALYTICS_SECRET_BACKEND`), and the target receives it under the name
611
- its own code reads (`GOOGLE_ANALYTICS_SECRET`). One entry, both names.
612
- - **`delivery:` says HOW a value reaches each target**
613
- ([#627](https://github.com/Omega-JS-Stack/omega/issues/627)): `'env'` (read from the
614
- composed `.env` at runtime — the backend), `'ci'` (the generated workflow injects it
615
- into the runner env for the build step), or `'bake'` (the build writes it into the
616
- shipped artifact, because the installed app runs with no `.env`). A bake implies the
617
- CI injection — the workflow delivers the value the build then bakes. One renderer in
618
- `@omega.js/config/env-delivery` derives everything from these declarations: each
619
- target's workflow secrets block, its bake list, and its publish-step secret set. No
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.
664
- - **A baked key is public at rest.** Anyone who unpacks the app can read it, so a
665
- `secret: true` entry may only bake when it also declares `publicAtRest: true` — the
666
- renderer THROWS otherwise, on every lane, so a real credential can never reach an
667
- artifact by accident. The GA Measurement Protocol secrets are the sanctioned baked keys.
668
- - **`machineLocal: true` marks this machine's own facts** (`OMEGA_FONTAWESOME_ROOT`):
669
- composed locally like any key, but filtered out of every rendered block and every
670
- published secret set — a laptop path has no business in CI.
671
- - **`requiredWhen: '<config path>'` is the conditional presence rule**
672
- ([#626](https://github.com/Omega-JS-Stack/omega/issues/626)): when the resolved config
673
- path is truthy, the key must be non-empty. Presence only, never a value-shape check,
674
- and one-directional. One checker, `checkEnvRules()` in `@omega.js/config/env-rules`,
675
- answers it for every consumer: the backend boot (production refuses, development warns
676
- once), the desktop and extension build bakes (build mode throws, development warns),
677
- and the manager's manage walk (warns per enabled target, never fails). A TARGET-LESS
678
- entry (`targets: []` — a SERVICE's own key, like `SENTRY_AUTH_TOKEN`) is owed when its
679
- path is truthy in the brand root **or in ANY enabled target's resolved config**
680
- ([#683](https://github.com/Omega-JS-Stack/omega/issues/683)): the service writes its
681
- values where the target lives — monitoring lands one Sentry DSN per surface and leaves
682
- the shared slot null — so a root-only read never fired on the shape brands carry.
683
- Violations name the BRAND-level key — the one a human sets in the brand `.env`.
684
- - **Nothing is CLEARED, because nothing is written into a hand file**
685
- ([#636](https://github.com/Omega-JS-Stack/omega/issues/636),
686
- [#678](https://github.com/Omega-JS-Stack/omega/issues/678)): the composed artifact is
687
- rebuilt from the cascade every verb, so a credential the brand root retires stops being
688
- served the moment it is dropped.
689
- - **`group:` picks the .env section**, and `ENV_GROUPS` owns the file order plus each
690
- section's comment. A group marked `file: false` (the `runtime` group) never reaches a
691
- brand `.env` at all — those keys resolve some other way (from config at boot, from the
692
- developer's own shell), so they are neither rendered as placeholders nor composed.
693
- - **Runtime/platform vars are NOT in the schema**: `FIREBASE_CONFIG`,
694
- `FUNCTIONS_EMULATOR`, `GCLOUD_PROJECT`, the `OMEGA_*_PORT` map, the test-mode
695
- switches. They are the runtime's facts about itself, not a brand's credentials, and
696
- the frameworks read them directly.
697
-
698
- ### One key, one entry — the environment supplies the value — #586
699
-
700
- A key whose value must differ between a local run and a deployed one is **not a second
701
- schema entry**. Public payment keys already split per machine through the config merge
702
- chain's local layer; secrets now split the same way through the `.env` cascade's
703
- environment overlay above — the brand puts the test credential in `.env.development`
704
- under the SAME name. The backend uses whatever key the env chain resolves, nothing more:
705
- the rule, and the `.env.development` advice that follows from it, live in
706
- [docs/backend/index.md](../backend/index.md) (the payment-keys paragraph).
707
-
708
- The schema declares WHAT a brand supplies, never which environment supplies it: `env.js`
709
- owns that. So `STRIPE_SECRET_KEY`, `PAYPAL_CLIENT_SECRET`, `CHARGEBEE_API_KEY` and
710
- `COINBASE_COMMERCE_API_KEY` (asked for through the setup contract, #608, only when
711
- `payment.providers.coinbase.enabled` is on) are one entry each, every provider library
712
- reads the one name, and there is no `<KEY>_DEV` twin, no live-shape guard, and no
713
- payment-specific rule anywhere (ruled 2026-08-26; the twin system was replaced before it shipped, so
714
- nothing migrates).
715
-
716
- ### One key per AI provider — #639
717
-
718
- `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. There is no second name for either.
719
-
720
- | Key | Owner | Read by | Asked at |
721
- |---|---|---|---|
722
- | `OPENAI_API_KEY` | `backend` | Contact inference, content + newsletter generation, the `chatgpt` translation provider | `manage` — the `ai` service's setup gate |
723
- | `ANTHROPIC_API_KEY` | `backend` | The backend's Anthropic provider (SVG generation, tool loops) | `manage` — the `ai` service's setup gate |
724
-
725
- - **The company-wide fallback is the COMPANY LAYER, never a second key** (Ian
726
- 2026-08-27). The legacy pair (`BACKEND_MANAGER_OPENAI_API_KEY`, then
727
- `OMEGA_OPENAI_API_KEY`) existed so one company key could serve every brand; the
728
- `.env` cascade already does that — put the value in the company `.env` and every
729
- brand under it resolves it, with a brand `.env` overriding. The prefixed names are
730
- gone from the schema and every reader; migration row in
731
- [breaking-changes.md](breaking-changes.md).
732
- - **Both are optional and neither gates a run.** The `ai` service declares them
733
- `gates: false`, so preflight never blocks on them and a brand that calls one
734
- provider is never nagged about the other. `ai.enabled: false` (what the gate's
735
- Disable lands) stops the ask for good.
736
-
737
- Who derives from it:
738
-
739
- | Lane | What it takes |
740
- |---|---|
741
- | `@omega.js/manager` workspace `env-keys` + the onboard `.env` stub | `generatedEnvKeys()` — name → the function that mints a value |
742
- | `@omega.js/manager` `lib/env-order.js` (canonical .env order) | `envFileGroups()` + `envKeysByGroup()` — the sections, their comments, their keys |
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) |
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 |
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)) |
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 |
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 |
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 |
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()`, called directly by every
756
- framework's `omega` instance and build module. It requires NOTHING, so it is bundled into browser
757
- artifacts (the desktop renderer, every extension bundle) and vendored into
758
- `@omega.js/client`, which reaches the same four through its own methods.
759
-
760
- **ONE INPUT.** On Node it is `process.env.OMEGA_ENVIRONMENT`. In a browser
761
- context, which can read neither env nor files, it is `config.environment`, the
762
- build fact every surface already bakes into `OMEGA_BUILD_JSON.config`
763
- ([#896](https://github.com/Omega-JS-Stack/omega/issues/896)), reached as
764
- `this.config.environment` off the instance the call is made on. Nothing else is
765
- consulted: no `app.isPackaged`, no `manifest.update_url`, no `NODE_ENV`, no
766
- terminal sniffing.
767
-
768
- **NO DEFAULT.** A context with no input throws and names the variable. A guessed
769
- environment is how a dev build ships as production and how a production build talks
770
- to an emulator, and both failures stay silent until a user finds them.
771
-
772
- **`setEnvironment(value)` is the only writer**, so a fourth word can never reach
773
- the variable. Who calls it, and with what:
774
-
775
- | Lane | Names |
776
- |---|---|
777
- | `@omega.js/backend`'s boot (`initialize()`, right after the `.env` cascade loads) | `envEnvironment()`, the AMBIENT answer (below) |
778
- | `@omega.js/desktop` / `@omega.js/extension` `src/build.js`, at load | `buildLaneEnvironment(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 |
779
- | `@omega.js/desktop`'s main process, at `initialize()` | the `environment` its baked config carries, for a packaged app that has no parent lane |
780
- | `@omega.js/web`'s verbs | `production` for `omega build`, `development` for `omega dev`, `testing` for `omega test` |
781
- | the desktop test runners, the extension `test` verb | `testing` |
782
-
783
- `envEnvironment()` (in `src/env.js`) is the PRODUCER of that input, never a
784
- second reader of it: it is the sniff a lane runs ONCE to decide what to set, and
785
- the default for the two overlay selections (`.env.<environment>` and
786
- `config/omega.<environment>.json5`). An already-set `OMEGA_ENVIRONMENT` wins
787
- inside it too, so the producer and the reader can never disagree in one process.
788
-
789
- ## Owner hooks (`config/hooks/`) — cp91
790
-
791
- `src/hooks.js` — owner-supplied code the frameworks call at named hook points, so
792
- company-specific logic lives in the OWNER'S tree, never in framework source. Layout is
793
- **nested, mirroring the call site** (Ian's directive): the account service's password
794
- step loads `config/hooks/account/password.js`; a future onboarding hook would live under
795
- `config/hooks/onboard/…` — one file per hook point, path = the invoking structure.
796
-
797
- - **Home = `config/`, versioned by default** (Ian 2026-07-11): hooks are AUTHORED code
798
- and sit with the other owner-authored omega inputs (omega.json5, seo.json5, chatsy.md,
799
- …) — never under machine-owned, gitignored `.omega/`, where a hook lost on a fresh
800
- clone would silently change behavior (passwords falling back to the seed channel and
801
- rotating). Secrets still belong in `.env` — a hook that needs one reads `process.env`;
802
- to keep a hook out of git anyway, add your own `config/hooks/` ignore line.
803
- - **Resolution order**: the brand root's own `config/hooks/<point>.js`, else the company
804
- tree's `company/config/hooks/<point>.js` (via `company: { id }`, #677): a company-wide
805
- hook covers every brand, a single brand can still override it. It is the ONE inheritance
806
- rule applied to one more relative path, and costs the hook loader no code of its own.
807
- - **Contract**: plain CJS, `module.exports = ({ … }) => …` (async fine). Each call site
808
- documents its hook's signature/return. Absent hook → `loadHook` returns null and the
809
- caller uses its default behavior; a hook that EXISTS but is broken (unloadable,
810
- non-function export, bad return) throws — an owner who wrote a hook never gets silent
811
- fallback.
812
- - **First (and so far only) hook point**: `account/password` —
813
- `({ email, domain, apex, brand }) => password` (string ≥ 6 chars), letting a company
814
- formula generate per-brand passwords without ever living in a repo the framework ships.
815
-
816
- ## Port auto-allocation (N7)
817
-
818
- `src/ports.js` — classic defaults, probe at boot, per-port +1 bump only when taken, so
819
- multiple brands run dev stacks concurrently. Single-brand dev with free defaults is
820
- byte-identical to the pre-N7 behavior (no bumping, no artifacts).
821
-
822
- - **`CLASSIC_PORTS`** — the historical defaults (functions 5001, hosting 5002, firestore
823
- 8080, auth 9099, database 9000, storage 9199, pubsub 8085, ui 4050, website 4000,
824
- livereload 35729, cdp 9222). **This map is the ONLY source of those numbers**
825
- ([#834](https://github.com/Omega-JS-Stack/omega/issues/834)): @omega.js/client
826
- carried four of them plus a classic dev origin, @omega.js/desktop's url-helpers
827
- and the desktop auth lib three more, and @omega.js/extension's url-helpers and
828
- background worker two, each as a last-resort fallback "for a build made with no
829
- stack up". Every one of those is gone. The numbers reach a browser exactly one
830
- way: each surface bakes the map into its build json with the classics as the
831
- FLOOR and the live stack's resolved (possibly bumped) numbers over them, so a
832
- dev artifact always carries a COMPLETE map and the classic dev ORIGIN rides it
833
- the same way. A read that finds no map throws `devFactMissing()`
834
- (`src/dev-facts.js`, the one home of that error), naming the fact it wanted and
835
- the build step that writes it: a missing map is a broken build, and nothing
836
- anywhere assumes a port again. Nothing identity-checks what answers on a
837
- classic port, which is why an assumed one failed as an auth mystery or a silent
838
- connection refusal rather than as a port problem.
839
- - **`resolvePorts({ wanted, pins, claimed })`** — each wanted port keeps its value when
840
- free, bumps +1 until free when taken (shared `claimed` set prevents two names landing
841
- on one port). `pins` (the config `ports` section) never bump — a busy pin throws.
842
- The free-check is a QUADRUPLE bind-probe (127.0.0.1, ::1 when the host has IPv6,
843
- the IPv4 wildcard 0.0.0.0, and the `::` wildcard) — on macOS/BSD, wildcard and
844
- specific-address listeners COEXIST on one port, and the two wildcard FAMILIES
845
- coexist with each other, so any single-surface probe false-positives against a
846
- sibling brand's stack and the real bind crashes later (found live at cp186: the
847
- playground's https proxy holds IPv6 `*:5002`; a 127.0.0.1-only probe handed 5002
848
- to the second brand's functions emulator — and again at #345: a foreign `0.0.0.0`
849
- squatter read free to the `::` probe and the auth emulator died with no bump).
850
- - **Ports file** — `writePortsFile(projectDir, ports, facts)` /
851
- `readPortsFile/clearPortsFile(projectDir)`: `<projectDir>/.temp/ports.json`
852
- (pid-stamped; readers ignore dead-pid leftovers). The allocator (the backend emulator
853
- boot) writes it; siblings of the same brand (`omega test` against a running emulator,
854
- the e2e harness) read it; cleared on clean shutdown. `facts` publishes the resolved
855
- NON-port facts beside the map — today `origin`, the website's dev origin
856
- ([#262](https://github.com/Omega-JS-Stack/omega/issues/262)).
857
- A consumer process targets `https`, the PUBLIC origin under the local certificate,
858
- and never `hosting`, the internal plain-http port the mkcert proxy forwards to; every
859
- `omega dev` leg trusts that certificate through `NODE_EXTRA_CA_CERTS` ([#795](https://github.com/Omega-JS-Stack/omega/issues/795)).
860
- - **Sibling map** — `readSiblingPorts(targetDir)` merges every OTHER target's live ports file
861
- in the same brand (a running backend's emulator map) for the target that asks;
862
- `readSiblingOrigin(targetDir)` reads the published dev website origin the same way. Read
863
- at USE time, never cached: the file appears when the backend boots and changes when it
864
- restarts.
865
- - **Env channel** — `portsToEnv(ports)` → `OMEGA_<NAME>_PORT` vars injected into spawned
866
- children; `envPort(name)` reads one, `envPorts(env)` reads the whole map back out.
867
- URL getters resolve env → classic default.
868
- - **Browser channel (cp89, [#300](https://github.com/Omega-JS-Stack/omega/issues/300))** —
869
- browser code can read neither env nor files, so a surface BAKES the map into its
870
- client config, under the same key on all three (`OMEGA_BUILD_JSON.config.dev`, #894):
871
- `omega dev` writes that line into the page chrome
872
- PER RENDER (its resolved website port with the sibling backend's map merged over it),
873
- and its auth-emulator proxy resolves the target port per REQUEST. The render-time
874
- bake is ADVISORY ([#346](https://github.com/Omega-JS-Stack/omega/issues/346)): the
875
- dev server REWRITES that chrome in every HTML response as it serves it, resolving the
876
- sibling maps per request, because the normal boot order builds the whole page fleet in
877
- under a second while the emulator suite seeds for minutes — nothing under `src/`
878
- changes when it lands, so no page would ever re-render onto it. Mid-session emulator
879
- restarts onto bumped numbers ride the same lane. Built `dist/` output is untouched on
880
- disk. Desktop (its renderer bundle) and extension (every bundle) bake the same map at
881
- the same key at build time, from the sibling file plus the env channel; production
882
- builds bake none. Drivers serving a
883
- STATIC build set `window.__OMEGA_DEV_PORTS__` (the devkit e2e harness — the site
884
- builds before the emulator boots), which is a FALLBACK: it fills only what a page's
885
- chrome omits, so a side channel no real browser has can never hide a broken real one.
886
- The dev WEBSITE ORIGIN rides the same map as one more resolved fact
887
- ([#262](https://github.com/Omega-JS-Stack/omega/issues/262)): `omega dev` publishes
888
- `dev.origin` (protocol AND port — the mkcert proxy fronts the public port by default,
889
- so a port number alone cannot say the scheme) into the chrome and into its ports file,
890
- and desktop/extension bake it from that file on their existing lanes, with
891
- `CLASSIC_DEV_ORIGIN` as the floor under it (#834) so the key is always present.
892
- The extension manifest's `externally_connectable` dev entry resolves from it at
893
- package time; nothing hardcodes a dev origin any more.
894
- `@omega.js/client`'s `getDevWebsiteOrigin()` is the one getter that answers it,
895
- and it THROWS when the artifact carries none rather than assuming
896
- `https://localhost:4000`, because a wrong dev origin fails as a silent
897
- connection refusal.
898
- `@omega.js/client` resolves chrome `dev.ports` → runtime global, and THROWS when
899
- neither answers ([#834](https://github.com/Omega-JS-Stack/omega/issues/834)):
900
- there is no classic-defaults step under them any more, and no
901
- "assuming the classic …" warning, because nothing is assumed. Its dev
902
- `getApiUrl` speaks plain http to a mapped `hosting` (the emulator serves http)
903
- and https to a mapped `https` (`mgr serve`'s mkcert proxy); neither mapped is
904
- the same loud error. Dev mode
905
- resolves the LOCAL stack for every source, including `source: 'company'` —
906
- `company.url` is a production concept, and dev deliberately makes no live server
907
- hits (ratified, Ian 2026-08-03, [#34](https://github.com/Omega-JS-Stack/omega/issues/34)).
908
- - **Website port (cp89)** — `omega dev` allocates through the same model: classic
909
- **4000** (pre-N7 it defaulted to 8080, colliding with the SAME brand's firestore
910
- emulator), bump when taken, `--port` flag or config `ports.website` pins; publishes
911
- its own ports file in the website target dir.
912
- - **Serve + per-target ports (cp90)** — `mgr serve` allocates through the same model
913
- (`--port` pins, taken bumps — the old kill-the-incumbent check is gone) and PUBLISHES
914
- its map: `https` (the mkcert proxy) + `hosting` (the internal plain-http
915
- firebase-serve port), so `omega dev` bakes even a bumped serve into the chrome and
916
- Stripe webhook forwarding targets the port that actually speaks http (it used to aim
917
- plain http at the TLS proxy). Desktop + extension `serve` allocate `livereload` — two
918
- targets of one brand land on distinct ports — and desktop allocates `cdp` when
919
- requested (`OMEGA_CDP_PORT` set); desktop URL getters mirror the backend's
920
- env-channel reads (`https` → mkcert, `hosting` → plain http, classic otherwise).
921
- The manager's Google-OAuth loopback binds an EPHEMERAL port (`listen(0)`, RFC 8252)
922
- instead of pinning 9876. The backend's `getWebsiteUrl` returns
923
- `http://localhost:4000` unless its own mkcert proxy is up (plain http on the public
924
- port 307s to https, so the link lands either way); desktop's reads the whole dev
925
- ORIGIN instead (baked `dev.origin`, then `OMEGA_WEBSITE_PORT` composed over https,
926
- then the classic `https://localhost:4000`), matching the browser-side answer scheme
927
- and all ([#747](https://github.com/Omega-JS-Stack/omega/issues/747)). The BROWSER-side answer
928
- is `getDevWebsiteOrigin()`, which needs the exact origin and takes it from the
929
- resolved map (#262). Packaged
930
- extension/desktop artifacts keep BUILD-TIME-BAKED ports by design (a shipped
931
- extension can't probe); the extension manifest's dev-website origin documents that
932
- inline.
933
- - **Config `ports` section** (schema, optional object) — explicit pins for any port name;
934
- unset = auto-allocate.
935
- - When a boot bumps emulator ports, the backend CLI materializes
936
- `firebase.resolved.json` next to firebase.json (same dir, so relative paths keep
937
- resolving) and boots firebase-tools with `--config`; the committed firebase.json never
938
- changes. Gitignored; removed on shutdown.
939
-
940
- Design + slice plan: [_attic/plans/archive/n7-port-allocation.md](../../_attic/plans/archive/n7-port-allocation.md).
941
-
942
- ## Validation
943
-
944
- `validateConfig(config, { target })` = shared schema + that target's refinements
945
- (`TARGET_SCHEMAS[target]`), run against the RESOLVED config. `brand.id` (URL-scheme-safe
946
- slug) and `brand.name` are the only universally required fields.
947
-
948
- **Undeclared keys WARN** ([#636](https://github.com/Omega-JS-Stack/omega/issues/636)):
949
- every leaf path of the resolved config no rule declares comes back as ONE warning naming
950
- them — never an error, because a brand config that outlives a framework version must still
951
- build. A rule of type `object`/`array` declares its whole subtree (a brand's postal
952
- address, an open provider map), and the `targets` namespace is exempt: those keys belong to
953
- a framework, or to a custom target. A finding is a hole to fill — either the key is dead,
954
- or the schema owes it a rule.
955
-
956
- **authDomain is the brand's own host** (cp268): when `cloud.config.authDomain` is set it
957
- must equal the BRAND host, `brand.url`, for every target a brand runs
958
- ([#588](https://github.com/Omega-JS-Stack/omega/issues/588)): one Firebase project, one
959
- backend, one authDomain. A target's own `url` is not read here (a
960
- `targets/admin` load would otherwise fail its own brand's authDomain), and the
961
- top-level `url` is only the fallback for a config carrying no `brand.url` at all. A
962
- `*.firebaseapp.com` value hard-fails (self-hosted `/__/auth/*` on the brand
963
- host is what keeps redirect sign-in working under browser storage partitioning; the web
964
- build emits those helper files), and any other mismatch fails naming both values. Absent
965
- passes, and `demo-*` (emulator-only) projects are exempt.
966
-
967
- **A product price is a bare NUMBER** ([#674](https://github.com/Omega-JS-Stack/omega/issues/674)):
968
- every entry of `payment.products[].prices` must be a number — `once: 49.99`, never
969
- `once: { amount: 49.99 }` — and the object shape is a config ERROR naming the product and
970
- the key. The two sides did not read it the same: the checkout page's resolver unwrapped
971
- `{ amount: N }` while the backend's confirmation URL and all three provider libraries took
972
- the bare number, so an object-shaped price rendered a correct order summary and reached
973
- the confirmation URL as `[object Object]`. There is no shared resolver to settle it in —
974
- the browser bundle cannot reach a build-time package, and the deployed backend runtime
975
- carries none either — so the SHAPE is settled here, in the one place both sides' catalog
976
- comes from. (`prices.amount` as a KEY is a different thing, a legacy one-time spelling the
977
- checkout still reads; its value is a number like every other.)
978
-
979
- ## The features catalog (`features`) and a product's values — #647
980
-
981
- A feature is **defined once**, at the top level, and a product names only its **value**.
982
- The two halves cannot disagree, because there is only one place a name, an icon or a
983
- definition can be written:
984
-
985
- ```json5
986
- {
987
- features: {
988
- saves: {
989
- name: 'Saves',
990
- icon: 'fa-solid fa-feather',
991
- definition: 'Notes, clips, and pages you can save per month.',
992
- usage: { pace: 'daily', mirror: ['teams'] },
993
- },
994
- templates: { name: 'Page templates', icon: 'fa-solid fa-palette', usage: { pace: false } },
995
- support: { name: 'Priority support', icon: 'fa-solid fa-headset', definition: 'Your tickets jump the queue.' },
996
- },
997
-
998
- payment: {
999
- products: [
1000
- { id: 'basic', name: 'Basic', features: { saves: 100 } },
1001
- { id: 'premium', name: 'Premium', features: { saves: 10000, templates: 40, support: true } },
1002
- { id: 'pro', name: 'Pro', features: { saves: -1, templates: 120, support: true } },
1003
- ],
1004
- },
1005
- }
1006
- ```
1007
-
1008
- ### The catalog
1009
-
1010
- | Key | Type | What it does |
1011
- |-----|------|--------------|
1012
- | `features.<id>.name` | string, **required** | The label every surface prints: pricing rows, the comparison matrix, the account's usage bars |
1013
- | `features.<id>.icon` | string | Font Awesome icon name ([icons.md](icons.md)) |
1014
- | `features.<id>.definition` | string | The dotted-underline tooltip. Authored ONCE — every card, row and bar renders this one |
1015
- | `features.<id>.usage` | object | Its presence makes the feature **counted** (metered per user). Absent = a **perk**, never counted |
1016
- | `features.<id>.usage.pace` | `'daily'` \| `false` | Day pacing is the DEFAULT. `false` opts out to a plain monthly counter |
1017
- | `features.<id>.usage.mirror` | string[] | Document KINDS this feature's counters also land on, resolved from `user.owns.<kind>` — declared here, never at a call site |
1018
-
1019
- **Key order is row order.** The pricing page's rows, the comparison matrix and the
1020
- account's usage bars all render the catalog in the order it is written.
1021
-
1022
- ### A product's values
1023
-
1024
- `payment.products[].features` is a MAP of `<catalog id>: value`:
1025
-
1026
- | The feature is | Its value is | Renders as |
1027
- |---|---|---|
1028
- | Counted | a number — the **monthly limit** | `100 Saves` |
1029
- | Counted | `-1` — unlimited | `Unlimited Saves` |
1030
- | Perk | `true` | a check, name only |
1031
- | Perk | a string | `24/7 Support` |
1032
- | Either | `false` (or absent) | nothing — the tier does not include it |
1033
-
1034
- The validator fails **a number on a perk** (it would draw a usage bar against a limit no
1035
- gate enforces) and **a perk value on a counted feature** (the gate would read it as zero,
1036
- so the plan advertises the feature and every call refuses it). It also fails a value on an
1037
- id the catalog does not define, because nothing reads it — the same silence a retired key
1038
- used to buy.
1039
-
1040
- ### What this replaces
1041
-
1042
- `payment.products[].limits`, the per-product `features` ARRAY, and the product-wide
1043
- `rateLimit` are **retired** — all three are validation errors naming their replacement.
1044
- The cross-product definition BACKFILL retires with them: nothing repeats, so nothing needs
1045
- unifying. The mapping is in [breaking-changes.md](breaking-changes.md).
1046
-
1047
- ### Top-level `usage` is reserved
1048
-
1049
- `usage` at the top level is reserved for **counting settings** (an anonymous-store mode, a
1050
- reset hour) and carries **no key today**. It is not a second home for the catalog. The
1051
- backend's gate reads `features`; how a user's counters behave is
1052
- [packages/backend/docs/usage-rate-limiting.md](../../packages/backend/docs/usage-rate-limiting.md).
1053
-
1054
- ## User connections (`connections`) — #771, #788, #792, #793
1055
-
1056
- Every key under `connections` is a PROVIDER a brand's users may link from the account page
1057
- — `google`, `discord`, `spotify`, `twitch`, `kick` ship with `@omega.js/backend`, and
1058
- any other one is a file the brand writes at `targets/backend/src/connections/<name>.js`
1059
- (the lane loads that directory before its own). So the section is free-form by design;
1060
- nothing here is required, and these are the keys an entry may carry:
1061
-
1062
- | Key | What it does |
1063
- |---|---|
1064
- | `enabled` | Whether the connection is offered on the account page. The five PACKAGED providers are `false` in the framework defaults — turning one on is the brand's act, since a card with no `CONNECTIONS_<PROVIDER>_CLIENT_ID` behind it could connect nothing. A brand's OWN provider is on unless this is `false` |
1065
- | `scope` | An array that WINS over the provider module's default scope |
1066
- | `name` | The card's title on the account page |
1067
- | `logo` | What the card draws: the NAME of a mark `@omega.js/web` ships (`core/logos/brandmarks/original/<name>.svg`, drawn inline), or a full URL (drawn as an `<img>`). A value carrying a `/` or a `:` is a URL; anything else is a mark name |
1068
- | `description` | The line under the title |
1069
-
1070
- Every key is a PROVIDER NAME, so it is strictly `[a-z0-9-]` — the same rule the
1071
- backend's confined loader enforces. A key outside it can never resolve to a
1072
- provider, and the account page says so on the card instead of offering a
1073
- connection that could only fail.
1074
-
1075
- **This section is the ONLY card list** ([#792](https://github.com/Omega-JS-Stack/omega/issues/792)):
1076
- every entry carrying a `name` and a `logo` renders a card, and the account layout's old
1077
- `connections:` frontmatter rows — which shadowed a brand's own entry and pointed at CDN
1078
- files that 404 — are gone. The framework's defaults carry `name`, `logo` and `description`
1079
- for the five packaged providers, so `google: { enabled: true }` is a complete card and any
1080
- key a brand writes wins through the ordinary merge chain. Those defaults RESOLVE only
1081
- (`materialize: false`, above): they are never copied into a brand's file. An enabled
1082
- provider with no `name` + `logo` after the merge gets the "unsupported connection" card,
1083
- which says exactly that.
1084
-
1085
- Secrets never live here: the credentials are the `CONNECTIONS_<PROVIDER>_CLIENT_ID` /
1086
- `CONNECTIONS_<PROVIDER>_CLIENT_SECRET` pair in the `.env` (the provider name uppercased,
1087
- dashes as underscores). The full provider contract — the module shape, the ONE context
1088
- every step takes, `pkce: 'S256'`, the route-owned identity uniqueness, and the `type` every
1089
- stored record carries — is `packages/backend/docs/connections.md`.
1090
-
1091
- ## The cancel-flow save offer (`payment.winback`) — #268
1092
-
1093
- When a customer starts cancelling a PAID subscription, the billing card pitches a
1094
- discount on the next cycle before it asks them why they are leaving. Accepting applies
1095
- the discount through the provider's own coupon plumbing, calls the cancel off, and
1096
- leaves the saving on the card (what comes off, and which bills it comes off — #325);
1097
- declining opens the cancellation questionnaire unchanged.
1098
-
1099
- The pitch is made per cancel ATTEMPT, never once per session (#324): a customer who
1100
- declines, closes the questionnaire and comes back to cancel meets the offer again,
1101
- because nothing about their subscription changed. Only a CLAIM (the discount is applied,
1102
- and the backend refuses a second one) or a refusal no retry fixes ends it.
1103
-
1104
- An accepted offer is recorded in TWO places, on purpose (#325). `payments-orders/{orderId}`
1105
- `.requests.winback` is the offer's MEMORY — what a second accept is refused against — and
1106
- the account carries the discount ITSELF at `subscription.discount`, shaped like every other
1107
- discount in the payment stack (`{ valid, code, percent | amount, duration }`) plus a
1108
- `source`. That is what the billing card renders on a later visit; without it the saving
1109
- disappeared on the next page load. `source` is the whole reason it is safe to read as a
1110
- claim: today only the winback claim writes this node, and `source` is what keeps the
1111
- read safe when checkout discounts start writing it too, because only
1112
- `source: 'winback'` says this customer already took the save offer.
1113
-
1114
- **The claim ends with the subscription it was made on (#333).** The account's node also
1115
- carries `resourceId`, the subscription the discount was applied to, stamped at claim
1116
- time. The unified webhook write carries no discount key, so a merge would otherwise keep
1117
- the node forever: a customer who churned and resubscribed carried a spent claim into the
1118
- NEW subscription, where `source: 'winback'` reads as "already claimed" and the save offer
1119
- is silently never pitched again. Every subscription-resource webhook compares the stamp against
1120
- the subscription the event is about and CLEARS the node on a mismatch (a new subscription
1121
- is a clean slate), while same-subscription traffic (renewals, cancellations, plan
1122
- changes) leaves the saving exactly as claimed. A node with no stamp predates it and
1123
- nothing can prove it belongs to an older subscription, so it is read as riding the one it
1124
- is found on and stamped there: no live discount is taken away on a guess, and it clears
1125
- on the next resubscribe like any other. CONSUMPTION is not cleared: a spent `once` coupon
1126
- still reads as applied until the subscription changes, because no provider's unified
1127
- shape reports whether the coupon is still attached (#333).
1128
-
1129
- The offer is the brand's, and **a brand that writes nothing gets one anyway**: 50% off
1130
- the next cycle, that cycle only.
1131
-
1132
- | Key | Type | Default | What it does |
1133
- |-----|------|---------|--------------|
1134
- | `payment.winback.enabled` | boolean | `true` | The whole off switch. `false` skips the pitch, and the cancel meets the data-retention warning instead (#341) |
1135
- | `payment.winback.percent` | integer 1-100 | `50` | Whole percentage off. Mutually exclusive with `amount` |
1136
- | `payment.winback.amount` | number > 0 | — | Flat amount off in `payment.currency`'s major unit (`10` = $10). Mutually exclusive with `percent` |
1137
- | `payment.winback.duration` | `'once'` \| `'forever'` | `'once'` | `once` discounts the next cycle only; `forever` is a permanent price cut |
1138
-
1139
- Setting **both** `percent` and `amount` is a validation error: a coupon is one shape or
1140
- the other everywhere in the payment stack, and two shapes on one offer has no honest
1141
- reading.
1142
-
1143
- `resolveWinbackOffer(payment)` is the ONE home of these defaults. The backend's
1144
- `POST /payments/winback` route resolves the brand's section through it, and the web
1145
- build bakes the same call into the client blob (`site.client.payment.winback`), so the
1146
- dialog the customer reads and the coupon the provider creates can never name different
1147
- numbers — the browser never applies a default of its own.
1148
-
1149
- Not every provider can discount a subscription that is already running. Stripe can
1150
- (the coupon plumbing the checkout already uses); PayPal has no discount object at all
1151
- and Chargebee has no way to reach a live subscription with one through existing
1152
- plumbing. Those two refuse with `not-supported-by-provider`, and the billing card
1153
- retires the offer for the session and opens the questionnaire — a subscriber can always
1154
- still cancel.
1155
-
1156
- ## Consent (`client.consent`) — #383
1157
-
1158
- The consent banner is a real GATE, so its config is schema-known even though the rest of
1159
- the `client` blob is not: a typo that silently disabled it would ship a site with no
1160
- consent surface and no error.
1161
-
1162
- ```json5
1163
- targets: {
1164
- web: {
1165
- client: {
1166
- consent: {
1167
- enabled: true, // default true; false ships NO banner
1168
- config: {
1169
- position: 'bottom-left', // bottom-left | bottom-right | bottom
1170
- content: {
1171
- message: 'We use cookies … See our { terms }.', // the banner face
1172
- panelIntro: 'We and our partners … See our { cookies } and { terms }.',
1173
- accept: 'Accept', // the big grant
1174
- customize: 'Customize', // opens the panel
1175
- acceptAll: 'Accept all', // the panel's pair
1176
- acceptNone: 'Accept none',
1177
- },
1178
- // `{terms}` and `{cookies}` link the terms and cookie-policy pages.
1179
- // `save` retired with the Save button (#391) — a config still setting
1180
- // it is ignored, not an error.
1181
- },
1182
- },
1183
- },
1184
- },
1185
- }
1186
- ```
1187
-
1188
- Two things are deliberately NOT config:
1189
-
1190
- - **The regime.** The visitor's browser timezone picks it — the strict opt-in roster (plus an
1191
- unplaceable timezone) gets opt-in, where no provider script loads until they accept; everywhere
1192
- else gets opt-out, where the scripts load and a first visit sees only the Cookies Settings
1193
- tab ([#391](https://github.com/Omega-JS-Stack/omega/issues/391)). There is no key that
1194
- forces one, because the answer is legal, not stylistic.
1195
- - **The colors.** The panel paints itself from the `--omega-*` token sheet, which is the
1196
- only way it is correct in both color modes. The old `palette`/`theme` keys are gone.
1197
-
1198
- `enabled: false` is legal only for a site that loads no analytics or marketing provider
1199
- at all — the gate and the banner are the same switch.
1200
-
1201
- ## Feature gating polarity (#527)
1202
-
1203
- Ratified 2026-08-24 (Ian). A feature has ONE switch with ONE polarity, and which
1204
- polarity it is follows from what the feature needs — never from taste at the read
1205
- site. Three cases:
1206
-
1207
- | Case | The switch | The read | Examples |
1208
- |------|-----------|----------|----------|
1209
- | **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` |
1210
- | **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` |
1211
- | **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` |
1212
-
1213
- - **A block by itself NEVER enables.** Authoring `providers: { adsense: {} }` is an opt-IN
1214
- to being asked, not an ON — the case-1 data or the case-2/3 `enabled` still decides.
1215
- - **Never a second switch on one feature.** Two switches let a config say ON to one half
1216
- of the stack and OFF to the other: adsense carried exactly that (the manager gated on
1217
- `enabled`, the site on `client` + `units`), so `{ client, enabled: false }` stopped the
1218
- account being managed while the site kept serving ads off it. #527 collapsed adsense to
1219
- case 1 and deleted both extra gates. A state that needs a second switch to express
1220
- (managed account, ad-free site) is deliberately inexpressible.
1221
- - **Every code-read switch has a schema rule** ([#546](https://github.com/Omega-JS-Stack/omega/issues/546)):
1222
- an undeclared key validates clean, so a typo (`enbaled: false`) silently reads as ON and
1223
- the validator cannot document what the key does. The rule carries the `default:` that
1224
- states the polarity — except where the SECTION is presence-gated (`advertising`), because
1225
- a materialized default would write the section into every brand and switch the feature on
1226
- for brands that configured none; there the ON answer lives at the read site.
1227
-
1228
- ## Tri-state provisioning values (#33)
1229
-
1230
- Provisioning-flow keys (org, billing account, service/agent ids — anything a manage
1231
- flow can set up interactively) follow ONE contract, enforced by the manager's
1232
- config-flow engine (`packages/manager/src/lib/config-flow.js`):
1233
-
1234
- | Value | Meaning |
1235
- |-------|---------|
1236
- | missing / `null` | ASK in an interactive run — the answer lands in omega.json5; without a TTY: warn + skip, aggregated in the run summary |
1237
- | `false` | The user opted OUT — silent skip, never prompt or warn again. `false` on an ancestor section (`inbound.chat.providers.chatsy: false`) opts out every key under it |
1238
- | anything else | Use it |
1239
-
1240
- Every ask offers the opt-out (the gate's "Disable" and, in selection flows, an inline
1241
- "No …" choice), so `false` is always reachable; delete the line to be asked again.
1242
- First consumers: `cloud.organizationId` (asked at project creation — pick an org or
1243
- create standalone) and `cloud.billingAccount` (pick/create a billing account or stay
1244
- on Spark). ONE cloud home (#23, reversing the old `gcp`-vs-`firebase` split): the
1245
- platform-level org and billing account, the provisioning switches (`cloud.shared`,
1246
- `cloud.supportEmail`, `cloud.apiSubdomain`) and the app config (`cloud.provider`,
1247
- `cloud.config`) are all `cloud.*`, and the project id has exactly one address —
1248
- `cloud.config.projectId`. `cloud.supportEmail`'s null auto-derives the authorizing
1249
- user's email instead of asking.
1250
-
1251
- ## The site global: the curated targets view (#85, #610)
1252
-
1253
- `toSiteGlobal()` (the web build's `site.*`) strips the raw `targets` machinery and
1254
- replaces it with a CURATED `site.targets` — an allow-list of display-safe facts per
1255
- declared target, never a spread of the raw config: every entry carries `enabled: true`,
1256
- desktop adds a derived `releasesUrl` plus the per-artifact `downloads` map once releases
1257
- are opted in, extension adds its store `listings`.
1258
-
1259
- It is the ONE home of those facts
1260
- ([#610](https://github.com/Omega-JS-Stack/omega/issues/610)). The legacy UJM-shaped
1261
- `targets.web.download` / `targets.web.extension` page maps — and the `site.download` /
1262
- `site.extension` data they filled — are GONE: a brand still carrying either key fails
1263
- validation naming the block it derives from, and `omega migrate` drops it with a note.
1264
-
1265
- - **The desktop derivation is OPT-IN (#124)**: `releasesUrl` appears only when
1266
- `targets.desktop.releases` is present (its
1267
- `enabled` defaults true when the block exists); `releases.enabled: false` always
1268
- suppresses, and a bare desktop target with no `releases` block derives nothing —
1269
- declaring the target does not mean a release exists yet. `releases.enabled` is ONE
1270
- switch for the whole release surface: the desktop build also reads it
1271
- (electron-builder publish config), so `false` turns off desktop publishing too — and
1272
- there it defaults true even with no `releases` block.
1273
- - **Desktop releases URL**: `https://github.com/<owner>/<name>/releases/latest`, where
1274
- owner and name come from `releasesRepo(config)` in
1275
- [repo.js](../../packages/config/src/repo.js): the brand's ONE public releases repo
1276
- ([#799](https://github.com/Omega-JS-Stack/omega/issues/799),
1277
- [#883](https://github.com/Omega-JS-Stack/omega/issues/883)). It is always
1278
- `<brand.id>-releases` under `repo.org`, with no override key left anywhere;
1279
- `targets.desktop.releases` presence is only the opt-in. Nothing addressable, no URL
1280
- (no org, no brand id). That helper is the ONE
1281
- home of the address: @omega.js/desktop's electron-builder publish block, its releases-repo
1282
- provisioning and its `finalize-release` uploads read the same call, so the feed a shipped
1283
- app polls and the link a download button carries cannot disagree.
1284
- - **Desktop direct downloads ([#620](https://github.com/Omega-JS-Stack/omega/issues/620),
1285
- [#867](https://github.com/Omega-JS-Stack/omega/issues/867))**:
1286
- `downloads.<platform>.<format>` = `<releasesUrl>/download/<asset>`, one per format the
1287
- brand SHIPS (`mac.dmg`, `windows.nsis`, `linux.deb`, `linux.appimage`, in offer order),
1288
- plus `linux.snap`, which is the Snap Store listing because the store holds that file.
1289
- The format keys and the asset names are `platforms.js`'s: the SAME table
1290
- @omega.js/desktop's `build-config` writes into `electron-builder.yml`, so a button
1291
- hands over the file and never lands on a GitHub page. They carry no version, which is
1292
- what keeps `/releases/latest/download/<asset>` pointing at the newest build forever:
1293
- releasing a desktop version never touches the website. Derived from
1294
- `targets.desktop.app.productName` → `brand.name`; no product name, no `downloads` (a
1295
- guessed filename is a dead button). A platform or format the brand dropped is absent
1296
- here too, so the site never renders a button for something nobody builds.
1297
- - **Extension listings**: `targets.extension.listings.<store>.{url,state}` for the six
1298
- stores the theme renders (chrome, firefox, edge, opera, safari, brave) —
1299
- schema-declared; url must be http(s). Entries with neither url nor state stay absent.
1300
- The `id` beside them (#893) is the publish lane's, not the page's: it never
1301
- reaches the curated view, because a listing with no url has nothing to link.
1302
- - **Idempotent by contract**: the web build applies `toSiteGlobal` twice (loadSiteData,
1303
- then configureOmega) — a curated `releasesUrl` and its `downloads` survive the second
1304
- pass unchanged.
1305
- - **The curated view is keyed by target NAME, and every fact derives from the entry's TYPE**
1306
- ([#886](https://github.com/Omega-JS-Stack/omega/issues/886)): a brand that names its desktop
1307
- target `app` gets `site.targets.app.releasesUrl` and `site.targets.app.downloads`. The name is
1308
- never the test. A target whose type derives nothing is a presence-only entry.
1309
-
1310
- Three consumers read the curated view and nothing else: the `/download` page (every
1311
- desktop button → `site.targets.desktop.downloads[platform][artifact]`), the `/extension` page
1312
- (`site.targets.extension.listings[browser].url`), and the shortlink generator
1313
- (`src/target-shortlinks.js`, #561), which publishes `/download/<platform>[/<artifact>]`
1314
- and `/extension/<store>` off the same facts. Mobile derives nothing while MAM is
1315
- parked, so the mobile band stays on its notify form.
1316
-
1317
- ## The browser subset: one `client` flag, one `OMEGA_BUILD_JSON`, one `build.js` (#894, #743)
1318
-
1319
- Three surfaces hand a config to a browser: web's pages and its service worker, desktop's
1320
- renderer windows, and every extension page plus its service worker. What may go in is ONE
1321
- decision, declared once and made once, and it is DELIVERED one way:
1322
-
1323
- - **The declaration** is `CLIENT_SECTIONS` in [src/schema.js](../../packages/config/src/schema.js):
1324
- a per-SECTION `client` flag. `true` ships the whole section (its keys are public by
1325
- design, and a secret-shaped key can never be in the file at all); an ARRAY of dot-paths
1326
- ships only that section's public half, for a section whose other keys are
1327
- provisioning-only. No row means the browser never sees it. Today: `advertising`,
1328
- `analytics`, `app`, `brand`, `captcha`, `client`, `company`, `connections`, `features`,
1329
- `listings`, `monitoring`, `payment`, `theme` ride whole; `cloud` rides
1330
- `['config', 'messaging.vapidKey']` (the Firebase WEB config is public, the GCP account
1331
- facts beside it are not); `forms` rides `['providers.slapform.formId']` and `inbound`
1332
- the three chatsy leaves the widget reads. A NEW section defaults to invisible, which
1333
- fails as a missing feature rather than as a leak.
1334
- - **The function** is `clientConfig(resolved)`
1335
- ([src/client-config.js](../../packages/config/src/client-config.js)): the flagged
1336
- sections, plus the build FACTS a surface composes onto the config before baking it
1337
- (`runtime`, `environment`, `version`, `buildTime`, `target`, `url`, `dev`). `runtime` is
1338
- baked on every surface, including desktop (#896): a packaged Electron renderer is a
1339
- browser with no Electron globals of its own, so nothing can sniff it from inside.
1340
- `payment` comes out with its winback offer RESOLVED, so the dialog a customer reads and
1341
- the coupon the backend creates name the same numbers. A secret-shaped key inside a
1342
- section about to ship THROWS: that config should never have loaded, and this is the last
1343
- place before it is published. A value a build is sanctioned to bake (`publicAtRest`,
1344
- "Config or env?" above) is added by that build AFTER this gate, never carried through it.
1345
- - **The wrapper** is the same name and the same five keys on every surface:
1346
- `OMEGA_BUILD_JSON = { config, package, mode, license, builtAt }`, reachable as
1347
- `self.OMEGA_BUILD_JSON` in a window and in a worker alike. `config` is the subset; the
1348
- rest are facts about the BUILD and never part of the client contract. `mode` is the same
1349
- THREE keys everywhere, `{ environment, build, publish }`: the build's verdict, whether it
1350
- was a build rather than a dev/watch run, and whether it publishes. A surface's own extra
1351
- verdicts (desktop's `server`) stay inside that surface's build module.
1352
- - **The delivery** is ONE FILE, `build.js` at the artifact's web root
1353
- ([#743](https://github.com/Omega-JS-Stack/omega/issues/743), Ian 2026-09-12: "make it
1354
- same shape and consumption everywhere as much as possible"). Two plain statements, written
1355
- by `composeBuildJson()` + `writeBuildJs()` in
1356
- [@omega.js/devkit/build-json](../../packages/devkit/src/build-json.js):
1357
-
1358
- ```js
1359
- self.OMEGA_BUILD_JSON = { config, package, mode, license, builtAt };
1360
- self.OMEGA_BUILD_JSON.config.dev = { … } | null;
1361
- ```
1362
-
1363
- Every HTML shell loads it with one script tag, FIRST (web's `core/_includes/core/head.html`
1364
- at `/build.js`, desktop's page template at `../../build.js`, the extension's at
1365
- `/build.js`), and every worker with one `importScripts('/build.js')` line (web's
1366
- `sw/omega.js`, the extension's `background.js`). No bundle carries a copy: the esbuild
1367
- banners and defines the browser bundles grew are retired, and so is web's inline foot
1368
- script. The `dev` map is its own statement because `omega dev` REWRITES that one line per
1369
- request (#346), so a site built before the emulator came up still serves the map of the
1370
- stack running right now.
1371
-
1372
- Desktop's MAIN and PRELOAD bundles are the one exception, and not to the file: they are
1373
- Node, they boot from the WHOLE resolved config inside the asar, and they keep carrying it
1374
- as a define plus a banner.
1375
-
1376
- A web page whose own `config:` block (#607) changes what a runtime reader sees emits ONE
1377
- more line after the loader tag, `Object.assign(self.OMEGA_BUILD_JSON.config, <delta>)`,
1378
- naming only the sections that actually differ.
1379
- - **`@omega.js/client` is handed `OMEGA_BUILD_JSON.config`** by the same call on all three
1380
- surfaces, and it owns the mapping onto its own contract: the `client` blob IS its top
1381
- level, `cloud.config` is the Firebase home it boots from, and
1382
- `monitoring.providers.sentry` is the one error-reporting switch. No surface composes a
1383
- second home for any of those.
1384
-
1385
- The backend is deliberately outside this: it stages the WHOLE resolved config to
1386
- `dist/config/omega.json5` and reads it with the loader, which is right for a server.
1387
-
1388
- ## Consumer access
1389
-
1390
- Each framework exposes the vendored loader — desktop: `require('@omega.js/desktop/config')`,
1391
- extension: `require('@omega.js/extension/config')` → `{ loadConfig, validateConfig, … }`.
1392
- Consumer workflows use this instead of raw JSON5 reads so brand-monorepo resolution
1393
- always applies.
1394
-
1395
- **Derived values reach brands as VALUES, never as a recipe to re-run**
1396
- ([#290](https://github.com/Omega-JS-Stack/omega/issues/290)). A brand target cannot require
1397
- this private package at runtime, so a framework that owns a derivation publishes its
1398
- ANSWER on the runtime config object the target already holds, under `resolved.*`: the
1399
- backend's `omega.config.resolved.github` carries `{ owner, name, slug }`, the brand's
1400
- SOURCE repo as one finished value. The derivations themselves stay here (`src/repo.js`):
1401
- one implementation, called by the framework, so no brand re-implements the rules and
1402
- drifts from them. New derived values join a framework's `resolved` group as real brand
1403
- needs surface.
1404
-
1405
- ## The repo block and the repos it derives (#883)
1406
-
1407
- ONE block says where a brand hosts its code, and it is two keys:
1408
-
1409
- ```json5
1410
- repo: { provider: 'github', org: 'Acme-Org' }
1411
- ```
1412
-
1413
- - **Presence is the switch.** A brand with no `repo` block hosts its source somewhere the
1414
- manager does not touch, and the repo service skips, exactly as a missing target key
1415
- means that target is not enabled. There is no `enabled` key.
1416
- - **`provider`** defaults to `github` and must be one of `REPO_PROVIDERS` (`['github']`).
1417
- A second provider is one table row plus its own helper, never a reshape of the block.
1418
- - **`org`** is the only typed value, and it is required whenever the block is present: it
1419
- owns every repo the brand's names derive under.
1420
-
1421
- **Every repo NAME derives from the `<brand.id>-<role>` rule** (Ian 2026-09-07,
1422
- [#809](https://github.com/Omega-JS-Stack/omega/issues/809)), one function per role in
1423
- [repo.js](../../packages/config/src/repo.js), which every package reads. No override key
1424
- of any kind exists: a repo name that must differ is a brand id that must differ.
1425
-
1426
- | Role | Derivation | Visibility | Holds |
1427
- |---|---|---|---|
1428
- | 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 |
1429
- | 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 |
1430
- | 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 |
1431
-
1432
- **`repoDrift(originSlug, config)`** is the one comparison of a checkout's `origin` with
1433
- the source repo ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)): the whole
1434
- slug, case-insensitive, null when they agree or the config derives no source repo, else
1435
- the line `origin is <slug> but config derives <derived>: fix repo.org in
1436
- config/omega.json5 or move the repo`. The boot prelude prints it; `omega manage` and
1437
- `omega deploy` refuse with it ([deploys.md](deploys.md#the-origin-gate-934)).
1438
-
1439
- **Visibility lives in the brand root's `package.json`**, never in omega.json5
1440
- (`brandVisibility(brandRoot)`): `private: true` or the field ABSENT is a private brand
1441
- (every brand monorepo is private by default, Ian 2026-09-11), and only a literal `false`
1442
- is a public one. The manage walk reconciles the repo to it in both directions.
1443
-
1444
- **`targets.<name>.hosting.provider`** says who SERVES a web target's built site, on web
1445
- targets only (a non-web target carrying `hosting` is a validation error). It defaults to
1446
- `github`, must be one of `HOSTING_PROVIDERS` (`['github']`), and is what `websiteRepo`
1447
- reads: another provider means there is no GitHub repo to address at all.
1448
-
1449
- **The Pages custom domain is ONE derivation**, `pagesHost(config, name)`: the bare host of
1450
- that target's `targetUrl`, and an empty string when the url names a default Pages address
1451
- (`*.github.io` is an ADDRESS, never a domain, [#366](https://github.com/Omega-JS-Stack/omega/issues/366)).
1452
- The manage walk sets the domain on the repo from it and the web deploy writes its `CNAME`
1453
- file from it, so the two can never claim different domains for one site.
1454
-
1455
- The by-hand steps for a brand still on the old shape are the register's
1456
- [2026-09-11 section](breaking-changes.md#2026-09-11-one-repo-block-and-the-website-repo-883).
1457
-
1458
- ## Defaults & self-healing
1459
-
1460
- Ian's ruling (2026-08-22, [#478](https://github.com/Omega-JS-Stack/omega/issues/478)): the config
1461
- stays complete and current. A subsystem that exists has its config structure IN the file —
1462
- visible and editable — instead of an invisible framework fallback, and every default has ONE
1463
- home.
1464
-
1465
- **The schema is that home.** A `schema.js` entry carries its own `default:` beside its type and
1466
- description; `schemaDefaults(target)` builds them into the merge chain's lowest layer, so nothing
1467
- else re-states a default. A key with no sane framework answer carries none: owner decisions,
1468
- tri-states that mean "ask" (`cloud.billingAccount`), ids the services provision
1469
- (`forms.providers.slapform.formId`), anything secret-shaped. The other standing exclusion is the
1470
- **presence gate** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)), which in practice
1471
- means monitoring: that service reads `monitoring.providers.sentry` presence as the pick of
1472
- Sentry, so its SDK knobs (`sampleRate`, `scrubEmail`, …) carry no default and keep their home in
1473
- the package that reads them — and `advertising`, whose adsense `client` id is the whole switch
1474
- ([#527](https://github.com/Omega-JS-Stack/omega/issues/527)), so a materialized block would turn
1475
- the web build's automatic ad placements on for a brand that configured none. `repo` is the third
1476
- ([#883](https://github.com/Omega-JS-Stack/omega/issues/883)): the BLOCK's presence enables the
1477
- repo service, so `repo.provider` carries no default either and the value a missing provider reads
1478
- lives beside the derivation, in `src/repo.js`. Blocks whose services
1479
- gate on `enabled` or provisioned ids rather than presence (cloudflare, searchConsole,
1480
- slapform, chatsy, replyify) DO carry defaults — see the polarity doctrine above.
1481
- Role-level switches beside any providers block
1482
- (`monitoring.enabled`, `marketing.campaigns.enabled`) are nobody's pick and always may.
1483
-
1484
- **`omega manage` materializes what a brand lacks.** The workspace service's `defaults` operation
1485
- (right after the `config` health check) diffs the brand's own `config/omega.json5` against the
1486
- schema defaults and writes the missing blocks through the comment-preserving editor, each key
1487
- documented with the schema's own description. The rules:
1488
-
1489
- - **Never an overwrite.** Only keys the brand has NOT authored are written — a `false` a brand
1490
- set (or a section it deliberately switched off) is a decision, and the heal never dives into it.
1491
- - **Highest missing path, once.** A brand with no `marketing` at all gets one `marketing` block,
1492
- not one edit per key inside it.
1493
- - **Idempotent.** A converged config leaves the file byte-identical; a dry run reports the blocks
1494
- and writes nothing.
1495
- - **New subsystems arrive on the next run.** Adding a `default:` to the schema is all it takes for
1496
- every consumer's config to grow the block the next time manage runs.
1497
-
1498
- `marketing.prune.enabled` is the first ruling this carries: pruning is ON by default and lands in
1499
- every brand config as an editable switch (Ian 2026-08-22, closing the
1500
- [#422](https://github.com/Omega-JS-Stack/omega/issues/422) follow-up).
1501
-
1502
- **`materialize: false` — a default that resolves but is never written**
1503
- ([#793](https://github.com/Omega-JS-Stack/omega/issues/793)). A rule may carry the flag beside its
1504
- `default:`, and then `schemaDefaults()` still puts the value at the merge chain's lowest layer —
1505
- every reader resolves it — while `missingDefaults()` and `defaultComments()` skip it, so the manage
1506
- walk never writes it into `config/omega.json5`. The line for when to use it: **is this value an
1507
- OWNER's decision, or the framework's own fact?** A decision belongs in the brand's file, where it is
1508
- visible and editable (that is every ordinary default). A framework fact a brand may override and
1509
- rarely does — presentation the framework owns — belongs at the layer that owns it, because a copy in
1510
- every brand config is a copy that drifts from the thing it came from. The `connections` section is
1511
- the first: the framework ships the five packaged providers' `name`, `logo` and `description`, a
1512
- brand overrides any key through the ordinary merge chain, and nothing is copied into a brand file to
1513
- go stale ([packages/backend/docs/connections.md](../../packages/backend/docs/connections.md)).
1514
-
1515
- ## Writeback (comment-preserving edits)
1516
-
1517
- omega.json5 is hand-edited — comments, key order, and quote style carry meaning — so
1518
- programmatic writes are surgical text edits, not a re-stringify (omega-manager's
1519
- serializer rewrote the whole file in canonical order and lost comments; this replaces
1520
- it). The manager's services use it to land resolved IDs in config: the SendGrid list,
1521
- the Beehiiv publication, Stripe/PayPal product IDs, the Firebase SDK config.
1522
-
1523
- `applyConfigEdits(source, edits, { comments }?)` applies `{ 'dot.path': value }` edits to JSON5 text:
1524
- existing leaves get their value span replaced; missing branches insert as one property
1525
- before the containing object's closing brace (matching indent; house style: unquoted
1526
- keys, JSON.stringify strings, trailing commas). Every byte outside the edited spans
1527
- survives. Paths take dots, numeric array indexes, and `[key=value]` matchers that select
1528
- an array element by its own key — `payment.products[id=plus].stripe.productId` — so
1529
- writes self-locate in the file being edited instead of trusting an index computed from a
1530
- merged config. Array elements are never created.
1531
-
1532
- `comments` (dot-path → text) documents INSERTED keys only — the comment lands above the key its
1533
- path names, wherever inside the inserted block that is, wrapped at that key's indent. A path that
1534
- already exists keeps whatever the brand wrote above it. That is how the manage-run heal
1535
- ([Defaults & self-healing](#defaults--self-healing)) lands each materialized block with the
1536
- schema's own guidance beside it.
1537
-
1538
- Guarantees: edits whose value already matches are skipped entirely (reruns are
1539
- byte-identical); after every edit the result must JSON5-parse and hold the requested
1540
- value at the requested path, or the call throws and nothing is returned — a corrupted
1541
- config can't land on disk.
1542
-
1543
- `writeConfigValues(projectDir, edits, { dryRun }?)` is the file-level form: resolves the
1544
- standard locations, skips the write when nothing changes, and returns
1545
- `{ path, changed, applied }` (`applied` = the paths that actually differed). The manager
1546
- wraps it in `lib/config-write.js` (`writeBrandConfig(context, edits)`) for the uniform
1547
- dry-run gate + logging.
1548
-
1549
- **Deleting** is the same surgery in reverse ([#612](https://github.com/Omega-JS-Stack/omega/issues/612)):
1550
- `applyConfigRemovals(source, paths)` cuts the property a dot-path names — its key, its whole
1551
- subtree, the comma that separated it, and the `//` comment block documenting it, because that
1552
- comment describes the key being deleted and would otherwise dangle over the next one. A property
1553
- sharing its line (`{ a: 1, b: 2 }`) takes only itself and its separator. Absent paths are skipped,
1554
- so reruns are byte-identical, and each removal is verified (parses, path gone) or the call throws.
1555
- Array ELEMENTS are never removed, the mirror of never creating them. `removeConfigValues(projectDir,
1556
- paths, { dryRun }?)` is the file-level form → `{ path, changed, removed }`. Unlike a write it does
1557
- NOT normalize top-level key order: a deletion is surgical, and re-sorting the file around it would
1558
- bury the one line the caller means to report. The consumer is `omega migrate` at a brand root
1559
- ([manager/index.md](../manager/index.md)).
1560
-
1561
- ## Migration — legacy configs → omega.json5
1562
-
1563
- No framework reads the legacy files anymore. Convert once, delete the old file. General
1564
- recipe: shared-looking sections move to the TOP LEVEL (brand, analytics, payment, theme;
1565
- `oauth2` lands as `connections` (#788); `firebaseConfig` becomes `cloud: { provider: 'firebase', config: {…} }` and
1566
- `sentry` becomes `monitoring: { providers: { sentry: {…} } }`); everything framework-specific
1567
- moves under `targets.<name>`.
1568
-
1569
- ### The targets shape rename (#886)
1570
-
1571
- An OMEGA-era brand converts once: every entry declares its `type`, and the KEY is the folder.
1572
- The by-hand steps (and the rest of the register) are in
1573
- [breaking-changes.md](breaking-changes.md#2026-09-11-targets-keyed-by-name-886).
1574
-
1575
- | Legacy | New |
1576
- |---|---|
1577
- | `targets/website` (the canonical web folder) | **`targets/web`**: the folder is the key, and the key is `web` |
1578
- | `targets/website-admin` (an instance's suffixed folder) | **`targets/admin`**: the sibling key names its own folder |
1579
- | `web: [{ id: 'main' }, { id: 'admin' }]` (the instance ARRAY) | **sibling keys**: `web: { type: 'web' }, admin: { type: 'web' }` |
1580
- | `--target=website` | **`--target=web`**: the flag takes the NAME |
1581
-
1582
- ### The one repo block (#883)
1583
-
1584
- The brand's repo hosting used to be spelled in four places: `repo.providers.github`, a
1585
- separate top-level `github` identity, a `targets.<name>.github.repo` override, and the
1586
- desktop releases owner/repo. One block says it now (`repo: { provider, org }`), every repo
1587
- name derives from `<brand.id>-<role>`, and visibility is the brand root package.json's
1588
- `private` field. Each retired key is a registered PATH, so a brand still carrying one
1589
- fails validation instead of silently addressing a repo nothing publishes to. The by-hand
1590
- steps are the register's
1591
- [2026-09-11 section](breaking-changes.md#2026-09-11-one-repo-block-and-the-website-repo-883).
1592
-
1593
- | Retired path | New home |
1594
- |---|---|
1595
- | `repo.providers.github.org` | **`repo.org`** |
1596
- | `repo.providers.github.repo` | nothing: the source repo IS `<brand.id>-omega`. A name that must differ is a brand id that must differ |
1597
- | `repo.providers.github.private` | the brand root `package.json` `private` field (absent = private) |
1598
- | `repo.providers.github.shared` | nothing: an org may host many brands, and no brand rewrites an org profile |
1599
- | `repo.providers.github.enabled` | the PRESENCE of the `repo` block |
1600
- | `github.user` | nothing: the org is `repo.org` |
1601
- | `github.website` | nothing: a web target's site repo is `<brand.id>-<target name>` |
1602
- | `targets.<name>.github.repo` (every type) | nothing: the CMS commits to the SOURCE repo `<brand.id>-omega` |
1603
- | `targets.desktop.releases.owner` | nothing: `<brand.id>-releases` under `repo.org` |
1604
- | `targets.desktop.releases.repo` | nothing: `<brand.id>-releases` under `repo.org` (`releases: {}` stays the presence switch) |
1605
-
1606
- **Retired keys fail loudly** ([#142](https://github.com/Omega-JS-Stack/omega/issues/142)):
1607
- a name that was renamed OUTRIGHT is a validation error wherever it sits — shared level,
1608
- inside a `targets.<name>` entry, naming its replacement and
1609
- pointing back here. Today that is `web_manager` → `client`, `firebaseConfig` → `cloud`,
1610
- `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
1611
- everything under it vanished, since nothing dual-reads it. Names that live on as legitimate
1612
- keys elsewhere stay out of the list — the rows below are their only guide. `sentry` is the one
1613
- that reads like a contradiction and is not: its NEW home is itself a `sentry` key
1614
- (`monitoring.providers.sentry`), so a name test would fire on the very shape it is steering
1615
- people toward. Same for `google`/`meta` under `analytics.providers`.
1616
-
1617
- **`omega migrate` at the brand root deletes them** ([#612](https://github.com/Omega-JS-Stack/omega/issues/612)): both halves of the
1618
- list, from the file as AUTHORED, through the comment-preserving editor (`removeConfigValues`) —
1619
- the key, its subtree, and the comment documenting it, with every other byte untouched. One line
1620
- per key naming its replacement, `--dry-run` for the plan, idempotent (a converged brand's rerun
1621
- is byte-identical). It removes the dead key; moving the setting into the home named in the tables
1622
- below is still by hand, EXCEPT where a row carries a converter
1623
- ([#858](https://github.com/Omega-JS-Stack/omega/issues/858)): then the new value is written
1624
- first, through the same comment-preserving editor, and the old key is deleted in the same
1625
- run, so a brand never sits between the two shapes. A dry run prints both halves.
1626
-
1627
- `subdomains` is the newest name ([#588](https://github.com/Omega-JS-Stack/omega/issues/588),
1628
- Ian 2026-09-01). The key was READ by exactly one thing, the cloud hosting op, which ensured an
1629
- `api.{sub}.{domain}` per entry, and DECLARED by nothing: no schema rule, no default, never
1630
- materialized. The fact it was reaching for is a web target, so a target is its home now:
1631
- the NAME is the subdomain, and every subdomain shares one `api.<domain>`. It is a NAME test by the
1632
- rule above (`subdomains` exists nowhere else in the schema), so it fires wherever a brand wrote
1633
- it, including down inside a target entry's own `brand` block.
1634
-
1635
- | Retired key | New home |
1636
- |---|---|
1637
- | `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>` |
1638
-
1639
- `oauth2` is the newest name ([#788](https://github.com/Omega-JS-Stack/omega/issues/788),
1640
- Ian 2026-09-03). The product concept is a CONNECTION, and a connection will not always be an
1641
- OAuth grant — an API key or a bot token is one too — so the whole feature carries the product
1642
- word (the section, the route, the user-record field, the env prefix, the brand provider folder,
1643
- the callback URL) and each stored record names its own kind with `type: 'oauth2'`. A NAME test
1644
- by the rule above: `oauth2` exists nowhere else in the schema. The by-hand steps a carrying
1645
- brand still owes — the env rename, the provider-console redirect URI, the provider folder move —
1646
- are in [breaking-changes.md](breaking-changes.md#the-user-connection-feature-is-connections-788).
1647
-
1648
- | Retired key | New home |
1649
- |---|---|
1650
- | `oauth2` | **`connections`** — the per-provider block is unchanged. The credentials are the `CONNECTIONS_<PROVIDER>_CLIENT_ID`/`_SECRET` pair now, a brand's own provider module lives at `targets/backend/src/connections/<name>.js`, the route is `/omega/user/connections`, and the redirect URI to register is `<websiteUrl>/connections/callback` |
1651
-
1652
- The de-branding rekey ([#23](https://github.com/Omega-JS-Stack/omega/issues/23)) adds a
1653
- second, PATH-based half in the same file (`RETIRED_PATHS`): keys whose provider keeps its
1654
- own name one level down inside the new home, so a name test would false-positive. Each
1655
- entry matches ONE exact path from the root, array positions ignored, and the error names the
1656
- real path, index and all
1657
- ([#732](https://github.com/Omega-JS-Stack/omega/issues/732)). A `targets.<name>` row names the
1658
- TYPE, and the key is a NAME, so the row fires on EVERY target of that type: with `community:
1659
- { type: 'web' }` declared, `targets.community.meta.title` bounces against the
1660
- `targets.web.meta.title` row and the error names the real path
1661
- ([#886](https://github.com/Omega-JS-Stack/omega/issues/886)):
1662
-
1663
- | Retired path | New home |
1664
- |---|---|
1665
- | `slapform` | **`forms.providers.slapform`** |
1666
- | `chatsy` | **`inbound.chat.providers.chatsy`** (the widget `settings` moved here too — one home) |
1667
- | `replyify` | **`inbound.email.providers.replyify`** |
1668
- | `cloudflare` | **`edge.providers.cloudflare`** |
1669
- | `recaptcha` | **`captcha.providers.recaptcha`** (`site-key` → `siteKey`) |
1670
- | `searchConsole` | **`search.providers.searchConsole`** (`seo` already means the parasite-SEO content feature) |
1671
- | `gcp` | **`cloud`** (`cloud.organizationId`, `cloud.billingAccount`) |
1672
- | `firebase` | **`cloud`** (`cloud.shared`, `cloud.supportEmail`, `cloud.apiSubdomain`; projectId only at `cloud.config.projectId`) |
1673
- | `advertising.providers.google-adsense` | **`advertising.providers.adsense`** + camelCase slots |
1674
- | `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 |
1675
-
1676
- The one-provider-shape normalization ([#425](https://github.com/Omega-JS-Stack/omega/issues/425))
1677
- adds its own rows to the same `RETIRED_PATHS` half — every role names its vendors
1678
- `role.providers.<provider>` now, so the flat picks, the bare vendor key and payment's
1679
- fourth word are all retired. Key PRESENCE is the pick; `false` is the deliberate off
1680
- switch; no entry at all is "none chosen" (what a null provider meant). `cloud` stays the
1681
- ratified exception. Full rationale + the by-hand step per row:
1682
- [breaking-changes.md](breaking-changes.md#one-provider-shape--roleprovidersprovider-425).
1683
-
1684
- | Retired path | New home |
1685
- |---|---|
1686
- | `payment.providers` | **`payment.providers`** — contents identical. The SINGULAR `provider` (Firestore document fields, the intent schema, the payments route params, the email merge field, `libraries/payment/providers/`) is a live data contract and is unchanged |
1687
- | `certificates.apple` | **`certificates.providers.apple`** — no bare vendor keys; Windows signing sits beside it later |
1688
- | `domain.provider` | **`domain.providers.<registrar>`** — `{ namecheap: {} }` / `{ squarespace: {} }` |
1689
- | `domain.email.provider` | **`domain.email.providers.<provider>`** — `domain.email.forwarding` stays role-level (provider-agnostic) |
1690
- | `translation.provider` | **`translation.providers.<name>`** — `{ claude: {} }` / `{ chatgpt: {} }`; an absent block still means claude. `translation.model` stays role-level |
1691
- | `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 |
1692
- | `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` |
1693
- | `marketing.campaigns.provider` + `marketing.campaigns.listId` | **`marketing.campaigns.providers.sendgrid.listId`** — `marketing.campaigns.enabled` stays role-level |
1694
- | `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 |
1695
-
1696
- The retired-key sweep only ever runs over an omega.json5: the `omega migrate` converter
1697
- READS legacy files as input and emits the new names, and only its output is validated.
1698
-
1699
- AdSense's one-switch collapse ([#527](https://github.com/Omega-JS-Stack/omega/issues/527))
1700
- adds the last two rows ([#628](https://github.com/Omega-JS-Stack/omega/issues/628)). Both
1701
- were unregistered until then, so a brand still carrying the deleted gate validated CLEAN
1702
- while the account it meant to leave alone started being managed — the key reading exactly
1703
- like it still worked. `omega migrate` drops both with the note.
1704
-
1705
- | Retired path | New home |
1706
- |---|---|
1707
- | `advertising.providers.adsense.enabled` | **`advertising.providers.adsense`** — `client` presence is the ONE switch (managed account + rendered units + the ads.txt record). `advertising.providers.adsense: false` opts the provider out; there is no second gate |
1708
- | `advertising.providers.adsense.units` | **`advertising.providers.adsense`** — the render-only gate #527 refused. A managed-but-ad-free brand omits the block and manages the account by hand |
1709
-
1710
- The `meta` section is the other registered path ([#607](https://github.com/Omega-JS-Stack/omega/issues/607),
1711
- Ian 2026-08-26 — meta never exists in two places). It shipped for one wave beside the
1712
- bare `meta:` a page and a layout already wrote, which is two homes for one fact; deleting
1713
- the config half leaves page frontmatter as the only meta, with `brand.name` /
1714
- `brand.description` as the site-wide default the head falls back to. Registered at its
1715
- authored path AND at the `targets.web` overlay — never by key NAME, because
1716
- `analytics.providers.meta` is a legitimate key one level down.
1717
-
1718
- | Retired path | New home |
1719
- |---|---|
1720
- | `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)) |
1721
- | `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 |
1722
-
1723
- `translation.exclude` is the same ruling's second fold ([#858](https://github.com/Omega-JS-Stack/omega/issues/858),
1724
- Ian 2026-09-13). The key named what NOT to translate and defaulted to nothing, so a brand
1725
- that never thought about it paid a provider for every post it had, and a page had no way to
1726
- say anything at all. The list says what to TRANSLATE now: globs on page routes with `!`
1727
- negation, read in `.gitignore` order, defaulting to `['**', '!blog/**']` in the DEFAULTS
1728
- layer, and a page overrides it for itself with the SAME key name one level down
1729
- (`translation.include: true`/`false` in its own frontmatter). Registered at its authored
1730
- path AND at the `targets.web` overlay, the two places a brand can write it, never by key
1731
- NAME: `exclude` is a legitimate key elsewhere. This row carries a CONVERTER, so
1732
- `omega migrate` MOVES the setting instead of only deleting it.
1733
-
1734
- | Retired path | New home |
1735
- |---|---|
1736
- | `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)) |
1737
-
1738
- `targets.web.redirects` is the newest registered path ([#466](https://github.com/Omega-JS-Stack/omega/issues/466)).
1739
- It shipped in 0.45.0 and was withdrawn: static hosting has no server, so the map could only
1740
- ever be answered CLIENT-side off the built 404 page, and a search engine saw a 404 that
1741
- redirects rather than a move. Redirects are not web config at all now — a TEMPLATED
1742
- redirect needs edge computing, an enumerable one is a page. Registered at its authored
1743
- path, the one place a carrying brand has it.
1744
-
1745
- | Retired path | New home |
1746
- |---|---|
1747
- | `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)) |
1748
-
1749
- The four schema-less web sections are registered paths too
1750
- ([#850](https://github.com/Omega-JS-Stack/omega/issues/850), Ian 2026-09-09:
1751
- everything the build processes has a schema home). They were legacy UJM
1752
- presentation blocks the converter wrote under `targets.web` with no schema rule
1753
- anywhere, and @omega.js/web carried a PRIVATE list (`WEB_ONLY_SECTIONS`) purely
1754
- so its own `config:` guard would pass them. That list is gone: each key is a
1755
- registered path now, so a brand still carrying one hears it from the validator
1756
- instead of authoring a value nothing reads. Registered at their authored path,
1757
- the one place a carrying brand has them, and `omega migrate` drops the three
1758
- with a note and moves the currency.
1759
-
1760
- | Retired path | New home |
1761
- |---|---|
1762
- | `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 |
1763
- | `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` |
1764
- | `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 |
1765
- | `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 |
1766
-
1767
- `targets.desktop.downloads.*` are the newest registered paths ([#799](https://github.com/Omega-JS-Stack/omega/issues/799)).
1768
- The `download-server` mirror gave marketing a fixed filename, which the versionless artifact
1769
- names ([#620](https://github.com/Omega-JS-Stack/omega/issues/620)) made free: the site links
1770
- the ONE public releases repo directly and reads nothing from the mirror, so #799 deleted the
1771
- lane. Without these rows a brand carrying the block validates clean (it sits inside the
1772
- exempt `targets` namespace), gets a second repo provisioned and nothing published to it.
1773
- Registered per KEY at its authored path, never by name: the curated
1774
- `site.targets.desktop.downloads` map is a legitimate `downloads` one level down.
1775
-
1776
- | Retired path | New home |
1777
- |---|---|
1778
- | `targets.desktop.downloads.enabled` | **`targets.desktop.releases`**: one public releases repo per brand, and its versionless assets ARE the permanent download links |
1779
- | `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) |
1780
- | `targets.desktop.downloads.repo` | nothing: the releases repo derives as `<brand.id>-releases` (#883) |
1781
- | `targets.desktop.downloads.tag` | nothing: `/releases/latest/download/<asset>` is what the stable mirror tag was for |
1782
-
1783
- The company is ONE key now ([#677](https://github.com/Omega-JS-Stack/omega/issues/677)),
1784
- outside `brand`, and everything else about the company is RESOLVED at load. The typed
1785
- display name and the typed parent wordmark are registered retired PATHS; the typed
1786
- `company.url` (and any `company.name` / `company.images`) fails the LOAD instead, naming
1787
- the file, because the resolver fills those same keys and a retired-key sweep over a
1788
- resolved config would fire on its own answer. The by-hand step, and the `parent` /
1789
- `.omega/company.json` half, are in
1790
- [breaking-changes.md](breaking-changes.md#2026-09-12-one-company-key-and-a-company-tree-inside-the-parent-677).
1791
-
1792
- | Retired path | New home |
1793
- |---|---|
1794
- | `brand.company` | **`company.name`**, resolved from `company: { id: '<parent brand.id>' }`: the parent's name is the parent's to state |
1795
- | `brand.images.companyWordmark` | **`company.images.wordmark`**, resolved from the parent's own `brand.images.wordmark` |
1796
- | `company.url` (typed) | **`company.url`** (resolved): type `company: { id }` and the loader fills it; an authored one fails the load |
1797
- | `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` |
1798
-
1799
- ### electron-manager (`config/electron-manager.json` → `config/omega.json5`) — DONE (checkpoint 18)
1800
-
1801
- | Legacy | New |
1802
- |---|---|
1803
- | `brand`, `analytics`, `payment`, `theme` | top level, unchanged |
1804
- | `firebaseConfig` | **`cloud: { provider: 'firebase', config: {…} }`** (D12) |
1805
- | `sentry` | **`monitoring: { providers: { sentry: { dsn } } }`** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)) |
1806
- | `app` | `targets.desktop.app` |
1807
- | `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) |
1808
- | `autoUpdate`, `startup`, `releases`, `remoteConfig`, `restartManager` | `targets.desktop.<same key>` |
1809
- | `electronBuilder` overrides | `targets.desktop.electronBuilder` |
1810
- | `cdp` | `targets.desktop.cdp` |
1811
- | `windows` (optional) | `targets.desktop.windows` |
1812
- | `fileAssociations`, `protocols` | `targets.desktop.<same key>` |
1813
-
1814
- ### backend-manager (`functions/backend-manager-config.json` → `functions/config/omega.json5`) — DONE (checkpoint 19)
1815
-
1816
- | Legacy | New |
1817
- |---|---|
1818
- | `brand`, `analytics`, `payment` | top level, unchanged |
1819
- | `oauth2` | **`connections`** — the per-provider block is unchanged ([#788](https://github.com/Omega-JS-Stack/omega/issues/788)); the credentials become the `CONNECTIONS_<PROVIDER>_CLIENT_ID`/`_SECRET` pair |
1820
- | `firebaseConfig` | **`cloud: { provider: 'firebase', config: {…} }`** (D12) |
1821
- | `sentry` | **`monitoring: { providers: { sentry: { dsn } } }`** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)) |
1822
- | custom keys (`omega`, `mcp`, …) | top level, unchanged |
1823
- | `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 |
1824
- | `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 |
1825
-
1826
- Notes: @omega.js/backend's framework-defaults layer is `templates/config/omega.json5` resolved through
1827
- the same loader and passed as `options.defaults`; `initialize()` takes no
1828
- `backendManagerConfigPath` option (the loader discovers the file); boot warns on
1829
- schema findings, `npx omega test`'s target checks are the hard audit. The sandbox brand dogfoods the full
1830
- hierarchy: shared sections live in `brands/sandbox-brand/config/omega.json5` (brand level),
1831
- the backend target's local file carries only `targets.backend`.
1832
-
1833
- ### browser-extension-manager (`config/browser-extension-manager.json` → `config/omega.json5`) — DONE (checkpoint 20)
1834
-
1835
- | Legacy | New |
1836
- |---|---|
1837
- | `brand`, `analytics`, `theme` | top level, unchanged |
1838
- | `firebaseConfig` | **`cloud: { provider: 'firebase', config: {…} }`** (D12) |
1839
- | `sentry` | **`monitoring: { providers: { sentry: { dsn } } }`** ([#425](https://github.com/Omega-JS-Stack/omega/issues/425)) |
1840
- | custom keys (`liveReloadPort`, …) | top level, unchanged |
1841
- | `analytics.providers.google.secret` | **`.env` → `GOOGLE_ANALYTICS_SECRET`** (secrets never in omega.json5; loader hard-fails) |
1842
- | *(no extension-specific keys yet)* | `targets.extension: {}` — presence = enabled; extension-specific settings land here |
1843
-
1844
- Notes: `build.getConfig()` (`require('@omega.js/extension/build')`) returns the RESOLVED config (missing file → `{}`; schema
1845
- findings warn once per process — BXM has no separate audit surface). The build snapshot
1846
- (`OMEGA_BUILD_JSON`, the artifact's one `build.js`) bakes `GOOGLE_ANALYTICS_SECRET` from the environment at
1847
- build time, same value flow as before. `bxm setup` scaffolds + merges `config/omega.json5`
1848
- (the defaults merge now preserves consumer-only keys at every level — it previously
1849
- dropped them).
1850
-
1851
- ### ultimate-jekyll-manager (`src/_config.yml` + `config/ultimate-jekyll-manager.json`) — `omega migrate` (B4, checkpoint 32)
1852
-
1853
- One command converts the consumer in place (and `--check` previews without
1854
- writing). The converted file is validated through `loadConfig(root, 'web')`
1855
- before the report prints.
1856
-
1857
- | Legacy | New |
1858
- |---|---|
1859
- | `url` | `brand.url` (site.url derives; empty `baseurl` dropped) |
1860
- | `brand`, `theme` | top level, verbatim |
1861
- | `oauth2` | **`connections`** — same block, new name ([#788](https://github.com/Omega-JS-Stack/omega/issues/788)) |
1862
- | `analytics.{google,meta,tiktok}` (flat scalars) | `analytics.providers.<p>.id` — the unified spelling; the web chrome emits the client's flat shape from it |
1863
- | `web_manager.firebase.app.config` | **`cloud: { provider: 'firebase', config: {…} }`** (top level); the engine composes `cloud.config` back into `client.firebase.app.config` at build |
1864
- | `web_manager.payment` | **`payment`** (top level); composed back into `client.payment` (pricing layouts + the client read it there); credential keys set to `false` (legacy "disabled") are dropped |
1865
- | `web_manager` (rest: auth, exitPopup, …) | **`targets.web.client`** — the client-runtime settings blob, whole, under its new name (#1: `web_manager` → `client`, since it configures `@omega.js/client`; WebManager is not an OMEGA concept). No dual-read: the old key name is not honored anywhere |
1866
- | `web_manager.sentry` (`{ enabled, config: {…} }`) | **`monitoring: { enabled, providers: { sentry: {…} } }`** (top level) — the SDK knobs inside `config` ARE the provider block ([#485](https://github.com/Omega-JS-Stack/omega/issues/485)). Leaving them in the client blob meant the converted brand had no `monitoring` key at all, so the manager's monitoring service skipped every run and no Sentry project was ever reconciled |
1867
- | `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) |
1868
- | `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) |
1869
- | `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 |
1870
- | `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 |
1871
- | `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 |
1872
- | `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 |
1873
- | `recaptcha` (incl. `site-key`) | **`captcha.providers.recaptcha`** (`siteKey` — every key is camelCase, #23) |
1874
- | `cloudflare` (the purge `zone`) | **`edge.providers.cloudflare`** — one cloudflare home, shared with the manager's zone reconciliation (#23) |
1875
- | `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 |
1876
- | `permalink`, `pagination`, `collections`, `defaults`, `generators` | `targets.web.<same key>` (codemod rule 8's home — engine consumption of custom collections rides the consumer-theme waves) |
1877
- | UJM-json `distribute`, `sass.purgecss`, `imagemin`, `github.workflows` | `targets.web.{distribute,purgecss,imagemin,workflows}` — `imagemin` is LIVE (schema-known; `enabled: false` ships images verbatim, otherwise the build-time 320/640/1024 + webp matrix runs), and `purgecss.safelist` is LIVE too ([#250](https://github.com/Omega-JS-Stack/omega/issues/250)): schema-known (`{ standard, deep, greedy, keyframes }` arrays, or a bare array for `standard`), it merges over the framework's built-in safelist in the purge pass |
1878
- | UJM-json `webpack`, `gems`; `_config.yml` Jekyll machinery (`plugins`, `exclude`, …) | dropped, noted in the report |
1879
- | secret-shaped keys anywhere | dropped + warned — move to `.env` |
1880
-
1881
- Beyond config: the codemod rule table runs over `src/**` templates, the seed
1882
- `src/assets/js/main.js` is deleted (core main + boot runtime replace it;
1883
- customized ones are flagged with the port recipe), `main.scss`'s
1884
- `@use 'ultimate-jekyll-manager' with (…)` becomes `@use 'omega:main' with (…)`,
1885
- page-css self-`@use` lines are dropped, and Gemfile/Gemfile.lock/the legacy
1886
- configs are removed.
1887
-
1888
- ### omega-manager brand configs (`.brands/{id}/config.json`)
1889
-
1890
- The brand-config `targets` ARRAY's role is absorbed by key presence in the omega.json5
1891
- `targets` object. omega-manager's disperse writes omega.json5 from brand config + state at
1892
- its Phase-3 cutover (enumerating `SHARED_SECTIONS`, per-surface values into
1893
- `targets.<name>` overrides).
1894
-
1895
- ## Package API (quick reference)
1896
-
1897
- ```js
1898
- const {
1899
- loadConfig, // (projectDir, target?, { defaults }?) → { config, errors, warnings, enabled, name, files }
1900
- composeTargetConfig, // (projectDir, target) → { config, files } — company+brand+local frozen into ONE self-contained file (deploy upload boundary, #31)
1901
- hasOmegaConfig, // (projectDir) → boolean — "is this project migrated?"
1902
- resolveConfigPath, // (projectDir) → abs path | null
1903
- getEnabledTargets, // (config) → ['web', 'backend', …]
1904
- findBrandRoot, // (projectDir) → brand root | null — CLASSIFIES one target dir (THE hierarchy rule)
1905
- findBrandConfigPath, // (projectDir) → the BRAND layer's omega.json5 | null — the file a target with no local-layer file rides
1906
- resolveBrandRoot, // (startDir) → brand root | null — SEARCHES upward from anywhere (standalone → itself), bounded at the nearest .git
1907
- loadEnv, // (startDir) → { chain, loaded } — resolve + load the .env cascade
1908
- reloadEnv, // (startDir, options?) → same — drops the FILE-owned keys, then loads again, so an EDITED value lands and the shell still wins (#724)
1909
- resolveEnvChain, // (startDir) → { local, brand, company } .env paths (no loading)
1910
- loadEnvChain, // (paths) → loaded[] — dotenv strongest-first, nulls/missing skip
1911
- resolveCompany, // (brandRoot) → { id, name, url, images, root, dir, file(relPath), config }: the ONE company resolver (#677)
1912
- recordBrand, // ({ id, root, name, url }) → wrote?: the machine registry line every loadConfig refreshes
1913
- readRegistry, // () → { <brand.id>: { root, name, url, updatedAt } }: ~/.omega/brands.json (OMEGA_HOME moves it)
1914
- COMPANY_RESOLVED_FILE, // 'config/company-resolved.json5': the generated layer a deploy hands a runner
1915
- resolveHook, // (startRoot, 'account/password') → hook file | null (brand → company)
1916
- loadHook, // (startRoot, hookPath) → { fn, file } | null — broken hooks THROW
1917
- validateConfig, // (config, { target }?) → { errors, warnings }
1918
- runSchema, // low-level rule walker (EM's proven engine)
1919
- formatErrors, // errors → numbered block
1920
- resolvedBrandHost, // (config) → the BRAND's own host (`brand.url`, top-level `url` only as fallback), lowercased | '' — authDomain validation AND every persona address (#708)
1921
- findSecretKeys, // (object) → dot-paths of secret-shaped keys
1922
- findRetiredKeys, // (object) → [{ path, key, replacement, why }] — renamed-outright keys (#142)
1923
- chosenProvider, // (role.providers) → the picked provider name | null — presence is the pick, `false` the off switch (#425)
1924
- backendProjectType, // (targets.backend | resolved backend config) → 'firebase' | 'custom' — how the backend runs (#584)
1925
- BACKEND_PROJECT_TYPES,
1926
- applyConfigEdits, // (source, edits, { comments }?) → edited source — comment-preserving (see Writeback)
1927
- writeConfigValues, // (projectDir, edits, { dryRun, comments }?) → { path, changed, applied }
1928
- applyConfigRemovals, // (source, paths) → edited source — deletes a key, its subtree and its own comment (#612)
1929
- removeConfigValues, // (projectDir, paths, { dryRun }?) → { path, changed, removed } — absent paths skip, so reruns are byte-identical
1930
- schemaDefaults, // (target?) → the schema's own defaults, the merge chain's lowest layer (#478)
1931
- missingDefaults, // (rawConfig, target?) → [{ path, value }] — the blocks a brand file lacks (the manage heal's list)
1932
- defaultComments, // (target?) → { 'dot.path': description } — the guiding comments a materialized block carries
1933
- deepMerge, // agnostic layer merge
1934
- // The targets map (targets.js: every key is a NAME, #886)
1935
- targetEntries, // (config) → [{ name, type, …entry }] in config order; throws on a missing/unknown type
1936
- targetsOfType, // (config, 'web') → the same entries, that type only
1937
- targetPath, // (config, 'community') → 'targets/community'; an undeclared name throws with the declared list
1938
- targetNameFromDir, // (projectDir) → the target root's basename inside a brand (functions//dist/ normalize up); null standalone
1939
- targetUrl, // (config, name) → entry url → entry brand.url → brand.url when name IS the type → https://<name>.<brand host> | null
1940
- targetPortOffset, // (config, name) → position among the SAME-type targets (dev-port offsets)
1941
- brandHost, // (url) → the host exactly as brand.url states it (www kept, path dropped, port kept)
1942
- TARGET_NAME_PATTERN, TARGET_TYPES, // the name slug rule and [web, backend, desktop, extension, mobile, custom]
1943
- // The browser subset (#894): what a browser surface may see, and the one wrapper it is baked in
1944
- clientConfig, // (resolved + this build's facts) → the blob every browser surface bakes as OMEGA_BUILD_JSON.config
1945
- CLIENT_FACT_KEYS, // the build facts that ride beside the client sections (runtime, environment, version, buildTime, target, url, dev)
1946
- TARGETS, SHARED_SECTIONS, CLIENT_SECTIONS, SHARED_SCHEMA, TARGET_SCHEMAS,
1947
- } = require('@omega.js/config');
1948
- ```