@bongos/core 1.19.90 → 1.19.92

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.
@@ -324,6 +324,42 @@ function runMigrate(instanceDir, run = spawnSync) {
324
324
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
325
325
  }
326
326
 
327
+ // WHICH database will migrate target? (task 1002755)
328
+ //
329
+ // The pre-flight probe below is only meaningful if it tests the SAME database the migration
330
+ // will later use. It used to be handed `process.env.PGDATABASE` — undefined on a normal box —
331
+ // so psql fell through to a database named after the OS user, failed for an unrelated reason,
332
+ // and soft-skipped with "psql failed for another reason". migrate then picked its own name and
333
+ // hit a database that did not exist. Two soft failures composing into a hard one, discovered
334
+ // only AFTER the pin had moved.
335
+ //
336
+ // So resolve it here, up front, by asking the core's OWN resolver in a child pointed at this
337
+ // instance — the same precedence migrate.sh uses (PGDATABASE wins, else the instance's branding
338
+ // pack) and the same function, so the probe and the migration cannot disagree. Spawned rather
339
+ // than required because requiring pool.js constructs a pg Pool in this process.
340
+ // Returns { db, ran }:
341
+ // { db: 'name', ran: true } — resolved
342
+ // { db: null, ran: true } — the resolver RAN and this instance names no database. Fatal:
343
+ // migrate cannot proceed and we know it before the pin moves.
344
+ // { db: null, ran: false } — the resolver could not run at all (no node_modules yet, a
345
+ // stubbed spawn in tests). NOT fatal: that is a different problem
346
+ // from "no database configured", and guessing which one it is would
347
+ // block bumps for the wrong reason. migrate.sh makes the same
348
+ // distinction and still fails loudly later, so the net holds.
349
+ function resolveInstanceDb({ instanceDir }, run = spawnSync) {
350
+ if (process.env.PGDATABASE) return { db: process.env.PGDATABASE, ran: true };
351
+ const poolPath = path.join(instanceDir, 'node_modules', CORE_PKG, 'src', 'bongos', 'pool');
352
+ // the module path rides as argv, never interpolated into JS source — a Windows-shaped path's
353
+ // backslashes are escape sequences inside a string literal
354
+ const r = run(process.execPath, ['-e', "process.stdout.write(require(process.argv[1]).instanceDbName() || '')", poolPath], {
355
+ cwd: instanceDir,
356
+ encoding: 'utf8',
357
+ env: { ...process.env, CLOUDBONGOS_INSTANCE_ROOT: instanceDir },
358
+ });
359
+ if (!r || r.error || r.status !== 0 || typeof r.stdout !== 'string') return { db: null, ran: false };
360
+ return { db: String(r.stdout).trim() || null, ran: true };
361
+ }
362
+
327
363
  // Regenerate the per-instance API artifacts (OpenAPI spec + api-reference + typed
328
364
  // client) from the NEW core's routes — the claude-materialize sibling (task 2051).
329
365
  // WHY: the routes + generators ship WITH core, and the generated spec/docs/client also
@@ -748,10 +784,17 @@ async function runUpgrade(opts, deps = {}) {
748
784
  // mistake, and finding it at migrate means unwinding a pin that never should have
749
785
  // been written (task 1002712).
750
786
  if (!opts.skipMigrate) {
751
- const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db: process.env.PGDATABASE }, run);
787
+ const { db, ran } = (deps.resolveInstanceDb || resolveInstanceDb)({ instanceDir }, run);
788
+ if (ran && !db) {
789
+ return { ok: false, error: 'cannot determine which database to migrate.\n'
790
+ + ' Set PGDATABASE, or give this instance a db.database in config/branding.json.\n'
791
+ + ' Checked BEFORE the pin moves: migrate runs after the install, so a name that\n'
792
+ + ' cannot be resolved there costs a full rollback to discover (task 1002755).' };
793
+ }
794
+ const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db }, run);
752
795
  if (!dbId.ok) return { ok: false, error: dbId.reason };
753
- if (dbId.skipped) log(` • db identity pre-check: ${dbId.skipped}`);
754
- else log(' ✓ pre-flight: database reachable as the current user');
796
+ if (dbId.skipped) log(` • db identity pre-check${db ? ` (${db})` : ''}: ${dbId.skipped}`);
797
+ else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
755
798
  }
