Files
wmantly f42b69c084 feat(multi-site): wire selfUrl through setup.sh so joins register live
Extends the shipped CFG_MASTER_DIRECTORY_URL/JOIN_KEY join flow with the
selfUrl a spoke needs to register itself for live catalog replication
(theta-directory v2.4.0's POST /api/site/spokes) -- without this, every
spoke was permanently limited to the one-time join snapshot even after
the master gained the ability to push live updates.

setup.sh already computes CFG_SSO_HOST before this point in the script;
passes https://$CFG_SSO_HOST as bootstrap/site-join.js's third argument,
which forwards it as `selfUrl` in the POST /api/site/join body.
2026-08-10 16:50:07 -04:00

92 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
/*
* theta-suite site-join — runs inside the sso-manager container to adopt a
* master site's directory as a read-only spoke. Invoked by setup.sh when
* setup.env sets CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY:
*
* docker compose exec sso-manager node /bootstrap/site-join.js \
* https://sso.master.example.com stj_9f2e... https://sso.this-site.example.com
*
* The third argument (selfUrl, optional) is this site's own public SSO host
* (setup.sh passes https://$CFG_SSO_HOST) -- without it the join still
* succeeds, it just registers for one-time adoption only: the master has no
* way to reach this spoke to push live replication resync pings at it (see
* theta-directory's docs/site-join.md and utils/site_replicate.js).
*
* Self-contained (Node built-ins + global fetch), same rule as bootstrap.js —
* it does NOT require the SSO's internal models. It logs in as the bootstrap
* admin (reading /config/sso-secrets.js) and calls the SSO's own
* /api/site/join, which imports the master's resource catalog + LDAP tree and
* persists the spoke role in /config/site.json.
*
* Output (stdout, KEY=VALUE for setup.sh): JOINED, SITE_SLUG, RESOURCES, LDAP.
* Progress logs go to stderr.
*/
'use strict';
const sso = require('/config/sso-secrets.js');
const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || '';
const SSO_INTERNAL = 'http://localhost:3001';
const masterUrl = process.argv[2];
const joinKey = process.argv[3];
const selfUrl = process.argv[4] || '';
function log(msg) { console.error('[site-join] ' + msg); }
async function main() {
if (!masterUrl || !joinKey) {
throw new Error('usage: node /bootstrap/site-join.js <masterUrl> <joinKey>');
}
// 1. Login as the bootstrap admin (validates the password end-to-end).
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_USER_PASS }),
});
if (!loginRes.ok) {
throw new Error(`admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
}
const loginData = await loginRes.json();
const token = loginData.token;
if (!token) throw new Error('admin login returned no token');
log(`Logged in as ${ADMIN_UID}`);
// 2. Join the master.
const res = await fetch(`${SSO_INTERNAL}/api/site/join`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ masterUrl, joinKey, ...(selfUrl ? { selfUrl } : {}) }),
});
const text = await res.text().catch(() => '');
let data = null;
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
if (!res.ok) {
// A node that already joined is a no-op, not a failure (idempotent setup).
if (res.status === 400 && data && /already a spoke/i.test(data.message || '')) {
log('Already a spoke — nothing to do.');
console.log('JOINED=already');
return;
}
throw new Error(`join failed (${res.status}): ${(data && data.message) || text}`);
}
log(`Joined master site ${masterUrl} as ${data.siteSlug || '?'}`);
log(`Live replication: ${(data.replication && data.replication.note) || 'unknown'}`);
console.log([
`JOINED=yes`,
`SITE_SLUG=${data.siteSlug || ''}`,
`RESOURCES=${(data.resources && data.resources.created) || 0}`,
`LDAP=${(data.ldap && data.ldap.note) || ''}`,
`LIVE_REPLICATION=${(data.replication && data.replication.live) ? 'yes' : 'no'}`
].join(' '));
}
main().catch((e) => {
console.error('[site-join] FAILED: ' + e.message);
process.exit(1);
});