@bongos/core 1.19.838 → 1.19.840
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 +69 -39
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +2 -1
- package/docs/adr/0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -0
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +1 -1
- package/docs/branding-fill-prompt.md +1 -1
- package/docs/copy-inventory.md +3 -3
- package/docs/copy-registry.json +5 -5
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/github-push.js +2 -2
- package/modules/lifecycle/pr-assign.js +77 -0
- package/modules/lifecycle/routes/tasks.js +16 -1
- package/modules/status-ui/public/index.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-identity.js +9 -1
- package/scripts/gds/fitness.js +34 -1
- package/scripts/gds/paste-token.js +65 -14
- package/scripts/gds/provision-config.js +3 -0
- package/scripts/gds/provision-net.js +32 -0
- package/scripts/gds/provision.js +21 -4
- package/scripts/gds/ship-land.js +12 -0
- package/scripts/status-mirror-sync.js +6 -2
- package/src/bongos/routes/auth.js +24 -3
- package/src/bongos/serve-internal.js +9 -2
- package/src/branding.js +22 -2
- package/src/module-api.js +1 -1
- package/tests/bongos_cli.mjs +13 -4
- package/tests/cli_token_reissue.mjs +54 -7
- package/tests/fitness.mjs +47 -0
- package/tests/paste_token_api_base.mjs +87 -0
- package/tests/pr_assign.mjs +189 -0
- package/tests/provision.mjs +12 -0
- package/tests/provision_caddy_wiring.mjs +148 -0
- package/tests/status_company_attribution.mjs +60 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// modules/lifecycle/pr-assign.js — assign a ship's pull request to the builder who wrote
|
|
2
|
+
// it, and name them in its body (task 1003991).
|
|
3
|
+
//
|
|
4
|
+
// WHY. A builder box holds no GitHub credential (ADR 0055), so the server opens the PR
|
|
5
|
+
// with its own — and GitHub records the credential's account as the AUTHOR: 259 of the
|
|
6
|
+
// last 300 PRs. The builder's commits are theirs, but "created by me" is empty for them,
|
|
7
|
+
// and the PR is where CI and merge state live. An ASSIGNEE is the one PR-level link to a
|
|
8
|
+
// person the server can set for someone else; "assigned to me" then finds their work.
|
|
9
|
+
//
|
|
10
|
+
// A COURTESY, NEVER A GATE. assignPullRequest resolves { assigned, reason? } on every
|
|
11
|
+
// path and never rejects: a login GitHub will not assign (not a collaborator — it
|
|
12
|
+
// answers 201 and silently drops the name), a missing permission or a network fault
|
|
13
|
+
// must not be able to fail a ship that already pushed. A sibling of github-push.js
|
|
14
|
+
// rather than a function in it because that file sits at the line cap.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const githubPush = require('./github-push');
|
|
19
|
+
|
|
20
|
+
// GitHub's own login rule: 1–39 chars, alphanumeric with single inner hyphens — plus
|
|
21
|
+
// the underscore a managed-user login carries (`name_shortcode`). The login is
|
|
22
|
+
// interpolated into a PR body as an @-mention, so anything else never gets that far.
|
|
23
|
+
const GITHUB_LOGIN_RE = /^[a-z\d](?:[a-z\d]|[-_](?=[a-z\d])){0,38}$/i;
|
|
24
|
+
// The assignment is the one OPTIONAL call between a finished push and the route's 201:
|
|
25
|
+
// a stalled GitHub must not make a ship that succeeded read as a failed publish.
|
|
26
|
+
const ASSIGN_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
function isGithubLogin(login) {
|
|
29
|
+
return typeof login === 'string' && GITHUB_LOGIN_RE.test(login);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The sentence the PR body carries, or '' when there is no usable login.
|
|
33
|
+
function prBuilderLine(login) {
|
|
34
|
+
return isGithubLogin(login) ? `Built by @${login}.` : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function logAssignFailure(reason, number, logImpl) {
|
|
38
|
+
try {
|
|
39
|
+
const sink = logImpl || require('../../src/module-api').logger('lifecycle');
|
|
40
|
+
sink.warn('pull request could not be assigned to its builder (the ship is unaffected)',
|
|
41
|
+
{ reason: githubPush.scrubCredential(String(reason)), pr: number });
|
|
42
|
+
} catch (_) { /* logging is never load-bearing */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function assignPullRequest({ number, login }, deps = {}) {
|
|
46
|
+
const miss = (reason, detail) => {
|
|
47
|
+
logAssignFailure(detail ? `${reason}: ${detail}` : reason, number, deps.logImpl);
|
|
48
|
+
return { assigned: false, reason };
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
if (!isGithubLogin(login)) return miss('bad_login');
|
|
52
|
+
if (!Number.isInteger(number) || number <= 0) return miss('bad_pr_number');
|
|
53
|
+
const token = await githubPush.resolveToken(deps);
|
|
54
|
+
const repoInfo = deps.repoInfo || require('../../src/module-api').repoInfo.loadRepoInfo();
|
|
55
|
+
if (!token || !repoInfo || repoInfo.error) return miss('unconfigured');
|
|
56
|
+
|
|
57
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
58
|
+
const res = await doFetch(
|
|
59
|
+
`https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}/issues/${number}/assignees`,
|
|
60
|
+
{
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { ...githubPush.ghHeaders(token), 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ assignees: [login] }),
|
|
64
|
+
signal: AbortSignal.timeout(ASSIGN_TIMEOUT_MS),
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
if (!res.ok) return miss(`http_${res.status}`);
|
|
68
|
+
const issue = await res.json();
|
|
69
|
+
const took = ((issue && issue.assignees) || [])
|
|
70
|
+
.some((a) => String(a && a.login).toLowerCase() === login.toLowerCase());
|
|
71
|
+
return took ? { assigned: true } : miss('not_assignable');
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return miss('threw', (e && e.message) || '');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { assignPullRequest, prBuilderLine };
|
|
@@ -46,6 +46,7 @@ const { makeAbandonHandler } = require('./abandon-handler');
|
|
|
46
46
|
// longer imports the grader — it resolves the `grade` kernel PORT at grade time
|
|
47
47
|
// (seams.resolveOptional('grade'), in POST /tasks/:id/grade below).
|
|
48
48
|
const githubPush = require('../github-push');
|
|
49
|
+
const prAssign = require('../pr-assign');
|
|
49
50
|
const shipAssurance = require('../ship-assurance');
|
|
50
51
|
const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
|
|
51
52
|
const seams = api;
|
|
@@ -1125,12 +1126,25 @@ module.exports = function buildTasksRouter() {
|
|
|
1125
1126
|
fs.writeFileSync(bundlePath, bundleBuf);
|
|
1126
1127
|
const { head_sha: pushedSha } = await githubPush.pushBundle({ bundlePath, branch, headSha });
|
|
1127
1128
|
const title = `task ${id}: ${deHashRefs(valueSummary) || branch}`.slice(0, 240);
|
|
1129
|
+
// task 1003991: the server's credential is the PR's AUTHOR, so the builder who
|
|
1130
|
+
// wrote the work is named in the body and made its assignee. The claim holder,
|
|
1131
|
+
// not the caller — an Archon re-shipping someone's strand did not write it. A
|
|
1132
|
+
// lookup that FAILS names nobody (a PR body is never rewritten, so a wrong name
|
|
1133
|
+
// is permanent); only a never-claimed task falls back to the caller.
|
|
1134
|
+
const builderLogin = await db.getLastClaimHolder(id)
|
|
1135
|
+
.then((h) => (h ? h.github_login : req.builder.github_login), () => null);
|
|
1136
|
+
const builderLine = prAssign.prBuilderLine(builderLogin);
|
|
1128
1137
|
const body =
|
|
1129
|
-
`Auto-opened by GDS server-mediated publish (ADR 0055) for task ${id}
|
|
1138
|
+
`Auto-opened by GDS server-mediated publish (ADR 0055) for task ${id}.` +
|
|
1139
|
+
`${builderLine ? ` ${builderLine}` : ''}\n\n` +
|
|
1130
1140
|
`${deHashRefs(valueSummary) || '(no value summary)'}\n\n` +
|
|
1131
1141
|
`Merging triggers .github/workflows/deploy-prod.yml -> ${branding().domains.publicOrigin}. ` +
|
|
1132
1142
|
`Auto-merge is enabled — GitHub lands it once the required checks pass.`;
|
|
1133
1143
|
const pr = await githubPush.openOrFindPullRequest({ branch, title, body });
|
|
1144
|
+
// Best-effort by contract (pr-assign.js never rejects); the catch is for a future
|
|
1145
|
+
// edit that forgets — an assignment must not be able to fail a pushed ship.
|
|
1146
|
+
const assignment = await prAssign.assignPullRequest({ number: pr.number, login: builderLogin })
|
|
1147
|
+
.catch(() => ({ assigned: false }));
|
|
1134
1148
|
// task 1001431: ask WHY when arming fails. `auto_merge: false` on its own told the
|
|
1135
1149
|
// builder nothing, and the commonest cause is a repo with allow_auto_merge OFF —
|
|
1136
1150
|
// a permanent condition that reads exactly like a transient blip.
|
|
@@ -1157,6 +1171,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1157
1171
|
}
|
|
1158
1172
|
return res.status(201).json({
|
|
1159
1173
|
ok: true, pr_url: pr.url, pr_number: pr.number, head_sha: pushedSha, auto_merge: autoMerge,
|
|
1174
|
+
pr_assignee: assignment.assigned ? builderLogin : null,
|
|
1160
1175
|
// Only present when arming failed — the publish itself still succeeded (201).
|
|
1161
1176
|
...(autoMerge ? {} : { auto_merge_reason: autoMergeResult.reason }),
|
|
1162
1177
|
});
|
|
@@ -352,7 +352,8 @@
|
|
|
352
352
|
<svg><use href="#orn-diamond"/></svg>
|
|
353
353
|
</div>
|
|
354
354
|
<p class="foot__seal">By order of the Senate</p>
|
|
355
|
-
|
|
355
|
+
<!-- task 1002853: every company field is optional, so each clause drops whole when its pack field is empty -->
|
|
356
|
+
<!--brand-if:companyName--><p>{{worldName}} is the work of <!--brand-if:companySite--><a href="{{companySite}}" target="_blank" rel="noopener"><!--/brand-if:companySite--><strong>{{companyName}}</strong><!--brand-if:companySite--></a><!--/brand-if:companySite--><!--brand-if:companyTagline-->, {{companyTagline}}<!--/brand-if:companyTagline-->.</p><!--/brand-if:companyName-->
|
|
356
357
|
<p>{{Currency}} are wrought by the lawmen's review; redemption awaits their seal.</p>
|
|
357
358
|
</footer>
|
|
358
359
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.840",
|
|
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.840",
|
|
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.840",
|
|
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",
|
|
@@ -41,6 +41,9 @@ const { CORE_ROOT, MODULE_ROOTS, REPO_ROOT, rel } = require('./fitness-lib.js');
|
|
|
41
41
|
const PROMPT_IDENTITY_LITERALS = [
|
|
42
42
|
/off the boats/i,
|
|
43
43
|
/amazonprimea/i,
|
|
44
|
+
// The host's operating company (task 1002853): the scan already covered the status
|
|
45
|
+
// page's HTML, but with only the product listed its footer credited this company for years.
|
|
46
|
+
/hermeslines/i,
|
|
44
47
|
];
|
|
45
48
|
// Scan roots (recursive) + kernel-prompt CLIs that live outside them.
|
|
46
49
|
// scripts/gds joined at task 1002427: the builder CLI was UNSCANNED, which is how
|
|
@@ -101,7 +104,7 @@ const IDENTITY_SCAN_EXTRA_FILES = [];
|
|
|
101
104
|
const IDENTITY_SCAN_ALLOWED = {
|
|
102
105
|
// task 1003206 moved Check 13's own regexes out of fitness.js into THIS file
|
|
103
106
|
// when the monolith was split, so the detector's self-carve-out moved with them.
|
|
104
|
-
'scripts/gds/fitness-checks-identity.js':
|
|
107
|
+
'scripts/gds/fitness-checks-identity.js': 3,
|
|
105
108
|
'scripts/gds/package-core.js': 1,
|
|
106
109
|
// audit-replay.sh + db-backup-{nightly,offsite}.sh were carved out here as
|
|
107
110
|
// "host-pinned ops"; task 1002757 RETIRED that justification for them — each
|
|
@@ -125,6 +128,11 @@ const IDENTITY_SCAN_ALLOWED = {
|
|
|
125
128
|
'scripts/gds/seed-productization-tasks.js': 6,
|
|
126
129
|
'scripts/gds/seed-provisioning-tasks.js': 2,
|
|
127
130
|
'scripts/gds/version-close.js': 1,
|
|
131
|
+
// task 1002853 (the company literal joined the list): audit-authorship.sh is itself a
|
|
132
|
+
// detector — its ops allowlist names the founder's commit address so it can exempt it; grade-smoke.js
|
|
133
|
+
// is a protected grader probe whose fixture task prose names the company, the seed-*.js rationale.
|
|
134
|
+
'scripts/gds/audit-authorship.sh': 1,
|
|
135
|
+
'scripts/gds/grade-smoke.js': 1,
|
|
128
136
|
};
|
|
129
137
|
// `.sh` joined at task 1002567: the extension set was `.js/.html/.css`, so NO shell
|
|
130
138
|
// script had ever been scanned — which is how scripts/gds/lib.sh kept a hardcoded
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -113,6 +113,12 @@ const ROOT_CHAR_HARD_MAX = 42000; // ~10.5K tokens — the always-loaded file mu
|
|
|
113
113
|
const ROOT_CHAR_TARGET = 32000; // ~8K tokens — aspirational, mirrors the ~200-line aim.
|
|
114
114
|
const estTokens = (n) => Math.round(n / 4); // ~4 chars/token for markdown prose.
|
|
115
115
|
|
|
116
|
+
// Where the charter BODY lives since task 1003687. It is guidance for EDITING a
|
|
117
|
+
// CLAUDE.md, so it is paths-scoped (`paths:` frontmatter, same mechanism as
|
|
118
|
+
// .claude/rules/hook-authoring.md) and costs nothing in the sessions — the large
|
|
119
|
+
// majority — that never open one. The kernel keeps the heading and a pointer.
|
|
120
|
+
const CHARTER_RULE_REL = '.claude/rules/claudemd-authoring.md';
|
|
121
|
+
|
|
116
122
|
function checkClaudeMdBudget(opts = {}) {
|
|
117
123
|
const p = path.join(INSTANCE_ROOT, 'CLAUDE.md'); // the root CLAUDE.md is INSTANCE content (materialized per instance; ADR 0108)
|
|
118
124
|
const text = opts.text != null ? opts.text : fs.readFileSync(p, 'utf8');
|
|
@@ -133,8 +139,35 @@ function checkClaudeMdBudget(opts = {}) {
|
|
|
133
139
|
}
|
|
134
140
|
if (!text.includes('## Working with this file (content charter)')) {
|
|
135
141
|
violations.push('CLAUDE.md is missing the content-charter section (ADR 0061) — the per-line test + "what goes where" table must stay.');
|
|
142
|
+
} else {
|
|
143
|
+
// Task 1003687 relocated the charter BODY to a paths-scoped rule, so the kernel
|
|
144
|
+
// keeps the heading and a pointer. That makes the heading cheap to fake: a section
|
|
145
|
+
// with nothing behind it satisfies a substring check while the rules it names are
|
|
146
|
+
// gone. So the charter must resolve to something — either inline here, or through a
|
|
147
|
+
// pointer whose target actually exists and still carries the two rules + the table.
|
|
148
|
+
const namesRule = text.includes(CHARTER_RULE_REL);
|
|
149
|
+
const inlineCharter = text.includes('Per-line test:');
|
|
150
|
+
if (namesRule) {
|
|
151
|
+
const rulePath = path.join(INSTANCE_ROOT, CHARTER_RULE_REL); // .claude/ is materialized INSTANCE content (ADR 0108 §3)
|
|
152
|
+
if (!fs.existsSync(rulePath)) {
|
|
153
|
+
violations.push(`CLAUDE.md points its content charter at ${CHARTER_RULE_REL}, but that file does not exist — a pointer to nothing is worse than no pointer (ADR 0061 / task 1003687).`);
|
|
154
|
+
} else {
|
|
155
|
+
const rule = fs.readFileSync(rulePath, 'utf8');
|
|
156
|
+
const missing = [];
|
|
157
|
+
if (!/^---\r?\n[\s\S]*?\bpaths:[\s\S]*?["']\*\*\/CLAUDE\.md["'][\s\S]*?^---$/m.test(rule)) {
|
|
158
|
+
missing.push('`paths:` frontmatter matching `**/CLAUDE.md` (without it the rule never loads, and the charter is simply gone)');
|
|
159
|
+
}
|
|
160
|
+
if (!rule.includes('Per-line test:')) missing.push('the per-line test');
|
|
161
|
+
if (!rule.includes('## What goes where')) missing.push('the "what goes where" table');
|
|
162
|
+
if (missing.length) {
|
|
163
|
+
violations.push(`${CHARTER_RULE_REL} is missing ${missing.join(', ')} — the kernel delegates the charter to it, so the charter is only as real as that file (ADR 0061 / task 1003687).`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (!inlineCharter) {
|
|
167
|
+
violations.push('The CLAUDE.md content-charter section is hollow — it has the heading but neither the per-line test inline nor a pointer to `' + CHARTER_RULE_REL + '`. The heading exists to make the rules findable; on its own it only satisfies this check (ADR 0061).');
|
|
168
|
+
}
|
|
136
169
|
}
|
|
137
|
-
return { name: 'root CLAUDE.md budget + charter', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings, note: `≤ ${ROOT_HARD_MAX} lines / ${ROOT_CHAR_HARD_MAX} chars (~${estTokens(ROOT_CHAR_HARD_MAX)} tok); targets ~${ROOT_TARGET} lines / ~${estTokens(ROOT_CHAR_TARGET)} tok; charter
|
|
170
|
+
return { name: 'root CLAUDE.md budget + charter', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings, note: `≤ ${ROOT_HARD_MAX} lines / ${ROOT_CHAR_HARD_MAX} chars (~${estTokens(ROOT_CHAR_HARD_MAX)} tok); targets ~${ROOT_TARGET} lines / ~${estTokens(ROOT_CHAR_TARGET)} tok; charter resolves (inline or via ${CHARTER_RULE_REL}).` };
|
|
138
171
|
}
|
|
139
172
|
|
|
140
173
|
// ============================================================================
|
|
@@ -32,27 +32,71 @@ const { SESSION_FILENAMES, readSessionConfigSync } = require('../../src/instance
|
|
|
32
32
|
// WRITE the session to the configured instance dir (OTB → ~/.config/otb,
|
|
33
33
|
// unchanged; a fresh instance → its neutral dir). R61 / task 1200.
|
|
34
34
|
const SESSION_PATH = ic.configPath(SESSION_FILENAMES[0]);
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
35
|
+
|
|
36
|
+
// `--api-base <url>` / `--api-base=<url>`, plus the positional token. Hand-rolled
|
|
37
|
+
// because this script has no arg parser and one flag does not earn a dependency.
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const out = { token: null, apiBase: null };
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const a = String(argv[i]);
|
|
42
|
+
if (a === '--api-base') { out.apiBase = argv[i + 1] === undefined ? '' : String(argv[i + 1]); i += 1; continue; }
|
|
43
|
+
if (a.startsWith('--api-base=')) { out.apiBase = a.slice('--api-base='.length); continue; }
|
|
44
|
+
if (out.token === null && !a.startsWith('-')) out.token = a;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// WHICH INSTANCE IS THIS TOKEN FOR? (task 1003722)
|
|
50
|
+
//
|
|
51
|
+
// The old order was env → the EXISTING session's api_base → branding default, on the
|
|
52
|
+
// reasoning that a re-auth already knows which instance the builder is on. That holds
|
|
53
|
+
// right up until the builder uses a SECOND instance: the stale api_base then wins over
|
|
54
|
+
// the instance that actually minted the token, so the paste is verified against — and
|
|
55
|
+
// written as — the wrong host. The reported symptom was a token from this instance
|
|
56
|
+
// landing as a token for an unrelated one, after which every /builder-* command
|
|
57
|
+
// authenticated somewhere unintended.
|
|
58
|
+
//
|
|
59
|
+
// A token is minted BY an instance, so the issuer is a property of the token, not of
|
|
60
|
+
// the machine. The hall now stamps it into the paste command, and an explicit flag
|
|
61
|
+
// outranks every inferred source: it is per-invocation, it came from the minting
|
|
62
|
+
// instance, and it is the only source that is right by construction.
|
|
63
|
+
//
|
|
64
|
+
// Below the flag the old order is untouched, so an older instance whose paste command
|
|
65
|
+
// carries no flag behaves exactly as before — and so does the founder-host regression
|
|
66
|
+
// this file already guards (task 1002427: hardcoding a host here rejected a valid
|
|
67
|
+
// token with a 401).
|
|
68
|
+
function resolveBaseForPaste({ flag, resolve = () => ic.resolveApiBase() } = {}) {
|
|
69
|
+
const f = typeof flag === 'string' ? flag.trim() : '';
|
|
70
|
+
if (!f) return { base: resolve(), source: 'resolved' };
|
|
71
|
+
if (!/^https?:\/\/[^\s/]+/i.test(f)) return { base: null, source: 'invalid' };
|
|
72
|
+
return { base: f.replace(/\/+$/, ''), source: 'flag' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ARGS = parseArgs(process.argv.slice(2));
|
|
76
|
+
const RESOLVED = resolveBaseForPaste({ flag: ARGS.apiBase });
|
|
77
|
+
const API_BASE = RESOLVED.base;
|
|
42
78
|
|
|
43
79
|
function usage(extra) {
|
|
44
80
|
if (extra) console.error(extra);
|
|
45
81
|
console.error('');
|
|
46
|
-
console.error('Usage: bongos paste-token <token>');
|
|
82
|
+
console.error('Usage: bongos paste-token <token> [--api-base <url>]');
|
|
47
83
|
console.error(' (inside a core checkout: node scripts/gds/paste-token.js <token>)');
|
|
48
84
|
console.error('');
|
|
49
|
-
|
|
85
|
+
// Fall back to the inferred base for this hint when --api-base was the thing that
|
|
86
|
+
// was malformed — otherwise the error message that explains the bad flag would
|
|
87
|
+
// itself print "null/builders/settings".
|
|
88
|
+
const hintBase = API_BASE || ic.resolveApiBase();
|
|
89
|
+
console.error('Get a token on your Settings page: ' + hintBase + '/builders/settings');
|
|
50
90
|
console.error('— under "CLI Access" click "Re-issue CLI token" (you must be signed in).');
|
|
51
91
|
console.error('The page prints the exact paste-command — just run that line.');
|
|
52
92
|
}
|
|
53
93
|
|
|
54
94
|
async function main() {
|
|
55
|
-
|
|
95
|
+
if (RESOLVED.source === 'invalid') {
|
|
96
|
+
usage(`--api-base must be an http(s) URL, got: ${ARGS.apiBase}`);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
const token = ARGS.token;
|
|
56
100
|
if (!token) {
|
|
57
101
|
usage('No token argument provided.');
|
|
58
102
|
process.exit(2);
|
|
@@ -138,7 +182,14 @@ async function main() {
|
|
|
138
182
|
}
|
|
139
183
|
}
|
|
140
184
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
// Run only as a script. Exporting the two pure helpers lets the decision that caused
|
|
186
|
+
// task 1003722 — WHICH instance a pasted token belongs to — be tested directly, rather
|
|
187
|
+
// than inferred from a text scan of this file.
|
|
188
|
+
if (require.main === module) {
|
|
189
|
+
main().catch((err) => {
|
|
190
|
+
console.error('paste-token failed:', err && err.message ? err.message : err);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { parseArgs, resolveBaseForPaste };
|
|
@@ -55,6 +55,9 @@ const CONFIG = {
|
|
|
55
55
|
// <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
|
|
56
56
|
// the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
|
|
57
57
|
caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
|
|
58
|
+
// The base Caddyfile the running Caddy loads — read (never written) to prove it imports
|
|
59
|
+
// the snippets and carries the on-demand TLS ask (task 1002860, ADR 0303).
|
|
60
|
+
caddyfile: process.env.PROVISION_CADDYFILE || '/etc/caddy/Caddyfile',
|
|
58
61
|
// Where nightly pg_dump backups land (db-backup-nightly.sh's GDS_BACKUP_DIR
|
|
59
62
|
// default). Shared across co-tenant instances on one box — dumps are named
|
|
60
63
|
// `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
|
|
@@ -240,6 +240,36 @@ function caddyBlock(inst) {
|
|
|
240
240
|
function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
|
|
241
241
|
return path.posix.join(snippetDir, `${inst.slug}.caddy`);
|
|
242
242
|
}
|
|
243
|
+
// Does the base Caddyfile carry what a provisioned domain needs (task 1002860, ADR 0303)?
|
|
244
|
+
// Read from Caddy's RESOLVED config, not the file's text, so the answer holds whatever form
|
|
245
|
+
// the operator wrote it in: an `ask` (2.7) or a `permission` module (2.8+), an absolute or
|
|
246
|
+
// relative import, directives split across imported files. `requireHost` asks whether the
|
|
247
|
+
// domain is served, which only a written snippet can make true. Returns the operator
|
|
248
|
+
// instruction, or null when wired.
|
|
249
|
+
function caddyWiringGap(adaptedJson, { domain, requireHost = false, caddyfile = CONFIG.caddyfile, snippetDir = CONFIG.caddySnippetDir } = {}) {
|
|
250
|
+
let apps;
|
|
251
|
+
try { apps = JSON.parse(adaptedJson).apps || {}; } catch (_) {
|
|
252
|
+
return `could not read Caddy's resolved config ('caddy adapt --config ${caddyfile}' printed no JSON)`;
|
|
253
|
+
}
|
|
254
|
+
const onDemand = apps.tls && apps.tls.automation && apps.tls.automation.on_demand;
|
|
255
|
+
if (!JSON.stringify(onDemand || {}).includes('/provisioning/tls-check')) {
|
|
256
|
+
return `${caddyfile} has no on_demand_tls ask for the provisioning tls-check, so no provisioned domain can get a certificate — add 'on_demand_tls { ask <platform origin>/api/bongos/provisioning/tls-check }' to its global options block and reload Caddy`;
|
|
257
|
+
}
|
|
258
|
+
if (requireHost && !JSON.stringify(apps.http || {}).includes(JSON.stringify(domain))) {
|
|
259
|
+
return `${caddyfile} does not import ${snippetDir}/*.caddy, so Caddy will never serve ${domain} — add 'import ${snippetDir}/*.caddy' at the top level of ${caddyfile} and reload Caddy`;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
// `caddy adapt` reads config files only: no privilege, no certificate keys, no running server.
|
|
264
|
+
function checkCaddyWiring(exec, opts = {}) {
|
|
265
|
+
const caddyfile = opts.caddyfile || CONFIG.caddyfile;
|
|
266
|
+
let res;
|
|
267
|
+
try { res = exec(`caddy adapt --config ${caddyfile} --adapter caddyfile`); } catch (e) {
|
|
268
|
+
const why = String((e && (e.stderr || e.message)) || e).trim().slice(0, 200);
|
|
269
|
+
return `'caddy adapt --config ${caddyfile}' failed (${why}) — the base Caddyfile must load before a site is added to it`;
|
|
270
|
+
}
|
|
271
|
+
return caddyWiringGap(res && res.stdout, { ...opts, caddyfile });
|
|
272
|
+
}
|
|
243
273
|
function healthzCmd(inst) {
|
|
244
274
|
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
|
|
245
275
|
}
|
|
@@ -281,6 +311,8 @@ module.exports = {
|
|
|
281
311
|
DNS_BLACKHOLE_IP,
|
|
282
312
|
caddyBlock,
|
|
283
313
|
caddySnippetPath,
|
|
314
|
+
caddyWiringGap,
|
|
315
|
+
checkCaddyWiring,
|
|
284
316
|
checkDnsTokenScope,
|
|
285
317
|
classifyZoneScope,
|
|
286
318
|
dnsReleaseEnv,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -52,7 +52,7 @@ const crypto = require('node:crypto');
|
|
|
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, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, 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
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
|
-
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
|
+
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
57
57
|
const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
58
58
|
const { teardownInstance } = require('./provision-teardown.js');
|
|
@@ -263,6 +263,13 @@ async function provisionInstance(inst, deps) {
|
|
|
263
263
|
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
|
+
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
267
|
+
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
268
|
+
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
|
269
|
+
if (inst.domain && inst.hosting_shape !== 'dedicated' && apply) {
|
|
270
|
+
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
271
|
+
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
272
|
+
}
|
|
266
273
|
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
267
274
|
if (room.ok === false) return room;
|
|
268
275
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
@@ -502,11 +509,14 @@ async function provisionInstance(inst, deps) {
|
|
|
502
509
|
// Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
|
|
503
510
|
// injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
|
|
504
511
|
// The main Caddyfile carries a one-time `import <snippetDir>/*.caddy` (operator
|
|
505
|
-
// setup)
|
|
512
|
+
// setup) — verified after the write, since only a written snippet proves the import
|
|
513
|
+
// reaches it. The reload is loud: Caddy keeps its old config when it rejects the new one.
|
|
506
514
|
log(' [caddy] write site snippet + reload');
|
|
507
515
|
if (local) {
|
|
508
516
|
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
509
|
-
|
|
517
|
+
const gap = inst.domain && apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
|
|
518
|
+
if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
|
|
519
|
+
boxExec(`${sudoP}systemctl reload caddy`);
|
|
510
520
|
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
511
521
|
|
|
512
522
|
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
@@ -740,6 +750,11 @@ async function domainAttachInstance(inst, deps) {
|
|
|
740
750
|
}
|
|
741
751
|
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
742
752
|
}
|
|
753
|
+
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
|
|
754
|
+
if (apply) {
|
|
755
|
+
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
756
|
+
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
757
|
+
}
|
|
743
758
|
|
|
744
759
|
// The A-record target — the same derivation as provisionInstance step 1.
|
|
745
760
|
const recordIp = standalone
|
|
@@ -791,7 +806,9 @@ async function domainAttachInstance(inst, deps) {
|
|
|
791
806
|
// (and tls-check stops vouching for it) with no separate un-wire step.
|
|
792
807
|
log(' [caddy] write site snippet + reload');
|
|
793
808
|
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
794
|
-
|
|
809
|
+
const gap = apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
|
|
810
|
+
if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
|
|
811
|
+
boxExec(`${sudoP}systemctl reload caddy`);
|
|
795
812
|
|
|
796
813
|
// Release what the re-point moved off, AFTER the new address is wired: the DNS
|
|
797
814
|
// record is the only half of an abandoned address the platform still holds, and
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -209,6 +209,8 @@ async function ciLandServer(taskId, branch, valueSummary) {
|
|
|
209
209
|
return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
|
|
210
210
|
}
|
|
211
211
|
console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
|
|
212
|
+
const assigneeLine = prAssigneeLine(r.data.pr_assignee);
|
|
213
|
+
if (assigneeLine) console.log(assigneeLine);
|
|
212
214
|
return pollServerPublish(taskId, branch);
|
|
213
215
|
} finally {
|
|
214
216
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
@@ -332,6 +334,15 @@ function reconcileBlockedLines(taskId, reason) {
|
|
|
332
334
|
];
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
// task 1003991 — the server opens the PR with ITS credential, so "created by me" on
|
|
338
|
+
// GitHub is empty for the builder; it assigns the PR to them instead, and this says
|
|
339
|
+
// where that makes it findable. Null when the server assigned nobody. Pure + exported.
|
|
340
|
+
function prAssigneeLine(login) {
|
|
341
|
+
return /^[A-Za-z\d_-]{1,39}$/.test(String(login || ''))
|
|
342
|
+
? ` ci-land: the PR is assigned to @${login} — on GitHub it is under Pull requests → Assigned to you (the server opened it, so "Created by you" stays empty).`
|
|
343
|
+
: null;
|
|
344
|
+
}
|
|
345
|
+
|
|
335
346
|
// task 1003988 — what to tell a builder while their PR sits RED. The poll used to
|
|
336
347
|
// wait out its nine minutes in silence, so a credential-less box learned only that
|
|
337
348
|
// the land was "pending" and pushed the same commit again (PR 922: nine red runs).
|
|
@@ -724,6 +735,7 @@ module.exports = {
|
|
|
724
735
|
landed,
|
|
725
736
|
pickStaleGateReviewRun,
|
|
726
737
|
reconcileBlockedLines,
|
|
738
|
+
prAssigneeLine,
|
|
727
739
|
redPrLine,
|
|
728
740
|
redPrLines,
|
|
729
741
|
reconciledCardStatus,
|
|
@@ -48,7 +48,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
48
48
|
|
|
49
49
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
50
50
|
const SRC = path.join(REPO_ROOT, 'modules', 'status-ui', 'public');
|
|
51
|
-
const { branding, clientBranding } = require('../src/branding');
|
|
51
|
+
const { branding, clientBranding, applyBrandConditionals } = require('../src/branding');
|
|
52
52
|
|
|
53
53
|
// Stamp instance-identity tokens into the mirror HTML. The off-box mirror is
|
|
54
54
|
// served by a static Pages host, NOT the branding-injecting Node server — so this
|
|
@@ -63,15 +63,19 @@ function brandTokens(html, brand = {}, publicOrigin = '', buildersOrigin = '') {
|
|
|
63
63
|
const tagline = (brand.copy && brand.copy.tagline) || '';
|
|
64
64
|
const cur = (brand.currency && brand.currency.label) || 'credits';
|
|
65
65
|
const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
|
|
66
|
+
const company = (brand.identity && brand.identity.company) || {};
|
|
66
67
|
const esc = (s) => String(s == null ? '' : s)
|
|
67
68
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
68
69
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
69
|
-
return html
|
|
70
|
+
return applyBrandConditionals(html, { companyName: company.name, companySite: company.site, companyTagline: company.tagline })
|
|
70
71
|
.split('{{worldName}}').join(esc(world))
|
|
71
72
|
.split('{{productName}}').join(esc(product))
|
|
72
73
|
.split('{{Currency}}').join(esc(Cur))
|
|
73
74
|
.split('{{currency}}').join(esc(cur))
|
|
74
75
|
.split('{{tagline}}').join(esc(tagline))
|
|
76
|
+
.split('{{companyName}}').join(esc(company.name))
|
|
77
|
+
.split('{{companySite}}').join(esc(company.site))
|
|
78
|
+
.split('{{companyTagline}}').join(esc(company.tagline))
|
|
75
79
|
.split('{{publicOrigin}}').join(esc(publicOrigin || ''))
|
|
76
80
|
.split('{{buildersOrigin}}').join(esc(buildersOrigin || publicOrigin || ''));
|
|
77
81
|
}
|
|
@@ -1066,20 +1066,41 @@ module.exports = function buildAuthRouter() {
|
|
|
1066
1066
|
// The builder kills it from this same Settings page, under Active sessions.
|
|
1067
1067
|
expiresAt: null,
|
|
1068
1068
|
});
|
|
1069
|
+
// NAME THE INSTANCE THAT MINTED THIS (task 1003722). A bearer is only
|
|
1070
|
+
// meaningful against the instance that issued it, and the CLI cannot work
|
|
1071
|
+
// that out on its own: paste-token resolves its base as env → the EXISTING
|
|
1072
|
+
// session's api_base → branding default. So on any machine that has talked
|
|
1073
|
+
// to another instance, the STALE api_base wins and a perfectly good token is
|
|
1074
|
+
// verified against — and then bound to — the wrong host. That is the reported
|
|
1075
|
+
// bug: a token minted here landed as a token for a different instance.
|
|
1076
|
+
//
|
|
1077
|
+
// The issuer is the one fact that cannot be recovered at the other end, so it
|
|
1078
|
+
// travels with the token. Same discipline as configuredOAuthOrigin() above:
|
|
1079
|
+
// this instance may state its OWN identity and must assume nothing about the
|
|
1080
|
+
// recipient's machine.
|
|
1081
|
+
const issuer = String(resolveEnv('PUBLIC_ORIGIN') || configuredOAuthOrigin() || '')
|
|
1082
|
+
.trim().replace(/\/+$/, '');
|
|
1069
1083
|
res.json({
|
|
1070
1084
|
ok: true,
|
|
1071
1085
|
token,
|
|
1072
1086
|
expires_at: row.expires_at,
|
|
1073
1087
|
builder_login: req.builder.github_login,
|
|
1088
|
+
api_base: issuer || null,
|
|
1074
1089
|
// A VERB, never a path (task 1003961). This string is composed HERE and run
|
|
1075
1090
|
// THERE — on an instance, where the core is an installed dependency and
|
|
1076
1091
|
// `scripts/gds/` does not exist, so the previous
|
|
1077
1092
|
// `node scripts/gds/paste-token.js <token>` named a file the builder did not
|
|
1078
1093
|
// have and the copy button handed out a command that could not run. `bongos`
|
|
1079
1094
|
// is on PATH in any installed instance, so the verb form is layout- and
|
|
1080
|
-
// directory-independent.
|
|
1081
|
-
//
|
|
1082
|
-
|
|
1095
|
+
// directory-independent.
|
|
1096
|
+
//
|
|
1097
|
+
// The --api-base flag carries the issuer above. Omitted when this instance
|
|
1098
|
+
// cannot state its own origin, because a flag with an empty value would be
|
|
1099
|
+
// worse than none: paste-token would take '' as an explicit instruction and
|
|
1100
|
+
// stop falling back to the resolution that works today.
|
|
1101
|
+
paste_command: issuer
|
|
1102
|
+
? `bongos paste-token ${token} --api-base ${issuer}`
|
|
1103
|
+
: `bongos paste-token ${token}`,
|
|
1083
1104
|
});
|
|
1084
1105
|
} catch (err) {
|
|
1085
1106
|
log.error('[gds] POST /auth/cli-token/issue', err);
|
|
@@ -46,7 +46,7 @@ const { buildStampedHtml } = require('../asset-version');
|
|
|
46
46
|
const { buildInfo } = require('../build-info');
|
|
47
47
|
const { buildDevboxDownloads } = require('../devbox-downloads');
|
|
48
48
|
const { buildBongosDownloads } = require('../bongos-downloads');
|
|
49
|
-
const { clientBranding, branding } = require('../branding');
|
|
49
|
+
const { clientBranding, branding, applyBrandConditionals } = require('../branding');
|
|
50
50
|
const { prelaunchGate } = require('./prelaunch-gate');
|
|
51
51
|
const { platformVisibilityGate } = require('./platform-visibility-gate');
|
|
52
52
|
const { clientModules, isModuleEnabled } = require('../modules');
|
|
@@ -205,6 +205,8 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
|
|
|
205
205
|
// served page can carry a correct cross-host href or canonical/og: URL: a client
|
|
206
206
|
// fill cannot reach them, and a baked one would make every instance advertise
|
|
207
207
|
// whichever instance happened to author the file (task 1002748, direction C5/C6).
|
|
208
|
+
// {{companyName}} {{companySite}} {{companyTagline}} are optional, so a page wraps
|
|
209
|
+
// each clause in a brand-if region that drops whole when its field is empty (task 1002853).
|
|
208
210
|
// Pure + exported for the test; both origins are injectable so it drives with zero I/O.
|
|
209
211
|
function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl(), instanceBase = safeInstanceBase(), hubOrigin = safeHubOrigin()) {
|
|
210
212
|
const world = (brand.identity && brand.identity.worldName) || '';
|
|
@@ -213,12 +215,17 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
|
|
|
213
215
|
const cur = (brand.currency && brand.currency.label) || 'credits';
|
|
214
216
|
const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
|
|
215
217
|
const hall = hallOrigin || origin || '';
|
|
216
|
-
|
|
218
|
+
const company = (brand.identity && brand.identity.company) || {};
|
|
219
|
+
const conditioned = applyBrandConditionals(html == null ? '' : html, { companyName: company.name, companySite: company.site, companyTagline: company.tagline });
|
|
220
|
+
return dropEmptyHrefLinks(conditioned, { publicOrigin: origin || '', buildersOrigin: hall, sourceRepoUrl: sourceRepo || '' })
|
|
217
221
|
.split('{{worldName}}').join(esc(world))
|
|
218
222
|
.split('{{productName}}').join(esc(product))
|
|
219
223
|
.split('{{Currency}}').join(esc(Cur))
|
|
220
224
|
.split('{{currency}}').join(esc(cur))
|
|
221
225
|
.split('{{tagline}}').join(esc(tagline))
|
|
226
|
+
.split('{{companyName}}').join(esc(company.name))
|
|
227
|
+
.split('{{companySite}}').join(esc(company.site))
|
|
228
|
+
.split('{{companyTagline}}').join(esc(company.tagline))
|
|
222
229
|
.split('{{publicOrigin}}').join(esc(origin || ''))
|
|
223
230
|
// a pack that names no hall of its own resolves to its OWN apex — never to
|
|
224
231
|
// whichever instance authored the file
|