756
799
 
757
800
  // 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
@@ -1005,7 +1048,7 @@ async function main(argv = process.argv.slice(2)) {
1005
1048
  module.exports = {
1006
1049
  runUpgrade, parseArgs, main, rollback,
1007
1050
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1008
- vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
1051
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
1009
1052
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
1010
1053
  CORE_PKG, ARTIFACT,
1011
1054
  };
@@ -32,7 +32,13 @@ INVOKE_CWD="${INIT_CWD:-$PWD}"
32
32
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
33
33
  DEFAULT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
34
34
  CORE_ROOT="$(cd "$DEFAULT_ROOT" && node -e "process.stdout.write(require('./src/instance-config').resolveCoreRoot())" 2>/dev/null || echo "$DEFAULT_ROOT")"
35
- INSTANCE_ROOT="$(cd "$INVOKE_CWD" && node -e "process.stdout.write(require('$DEFAULT_ROOT/src/instance-config').resolveInstanceRootExplicit() || '')" 2>/dev/null || true)"
35
+ # The module path rides in as argv rather than being interpolated into the JS source: a
36
+ # Windows-shaped DEFAULT_ROOT contains backslashes, which are ESCAPE SEQUENCES inside a JS
37
+ # string literal ("\U", "\r", "\D"), so interpolating corrupts the path, the require throws,
38
+ # and this silently falls back to DEFAULT_ROOT — precisely the F6 shape described above, just
39
+ # triggered by the path separator instead of the cwd. Fixed alongside task 1002755, which hit
40
+ # the identical bug one resolution further down.
41
+ INSTANCE_ROOT="$(cd "$INVOKE_CWD" && node -e "process.stdout.write(require(process.argv[1]).resolveInstanceRootExplicit() || '')" "$DEFAULT_ROOT/src/instance-config" 2>/dev/null || true)"
36
42
  INSTANCE_ROOT="${INSTANCE_ROOT:-$DEFAULT_ROOT}"
37
43
  # Run from the instance root so instance-relative paths resolve (the post-deploy art check below)
38
44
  # AND so the enabledModules() read finds the instance's config/modules.json (resolveInstanceRoot
@@ -49,17 +55,68 @@ if [ "${1:-}" = "--print-roots" ]; then
49
55
  exit 0
50
56
  fi
51
57
 
52
- DB="${PGDATABASE:-amazonprimea}"
53
-
54
58
  # Test/ops seam (added with the 1.18.19 space-in-path fix): --print-migrations walks the REAL core-migration loop but reports
55
59
  # each path instead of applying it, and touches NO DB. tests/migrate_root_resolution.mjs uses it to
56
60
  # assert that a checkout whose path contains a space still yields whole, readable paths. Reporting
57
61
  # through the real loop (rather than a copy of it in the test) is the point: if the loop ever reverts
58
62
  # to an unquoted `for f in $MIGRATIONS`, this seam fragments the paths and the test fails. Also handy
59
63
  # in the field — `npm run migrate -- --print-migrations` shows exactly what migrate would apply.
64
+ #
65
+ # Resolved BEFORE the database name below, because a dry run touches no DB (every psql call is
66
+ # guarded by DRY_RUN) and so must not require one.
60
67
  DRY_RUN=0
61
68
  if [ "${1:-}" = "--print-migrations" ]; then DRY_RUN=1; fi
62
69
 
70
+ # --- Which database? (task 1002755) ---------------------------------------
71
+ # SAME precedence as src/bongos/pool.js: an explicit PGDATABASE wins, else the instance's own
72
+ # branding pack names it (config/branding.json db.database, else the neutral starter). Resolved by
73
+ # CALLING pool.instanceDbName() rather than re-reading the config here, so the migrator and the
74
+ # running server can never disagree about which database this instance is.
75
+ #
76
+ # There is deliberately NO literal fallback. This script ships inside the core to EVERY instance,
77
+ # so a baked-in default is one host's database name pointed at everyone else's box: on a co-hosted
78
+ # machine it silently targets a stranger's data, and everywhere else it dies with a confusing
79
+ # "database ... does not exist" long after the caller has moved its version pin. pool.js already
80
+ # made exactly this call for the server ("NOT hard-defaulting to a host's DB name", pool.js:10-16);
81
+ # this is the migrator catching up, not a new policy.
82
+ # The module path rides in as argv, NOT interpolated into the JS source: a Windows-shaped
83
+ # CORE_ROOT contains backslashes, and inside a JS string literal those are escape sequences
84
+ # ("\U", "\r"), so interpolating silently corrupts the path and this resolution degrades to
85
+ # empty. argv values are never parsed as JS.
86
+ DB="${PGDATABASE:-}"
87
+ DB_RESOLVER_ERR=""
88
+ if [ -z "$DB" ]; then
89
+ # stderr is CAPTURED, not discarded: "the pack names no database" and "the resolver could not
90
+ # run at all" are different problems with different fixes, and collapsing both into an empty
91
+ # string sends the operator to edit a config file when the real answer is `npm install`.
92
+ # (pool.js requires pg, so an instance with no node_modules fails here rather than resolving.)
93
+ if DB_RESOLVED="$(node -e "process.stdout.write(require(process.argv[1]).instanceDbName() || '')" "$CORE_ROOT/src/bongos/pool" 2>&1)"; then
94
+ DB="$DB_RESOLVED"
95
+ else
96
+ DB_RESOLVER_ERR="$DB_RESOLVED"
97
+ fi
98
+ fi
99
+ if [ -z "$DB" ] && [ "$DRY_RUN" = 0 ]; then
100
+ echo "migrate: cannot determine which database to migrate." >&2
101
+ if [ -n "$DB_RESOLVER_ERR" ]; then
102
+ echo " the db-name resolver did not run: $(printf '%s' "$DB_RESOLVER_ERR" | head -n 1)" >&2
103
+ echo " if the core's dependencies are not installed yet, run: npm install" >&2
104
+ else
105
+ echo " Set PGDATABASE, or give this instance a db.database in config/branding.json." >&2
106
+ fi
107
+ echo " Refusing to guess: a baked-in default would target another instance's database." >&2
108
+ exit 2
109
+ fi
110
+
111
+ # Test/ops seam: --print-db reports the database this run WOULD migrate, and exits before any DB
112
+ # work. `bongos upgrade` calls it to learn the name up front, so its pre-flight can prove the DB is
113
+ # reachable BEFORE the version pin moves — otherwise an unresolvable name is only discovered at
114
+ # migrate time, and the pin has to be unwound (task 1002755).
115
+ if [ "${1:-}" = "--print-db" ]; then
116
+ printf '%s\n' "$DB"
117
+ exit 0
118
+ fi
119
+
63
120
  # --- Instance-seed policy (task 1704 / ADR 0105) --------------------------
64
121
  # OTB/product scope-truth (versions, goals, done_when_criteria) lives in seed
65
122
  # migrations under migrations/instance/ (plus a few guarded blocks embedded in the
@@ -57,17 +57,23 @@ const { branding, clientBranding } = require('../src/branding');
57
57
  // with src/bongos/serve-internal.js applyBrandTokens. `brand` is the client-safe pack
58
58
  // and `publicOrigin` the resolved origin — both injected, never read ambiently, so the
59
59
  // transform stays pure and testable.
60
- function brandTokens(html, brand = {}, publicOrigin = '') {
60
+ function brandTokens(html, brand = {}, publicOrigin = '', buildersOrigin = '') {
61
61
  const world = (brand.identity && brand.identity.worldName) || '';
62
62
  const product = (brand.identity && brand.identity.productName) || '';
63
+ const tagline = (brand.copy && brand.copy.tagline) || '';
63
64
  const cur = (brand.currency && brand.currency.label) || 'credits';
64
65
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
66
+ const esc = (s) => String(s == null ? '' : s)
67
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
65
69
  return html
66
- .split('{{worldName}}').join(world)
67
- .split('{{productName}}').join(product)
68
- .split('{{Currency}}').join(Cur)
69
- .split('{{currency}}').join(cur)
70
- .split('{{publicOrigin}}').join(publicOrigin || '');
70
+ .split('{{worldName}}').join(esc(world))
71
+ .split('{{productName}}').join(esc(product))
72
+ .split('{{Currency}}').join(esc(Cur))
73
+ .split('{{currency}}').join(esc(cur))
74
+ .split('{{tagline}}').join(esc(tagline))
75
+ .split('{{publicOrigin}}').join(esc(publicOrigin || ''))
76
+ .split('{{buildersOrigin}}').join(esc(buildersOrigin || publicOrigin || ''));
71
77
  }
72
78
 
73
79
  // ---- the three off-box index.html injections -------------------------------
@@ -153,21 +153,45 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
153
153
  // stays brand-free (the fitness identity gate). Runs BEFORE the __BRANDING__
154
154
  // script is inserted; the injected JSON holds resolved values (never tokens), so
155
155
  // order is immaterial. Tokens: {{worldName}} {{productName}} {{currency}}
156
- // (lowercase label) {{Currency}} (Capitalized) {{publicOrigin}} (the apex origin,
157
- // for the one functional cross-instance link — sourced from the FULL pack's
158
- // domains, which clientBranding deliberately omits). Pure + exported for the test;
159
- // `origin` is injectable so the test drives it with zero I/O.
160
- function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin()) {
156
+ // (lowercase label) {{Currency}} (Capitalized) {{tagline}} (the pack's one-line
157
+ // pitch), plus the two ORIGINS — {{publicOrigin}} (this instance's apex) and
158
+ // {{buildersOrigin}} (its hall). Both origins are sourced from the FULL pack's
159
+ // `domains`, which clientBranding deliberately omits, so they are the only way a
160
+ // served page can carry a correct cross-host href or canonical/og: URL: a client
161
+ // fill cannot reach them, and a baked one would make every instance advertise
162
+ // whichever instance happened to author the file (task 1002748, direction C5/C6).
163
+ // Pure + exported for the test; both origins are injectable so it drives with zero I/O.
164
+ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl()) {
161
165
  const world = (brand.identity && brand.identity.worldName) || '';
162
166
  const product = (brand.identity && brand.identity.productName) || '';
167
+ const tagline = (brand.copy && brand.copy.tagline) || '';
163
168
  const cur = (brand.currency && brand.currency.label) || 'credits';
164
169
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
165
170
  return String(html == null ? '' : html)
166
- .split('{{worldName}}').join(world)
167
- .split('{{productName}}').join(product)
168
- .split('{{Currency}}').join(Cur)
169
- .split('{{currency}}').join(cur)
170
- .split('{{publicOrigin}}').join(origin || '');
171
+ .split('{{worldName}}').join(esc(world))
172
+ .split('{{productName}}').join(esc(product))
173
+ .split('{{Currency}}').join(esc(Cur))
174
+ .split('{{currency}}').join(esc(cur))
175
+ .split('{{tagline}}').join(esc(tagline))
176
+ .split('{{publicOrigin}}').join(esc(origin || ''))
177
+ // a pack that names no hall of its own resolves to its OWN apex — never to
178
+ // whichever instance authored the file
179
+ .split('{{buildersOrigin}}').join(esc(hallOrigin || origin || ''))
180
+ .split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''));
181
+ }
182
+ // Every token lands in HTML — as element text, or inside a double-quoted
183
+ // attribute (href/content). Escaping is defence in depth, not a live fix: the
184
+ // only source is config/branding.json, which is committed, code-reviewed and
185
+ // Archon-only, never request-supplied. But a pack string carrying a quote or a
186
+ // '<' would otherwise break out of its attribute or close <title> early, and
187
+ // the values now reach four times as many places as when this was written.
188
+ // No token is ever emitted into a <script> or a CSS context (checked across
189
+ // public-landing/, hall-ui/, status-ui/) — that would need different escaping.
190
+ // Ampersand first, or the other replacements get double-escaped.
191
+ function esc(s) {
192
+ return String(s == null ? '' : s)
193
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
194
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
171
195
  }
