@bongos/core 1.19.332 → 1.19.333
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 +160 -40
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/module-api.js +3 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- package/tests/reward_gate.mjs +3 -2
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// scripts/gds/provision-config.js — the provisioner's CONFIG block, its env
|
|
2
|
+
// readers and the lazy dependency loader. Extracted FIRST so every other piece
|
|
3
|
+
// can share it without requiring provision.js back (the split must stay a DAG).
|
|
4
|
+
//
|
|
5
|
+
// Carved out of scripts/gds/provision.js by task 1003206 (goal 1000079, the R03
|
|
6
|
+
// de-monolith plan) to bring the control-plane CLI under the 1,500-line ratchet.
|
|
7
|
+
// A STRUCTURAL move: bodies unchanged, provision.js requires every name back, so
|
|
8
|
+
// the intent runner and its unit templates behave exactly as before.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
15
|
+
|
|
16
|
+
// The verified OAuth-cred placement helpers (task 1979) — reused by the GitHub App
|
|
17
|
+
// Manifest flow (task 2080) to VALIDATE the creds GitHub returns before writing them
|
|
18
|
+
// and to PARSE the post-restart /healthz auth_configured probe. A sibling script; a
|
|
19
|
+
// plain require (its require.main guard means importing it runs no CLI).
|
|
20
|
+
const oauthSecret = require('./oauth-secret');
|
|
21
|
+
|
|
22
|
+
// The registry-install gates + env-fed .npmrc (ADR 0134, task 2090). Lets the
|
|
23
|
+
// standalone scaffold OPT IN to pulling the PRIVATE @bongos/core from the npm
|
|
24
|
+
// registry (PROVISION_CORE_FROM_REGISTRY=1) instead of vendoring a tarball, with a
|
|
25
|
+
// fail-soft fallback to the vendored path when no NPM_TOKEN is present.
|
|
26
|
+
const { registryCoreRequested, hasNpmToken } = require('./npmrc');
|
|
27
|
+
|
|
28
|
+
// The HTTP User-Agent the manifest conversion stamps its GitHub call with (GitHub
|
|
29
|
+
// requires a UA). Overridable so a fork can identify itself; not secret.
|
|
30
|
+
const MANIFEST_UA = process.env.PROVISION_GITHUB_UA || 'cloudbongos-provisioner';
|
|
31
|
+
|
|
32
|
+
// The running core version (no caret) — for naming the vendored tarball the standalone
|
|
33
|
+
// scaffold pins. Fail-soft (a partial checkout returns null; the scaffold then errors
|
|
34
|
+
// with guidance rather than pinning a `…-null.tgz`). Mirrors init.js coreVersionSafe().
|
|
35
|
+
function coreVersionSafe() {
|
|
36
|
+
try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function numEnv(name, dflt) {
|
|
40
|
+
const v = process.env[name];
|
|
41
|
+
if (v == null || v === '') return dflt;
|
|
42
|
+
const n = Number(v);
|
|
43
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
44
|
+
}
|
|
45
|
+
// A boolean env flag: '1'/'true'/'yes'/'on' (case-insensitive) → true, else false.
|
|
46
|
+
function boolEnv(name) {
|
|
47
|
+
return /^(1|true|yes|on)$/i.test(String(process.env[name] || '').trim());
|
|
48
|
+
}
|
|
49
|
+
function hasFlag(args, name) { return args.includes(name); }
|
|
50
|
+
|
|
51
|
+
const CONFIG = {
|
|
52
|
+
// The port range a CO-TENANT instance process binds on the shared box. 3000 (OTB)
|
|
53
|
+
// and 3002 (cloudbongos.com) are taken; new co-tenant instances start at 3003.
|
|
54
|
+
portBase: numEnv('PROVISION_PORT_BASE', 3003),
|
|
55
|
+
reservedPorts: (process.env.PROVISION_RESERVED_PORTS || '3000,3001,3002')
|
|
56
|
+
.split(',').map((s) => Number(s.trim())).filter(Number.isFinite),
|
|
57
|
+
// Where a per-instance secrets file lives: /etc/<slug>/web.env (the standup
|
|
58
|
+
// runbook's /etc/cloudbongos/web.env shape). chmod 600, OUTSIDE the repo.
|
|
59
|
+
etcBase: process.env.PROVISION_ETC_BASE || '/etc',
|
|
60
|
+
// The dir holding per-instance Caddy site snippets. Each instance gets its OWN
|
|
61
|
+
// <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
|
|
62
|
+
// the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
|
|
63
|
+
caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
|
|
64
|
+
// Where nightly pg_dump backups land (db-backup-nightly.sh's GDS_BACKUP_DIR
|
|
65
|
+
// default). Shared across co-tenant instances on one box — dumps are named
|
|
66
|
+
// `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
|
|
67
|
+
// its own DB. Owned by the app user, mode 0750. (task 2213, productizes 2210.)
|
|
68
|
+
backupDir: process.env.PROVISION_BACKUP_DIR || '/var/backups/gds',
|
|
69
|
+
// Dedicated-droplet defaults (only used for hosting_shape='dedicated').
|
|
70
|
+
region: process.env.PROVISION_REGION || 'nyc3',
|
|
71
|
+
baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
72
|
+
tag: process.env.PROVISION_TAG || 'cloudbongos-instance',
|
|
73
|
+
doToken: process.env.DO_API_TOKEN || null,
|
|
74
|
+
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
75
|
+
// The Cloudflare DNS hook (the SAME infra/box-dns-cloudflare.sh box.js uses).
|
|
76
|
+
// P4 (#1945) formalizes the UPSERT: point the instance's domain at the target
|
|
77
|
+
// public IP (below), in the domain's OWN Cloudflare zone, proxied=false (see
|
|
78
|
+
// dnsUpsertEnv). No hook configured → DNS is skipped (a co-tenant instance is
|
|
79
|
+
// still reachable by IP; TLS then can't be issued until DNS lands).
|
|
80
|
+
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
81
|
+
// This box's PUBLIC IP — the A-record target for a CO-TENANT instance's domain.
|
|
82
|
+
// Set in the control-plane /etc/<inst>/box.env (never the web tier). A dedicated
|
|
83
|
+
// instance instead points its A record at the freshly-created droplet's IP. Under
|
|
84
|
+
// remote-exec (PROVISION_REMOTE_HOST) this is the CO-HOSTING box's IP — where the
|
|
85
|
+
// co-tenant actually runs (ADR 0130).
|
|
86
|
+
publicIp: process.env.PROVISION_PUBLIC_IP || process.env.BOX_PUBLIC_IP || null,
|
|
87
|
+
// The CONTROL PLANE's OWN public IP — the A-record target for a STANDALONE instance
|
|
88
|
+
// (task 2074). A standalone runs control-plane-LOCAL (its scaffolded repo dir lives
|
|
89
|
+
// here, not on the remote co-hosting box), so its domain must point at THIS box, not
|
|
90
|
+
// at publicIp (which, under remote-exec, is the co-hosting box). Only needed when
|
|
91
|
+
// remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
|
|
92
|
+
// since the control plane and the run box are then the same machine.
|
|
93
|
+
controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
|
|
94
|
+
// Parent dir for a STANDALONE instance's OWN repo checkout (hosting_shape='standalone',
|
|
95
|
+
// ADR 0108): each lives at <standaloneBase>/<slug> with @bongos/core installed as
|
|
96
|
+
// an npm dep. Distinct from co-tenant (which shares THIS control-plane checkout).
|
|
97
|
+
standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
|
|
98
|
+
// ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
|
|
99
|
+
// The intent runner runs as a NON-root user (provision-intent-runner.service:
|
|
100
|
+
// User=lars) that holds passwordless sudo. That user cannot itself write under
|
|
101
|
+
// /etc, reload systemd/Caddy, or peer-auth to Postgres as a superuser — so the
|
|
102
|
+
// runbook steps below FAIL as bare commands (the goal-35 blocker). With
|
|
103
|
+
// PROVISION_SUDO=1 (alias PROVISION_PRIVILEGED=1) the privileged steps escalate:
|
|
104
|
+
// • /etc writes → temp file (lars-writable) + `sudo install -D -m <mode>`
|
|
105
|
+
// • systemctl / rm /etc → `sudo …`
|
|
106
|
+
// • createdb / migrate → `sudo -u postgres …` (postgres peer-auths as the PG
|
|
107
|
+
// superuser — the Ubuntu default — so it never needs `lars` to be a PG role)
|
|
108
|
+
// OFF (default) preserves the run-as-root / test / co-tenant-legacy behavior
|
|
109
|
+
// (bare commands, direct fs writes), so nothing outside a sudo context changes.
|
|
110
|
+
// Dry-run ALWAYS logs the exact command either way. NB: the runner unit must NOT
|
|
111
|
+
// set NoNewPrivileges=true — it blocks sudo (see infra/provision-intent-runner.service).
|
|
112
|
+
privileged: boolEnv('PROVISION_SUDO') || boolEnv('PROVISION_PRIVILEGED'),
|
|
113
|
+
// ── Remote orchestration over SSH (task 2065, ADR 0130) ──────────────────
|
|
114
|
+
// The token-separation endgame (ADR 0126): run this runner ON the token-holding
|
|
115
|
+
// control-plane box, and have it drive co-tenant workloads that still LIVE on the
|
|
116
|
+
// co-hosting box over SSH — so the co-hosting box can hold ZERO cloud tokens.
|
|
117
|
+
// When PROVISION_REMOTE_HOST is set, the BOX-MUTATING steps (createdb, migrate,
|
|
118
|
+
// /etc writes, systemctl, Caddy, healthz) run on that host over `ssh`; the
|
|
119
|
+
// TOKEN step (the Cloudflare DNS hook) + the standalone repo scaffold STAY local
|
|
120
|
+
// on the control plane, where the token + the packaging tools live. UNSET
|
|
121
|
+
// (default) = every step runs locally, byte-identical to today.
|
|
122
|
+
remoteHost: process.env.PROVISION_REMOTE_HOST || null,
|
|
123
|
+
// The co-hosting box's checkout dir — the remote migrate's cwd. NO baked-in default
|
|
124
|
+
// (task 1002756: the founder-pinned path was an identity leak, and a silent wrong
|
|
125
|
+
// cwd aims mutating SSH steps at a dir that may not exist); remote mode requires it
|
|
126
|
+
// explicitly (guarded at the makeExec call), local mode never reads it.
|
|
127
|
+
remoteCwd: process.env.PROVISION_REMOTE_CHECKOUT || null,
|
|
128
|
+
// ssh options for the control-plane → co-hosting hop. BatchMode so a missing key
|
|
129
|
+
// fails fast (never hangs on a prompt); accept-new pins the host key on first
|
|
130
|
+
// connect (the operator can pre-seed known_hosts for strict pinning).
|
|
131
|
+
sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
|
135
|
+
// apex (env → bot@<apex> → neutral), never the hub's address hardcoded.
|
|
136
|
+
const { provisionerBotEmail } = require('./instance-apex');
|
|
137
|
+
|
|
138
|
+
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
139
|
+
|
|
140
|
+
// Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
|
|
141
|
+
// instance is a full Node boot away from reporting auth_configured=true, and the
|
|
142
|
+
// old 10×1s poll routinely lost that race — recording a failure for a setup that
|
|
143
|
+
// succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
|
|
144
|
+
// patience; the poll runs after the intent loop, so it delays nothing.
|
|
145
|
+
const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
|
|
146
|
+
const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
let _deps;
|
|
152
|
+
function loadDeps() {
|
|
153
|
+
if (_deps) return _deps;
|
|
154
|
+
// eslint-disable-next-line global-require
|
|
155
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
156
|
+
// eslint-disable-next-line global-require
|
|
157
|
+
const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
|
|
158
|
+
// eslint-disable-next-line global-require
|
|
159
|
+
const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
|
|
160
|
+
// Cross-site identity (ADR 0141 §4, task #1002109). Resolve the hub-side module by a
|
|
161
|
+
// VARIABLE key, gated on isModuleEnabled — the module-loader's own dynamic-dispatch
|
|
162
|
+
// pattern (the run-unit-tests.js precedent), NOT a static 'modules/platform-identity/'
|
|
163
|
+
// literal that would couple this publishable core file to an instance-EXCLUDED module
|
|
164
|
+
// (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
|
|
165
|
+
// module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
|
|
166
|
+
// dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
|
|
167
|
+
let platformIdentity = null, hubKeys = null;
|
|
168
|
+
const PI_KEY = 'platform-identity';
|
|
169
|
+
let piEnabled = false;
|
|
170
|
+
try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
|
|
171
|
+
if (piEnabled) {
|
|
172
|
+
const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
|
|
173
|
+
try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
|
|
174
|
+
catch { platformIdentity = null; hubKeys = null; }
|
|
175
|
+
}
|
|
176
|
+
_deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
|
|
177
|
+
return _deps;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// PURE step-command generators (exported for tests). Each returns the exact shell
|
|
182
|
+
// command a runbook step runs. Kept pure so local exec (co-tenant) and the
|
|
183
|
+
// dedicated droplet's cloud-init both build from ONE source of truth.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
// A slug is validated DNS/db-safe upstream (provisioning.isValidSlug), so it is
|
|
187
|
+
// already shell-safe (only [a-z0-9-]). We still never interpolate untrusted text.
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
CONFIG,
|
|
191
|
+
MANIFEST_UA,
|
|
192
|
+
MANIFEST_VERIFY_INTERVAL_MS,
|
|
193
|
+
MANIFEST_VERIFY_TRIES,
|
|
194
|
+
MAX_INTENT_ATTEMPTS,
|
|
195
|
+
REPO_ROOT,
|
|
196
|
+
coreVersionSafe,
|
|
197
|
+
hasFlag,
|
|
198
|
+
hasNpmToken,
|
|
199
|
+
loadDeps,
|
|
200
|
+
numEnv,
|
|
201
|
+
oauthSecret,
|
|
202
|
+
provisionerBotEmail,
|
|
203
|
+
registryCoreRequested,
|
|
204
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// scripts/gds/provision-net.js — how an instance becomes reachable: federation
|
|
2
|
+
// with the identity hub, Cloudflare zone/DNS resolution and token scope, the
|
|
3
|
+
// Caddy site block, and the healthz probe.
|
|
4
|
+
//
|
|
5
|
+
// Carved out of scripts/gds/provision.js by task 1003206 (goal 1000079, the R03
|
|
6
|
+
// de-monolith plan) to bring the control-plane CLI under the 1,500-line ratchet.
|
|
7
|
+
// A STRUCTURAL move: bodies unchanged, provision.js requires every name back, so
|
|
8
|
+
// the intent runner and its unit templates behave exactly as before.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const { CONFIG } = require('./provision-config.js');
|
|
15
|
+
const { provisionedEnvPrefix } = require('./provision-units.js');
|
|
16
|
+
|
|
17
|
+
function federationHubOrigin() {
|
|
18
|
+
const explicit = process.env.PROVISION_HUB_ORIGIN;
|
|
19
|
+
if (explicit) return String(explicit).replace(/\/+$/, '');
|
|
20
|
+
const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
|
|
21
|
+
return own ? String(own).replace(/\/+$/, '') : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Whether provisioning should federate this instance's sign-in through the hub.
|
|
25
|
+
// Dormant-by-default: true ONLY when the control plane is hub-configured (the
|
|
26
|
+
// platform-identity module is present, a signing key is loadable, AND a hub origin
|
|
27
|
+
// resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
|
|
28
|
+
// written locally — a `standalone` is owner-hosted and keeps its own GitHub app, the
|
|
29
|
+
// self-hosted path; a `dedicated` droplet has no local web.env write yet, same gap the
|
|
30
|
+
// ADR 0133 manifest flow leaves — see placeManifestCreds), and it has a public domain
|
|
31
|
+
// (the origin the hub binds the CLI mint audience to — the #1002108 account-takeover
|
|
32
|
+
// fix). Returns { federate, reason } so the caller can log WHY it skipped. PURE-ish
|
|
33
|
+
// (reads env + the injected module handles; no DB, no I/O).
|
|
34
|
+
function shouldFederate(inst, deps) {
|
|
35
|
+
const { platformIdentity, hubKeys } = deps;
|
|
36
|
+
if (!platformIdentity || !hubKeys) return { federate: false, reason: 'platform-identity module not present (control plane is not the hub)' };
|
|
37
|
+
if (!hubKeys.hubSigningConfigured()) return { federate: false, reason: 'hub signing key not configured (<PREFIX>_HUB_SIGNING_KEY unset)' };
|
|
38
|
+
if (!federationHubOrigin()) return { federate: false, reason: 'hub origin not resolvable (set PROVISION_HUB_ORIGIN or <PREFIX>_PUBLIC_ORIGIN)' };
|
|
39
|
+
if (inst.hosting_shape !== 'co-tenant') return { federate: false, reason: `${inst.hosting_shape} keeps its own GitHub app (not the co-tenant hosted path)` };
|
|
40
|
+
if (!inst.domain) return { federate: false, reason: 'no domain yet — federation needs the instance public origin' };
|
|
41
|
+
return { federate: true, reason: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Register this hosted instance as an sso_clients row on the HUB (fresh per-client
|
|
45
|
+
// secret) and return the federation block to bake into its web.env (ADR 0141 §4).
|
|
46
|
+
// SECURITY: `origin` MUST be the instance's EXACT public origin — the hub binds the
|
|
47
|
+
// CLI-federation mint audience to it (the #1002108 fix), so a mismatch would break
|
|
48
|
+
// device sign-in (and a wrong origin would be a takeover vector). client_id is the
|
|
49
|
+
// (unique, DNS-safe) slug, so a re-provision rotates the SAME row's secret via
|
|
50
|
+
// registerClient's ON CONFLICT rather than orphaning a client. redirect_uri lists both
|
|
51
|
+
// callback prefixes the core may emit (/api/gds is what auth.js builds today) so the
|
|
52
|
+
// hub's exact-match allow-list accepts the browser leg. --apply gates the real hub
|
|
53
|
+
// write; dry-run returns a placeholder block (so webEnvBody can still be composed) + logs.
|
|
54
|
+
async function federateInstance(inst, deps) {
|
|
55
|
+
const { db, platformIdentity, hubKeys, log, apply } = deps;
|
|
56
|
+
const origin = `https://${inst.domain}`;
|
|
57
|
+
const clientId = inst.slug;
|
|
58
|
+
const hubOrigin = federationHubOrigin();
|
|
59
|
+
const hubPublicKey = hubKeys.hubPublicKeyB64();
|
|
60
|
+
if (!hubPublicKey) throw new Error('hub signing key is set but unreadable — cannot derive HUB_PUBLIC_KEY');
|
|
61
|
+
const redirectUris = [
|
|
62
|
+
`${origin}/api/gds/auth/web/callback`,
|
|
63
|
+
`${origin}/api/bongos/auth/web/callback`,
|
|
64
|
+
];
|
|
65
|
+
if (!apply) {
|
|
66
|
+
log(` [federate] would register client '${clientId}' on the hub (origin ${origin}) + write ${provisionedEnvPrefix()}_HUB_* + AUTH_IDP into web.env`);
|
|
67
|
+
return { hubOrigin, clientId, clientSecret: '0'.repeat(43), hubPublicKey };
|
|
68
|
+
}
|
|
69
|
+
const clientSecret = crypto.randomBytes(32).toString('base64url');
|
|
70
|
+
// Best-effort owner github_id — nice for the hub catalog's ownership link, but
|
|
71
|
+
// nullable in registerClient, so a lookup miss must never fail the provision.
|
|
72
|
+
let ownerGithubId = null;
|
|
73
|
+
try {
|
|
74
|
+
const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [inst.owner_builder_id]);
|
|
75
|
+
ownerGithubId = rows[0] ? rows[0].github_id : null;
|
|
76
|
+
} catch { /* non-fatal */ }
|
|
77
|
+
await platformIdentity.registerClient(
|
|
78
|
+
// type: the owner's declaration rides the instance row from the create
|
|
79
|
+
// wizard (task 1002772) and lands on the catalog row here — never guessed.
|
|
80
|
+
{ clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId, type: inst.type },
|
|
81
|
+
{ pool: db },
|
|
82
|
+
);
|
|
83
|
+
log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
|
|
84
|
+
return { hubOrigin, clientId, clientSecret, hubPublicKey };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
|
|
88
|
+
// registrable domains, which for the common case is the last two DNS labels
|
|
89
|
+
// (mercury.example.com → example.com; apex example.com → example.com). Multi-part
|
|
90
|
+
// public suffixes (foo.co.uk) are NOT special-cased here — set CLOUDFLARE_ZONE in
|
|
91
|
+
// the control-plane box.env to override (box-dns-cloudflare.sh honours it). PURE.
|
|
92
|
+
function zoneForDomain(domain) {
|
|
93
|
+
const d = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
|
|
94
|
+
if (!d) return '';
|
|
95
|
+
const labels = d.split('.').filter(Boolean);
|
|
96
|
+
return labels.length <= 2 ? labels.join('.') : labels.slice(-2).join('.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The env a DNS UPSERT invocation of infra/box-dns-cloudflare.sh needs for a
|
|
100
|
+
// PROVISIONED host: the hostname, the target public IP (the A-record content), the
|
|
101
|
+
// zone the record lives in, and whether Cloudflare proxies it.
|
|
102
|
+
// • proxied=false (default) is LOAD-BEARING for a CO-TENANT/dedicated host: it uses
|
|
103
|
+
// Caddy on-demand ACME (Let's Encrypt), whose challenge must reach Caddy directly —
|
|
104
|
+
// Cloudflare proxy ON would intercept :80/:443 at the edge and the challenge would
|
|
105
|
+
// silently fail (ADR 0003 / docs/recipes/ops-gotchas.md). Do NOT proxy those.
|
|
106
|
+
// • proxied=true for a STANDALONE host (task 2077): it runs control-plane-local under
|
|
107
|
+
// the *.cloudbongos.com wildcard Cloudflare Origin cert, so on-demand ACME never
|
|
108
|
+
// fires (a matching cert already exists) and proxied=false would serve that origin
|
|
109
|
+
// cert — untrusted to direct clients. Proxying lets the CF edge present a public cert
|
|
110
|
+
// (origin cert valid behind Full-strict) AND hides the control-plane IP.
|
|
111
|
+
// An explicit `zone` wins over the derived one (for a multi-part-suffix domain). PURE.
|
|
112
|
+
function dnsUpsertEnv(inst, { publicIp, zone, proxied = false } = {}) {
|
|
113
|
+
return {
|
|
114
|
+
BOX_HOSTNAME: inst.domain,
|
|
115
|
+
BOX_IP: publicIp,
|
|
116
|
+
CLOUDFLARE_ZONE: zone || zoneForDomain(inst.domain),
|
|
117
|
+
BOX_PROXIED: proxied ? '1' : '0',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── DNS token-scope PREFLIGHT (task 1980) ────────────────────────────────────
|
|
122
|
+
// Onboarding a NEW project on a domain the control-plane's Cloudflare token does NOT
|
|
123
|
+
// cover fails LATE: the DNS UPSERT (step 5) errors mid-provision (the Emersonian
|
|
124
|
+
// standup — the token only covered the platform's original zone, so the new project's
|
|
125
|
+
// record couldn't be created until it was widened MID-FLOW). This preflight detects
|
|
126
|
+
// "the configured token can't reach the project's zone" UP FRONT, before the
|
|
127
|
+
// connect-address step, and GUIDES the fix — instead of a half-done provision. It is
|
|
128
|
+
// READ-ONLY (a CF `GET /zones?name=<zone>`); it never mutates DNS.
|
|
129
|
+
const CF_API_BASE = process.env.CLOUDFLARE_API_BASE || 'https://api.cloudflare.com/client/v4';
|
|
130
|
+
|
|
131
|
+
// PURE: given the outcome of `GET /zones?name=<zone>` (HTTP status + parsed JSON) and
|
|
132
|
+
// the target zone, decide whether the token can MANAGE that zone, with actionable
|
|
133
|
+
// guidance when it can't. Exported + unit-tested with no network.
|
|
134
|
+
function classifyZoneScope({ status, body, zone }) {
|
|
135
|
+
const z = String(zone || '');
|
|
136
|
+
if (status === 401 || status === 403) {
|
|
137
|
+
return { reachable: false, reason: `Cloudflare rejected the token (HTTP ${status})`,
|
|
138
|
+
guidance: `The DNS token is invalid or lacks Zone:Read. Set CLOUDFLARE_API_TOKEN in the control-plane box.env to a token scoped to include ${z} (Zone:DNS:Edit + Zone:Read).` };
|
|
139
|
+
}
|
|
140
|
+
if (status !== 200 || !body || body.success !== true) {
|
|
141
|
+
const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
|
|
142
|
+
return { reachable: false, reason: `Cloudflare zone lookup failed (HTTP ${status}${errs ? `: ${errs}` : ''})`,
|
|
143
|
+
guidance: `Check CLOUDFLARE_API_TOKEN + network from the control plane, then retry.` };
|
|
144
|
+
}
|
|
145
|
+
const result = Array.isArray(body.result) ? body.result : [];
|
|
146
|
+
const match = result.find((r) => r && r.name === z);
|
|
147
|
+
if (!match) {
|
|
148
|
+
return { reachable: false, reason: `the token authenticated, but zone "${z}" is not in its scope`,
|
|
149
|
+
guidance: `Widen the Cloudflare API token to include ${z}: CF dashboard → My Profile → API Tokens → edit the token → Zone Resources → Include → ${z} (or use an account-scoped token). If ${z} is a multi-part public suffix (e.g. foo.co.uk), set CLOUDFLARE_ZONE explicitly in the control-plane box.env.` };
|
|
150
|
+
}
|
|
151
|
+
return { reachable: true, reason: `token can manage zone "${z}" (id ${match.id})`, zoneId: match.id };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Query Cloudflare for the domain's zone with the configured token. Injectable
|
|
155
|
+
// `token`/`fetchImpl` for tests. Returns { zone, reachable, reason, guidance[, zoneId] }.
|
|
156
|
+
// A missing token/fetch/domain is reported as not-reachable-with-guidance — it never
|
|
157
|
+
// throws for a config gap (the caller decides whether to hard-fail).
|
|
158
|
+
async function checkDnsTokenScope(domain, { token = process.env.CLOUDFLARE_API_TOKEN, zoneOverride = process.env.CLOUDFLARE_ZONE, fetchImpl } = {}) {
|
|
159
|
+
const zone = zoneOverride || zoneForDomain(domain);
|
|
160
|
+
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
161
|
+
if (!zone) return { zone, reachable: false, reason: 'no domain given', guidance: 'provide a domain before the address step' };
|
|
162
|
+
if (!token) return { zone, reachable: false, reason: 'no CLOUDFLARE_API_TOKEN configured', guidance: `Set CLOUDFLARE_API_TOKEN (Zone:DNS:Edit on ${zone}) in the control-plane box.env before provisioning DNS for ${domain}.` };
|
|
163
|
+
if (!doFetch) return { zone, reachable: false, reason: 'no fetch available', guidance: 'run on a Node with global fetch, or inject fetchImpl' };
|
|
164
|
+
let status = 0; let bodyJson = null;
|
|
165
|
+
try {
|
|
166
|
+
const res = await doFetch(`${CF_API_BASE}/zones?name=${encodeURIComponent(zone)}`, {
|
|
167
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
168
|
+
});
|
|
169
|
+
status = res.status;
|
|
170
|
+
bodyJson = await res.json().catch(() => null);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return { zone, reachable: false, reason: `Cloudflare request failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
|
|
173
|
+
}
|
|
174
|
+
return { zone, ...classifyZoneScope({ status, body: bodyJson, zone }) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A Caddy reverse-proxy site block for the instance's domain → its local port.
|
|
178
|
+
// TLS = on-demand ACME (P4, ADR 0111 §4): Caddy fetches a Let's Encrypt cert for
|
|
179
|
+
// this host LAZILY, on the first TLS handshake — no manual Cloudflare Origin-Cert
|
|
180
|
+
// console step (the least-automated leg). It is GATED by the global
|
|
181
|
+
// `on_demand_tls { ask … }` allow-list in infra/Caddyfile (→ /api/bongos/
|
|
182
|
+
// provisioning/tls-check), so Caddy only mints a cert for a host this module
|
|
183
|
+
// actually provisioned — never an arbitrary SNI. The A record is proxied=false
|
|
184
|
+
// (dnsUpsertEnv) so the challenge reaches Caddy. No domain → a comment
|
|
185
|
+
// (bring-your-own-domain is deferred per-instance, ADR 0111 §4).
|
|
186
|
+
function caddyBlock(inst) {
|
|
187
|
+
if (!inst.domain) {
|
|
188
|
+
return `# ${inst.slug}: no domain yet (bring-your-own-domain pending) — reachable on 127.0.0.1:${inst.port}\n`;
|
|
189
|
+
}
|
|
190
|
+
return `${inst.domain} {
|
|
191
|
+
tls {
|
|
192
|
+
on_demand
|
|
193
|
+
}
|
|
194
|
+
reverse_proxy 127.0.0.1:${inst.port}
|
|
195
|
+
}
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
function caddySnippetPath(inst, { snippetDir = CONFIG.caddySnippetDir } = {}) {
|
|
199
|
+
return path.posix.join(snippetDir, `${inst.slug}.caddy`);
|
|
200
|
+
}
|
|
201
|
+
function healthzCmd(inst) {
|
|
202
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// The injectable executor. dry-run → log-only; --apply → child_process.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
CF_API_BASE,
|
|
211
|
+
caddyBlock,
|
|
212
|
+
caddySnippetPath,
|
|
213
|
+
checkDnsTokenScope,
|
|
214
|
+
classifyZoneScope,
|
|
215
|
+
dnsUpsertEnv,
|
|
216
|
+
federateInstance,
|
|
217
|
+
federationHubOrigin,
|
|
218
|
+
healthzCmd,
|
|
219
|
+
shouldFederate,
|
|
220
|
+
zoneForDomain,
|
|
221
|
+
};
|