@bongos/core 1.19.354 → 1.19.356
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +48 -28
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0190-settings-apply-in-place-env-patch.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/modules/provisioning/migrations/provisioning_010_settings_apply.sql +24 -0
- package/modules/provisioning/provisioning.js +65 -23
- package/modules/provisioning/routes/provisioning.js +75 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +15 -2
- package/scripts/gds/provision-units.js +58 -0
- package/scripts/gds/provision.js +142 -12
- package/src/bongos/serve-internal.js +16 -2
- package/src/branding.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/currency_label.mjs +12 -1
- package/tests/instance_manifest.mjs +8 -0
- package/tests/landing_page.mjs +18 -0
- package/tests/projects_hub_pre_uat.mjs +14 -0
- package/tests/provision_settings_apply.mjs +464 -0
- package/tests/provisioning_settings.mjs +8 -4
- package/tests/provisioning_settings_apply.mjs +372 -0
- package/tests/provisioning_settings_env.mjs +26 -9
- package/tests/provisioning_teardown_intent.mjs +5 -4
|
@@ -659,6 +659,17 @@ module.exports = function provisioningRoutes() {
|
|
|
659
659
|
// Body: any subset of the known keys; strict validation refuses unknown keys
|
|
660
660
|
// and off-vocabulary values. rank: any authenticated builder (own resource;
|
|
661
661
|
// archon may change any — the override is audited via the event's actor).
|
|
662
|
+
// The platform's row is updated first; then, for an ACTIVE instance on a shape
|
|
663
|
+
// the runner can reach, a 'settings-apply' intent is enqueued (task 1003140):
|
|
664
|
+
// the control-plane runner patches the instance's web.env in place and
|
|
665
|
+
// RESTARTS it, because the branding pack is memoized for the process lifetime.
|
|
666
|
+
// A save that changes nothing neither writes nor restarts. The response is a
|
|
667
|
+
// request receipt — `saved` is what the platform now stores, and `push`
|
|
668
|
+
// { queued, restart, reachable, message } says whether a restart was queued
|
|
669
|
+
// and when the value takes effect — never a claim the instance already runs
|
|
670
|
+
// it; a client reads that back from the instance's own public GET /instance
|
|
671
|
+
// manifest (brand.project). 409 intent_conflict (details.recorded: true) when
|
|
672
|
+
// the open slot is held by other work — the change IS saved.
|
|
662
673
|
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
663
674
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
664
675
|
if (!id) return;
|
|
@@ -677,13 +688,70 @@ module.exports = function provisioningRoutes() {
|
|
|
677
688
|
try {
|
|
678
689
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
679
690
|
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
691
|
+
// The archon override carries WHICH archon (the force-teardown convention) on
|
|
692
|
+
// both the event and the intent it may enqueue below.
|
|
693
|
+
const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
694
|
+
// A save that changes nothing writes nothing and restarts nothing — a
|
|
695
|
+
// whole-form save or a retried request must not bounce a live project. The
|
|
696
|
+
// one exception is the owner re-kick (the domain route's F19 shape): the same
|
|
697
|
+
// values re-sent while a retired push left its error_note re-queue the push.
|
|
698
|
+
const before = provisioning.effectiveSettings(inst);
|
|
699
|
+
const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
|
|
700
|
+
const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
|
|
701
|
+
if (changed) {
|
|
702
|
+
await provisioning.recordEvent(pool, {
|
|
703
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
|
|
704
|
+
detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
|
705
|
+
actor,
|
|
706
|
+
}).catch(() => {});
|
|
707
|
+
}
|
|
708
|
+
// The push (task 1003140). The row is now the platform's truth, but a
|
|
709
|
+
// RUNNING instance reads its settings from web.env through a pack memoized
|
|
710
|
+
// for the process lifetime — so an ACTIVE instance on a reachable shape gets
|
|
711
|
+
// a 'settings-apply' intent (web.env patched in place + RESTART, drained by
|
|
712
|
+
// the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
|
|
713
|
+
// the restart is acceptable and that the surface must SAY so plainly. Every
|
|
714
|
+
// other state stores the value and says when it takes effect. One receipt
|
|
715
|
+
// shape on every branch — `restart` = a restart is what applies it,
|
|
716
|
+
// `reachable` = the platform can perform that restart — and state-only
|
|
717
|
+
// sentences throughout (the F16 rule): no wall-clock promises.
|
|
718
|
+
const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
|
|
719
|
+
const receipt = (fields) => ({ reachable, ...fields });
|
|
720
|
+
let push;
|
|
721
|
+
if (!changed && !inst.error_note) {
|
|
722
|
+
push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
|
|
723
|
+
} else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
|
|
724
|
+
const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
|
|
725
|
+
push = receipt({ queued: false, restart: false, message: offline
|
|
726
|
+
? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
|
|
727
|
+
: inst.status === 'error'
|
|
728
|
+
? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
|
|
729
|
+
: 'Saved. It takes effect when the project finishes standing up.' });
|
|
730
|
+
} else if (!reachable) {
|
|
731
|
+
// A dedicated droplet has no runner write path — refuse the push honestly
|
|
732
|
+
// rather than queue one that never lands (the runner refuses it too).
|
|
733
|
+
push = receipt({ queued: false, restart: true, message:
|
|
734
|
+
'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' });
|
|
735
|
+
} else {
|
|
736
|
+
let r = null;
|
|
737
|
+
try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
|
|
738
|
+
catch (err) {
|
|
739
|
+
// The value IS saved — say so; a 500 here would deny a durable write.
|
|
740
|
+
log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
|
|
741
|
+
push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
|
|
742
|
+
}
|
|
743
|
+
if (r && r.conflict) {
|
|
744
|
+
return res.fail('intent_conflict', { status: 409,
|
|
745
|
+
message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
|
|
746
|
+
details: { action: r.intent.action, state: r.intent.state, recorded: true } });
|
|
747
|
+
}
|
|
748
|
+
if (r) {
|
|
749
|
+
push = receipt({ queued: r.created, restart: true, message: r.created
|
|
750
|
+
? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
|
|
751
|
+
: 'Saved. A restart is already queued for this project and will carry this change.' });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
|
|
687
755
|
} catch (err) {
|
|
688
756
|
log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
|
|
689
757
|
res.fail('settings_update_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.356",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.356",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.356",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -3407,7 +3407,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3407
3407
|
var PROG_FLOWS = {
|
|
3408
3408
|
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
3409
3409
|
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
3410
|
-
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
|
|
3410
|
+
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
|
|
3411
|
+
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
|
|
3411
3412
|
};
|
|
3412
3413
|
/* No live-region attributes here: this strip is destroyed and rebuilt on
|
|
3413
3414
|
every 5s tick, and a live region that is itself replaced never announces.
|
|
@@ -3459,6 +3460,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3459
3460
|
setProgress(progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown'));
|
|
3460
3461
|
return false;
|
|
3461
3462
|
}
|
|
3463
|
+
/* task 1003140: a settings change restarts the project — the panel says so, by state */
|
|
3464
|
+
if (oi.action === 'settings-apply') {
|
|
3465
|
+
setProgress(oi.state === 'running'
|
|
3466
|
+
? progressMarkup(1, 1, 'Applying a settings change — the project restarts to pick it up.', '', 'settings')
|
|
3467
|
+
: progressMarkup(0, 0, 'A settings change is queued — this panel updates when it starts.', '', 'settings'));
|
|
3468
|
+
return false;
|
|
3469
|
+
}
|
|
3462
3470
|
}
|
|
3463
3471
|
if (s === 'requested') {
|
|
3464
3472
|
setProgress(progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup'));
|
|
@@ -3897,7 +3905,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3897
3905
|
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
3898
3906
|
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
3899
3907
|
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
|
|
3900
|
-
address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' }
|
|
3908
|
+
address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
|
|
3909
|
+
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
|
|
3901
3910
|
};
|
|
3902
3911
|
function stripMarkup(flow, stageNow, stagesDone, note) {
|
|
3903
3912
|
var f = FLOWS[flow];
|
|
@@ -3930,6 +3939,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3930
3939
|
var a = inst.open_intent.action;
|
|
3931
3940
|
if (a === 'teardown') return stripMarkup('teardown', 0, 0, 'A teardown is queued — this card updates when it starts.');
|
|
3932
3941
|
if (a === 'domain-attach') return stripMarkup('address', 1, 1, 'Setting up DNS and the secure connection' + (inst.domain ? ' for <strong>' + esc(inst.domain) + '</strong>' : '') + '.');
|
|
3942
|
+
/* task 1003140: a settings change is pushed by restarting the project — say so, plainly */
|
|
3943
|
+
if (a === 'settings-apply') return inst.open_intent.state === 'running'
|
|
3944
|
+
? stripMarkup('settings', 1, 1, 'Applying a settings change — the project restarts to pick it up.')
|
|
3945
|
+
: stripMarkup('settings', 0, 0, 'A settings change is queued — this card updates when it starts.');
|
|
3933
3946
|
if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
|
|
3934
3947
|
return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
|
|
3935
3948
|
}
|
|
@@ -237,6 +237,60 @@ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
|
237
237
|
return path.posix.join(etcBase, inst.slug, 'web.env');
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// The settings env vars the runner pushes to a RUNNING instance (task 1003140):
|
|
241
|
+
// the module's settingsEnvVars under the prefix the instance actually resolves —
|
|
242
|
+
// the same pair webEnvBody writes at standup, so standup and push cannot drift.
|
|
243
|
+
function settingsEnvVarsFor(inst) {
|
|
244
|
+
const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
245
|
+
return settingsEnvVars(inst, provisionedEnvPrefix());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The instance's own public manifest — the settings read-back ADR 0190 names
|
|
249
|
+
// (GET /api/gds/instance → brand.project). Sibling of provision-net's healthzCmd:
|
|
250
|
+
// same loopback + timeout, a different question ("did you READ it?").
|
|
251
|
+
function instanceManifestCmd(inst) {
|
|
252
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/gds/instance`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Did the restarted instance CONSUME the pushed settings? Compares each key of
|
|
256
|
+
// effectiveSettings(inst) against the manifest's brand.project (camelCase — the
|
|
257
|
+
// src/branding.js ENV_OVERRIDES targets). true = every key matches; false = it
|
|
258
|
+
// answers with a different value; null = unknown — the manifest carries no
|
|
259
|
+
// project block (a core that predates task 1003139) or did not parse. PURE.
|
|
260
|
+
function settingsConsumed(stdout, inst) {
|
|
261
|
+
const { effectiveSettings } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
262
|
+
let j;
|
|
263
|
+
try { j = JSON.parse(String(stdout || '').trim()); } catch { return null; }
|
|
264
|
+
const project = j && j.brand && j.brand.project;
|
|
265
|
+
if (!project || typeof project !== 'object') return null;
|
|
266
|
+
const camel = (k) => k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
267
|
+
return Object.entries(effectiveSettings(inst)).every(([k, v]) => project[camel(k)] === v);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Upsert KEY=VALUE lines into an env-file body IN PLACE (task 1003140): an
|
|
271
|
+
// existing `KEY=` line is replaced where it stands, a missing one is appended at
|
|
272
|
+
// the end — every other line (creds, origins, the federation block, comments) is
|
|
273
|
+
// untouched. This is how a setting reaches a RUNNING instance: the full-template
|
|
274
|
+
// re-write webEnvBody produces would BLANK the OAuth creds placeManifestCreds
|
|
275
|
+
// placed (this leg has no way to know them), so a live file is patched, never
|
|
276
|
+
// regenerated. The oauth-secret.js fillOAuthEnv shape. PURE; keys are matched at
|
|
277
|
+
// line start, exactly (a KEY that is a prefix of another never collides).
|
|
278
|
+
function upsertEnvVars(body, vars) {
|
|
279
|
+
const esc = (k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
280
|
+
let text = String(body == null ? '' : body);
|
|
281
|
+
const missing = [];
|
|
282
|
+
for (const [key, value] of Object.entries(vars || {})) {
|
|
283
|
+
const re = new RegExp(`^${esc(key)}=.*$`, 'm');
|
|
284
|
+
if (re.test(text)) text = text.replace(re, () => `${key}=${value}`);
|
|
285
|
+
else missing.push(`${key}=${value}`);
|
|
286
|
+
}
|
|
287
|
+
if (missing.length) {
|
|
288
|
+
if (text.length && !text.endsWith('\n')) text += '\n';
|
|
289
|
+
text += `${missing.join('\n')}\n`;
|
|
290
|
+
}
|
|
291
|
+
return text;
|
|
292
|
+
}
|
|
293
|
+
|
|
240
294
|
// ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
|
|
241
295
|
// (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
|
|
242
296
|
// registration is a direct in-process call against the hub pool — no HTTP hop.
|
|
@@ -254,9 +308,13 @@ module.exports = {
|
|
|
254
308
|
backupTimer,
|
|
255
309
|
backupTimerPath,
|
|
256
310
|
backupUnitName,
|
|
311
|
+
instanceManifestCmd,
|
|
257
312
|
provisionedEnvPrefix,
|
|
258
313
|
serviceUnit,
|
|
259
314
|
serviceUnitPath,
|
|
315
|
+
settingsConsumed,
|
|
316
|
+
settingsEnvVarsFor,
|
|
317
|
+
upsertEnvVars,
|
|
260
318
|
webEnvBody,
|
|
261
319
|
webEnvPath,
|
|
262
320
|
};
|
package/scripts/gds/provision.js
CHANGED
|
@@ -51,11 +51,14 @@ const crypto = require('node:crypto');
|
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
|
-
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
54
|
+
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
|
|
57
57
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
|
-
|
|
58
|
+
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
59
|
+
// that carries a secret (a remote file placement embeds the base64 body), so the
|
|
60
|
+
// journal records "write <path> (N bytes)" and never the payload.
|
|
61
|
+
return function run(cmd, { allowFail = false, cwd, env, shown: shownAs } = {}) {
|
|
59
62
|
// Extra env is passed to the child (not interpolated into the command), so
|
|
60
63
|
// values like a hostname/IP never need shell quoting. It's shown in the dry-run
|
|
61
64
|
// plan for transparency — DNS env (BOX_HOSTNAME/BOX_IP/CLOUDFLARE_ZONE) is not
|
|
@@ -71,28 +74,37 @@ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = nul
|
|
|
71
74
|
const wd = cwd || remoteCwd;
|
|
72
75
|
const script = `${wd ? `cd ${wd} && ` : ''}${envPrefix}${cmd}`;
|
|
73
76
|
const sshCmd = `ssh ${sshOpts} ${remoteHost} bash -s`;
|
|
74
|
-
const shown = `[ssh ${remoteHost}] ${script}`;
|
|
77
|
+
const shown = `[ssh ${remoteHost}] ${shownAs || script}`;
|
|
75
78
|
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
76
79
|
try {
|
|
77
80
|
const stdout = cp.execSync(sshCmd, { input: script, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }).toString();
|
|
78
81
|
log(` ✓ ${shown}`);
|
|
79
82
|
return { ok: true, stdout };
|
|
80
83
|
} catch (e) {
|
|
81
|
-
|
|
84
|
+
// The script rides STDIN, so e.message names only the ssh hop — but a
|
|
85
|
+
// `shown` call site is secret-bearing by declaration: log the exit status
|
|
86
|
+
// alone, never stderr (a remote shell may echo the failing line back).
|
|
87
|
+
const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
|
|
88
|
+
if (allowFail) { log(` ~ ${shown} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
82
89
|
log(` ✖ ${shown}`);
|
|
83
90
|
throw e;
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
// Local step (control plane / today's behavior). cwd defaults to REPO_ROOT.
|
|
87
|
-
const shown = `${envPrefix}${cmd}`;
|
|
94
|
+
const shown = shownAs || `${envPrefix}${cmd}`;
|
|
95
|
+
const label = shownAs || cmd;
|
|
88
96
|
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
89
97
|
try {
|
|
90
98
|
const stdout = cp.execSync(cmd, { cwd: cwd || REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env }).toString();
|
|
91
|
-
log(` ✓ ${
|
|
99
|
+
log(` ✓ ${label}`);
|
|
92
100
|
return { ok: true, stdout };
|
|
93
101
|
} catch (e) {
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
// With `shown` set the command text is considered secret-bearing, so the
|
|
103
|
+
// non-fatal line carries only the exit status — never e.message, whose
|
|
104
|
+
// "Command failed: <cmd>" would echo the command back into the journal.
|
|
105
|
+
const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
|
|
106
|
+
if (allowFail) { log(` ~ ${label} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
107
|
+
log(` ✖ ${label}`);
|
|
96
108
|
throw e;
|
|
97
109
|
}
|
|
98
110
|
};
|
|
@@ -121,12 +133,15 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
|
|
|
121
133
|
];
|
|
122
134
|
if (mode) steps.push(`${sp}chmod ${mode.toString(8)} ${p}`);
|
|
123
135
|
const cmd = steps.join(' && ');
|
|
136
|
+
// The command text embeds the (base64) body — a web.env carries the OAuth
|
|
137
|
+
// secret and the App private key — so the exec logs THIS instead (task 1003140).
|
|
138
|
+
const shown = `write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''})`;
|
|
124
139
|
if (!apply) {
|
|
125
140
|
log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) on ${remoteHost}`);
|
|
126
|
-
if (exec) exec(cmd);
|
|
141
|
+
if (exec) exec(cmd, { shown });
|
|
127
142
|
return { dryRun: true };
|
|
128
143
|
}
|
|
129
|
-
exec(cmd);
|
|
144
|
+
exec(cmd, { shown });
|
|
130
145
|
log(` ✓ wrote ${p} (on ${remoteHost})`);
|
|
131
146
|
return { ok: true };
|
|
132
147
|
}
|
|
@@ -139,7 +154,8 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
|
|
|
139
154
|
if (exec) exec(installCmd);
|
|
140
155
|
return { dryRun: true };
|
|
141
156
|
}
|
|
142
|
-
|
|
157
|
+
// Owner-only while staged: the destination is 0600 and the body may carry secrets.
|
|
158
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
143
159
|
try { exec(installCmd); } finally { fs.rmSync(tmp, { force: true }); }
|
|
144
160
|
log(` ✓ wrote ${p} (via sudo install)`);
|
|
145
161
|
return { ok: true };
|
|
@@ -642,6 +658,116 @@ async function domainAttachInstance(inst, deps) {
|
|
|
642
658
|
return { ok: true };
|
|
643
659
|
}
|
|
644
660
|
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// Push a changed per-project setting to an ALREADY-ACTIVE instance (task
|
|
663
|
+
// 1003140 — the live-push half of the platform→instance settings channel; task
|
|
664
|
+
// 1003139 built the standup half). The branding pack is memoized + deep-frozen
|
|
665
|
+
// for the process lifetime, so a change reaches a running instance only by
|
|
666
|
+
// re-writing its web.env and RESTARTING the service: the domain-attach shape
|
|
667
|
+
// minus the DNS legs. The settings lines are patched IN PLACE (upsertEnvVars) —
|
|
668
|
+
// a template re-write via webEnvBody would blank the OAuth creds that
|
|
669
|
+
// placeManifestCreds placed, which this leg has no way to know. EVERY expected
|
|
670
|
+
// failure — an unreadable file, a refused write, a restart that exits non-zero —
|
|
671
|
+
// RETURNS { ok:false } instead of throwing: the drain loop's catch flips the
|
|
672
|
+
// instance to 'error', which must never happen to a healthy ACTIVE instance over
|
|
673
|
+
// a settings push (ADR 0190).
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
async function settingsApplyInstance(inst, deps) {
|
|
676
|
+
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
677
|
+
// Same box routing as domainAttachInstance: a standalone runs control-plane-local.
|
|
678
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
679
|
+
const boxExec = standalone ? (deps.controlExec || exec) : exec;
|
|
680
|
+
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
681
|
+
const sudoP = privileged ? 'sudo ' : '';
|
|
682
|
+
const vars = settingsEnvVarsFor(inst);
|
|
683
|
+
const shown = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
684
|
+
log(`settings-apply ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}): ${shown}`);
|
|
685
|
+
|
|
686
|
+
// Pre-active states get these values at standup from the same row; post-active
|
|
687
|
+
// ones have nothing to restart. A clean no-op (not an error) so a raced
|
|
688
|
+
// teardown never burns the intent's retry budget.
|
|
689
|
+
if (inst.status !== 'active') {
|
|
690
|
+
log(` instance is '${inst.status}' — nothing to push now (standup writes the same values)`);
|
|
691
|
+
return { ok: true, noop: true };
|
|
692
|
+
}
|
|
693
|
+
// A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
|
|
694
|
+
// exec path to it (the placeManifestCreds refusal) — say so rather than report a
|
|
695
|
+
// push that never landed. The route refuses to enqueue for this shape too.
|
|
696
|
+
if (inst.hosting_shape === 'dedicated') {
|
|
697
|
+
return { ok: false, error: `settings push is not automated for a dedicated droplet — set ${shown} in its ${webEnvPath(inst)} and restart the ${inst.slug} service there` };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Read the file as it stands so ONLY the settings lines change. Dry-run runs no
|
|
701
|
+
// command, so it has no file to patch — it PLANS the patch instead of writing a
|
|
702
|
+
// synthetic two-line body (which would read as the exact overwrite this leg
|
|
703
|
+
// exists to avoid). An unreadable or empty file is refused, never "patched":
|
|
704
|
+
// writing two lines over a missing PORT/PGDATABASE would take the instance down.
|
|
705
|
+
const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
|
|
706
|
+
const current = String((read && read.stdout) || '');
|
|
707
|
+
if (!apply) {
|
|
708
|
+
log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
|
|
709
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
710
|
+
boxExec(healthzCmd(inst), { allowFail: true });
|
|
711
|
+
log(` ✓ would restart ${inst.slug} with the new settings (dry-run — nothing ran)`);
|
|
712
|
+
return { ok: true, healthy: null, consumed: null };
|
|
713
|
+
}
|
|
714
|
+
if (!read || read.softFailed || !current.trim()) {
|
|
715
|
+
return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
|
|
719
|
+
boxWriteFile(webEnvPath(inst), upsertEnvVars(current, vars), { mode: 0o600, sudo: true });
|
|
720
|
+
log(' [service] restart so the memoized branding pack re-reads them');
|
|
721
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
722
|
+
} catch (e) {
|
|
723
|
+
// ok:false, never a throw: the retry re-reads the file (the upsert is
|
|
724
|
+
// idempotent) and restarts again; the instance's status is left alone.
|
|
725
|
+
return { ok: false, error: `could not apply the settings on ${inst.slug}: ${(e && e.message) || e}` };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Verify it came back. PATIENT (the F23 lesson — the manifest probe's budget):
|
|
729
|
+
// the child must boot before it can answer, and a false "down" here is not
|
|
730
|
+
// cosmetic — it arms the owner's re-kick paths. Silence after the WHOLE budget
|
|
731
|
+
// is a verdict on any shape and is recorded immediately (provisionInstance step
|
|
732
|
+
// 7's rule); a loopback PASS proves the process, not the address — the
|
|
733
|
+
// domain-scoped sweep earns that claim.
|
|
734
|
+
log(' [healthz] verify the instance answers after the restart');
|
|
735
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
736
|
+
let healthy = false;
|
|
737
|
+
for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
|
|
738
|
+
const res = boxExec(healthzCmd(inst), { allowFail: true });
|
|
739
|
+
if (res && !res.softFailed) { healthy = true; break; }
|
|
740
|
+
if (i < MANIFEST_VERIFY_TRIES - 1) await sleep(MANIFEST_VERIFY_INTERVAL_MS);
|
|
741
|
+
}
|
|
742
|
+
// Answering is not the same as having READ the new values: a core that predates
|
|
743
|
+
// task 1003139 restarts fine and ignores them. Ask the instance's own manifest
|
|
744
|
+
// (the read-back ADR 0190 names) and record what it says — never a success it
|
|
745
|
+
// did not earn. null = the manifest carries no project block (unknown).
|
|
746
|
+
let consumed = null;
|
|
747
|
+
if (healthy) {
|
|
748
|
+
const m = boxExec(instanceManifestCmd(inst), { allowFail: true });
|
|
749
|
+
consumed = settingsConsumed(m && m.stdout, inst);
|
|
750
|
+
}
|
|
751
|
+
const outcome = !healthy ? 'restarted, but not answering /healthz yet'
|
|
752
|
+
: consumed === true ? 'restarted and reads the new values'
|
|
753
|
+
: consumed === false ? 'restarted, but still reports the OLD values — its core may predate the settings channel'
|
|
754
|
+
: 'restarted; its core does not publish its settings yet (pin predates task 1003139), so whether it reads them is unknown';
|
|
755
|
+
const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
|
|
756
|
+
if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
|
|
757
|
+
// Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
|
|
758
|
+
// from an earlier retired push must not outlive its own recovery.
|
|
759
|
+
if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
|
|
760
|
+
await provisioning.recordEvent(db, {
|
|
761
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
|
|
762
|
+
detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
|
|
763
|
+
actor: 'runner',
|
|
764
|
+
}).catch(() => {});
|
|
765
|
+
log(healthy && consumed !== false
|
|
766
|
+
? ` ✓ ${inst.slug} ${outcome}`
|
|
767
|
+
: ` ⚠ ${inst.slug} ${outcome}${healthy ? '' : ' — its card will read "unreachable"'}`);
|
|
768
|
+
return { ok: true, healthy, consumed };
|
|
769
|
+
}
|
|
770
|
+
|
|
645
771
|
// ---------------------------------------------------------------------------
|
|
646
772
|
// Teardown one instance (reverse the runbook; idempotent).
|
|
647
773
|
// ---------------------------------------------------------------------------
|
|
@@ -922,6 +1048,7 @@ async function cmdRunIntents(deps) {
|
|
|
922
1048
|
if (intent.action === 'provision') result = await provisionInstance(inst, deps);
|
|
923
1049
|
else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
|
|
924
1050
|
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1051
|
+
else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
|
|
925
1052
|
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
926
1053
|
if (result && result.ok === false) {
|
|
927
1054
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
@@ -998,7 +1125,8 @@ Usage:
|
|
|
998
1125
|
|
|
999
1126
|
Commands:
|
|
1000
1127
|
run-intents drain provisioning_intents (the systemd-timer entry point):
|
|
1001
|
-
execute each pending provision/teardown
|
|
1128
|
+
execute each pending provision / teardown / domain-attach /
|
|
1129
|
+
settings-apply intent
|
|
1002
1130
|
provision <slug> provision one instance by slug (composes the standup runbook)
|
|
1003
1131
|
teardown <slug> tear down one instance by slug
|
|
1004
1132
|
reconcile re-enqueue instances stuck mid-flight with no open intent
|
|
@@ -1108,6 +1236,8 @@ module.exports = {
|
|
|
1108
1236
|
// executors + orchestration (injectable deps for tests)
|
|
1109
1237
|
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1110
1238
|
domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1239
|
+
// live settings push (task 1003140) + the in-place env patch + read-back it rides on
|
|
1240
|
+
settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
|
|
1111
1241
|
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1112
1242
|
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1113
1243
|
// cross-site identity federation (ADR 0141 §4, task #1002109)
|
|
@@ -169,7 +169,8 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
|
|
|
169
169
|
const tagline = (brand.copy && brand.copy.tagline) || '';
|
|
170
170
|
const cur = (brand.currency && brand.currency.label) || 'credits';
|
|
171
171
|
const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
|
|
172
|
-
|
|
172
|
+
const hall = hallOrigin || origin || '';
|
|
173
|
+
return dropEmptyHrefLinks(String(html == null ? '' : html), { publicOrigin: origin || '', buildersOrigin: hall, sourceRepoUrl: sourceRepo || '' })
|
|
173
174
|
.split('{{worldName}}').join(esc(world))
|
|
174
175
|
.split('{{productName}}').join(esc(product))
|
|
175
176
|
.split('{{Currency}}').join(esc(Cur))
|
|
@@ -178,10 +179,23 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
|
|
|
178
179
|
.split('{{publicOrigin}}').join(esc(origin || ''))
|
|
179
180
|
// a pack that names no hall of its own resolves to its OWN apex — never to
|
|
180
181
|
// whichever instance authored the file
|
|
181
|
-
.split('{{buildersOrigin}}').join(esc(
|
|
182
|
+
.split('{{buildersOrigin}}').join(esc(hall))
|
|
182
183
|
.split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''))
|
|
183
184
|
.split('{{instanceBase}}').join(esc(instanceBase || ''));
|
|
184
185
|
}
|
|
186
|
+
// A link whose href is a BARE origin token that resolves empty is dropped whole,
|
|
187
|
+
// server-side (task 1003242; the branding contract): a pack that names no domains
|
|
188
|
+
// would otherwise stamp `href=""` — a dead hall pill on every page carrying one,
|
|
189
|
+
// and only index.html scrubs those client-side. Exact-token hrefs only: a token
|
|
190
|
+
// inside a longer URL still stamps (an empty prefix leaves a relative path).
|
|
191
|
+
function dropEmptyHrefLinks(html, values) {
|
|
192
|
+
let out = html;
|
|
193
|
+
for (const [token, value] of Object.entries(values)) {
|
|
194
|
+
if (value) continue;
|
|
195
|
+
out = out.replace(new RegExp(`<a\\b[^>]*\\bhref="\\{\\{${token}\\}\\}"[^>]*>[\\s\\S]*?<\\/a>`, 'g'), '');
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
185
199
|
// Every token lands in HTML — as element text, or inside a double-quoted
|
|
186
200
|
// attribute (href/content). Escaping is defence in depth, not a live fix: the
|
|
187
201
|
// only source is config/branding.json, which is committed, code-reviewed and
|
package/src/branding.js
CHANGED
|
@@ -204,6 +204,17 @@ function clientBranding(b = branding()) {
|
|
|
204
204
|
},
|
|
205
205
|
rankLabels: b.rankLabels || {},
|
|
206
206
|
reward: clientReward(),
|
|
207
|
+
// Per-project policy the platform pushes (tasks 1003139 / 1003140). PUBLIC by
|
|
208
|
+
// nature — a gated project's gate says "sign in", an invite-only one's door
|
|
209
|
+
// says so — and the READ-BACK a manage page needs: a settings PATCH on the
|
|
210
|
+
// platform is only a request until this instance restarts with the value, so
|
|
211
|
+
// the honest place to learn what it RESOLVED from its environment is its own
|
|
212
|
+
// GET /instance manifest. Resolved, not enforced: enforcement lands with
|
|
213
|
+
// tasks 1003043 (visibility) and 1003044 (joinability).
|
|
214
|
+
project: {
|
|
215
|
+
platformVisibility: b.project?.platformVisibility || 'public',
|
|
216
|
+
joinability: b.project?.joinability || 'apply',
|
|
217
|
+
},
|
|
207
218
|
};
|
|
208
219
|
}
|
|
209
220
|
|
package/src/module-api.js
CHANGED
|
@@ -1138,8 +1138,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1138
1138
|
// landed since 1.19.352 with no explicit bump. run 32851392269. (task 1002620)
|
|
1139
1139
|
// 1.19.354 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1140
1140
|
// landed since 1.19.353 with no explicit bump. run 32852045058. (task 1002620)
|
|
1141
|
+
// 1.19.355 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1142
|
+
// landed since 1.19.354 with no explicit bump. run 32853063015. (task 1002620)
|
|
1143
|
+
// 1.19.356 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1144
|
+
// landed since 1.19.355 with no explicit bump. run 32861072779. (task 1002620)
|
|
1141
1145
|
// ---------------------------------------------------------------------------
|
|
1142
|
-
const CORE_VERSION = '1.19.
|
|
1146
|
+
const CORE_VERSION = '1.19.356'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1143
1147
|
|
|
1144
1148
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1145
1149
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/currency_label.mjs
CHANGED
|
@@ -47,6 +47,14 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
|
|
|
47
47
|
assert.ok(c.theme.colors && c.theme.favicon, 'the WEB theme is what ships');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
|
|
51
|
+
assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply' });
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only' } }).project,
|
|
54
|
+
{ platformVisibility: 'gated', joinability: 'invite_only' },
|
|
55
|
+
'what the instance actually resolved (env override included) is what it publishes');
|
|
56
|
+
});
|
|
57
|
+
|
|
50
58
|
test('clientBranding is a SMALL allow-list — no server-only field leaks to the browser', () => {
|
|
51
59
|
const projected = clientBranding({
|
|
52
60
|
...OTB_ISH,
|
|
@@ -59,7 +67,10 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
|
|
|
59
67
|
// reward POLICY, so a builder on a cost-plus-only instance isn't shown a per-task
|
|
60
68
|
// estimate that will never pay. The allow-list was not updated with it, so this guard
|
|
61
69
|
// had been failing since that landed (task 1002470).
|
|
62
|
-
|
|
70
|
+
// `project` joined the allow-list in task 1003140: the per-project policy is public
|
|
71
|
+
// by nature and is the settings read-back a manage page needs (see clientBranding).
|
|
72
|
+
assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
|
|
73
|
+
assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility']);
|
|
63
74
|
for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
|
|
64
75
|
assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
|
|
65
76
|
}
|
|
@@ -19,6 +19,8 @@ const BRAND_OTB = {
|
|
|
19
19
|
theme: { colors: {}, fonts: { display: '', body: '', links: [] }, favicon: '', backdrop: '' },
|
|
20
20
|
copy: { tagline: 'Straight off the boats', hallName: 'Builders’ Hall', notFound: 'Page not found.' },
|
|
21
21
|
rankLabels: { xenos: 'Xenos', thetes: 'Thetes', metic: 'Metic', archon: 'Archon' },
|
|
22
|
+
// the per-project policy the platform pushes (task 1003140) — the settings read-back
|
|
23
|
+
project: { platformVisibility: 'gated', joinability: 'invite_only' },
|
|
22
24
|
};
|
|
23
25
|
const BRAND_VANILLA = {
|
|
24
26
|
identity: { productName: 'Cloud Bongos', worldName: 'Cloud Bongos', company: { name: '' } },
|
|
@@ -45,6 +47,12 @@ test('manifest: schema id + headline name + embedded brand', () => {
|
|
|
45
47
|
assert.deepEqual(m.modules, MODULES_OTB);
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
test('manifest: brand.project rides the manifest — the settings read-back a manage page needs (task 1003140 / ADR 0190)', () => {
|
|
51
|
+
const m = buildInstanceManifest({ branding: BRAND_OTB, modules: MODULES_OTB, rankTier: RANK_TIER, repo: REPO, authConfigured: true, openEnrollment: false, devBoxEnabled: true });
|
|
52
|
+
assert.deepEqual(m.brand.project, { platformVisibility: 'gated', joinability: 'invite_only' },
|
|
53
|
+
'a client reads what the instance RESOLVED from here, never from the platform PATCH response');
|
|
54
|
+
});
|
|
55
|
+
|
|
48
56
|
test('manifest: rank ladder ordered entry→top, labelled from brand', () => {
|
|
49
57
|
const m = buildInstanceManifest({ branding: BRAND_OTB, modules: MODULES_OTB, rankTier: RANK_TIER, repo: REPO, authConfigured: true, openEnrollment: false, devBoxEnabled: true });
|
|
50
58
|
assert.deepEqual(m.ranks.map((r) => r.key), ['xenos', 'thetes', 'metic', 'archon']);
|
package/tests/landing_page.mjs
CHANGED
|
@@ -142,6 +142,24 @@ test('an unstamped link is removed, not left dangling', () => {
|
|
|
142
142
|
assert.ok(out.length > 0);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test('a pack with NO domains drops the hall pill server-side rather than stamping href="" (task 1003242)', () => {
|
|
146
|
+
// the hub has no client scrubber, so an empty token must never reach it as a dead link
|
|
147
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.html'), 'utf8');
|
|
148
|
+
assert.match(HUB, /data-link-hall href="\{\{buildersOrigin\}\}"/, 'the hub pill is token-stamped');
|
|
149
|
+
const none = applyBrandTokens(HUB, BRAND, '', '');
|
|
150
|
+
assert.doesNotMatch(none, /href=""/, 'no empty href survives the stamp');
|
|
151
|
+
assert.doesNotMatch(none, /data-link-hall/, 'the pill element is omitted whole, not left dead');
|
|
152
|
+
assert.doesNotMatch(none, /\{\{[a-zA-Z]+\}\}/, 'every other token still stamps');
|
|
153
|
+
const apexOnly = applyBrandTokens(HUB, BRAND, 'https://spike-harbor.example', '');
|
|
154
|
+
assert.match(apexOnly, /data-link-hall href="https:\/\/spike-harbor\.example"/, 'an apex alone keeps the pill, pointed at the apex');
|
|
155
|
+
const both = applyBrandTokens(HUB, BRAND, 'https://spike-harbor.example', 'https://builders.spike-harbor.example');
|
|
156
|
+
assert.match(both, /data-link-hall href="https:\/\/builders\.spike-harbor\.example"/);
|
|
157
|
+
// the landing page too — its client-side scrubber is no longer the only guard
|
|
158
|
+
assert.doesNotMatch(applyBrandTokens(HTML, BRAND, '', ''), /<a\b[^>]*href=""/);
|
|
159
|
+
// a token INSIDE a longer href is a relative path once stamped empty, not a dead link — left alone
|
|
160
|
+
assert.equal(applyBrandTokens('<a href="{{publicOrigin}}/docs">Docs</a>', BRAND, '', ''), '<a href="/docs">Docs</a>');
|
|
161
|
+
});
|
|
162
|
+
|
|
145
163
|
// ── the branding cascade + portability ─────────────────────────────────────
|
|
146
164
|
|
|
147
165
|
test('the page declares no :root — a page-local one defeats re-branding (C1)', () => {
|