172
196
  // The instance apex origin from the full branding pack (domains is server-only,
173
197
  // so it never rides clientBranding). Best-effort so a branding hiccup can't crash
@@ -175,6 +199,25 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
175
199
  function safePublicOrigin() {
176
200
  try { return branding().domains.publicOrigin || ''; } catch { return ''; }
177
201
  }
202
+ // The instance's OWN builders' hall origin, same server-only source. Empty is fine:
203
+ // applyBrandTokens falls back to the apex, so a pack that names no hall still gets
204
+ // a same-instance link rather than a dangling href.
205
+ function safeBuildersOrigin() {
206
+ try { return branding().domains.buildersOrigin || ''; } catch { return ''; }
207
+ }
208
+ // The instance's own source repo, from the pack's `repo: {owner, name}` binding —
209
+ // another server-only field (the branding contract keeps repo off clientBranding
210
+ // alongside domains). A page that wants to link "read the source" must take it
211
+ // from here: the authorship audit fails any hardcoded owner/name, and rightly so,
212
+ // since a baked one would point every instance at whoever authored the file.
213
+ // '' when the pack names no repo — the page drops the link rather than emit a
214
+ // dangling href.
215
+ function safeSourceRepoUrl() {
216
+ try {
217
+ const repo = branding().repo;
218
+ return repo && repo.owner && repo.name ? `https://github.com/${repo.owner}/${repo.name}` : '';
219
+ } catch { return ''; }
220
+ }
178
221
  // Instance product/world name for the API-discovery index (task 1918). Best-effort:
