141 lines
4.8 KiB
JavaScript
141 lines
4.8 KiB
JavaScript
// Starts a headless Foundry session on a ThreeHats foundryvtt-rest-api-relay.
|
|
//
|
|
// Generalized version: every value comes from the environment (no hardcoded
|
|
// host). Reads this repo's .env first, then process env. Required: RELAY_API_KEY,
|
|
// RELAY_URL, FOUNDRY_URL, RELAY_USER, RELAY_PASSWORD, FOUNDRY_WORLD.
|
|
//
|
|
// Flow (per docs/relay-api.md):
|
|
// 1. POST /session-handshake (x-api-key, x-foundry-url, x-username) -> {nonce, publicKey, token}
|
|
// 2. RSA-OAEP encrypt {password, nonce} with the relay's public key.
|
|
// 3. POST /start-session {handshakeToken, encryptedPassword, world} -> headless launch.
|
|
//
|
|
// Run from the repo root: node scripts/start-relay-session.js
|
|
// (The /setup skill does this for you after the Foundry rest-api module is connected.)
|
|
|
|
const crypto = require('crypto');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { URL } = require('url');
|
|
|
|
// --- Load .env (simple KEY=value; real process.env wins) ---
|
|
(function loadDotEnv() {
|
|
const p = path.join(__dirname, '..', '.env');
|
|
let txt;
|
|
try { txt = fs.readFileSync(p, 'utf8'); } catch { return; } // no .env is fine
|
|
for (const line of txt.split(/\r?\n/)) {
|
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
if (!m) continue;
|
|
const [, k, vRaw] = m;
|
|
if (process.env[k] !== undefined) continue; // real env wins over .env
|
|
const v = vRaw.replace(/^['"]|['"]$/g, '');
|
|
if (v !== '') process.env[k] = v;
|
|
}
|
|
})();
|
|
|
|
const env = (k) => (process.env[k] !== undefined && process.env[k] !== '' ? process.env[k] : '');
|
|
|
|
const API_KEY = env('RELAY_API_KEY');
|
|
const RELAY_URL = env('RELAY_URL');
|
|
const FOUNDRY_URL = env('FOUNDRY_URL');
|
|
const USERNAME = env('RELAY_USER');
|
|
const PASSWORD = env('RELAY_PASSWORD');
|
|
const WORLD = env('FOUNDRY_WORLD');
|
|
|
|
const missing = [
|
|
['RELAY_API_KEY', API_KEY],
|
|
['RELAY_URL', RELAY_URL],
|
|
['FOUNDRY_URL', FOUNDRY_URL],
|
|
['RELAY_USER', USERNAME],
|
|
['RELAY_PASSWORD', PASSWORD],
|
|
['FOUNDRY_WORLD', WORLD],
|
|
].filter(([, v]) => !v).map(([k]) => k);
|
|
if (missing.length) {
|
|
console.error(`Missing required env var(s): ${missing.join(', ')}.\n` +
|
|
`Copy .env.example to .env and fill them in (or run the /setup skill).`);
|
|
process.exit(1);
|
|
}
|
|
|
|
let relay;
|
|
try {
|
|
relay = new URL(RELAY_URL);
|
|
} catch {
|
|
console.error(`RELAY_URL is not a valid URL: ${RELAY_URL}`);
|
|
process.exit(1);
|
|
}
|
|
const transport = relay.protocol === 'https:' ? https : http;
|
|
|
|
function post(pathname, headers, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const data = JSON.stringify(body || {});
|
|
const req = transport.request(
|
|
{
|
|
protocol: relay.protocol,
|
|
hostname: relay.hostname,
|
|
port: relay.port || (relay.protocol === 'https:' ? 443 : 80),
|
|
path: pathname,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(data),
|
|
'x-api-key': API_KEY,
|
|
...headers,
|
|
},
|
|
},
|
|
(res) => {
|
|
let d = '';
|
|
res.on('data', (c) => (d += c));
|
|
res.on('end', () => {
|
|
if (res.statusCode && res.statusCode >= 400) {
|
|
reject(new Error(`relay ${res.statusCode} POST ${pathname}: ${d.slice(0, 300)}`));
|
|
return;
|
|
}
|
|
try { resolve(JSON.parse(d)); } catch { resolve(d); }
|
|
});
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.write(data);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
console.log(`Starting handshake on ${RELAY_URL.href} (world: ${WORLD}, foundry: ${FOUNDRY_URL}, user: ${USERNAME})...`);
|
|
let hs;
|
|
try {
|
|
hs = await post('/session-handshake', {
|
|
'x-foundry-url': FOUNDRY_URL,
|
|
'x-username': USERNAME,
|
|
}, {});
|
|
} catch (e) {
|
|
console.error(`Handshake failed: ${e.message}\n` +
|
|
`Is the relay up (docker compose up -d relay) and is the Foundry rest-api module connected to it?`);
|
|
process.exit(1);
|
|
}
|
|
if (!hs || !hs.nonce || !hs.publicKey || !hs.token) {
|
|
console.error('Handshake returned an unexpected response:', JSON.stringify(hs));
|
|
process.exit(1);
|
|
}
|
|
|
|
const payload = JSON.stringify({ password: PASSWORD, nonce: hs.nonce });
|
|
const encryptedPassword = crypto.publicEncrypt(
|
|
{ key: hs.publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
|
Buffer.from(payload)
|
|
).toString('base64');
|
|
|
|
console.log('Launching headless session (may take up to 2 min)...');
|
|
try {
|
|
const result = await post('/start-session', {}, {
|
|
handshakeToken: hs.token,
|
|
encryptedPassword,
|
|
world: WORLD,
|
|
});
|
|
console.log('Result:', JSON.stringify(result, null, 2));
|
|
} catch (e) {
|
|
console.error(`Start-session failed: ${e.message}\n` +
|
|
`Check FOUNDRY_URL/RELAY_USER/RELAY_PASSWORD/FOUNDRY_WORLD are correct and the world exists.`);
|
|
process.exit(1);
|
|
}
|
|
})(); |