Files
christianandClaude Opus 5 c9eef0672c Church Translate, as it runs at the campuses
Live sermon translation into 100+ languages, running entirely on one Mac per
campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own
process, and a built-in web server the congregation reads from on their
phones. No audio leaves the building.

This is the source only. What used to sit beside it — 14 GB of speech and
translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron
caches, and the app data holding the operator PIN and every service transcript
— is machine state and downloaded assets, and stays in the NAS backup where it
belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too
(533 MB, re-fetched for packaging); the browser-side runtime beside them is
kept, so a checkout still serves the congregation page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:41:21 -04:00

62 lines
3.3 KiB
JavaScript

/* The operator desk must be unreachable from the internet — page, API and
socket — and completely normal on the LAN. */
import { spawn } from 'child_process';
import { WebSocket } from 'ws';
import fs from 'fs'; import os from 'os'; import path from 'path';
import { fileURLToPath } from 'url';
const DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-oplocal-'));
const PORT = 3198;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srv = spawn(process.execPath, [path.join(__dirname, 'server', 'server.js')],
{ env: { ...process.env, PORT: String(PORT), DATA_DIR: DIR }, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = []; srv.stdout.on('data', d => logs.push(String(d))); srv.stderr.on('data', d => logs.push(String(d)));
const sleep = ms => new Promise(r => setTimeout(r, ms));
const B = 'http://127.0.0.1:' + PORT;
const CFG = path.join(DIR, 'config.json');
for (let i = 0; i < 80; i++) { if (fs.existsSync(CFG)) { try { await fetch(B + '/healthz'); break; } catch (_) {} } await sleep(150); }
const cfg = JSON.parse(fs.readFileSync(CFG, 'utf8'));
const PIN = cfg.operatorPin, KEY = cfg.controlToken;
console.log('new-install PIN: ' + PIN.length + ' digits (' + PIN + ')\n');
let pass = 0, fail = 0;
const check = (l, ok, d) => { console.log((ok ? ' ok ' : ' FAIL ') + l + (d ? ' ' + d : '')); ok ? pass++ : fail++; };
const TUN = { 'x-forwarded-for': '203.0.113.7' };
const code = async (p, h) => (await fetch(B + p, { headers: h || {} })).status;
console.log('from the LAN (correct PIN):');
check('/operator page loads', await code('/operator') === 200);
check('/api/config accepts the PIN', await code('/api/config?pin=' + PIN) === 200);
console.log('\nfrom the internet (same correct PIN):');
check('/operator page is 404', await code('/operator', TUN) === 404, 'not even the login box');
const c = await code('/api/config?pin=' + PIN, TUN);
check('/api/config is refused', c === 401 || c === 429, 'HTTP ' + c);
console.log('\nthe listener page must still work from the internet:');
check('/ is served', await code('/', TUN) === 200);
const lm = await code('/live/eng_Latn.m3u8', TUN);
check('/live/*.m3u8 answers', lm === 200 || lm === 404, 'HTTP ' + lm + ' (404 = nothing playing yet)');
console.log('\noperator WEBSOCKET:');
const tryWs = (headers) => new Promise((res) => {
const ws = new WebSocket(B.replace('http','ws') + '/ws?role=operator&pin=' + PIN, { headers });
const t = setTimeout(() => { try { ws.close(); } catch (_) {} res('timeout'); }, 3000);
ws.on('message', () => { clearTimeout(t); ws.close(); res('accepted'); });
ws.on('close', (c, r) => { clearTimeout(t); res('closed: ' + String(r || c)); });
ws.on('error', () => {});
});
check('LAN socket is accepted', await tryWs({}) === 'accepted');
const w = await tryWs(TUN);
check('tunnel socket is closed', w.startsWith('closed'), w);
console.log('\ncontrol API (Stream Deck) is unaffected — it uses a long random token:');
check('control works from the LAN', await code('/api/control/status?key=' + KEY) === 200);
console.log('\n--- log ---');
console.log(logs.join('').split('\n').filter(l => /refused|operator/.test(l)).map(l => ' ' + l).join('\n'));
console.log('\n' + pass + ' passed, ' + fail + ' failed');
srv.kill(); fs.rmSync(DIR, { recursive: true, force: true });
process.exit(fail ? 1 : 0);