179
222
  // a branding hiccup must not break the JSON response.
180
223
  function safeProductName() {
package/src/module-api.js CHANGED
@@ -607,8 +607,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
607
607
  // landed since 1.19.88 with no explicit bump. run 31657467485. (task 1002620)
608
608
  // 1.19.90 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
609
609
  // landed since 1.19.89 with no explicit bump. run 31717551308. (task 1002620)
610
+ // 1.19.91 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
611
+ // landed since 1.19.90 with no explicit bump. run 31722841411. (task 1002620)
612
+ // 1.19.92 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
613
+ // landed since 1.19.91 with no explicit bump. run 31727411036. (task 1002620)
610
614
  // ---------------------------------------------------------------------------
611
- const CORE_VERSION = '1.19.90'; // CI auto-patch carrier (ADR 0161); see changelog above
615
+ const CORE_VERSION = '1.19.92'; // CI auto-patch carrier (ADR 0161); see changelog above
612
616
 
613
617
  // A namespaced logger so a module's log lines are attributable + consistent.
614
618
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -65,12 +65,36 @@ function landingPage(file) {
65
65
  if (!_landingPages.has(file)) {
66
66
  let html = '';
67
67
  try {
68
- html = injectBrandingGlobal(fs.readFileSync(path.join(LANDING_DIR, file), 'utf8'));
68
+ html = injectBrandingGlobal(stripApexOnly(fs.readFileSync(path.join(LANDING_DIR, file), 'utf8')));
69
69
  } catch { /* absent → '' */ }
70
70
  _landingPages.set(file, html);
71
71
  }
72
72
  return _landingPages.get(file);
73
73
  }
74
+
75
+ // THE APEX CONDITIONAL (task 1002748). public-landing/ ships wholesale to every
76
+ // instance, so one file has to render two shapes: the platform's own front door,
77
+ // and a child project's. Sections that are true only of the platform — the
78
+ // catalog of OTHER instances, the "capture-resistant commons" claim, the door
79
+ // into the catalog — are wrapped in <!--apex-only:start--> … <!--apex-only:end-->
80
+ // and REMOVED here on any instance that is not the hub.
81
+ //
82
+ // Removed, not CSS-hidden: a child instance must not ship the platform's claims
83
+ // to a crawler, and hiding is not the same as not saying it.
84
+ //
85
+ // Comment sentinels rather than an attribute selector because this is a string
86
+ // transform, not a DOM one — matching a balanced close tag with a regex is the
87
+ // fragile shape, and a sentinel pair has no such failure mode.
88
+ //
89
+ // `platform-identity` IS the hub flag, not a proxy for it: its manifest reads
90
+ // "Enabled ONLY on the hub instance; ordinary hosted + self-hosted instances
91
+ // leave it off", and it is the module that owns the very /projects catalog these
92
+ // sections point at. So this asks the instance what it IS, and nothing anywhere
93
+ // has to know the platform's hostname (direction C5).
94
+ const APEX_ONLY = /<!--apex-only:start-->[\s\S]*?<!--apex-only:end-->/g;
95
+ function stripApexOnly(html, isHub = isModuleEnabled('platform-identity')) {
96
+ return isHub ? html : String(html).replace(APEX_ONLY, '');
97
+ }
74
98
  function landingHtml() { return landingPage('index.html'); }
75
99
  // The interactive landing gate (public-landing/gate/, ported from amazonprimea
76
100
  // in task 1002407). When an instance ships it, it is the apex front door
@@ -232,4 +256,4 @@ if (require.main === module) {
232
256
  });
