Files
spaces-startos/startos/main.ts
T
spacesopsandCursor 2b0224a678
Build Service / BuildPackage (push) Canceled after 0s
0.3.0:0
Update to upstream Spaces 0.3.0 and refresh bundled images (subs v0.1.2c,
certrelay v0.2.8, nacho v1.0.0b). Add Create/Show Registry API Keys for
registry-server, remove Configure Subspaces and SUBS_PUBLISH_REQUIRE_FINALIZED,
and align README/instructions with the new tags.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 19:35:15 -04:00

638 lines
22 KiB
TypeScript

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}`,
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',
// examples/registry-server requires both at boot; they must differ.
REGISTRY_API_KEY: store.registryApiKey ?? '',
SUBSD_API_KEY: store.subsdApiKey ?? '',
}
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" '`,
// Quoted delimiter: banner copy uses backticks around CLI names; an unquoted
// <<EOF would run command substitution (including `monitor` on PATH).
'cat <<\'EOF\'',
'',
// Banner rendered via renderBanner() — auto-wraps any line that exceeds the
// 96-cell text budget and pads every emitted line to exactly 100 cells, so
// the box always renders cleanly in a monospaced terminal regardless of
// edits below.
...renderBanner('Spaces', [
'spaced is managed by StartOS — do not run it manually.',
'Use the `spaces` alias to call space-cli, e.g.',
' spaces getserverinfo',
'`fabric` resolves handles; `monitor` is on PATH (copy-only, not auto-started).',
'Docs: https://docs.spacesprotocol.org/',
]),
'',
'EOF',
].join('\n')
// Appends the always-on certrelay chain (setup oneshot + certrelay daemon) to
// any existing chain. certrelay serves cryptographic proofs binding handles to
// owner keys anchored to Bitcoin. The certrelay-setup oneshot copies `fabric`
// and `monitor` onto /data/bin for the gotty terminal (PATH includes /data/bin).
// Only the certrelay server is started as a daemon — monitor is a CLI file only.
const withCertrelay = (chain: any): any =>
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 => {
const registryApiKey = store.registryApiKey
const subsdApiKey = store.subsdApiKey
if (
!registryApiKey ||
!subsdApiKey ||
registryApiKey.length === 0 ||
subsdApiKey.length === 0 ||
registryApiKey === subsdApiKey
) {
// taskRegistryApiKeys surfaces Create Registry API Keys when Subspaces is
// on without valid keys; refuse to start the registry half-configured.
throw new Error(
'Registry API keys are not configured. Run Create Registry API Keys.',
)
}
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 || '<no output>').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 || '<empty>').slice(0, 160),
stderr: (stderr || '<empty>').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
})