import { storeJson } from './fileModels/storeJson' import { i18n } from './i18n' import { sdk } from './sdk' import { APP_USER, BITCOIND_RPC_HOSTNAME, BITCOIND_RPC_PORT, CERTRELAY_ANCHOR_REFRESH, CERTRELAY_BIN, CERTRELAY_BIND, CERTRELAY_CHAIN, CERTRELAY_DATA_DIR, CERTRELAY_DEFAULT_BOOTSTRAP, CERTRELAY_DEFAULT_HEALTHCHECK_HANDLE, CERTRELAY_DEFAULT_SELF_URL, CERTRELAY_DIR, CERTRELAY_FABRIC_BIN, CERTRELAY_FABRIC_DEST, CERTRELAY_MONITOR_BIN, CERTRELAY_MONITOR_DEST, CERTRELAY_PORT, CERTRELAY_REMOTE_IP_HEADER, dataDir, NACHO_DEFAULT_IGNORE_NAMES, NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT, NACHO_DIR, NACHO_FALLBACK_API_BASE_URL, NACHO_PORT, renderBanner, SPACED_CHAIN, spacedRpcPort, SUBSPACES_DATA_DIR, SUBSPACES_DIR, SUBSPACES_PROVER_BIN, SUBSPACES_PROVER_DIR, SUBSPACES_PROVER_PORT, SUBSPACES_REGISTRY_BIN, SUBSPACES_REGISTRY_DIR, SUBSPACES_REGISTRY_PORT, SUBSPACES_SUBS_BIN, SUBSPACES_UI_PORT, SUBSPACES_WALLET, uiPort, } from './utils' export const main = sdk.setupMain(async ({ effects }) => { console.info(i18n('Starting Spaces!')) const store = await storeJson.read().const(effects) if ( !store?.password || !store?.btcAuth || !store?.spacedAuth ) { // taskSetPassword + taskBtcAuth + taskSeedSpacedAuth all seed these in // init; if they aren't populated yet, init hasn't finished — let StartOS // restart us. throw new Error('Spaces store.json is not yet populated.') } const { password: APP_PASSWORD, btcAuth, spacedAuth } = store const enableSubspaces = store.enableSubspaces === true // subs-prover is independently gated and defaults OFF (null/undefined => false). const enableSubsProver = store.enableSubsProver === true const spacedEnv = { SPACED_CHAIN, SPACED_DATA_DIR: dataDir, // Listen on all interfaces so the spaced RPC can be exposed externally via // the "Spaces API" StartOS interface. Internal clients (subs, certrelay, // indexer, space-cli, health checks) still connect over loopback below. SPACED_RPC_BIND: '0.0.0.0', SPACED_RPC_PORT: String(spacedRpcPort), SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`, SPACED_BLOCK_INDEX: 'true', SPACED_BITCOIN_RPC_URL: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`, SPACED_BITCOIN_RPC_USER: btcAuth.username, SPACED_BITCOIN_RPC_PASSWORD: btcAuth.password, SPACED_RPC_USER: spacedAuth.username, SPACED_RPC_PASSWORD: spacedAuth.password, // legacy aliases for `bitcoin-cli` / shell helpers that read these names BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME, BTC_RPC_PORT: String(BITCOIND_RPC_PORT), BTC_RPC_USER: btcAuth.username, BTC_RPC_PASSWORD: btcAuth.password, APP_USER, APP_PASSWORD, } // Certrelay config — user-tunable bits live in store.json (set via the // "Configure Certrelay" action); the rest are fixed package defaults. const certrelaySelfUrl = store.certrelaySelfUrl ?? CERTRELAY_DEFAULT_SELF_URL const certrelayBootstrap = store.certrelayBootstrap ?? CERTRELAY_DEFAULT_BOOTSTRAP const certrelayHealthcheckHandle = store.certrelayHealthcheckHandle ?? CERTRELAY_DEFAULT_HEALTHCHECK_HANDLE const nachoWorkshopPdfLinkText = store.nachoWorkshopPdfLinkText ?? NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT const mounts = sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: dataDir, readonly: false, }) const spacedSub = sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'spaced-sub', ) const termSub = sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'terminal-sub', ) const subspacesSub = sdk.SubContainer.of( effects, { imageId: 'subspaces' }, mounts, 'subspaces-sub', ) // Optional HTTP basic auth in front of subs (Web UI + API on 7777). Only // injected when both a stored credential pair and the enable toggle are set, // so flipping the toggle off (or clearing creds) leaves subs unauth. // NOTE: env var names match subs/src/main.rs clap #[arg(env=...)]: // SUBS_BASIC_AUTH_USER and SUBS_BASIC_AUTH_PASSWORD. const subsAuthActive = store.subsAuthEnabled === true && store.subsAuth != null const subsAuthEnv = subsAuthActive ? { SUBS_BASIC_AUTH_USER: store.subsAuth!.username, SUBS_BASIC_AUTH_PASSWORD: store.subsAuth!.password, } : {} // subs reads all of these from the environment (see subs/src/main.rs #[arg(env=...)]). const subsEnv = { SUBS_DATA_DIR: SUBSPACES_DATA_DIR, SUBS_PORT: String(SUBSPACES_UI_PORT), SUBS_WALLET: SUBSPACES_WALLET, SUBS_SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`, SUBS_SPACED_RPC_USER: spacedAuth.username, SUBS_SPACED_RPC_PASSWORD: spacedAuth.password, SUBS_PROVER_ENDPOINT: `http://127.0.0.1:${SUBSPACES_PROVER_PORT}`, SUBS_REGISTRY_ENDPOINT: `http://127.0.0.1:${SUBSPACES_REGISTRY_PORT}`, SUBS_PUBLISH_REQUIRE_FINALIZED: store.subsPublishRequireFinalized === true ? 'true' : 'false', HOME: SUBSPACES_DATA_DIR, RUST_LOG: 'subs=info,subs_prover=info,registry_server=info', ...subsAuthEnv, } // Optional HTTP basic auth in front of subs-prover (port 8888). Same wiring // pattern as subsAuthEnv above: only injected when both the toggle and // credentials are set. Env var names assume subs-prover reads // SUBS_PROVER_BASIC_AUTH_USER and SUBS_PROVER_BASIC_AUTH_PASSWORD. const subsProverAuthActive = store.subsProverAuthEnabled === true && store.subsProverAuth != null const subsProverAuthEnv = subsProverAuthActive ? { SUBS_PROVER_BASIC_AUTH_USER: store.subsProverAuth!.username, SUBS_PROVER_BASIC_AUTH_PASSWORD: store.subsProverAuth!.password, } : {} const proverEnv = { SUBS_PROVER_SERVER: '1', SUBS_PROVER_PORT: String(SUBSPACES_PROVER_PORT), HOME: SUBSPACES_PROVER_DIR, RUST_LOG: 'subs_prover=info', ...subsProverAuthEnv, } const registryEnv = { REGISTRY_SERVER_PORT: String(SUBSPACES_REGISTRY_PORT), HOME: SUBSPACES_REGISTRY_DIR, RUST_LOG: 'registry_server=info', } const certrelaySub = sdk.SubContainer.of( effects, { imageId: 'certrelay' }, mounts, 'certrelay-sub', ) const certrelayEnv = { CERTRELAY_CHAIN, CERTRELAY_DATA_DIR, CERTRELAY_BIND, CERTRELAY_PORT: String(CERTRELAY_PORT), CERTRELAY_SELF_URL: certrelaySelfUrl, // Credentials come from the local spaced server (store.spacedAuth). CERTRELAY_SPACED_RPC_URL: `http://${spacedAuth.username}:${spacedAuth.password}@127.0.0.1:${spacedRpcPort}`, CERTRELAY_REMOTE_IP_HEADER, CERTRELAY_BOOTSTRAP: certrelayBootstrap ? 'true' : 'false', CERTRELAY_ANCHOR_REFRESH, CERTRELAY_HEALTHCHECK_HANDLE: certrelayHealthcheckHandle, HOME: CERTRELAY_DATA_DIR, RUST_LOG: 'info', } const nachoSub = sdk.SubContainer.of( effects, { imageId: 'nacho' }, mounts, 'nacho-sub', ) // Derive EXPO_PUBLIC_API_BASE_URL from the subs-api StartOS interface so it // tracks the actual host:port StartOS exposes (instead of a hardcoded // domain). Prefer the mdns/.local URL — matches how a LAN browser reaches // the device — then fall back to any non-local URL, then loopback. // The interface is reached by walking its host ('subspaces-multi', the // MultiHost that bound 7777 in interfaces.ts). `.const()` makes this // reactive, and the `map` selector narrows the watch to this one interface: // if its address changes (clearnet enabled, Tor added, etc.), the service // restarts and nacho picks up the new URL. const subsApiIf = await sdk.host .getOwn( effects, 'subspaces-multi', (host) => host?.bindings[SUBSPACES_UI_PORT]?.interfaces['subs-api'], ) .const() const subsApiCandidateUrls = subsApiIf?.addressInfo ? [ ...subsApiIf.addressInfo.filter({ kind: 'mdns' }).format('urlstring'), ...subsApiIf.addressInfo.nonLocal.format('urlstring'), ] : [] const nachoApiBaseUrl = subsApiCandidateUrls[0] ?? NACHO_FALLBACK_API_BASE_URL // nacho is an Expo app; the image's entrypoint runs `expo start` on // EXPO_DEV_PORT (8082). EXPO_PUBLIC_* env vars are baked into the web bundle // at server time. The ignore list is NOT passed via env — nacho reads it at // runtime from /data/nacho/ignore_names.txt (managed by the "Configure Nacho" // action). nacho-setup seeds that file with the default if it's missing. const nachoEnv = { EXPO_PUBLIC_API_BASE_URL: nachoApiBaseUrl, EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT: nachoWorkshopPdfLinkText, EXPO_DEV_PORT: String(NACHO_PORT), NODE_ENV: 'development', CHOKIDAR_USEPOLLING: '1', HOME: NACHO_DIR, } const bashrc = [ 'export PATH=/root/.cargo/bin:/data/bin:/usr/local/bin:/usr/bin:/bin', "export PS1='spaces:\\w$ '", `alias spaces='space-cli --chain ${SPACED_CHAIN} --rpc-user "$SPACED_RPC_USER" --rpc-password "$SPACED_RPC_PASSWORD" '`, 'cat < chain .addOneshot('certrelay-setup', { subcontainer: certrelaySub, exec: { command: [ 'sh', '-c', `set -eu; \ mkdir -p ${CERTRELAY_DATA_DIR} /data/bin; \ echo "certrelay-setup: installing fabric CLI to ${CERTRELAY_FABRIC_DEST}..."; \ cp -f ${CERTRELAY_FABRIC_BIN} ${CERTRELAY_FABRIC_DEST}; \ chmod +x ${CERTRELAY_FABRIC_DEST}; \ echo "certrelay-setup: installing monitor CLI to ${CERTRELAY_MONITOR_DEST}..."; \ cp -f ${CERTRELAY_MONITOR_BIN} ${CERTRELAY_MONITOR_DEST}; \ chmod +x ${CERTRELAY_MONITOR_DEST}; \ echo "certrelay-setup: done."`, ], user: 'root', }, requires: [], }) .addDaemon('certrelay', { subcontainer: certrelaySub, exec: { command: [CERTRELAY_BIN], env: certrelayEnv, // /data is root-owned; the image's USER certrelay can't write it. user: 'root', }, ready: { display: i18n('Certrelay'), fn: () => sdk.healthCheck.checkPortListening(effects, CERTRELAY_PORT, { successMessage: i18n('certrelay is ready'), errorMessage: i18n('certrelay is not ready'), }), // Match the image's 120s start-period: anchor-refresh/checkpoint work // on first start can delay the listener. gracePeriod: 120_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['certrelay-setup', 'spaced'], }) // Appends the always-on nacho chain: setup oneshot (mkdir data dir) + nacho // daemon (the Expo dev server on 8082). Runs the image's own entrypoint so // we don't have to reproduce its Expo init logic. const withNacho = (chain: any): any => chain .addOneshot('nacho-setup', { subcontainer: nachoSub, exec: { command: [ 'sh', '-c', `set -eu; mkdir -p ${NACHO_DIR}; if [ ! -f ${NACHO_DIR}/ignore_names.txt ]; then printf %s '${NACHO_DEFAULT_IGNORE_NAMES}' > ${NACHO_DIR}/ignore_names.txt; fi; echo "nacho-setup: ensured ${NACHO_DIR} and ignore_names.txt"`, ], user: 'root', }, requires: [], }) .addDaemon('nacho', { subcontainer: nachoSub, exec: { // The image's entrypoint runs Expo on EXPO_DEV_PORT (8082) and // ignores CMD. Invoke it directly. command: ['sh', '/usr/local/bin/entrypoint.sh'], env: nachoEnv, // The image declares no USER; default would be root inside StartOS, // but be explicit so /data writes are unambiguous. user: 'root', }, ready: { display: i18n('Nacho'), fn: () => sdk.healthCheck.checkPortListening(effects, NACHO_PORT, { successMessage: i18n('nacho is ready'), errorMessage: i18n('nacho is not ready'), }), // Expo + Metro bundler can take a while to come up on first start. gracePeriod: 120_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['nacho-setup'], }) // Appends the Subspaces chain (fetch → build → wallet ensure → subs-prover → // subs daemon) to any existing chain. `as any` because the chain's TS type // depends on prior IDs and we can't easily express the union here. const withSubspaces = (chain: any): any => { let c = chain .addOneshot('subspaces-dirs', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `set -eu; \ mkdir -p ${SUBSPACES_DATA_DIR} ${SUBSPACES_PROVER_DIR} ${SUBSPACES_REGISTRY_DIR}; \ echo "subspaces-dirs: ensured data/prover/registry dirs under ${SUBSPACES_DIR}."`, ], user: 'root', }, requires: [], }) .addDaemon('subs-registry', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `cd ${SUBSPACES_REGISTRY_DIR} && exec ${SUBSPACES_REGISTRY_BIN} --port ${SUBSPACES_REGISTRY_PORT}`, ], env: registryEnv, user: 'root', }, ready: { display: i18n('Subspaces Registry'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_REGISTRY_PORT, { successMessage: i18n('registry-server is ready'), errorMessage: i18n('registry-server is not ready'), }), gracePeriod: 60_000, }, requires: ['subspaces-dirs'], }) .addDaemon('subs', { subcontainer: subspacesSub, exec: { command: [SUBSPACES_SUBS_BIN], env: subsEnv, user: 'root', }, ready: { display: i18n('Subspaces Web UI'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_UI_PORT, { successMessage: i18n('subspaces UI is ready'), errorMessage: i18n('subspaces UI is not ready'), }), gracePeriod: 60_000, }, // Intentionally NOT contingent on subs-prover: the RISC Zero prover can // take a while to become ready, and subs only needs it on-demand (proof // generation), not at startup. subs reaches the prover via // SUBS_PROVER_ENDPOINT once the prover is up. requires: ['subspaces-dirs', 'spaced', 'subs-registry'], }) // subs-prover is opt-in and DISABLED BY DEFAULT (toggle via the "Enable // Subspaces Prover" action). Its interface (8888) stays registered either // way (see interfaces.ts), but the daemon only starts when enabled. It's // added LAST and depends on every other subspaces daemon; it runs lengthy // timing tests on boot and NOTHING depends on it, so its slow startup never // blocks anything else. if (enableSubsProver) { c = c.addDaemon('subs-prover', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `cd ${SUBSPACES_PROVER_DIR} && exec ${SUBSPACES_PROVER_BIN} --server --server-port ${SUBSPACES_PROVER_PORT}`, ], env: proverEnv, // Run as root: the horologger/subs image declares USER subs, but our // /data volume + subdirs are root-owned, so the non-root user can't // traverse/write them. Root sidesteps the ownership mismatch. user: 'root', }, ready: { display: i18n('Subspaces Prover'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_PROVER_PORT, { successMessage: i18n('subs-prover is ready'), errorMessage: i18n('subs-prover is not ready'), }), gracePeriod: 60_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['subspaces-dirs', 'subs-registry', 'subs'], }) } return c } let chain: any = sdk.Daemons.of(effects) .addOneshot('bashrc', { subcontainer: termSub, exec: { command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF' ${bashrc} SPACES_BASHRC_EOF`], user: 'root', }, requires: [], }) .addDaemon('spaced', { subcontainer: spacedSub, exec: { command: ['/root/.cargo/bin/spaced'], env: spacedEnv, }, ready: { display: i18n('Spaced RPC'), fn: () => sdk.healthCheck.checkPortListening(effects, spacedRpcPort, { successMessage: i18n('spaced RPC is ready'), errorMessage: i18n('spaced RPC is not ready'), }), gracePeriod: 120_000, }, requires: [], }) .addDaemon('web-terminal', { subcontainer: termSub, exec: { command: [ 'gotty', '--port', String(uiPort), '-c', `${APP_USER}:${APP_PASSWORD}`, '--permit-write', '--reconnect', '/bin/bash', ], env: spacedEnv, }, ready: { display: i18n('Web Interface'), fn: () => sdk.healthCheck.checkPortListening(effects, uiPort, { successMessage: i18n('The web terminal is ready'), errorMessage: i18n('The web terminal is not ready'), }), }, requires: ['bashrc'], }) .addHealthCheck('sync', { ready: { display: i18n('Spaced Sync'), fn: async () => { try { const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) const stderr = (probe.stderr ?? '').toString().trim() const stdoutText = (probe.stdout ?? '').toString() const stdoutTrimmed = stdoutText.trim() if (probe.exitCode !== 0) { return { result: 'failure', message: i18n('space-cli exited ${code}: ${error}', { code: String(probe.exitCode), error: (stderr || stdoutTrimmed || '').slice(0, 200), }), } } let parsed: { ready?: boolean progress?: number chain?: { blocks?: number; headers?: number } } try { parsed = JSON.parse(stdoutText) } catch { return { result: 'failure', message: i18n( 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', { stdout: (stdoutTrimmed || '').slice(0, 160), stderr: (stderr || '').slice(0, 160), }, ), } } const progress = Math.min( 100, Math.max(0, Math.round((parsed.progress ?? 0) * 100)), ) const blocks = parsed.chain?.blocks ?? 0 const headers = parsed.chain?.headers ?? 0 if (parsed.ready === true && progress >= 100) { return { result: 'success', message: i18n( 'spaced is fully synced (blocks ${blocks} / headers ${headers}).', { blocks: String(blocks), headers: String(headers) }, ), } } return { result: 'loading', message: i18n( 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).', { pct: String(progress), blocks: String(blocks), headers: String(headers), }, ), } } catch (e) { return { result: 'failure', message: i18n('Spaced Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e), }), } } }, gracePeriod: 30_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], }) chain = withCertrelay(chain) chain = withNacho(chain) if (enableSubspaces) chain = withSubspaces(chain) return chain })