233
257
  }
234
258
 
235
- module.exports = { buildPlatformApp, startBackgroundServices };
259
+ module.exports = { buildPlatformApp, startBackgroundServices, stripApexOnly };
@@ -34,14 +34,16 @@ test('R74: the vanilla pack carries the Cloud Bongos LOOK (sunrise palette, Balo
34
34
  const c = clientBranding(vanilla());
35
35
  // fired down from the original #f97316 so white-on-accent clears WCAG AA
36
36
  // (2.80:1 -> 4.61:1, task 1002749) — same ember hue, readable
37
- assert.equal(c.theme.colors.accent, '#ce4806', 'sunrise orange accent');
38
- assert.equal(c.theme.colors.bg, '#fff7ed', 'warm sunrise base');
37
+ assert.equal(c.theme.colors.accent, '#ce4806', 'ember orange accent');
38
+ // ember marigold (task 1002748): the base is PIGMENTED, not near-white. The
39
+ // old #fff7ed sat within a few points of the cards and the page read as fog.
40
+ assert.equal(c.theme.colors.bg, '#f9d9a8', 'marigold base');
39
41
  assert.match(c.theme.fonts.display, /Baloo 2/);
40
42
  assert.match(c.theme.fonts.body, /Quicksand/);
41
43
  assert.ok(c.theme.fonts.links.some((l) => /Baloo\+2/.test(l) && /Quicksand/.test(l) && /Fira\+Code/.test(l)), 'loads Baloo 2 + Quicksand + Fira Code');
42
44
  assert.match(c.theme.favicon, /circle/, 'bongo-pair favicon (circles), not the Greek Α');
43
45
  assert.doesNotMatch(c.theme.favicon, /font-family='serif'>Α/);
44
- assert.match(c.theme.backdrop, /linear-gradient\(180deg/, 'sunrise backdrop gradient');
46
+ assert.equal(c.theme.backdrop, '#f9d9a8', 'the marigold ground holds all the way down');
45
47
  assert.equal(c.copy.tagline, 'Building software is laying down a beat.');
46
48
  assert.equal(c.rankLabels.archon, 'Owner', 'plain rank labels, not "Archon"');
47
49
  });
@@ -50,9 +52,9 @@ test('R74: serving the hall on a vanilla boot injects the Cloud Bongos sunrise t
50
52
  const c = clientBranding(vanilla());
51
53
  const html = '<head><link rel="stylesheet" href="/builders/style.css"><title>x</title></head><body></body>';
52
54
  const out = injectBrandingGlobal(html, c);
53
- // palette override + sunrise body backdrop + bongo favicon + the type links
55
+ // palette override + the marigold body backdrop + bongo favicon + the type links
54
56
  assert.match(out, /:root\{[^}]*--accent:#ce4806;/);
55
- assert.match(out, /body\{background:linear-gradient\(180deg/);
57
+ assert.match(out, /body\{background:#f9d9a8;\}/);
56
58
  assert.match(out, /<link rel="icon" href="data:image\/svg\+xml[^"]*circle/);
57
59
  assert.match(out, /fonts\.googleapis\.com\/css2\?family=Baloo\+2/);
58
60
  // and it lands AFTER the stylesheet so the Cloud Bongos look wins
@@ -63,8 +65,10 @@ test('R74: themeMarkup paints the backdrop only when the pack supplies one', ()
63
65
  // OTB-shaped pack (no backdrop) → no body{background} rule.
64
66
  const otb = themeMarkup({ colors: { bg: '#ece0c2' }, fonts: { links: [] }, favicon: '', backdrop: '' });
65
67
  assert.doesNotMatch(otb, /body\{background/);
66
- // Cloud Bongos pack → backdrop present.
67
- assert.match(themeMarkup(clientBranding(vanilla()).theme), /body\{background:linear-gradient/);
68
+ // Cloud Bongos pack → backdrop present. Asserted by PRESENCE, not by shape:
69
+ // this test's subject is whether a backdrop paints at all, so it must not
70
+ // re-break every time the palette legitimately changes.
71
+ assert.match(themeMarkup(clientBranding(vanilla()).theme), /body\{background:.+\}/);
68
72
  });
69
73
 
70
74
  test('customer-0: GDS_BRANDING_FILE selects the cloudbongos.com instance pack (task 1374)', () => {