Files
2026-08-25 16:54:57 -04:00

157 lines
5.6 KiB
TypeScript

import { mkdir, writeFile } from 'fs/promises'
import { storeJson } from './fileModels/storeJson'
import { i18n } from './i18n'
import { sdk } from './sdk'
import {
dataDir,
DEFAULT_OPERATOR_RELAY,
DEFAULT_PLATFORM_CALLBACK_HOST,
DEFAULT_PLATFORM_MODE,
SPACED_RPC_URL,
SPACED_WALLETLOAD_NAME,
spacesDataDir,
startOsHostnameFromUrl,
nodeExtraCaCertContainerPath,
nodeExtraCaCertVolumeSubpath,
SPACES_PACKAGE_ID,
uiPort,
} from './utils'
type SpacedAuth = { username: string; password: string }
export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting SpacesOps!'))
// Read with .const() so a store.json change (e.g. from Import Operator Key or
// Configure Platform) triggers an automatic service restart.
const store = await storeJson.read().const(effects)
if (
!store?.operatorSecretHex ||
!store?.operatorPublicHex ||
!store?.sessionSecret
) {
// taskOperatorKeys + taskSessionSecret seed these in init; if they aren't
// populated yet, init hasn't finished — let StartOS restart us.
throw new Error(
'SpacesOps store.json is not yet populated (operator keys / session secret missing).',
)
}
// Mount our own volume at /data AND the Spaces 'main' volume (read-only) at
// /spaces-data so we can read the spaced RPC credentials Spaces seeded there.
const mounts = sdk.Mounts.of()
.mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
})
.mountDependency({
dependencyId: 'spaces',
volumeId: 'main',
subpath: null,
mountpoint: spacesDataDir,
readonly: true,
type: 'directory',
})
const sub = sdk.SubContainer.of(
effects,
{ imageId: 'spacesops' },
mounts,
'spacesops-sub',
)
// setupMain runs in the StartOS runtime, NOT inside the container, so it
// cannot fs-read the mounted Spaces volume directly. Exec a read inside the
// subcontainer instead (mirrors how spaces-startos execs space-cli for its
// sync health check). Throw if the creds aren't there yet — StartOS restarts
// us until Spaces is installed and its store.json is seeded.
const probe = await sub.exec(['cat', `${spacesDataDir}/store.json`])
if (probe.exitCode !== 0) {
throw new Error(
`Spaces RPC creds not available yet: could not read ${spacesDataDir}/store.json (exit ${probe.exitCode}). Is the Spaces service installed?`,
)
}
let spacesStore: { spacedAuth?: SpacedAuth | null }
try {
spacesStore = JSON.parse((probe.stdout ?? '').toString())
} catch {
throw new Error(
'Spaces RPC creds not available yet: Spaces store.json is not valid JSON.',
)
}
const spacedAuth = spacesStore.spacedAuth
if (!spacedAuth || !spacedAuth.username || !spacedAuth.password) {
throw new Error(
'Spaces RPC creds not available yet: spacedAuth missing from Spaces store.json. Wait for the Spaces service init to finish.',
)
}
const env: Record<string, string> = {
// The app defaults to 127.0.0.1:3000, which the StartOS proxy cannot reach.
PLATFORM_HOST: '0.0.0.0',
PLATFORM_PORT: String(uiPort),
PLATFORM_DB_PATH: `${dataDir}/local.db`,
PLATFORM_MODE: store.platformMode ?? DEFAULT_PLATFORM_MODE,
PLATFORM_SESSION_SECRET: store.sessionSecret,
OPERATOR_SECRET_HEX: store.operatorSecretHex,
OPERATOR_PUBLIC_HEX: store.operatorPublicHex,
OPERATOR_RELAY: store.operatorRelay ?? DEFAULT_OPERATOR_RELAY,
SPACED_RPC_URL,
SPACED_RPC_USER: spacedAuth.username,
SPACED_RPC_PASSWORD: spacedAuth.password,
SPACED_WALLETLOAD_NAME,
}
// Optional integrations — only injected when configured (Configure Platform).
if (store.coingeckoApiKey) env.COINGECKO_API_KEY = store.coingeckoApiKey
if (store.coingeckoTokenCoins)
env.COINGECKO_TOKEN_COINS = store.coingeckoTokenCoins
if (store.subsUrl) env.SUBS_URI_VALUE = store.subsUrl
if (store.subsUser) env.SUBS_RPC_USER = store.subsUser
if (store.subsPassword) env.SUBS_RPC_PASSWORD = store.subsPassword
env.PLATFORM_CALLBACK_HOST =
store.platformCallbackHost ?? DEFAULT_PLATFORM_CALLBACK_HOST
// The image's setup-spacesops-env.sh defaults NODE_EXTRA_CA_CERTS to
// /app/certs/startos-local-root-ca.pem, a root CA baked in at build time from
// another box. Fetch this box's StartOS root CA and point the variable at it
// instead, so outbound HTTPS to *.startos succeeds (SUBS_URI host when
// configured, else Spaces).
const caHostname =
(store.subsUrl && startOsHostnameFromUrl(store.subsUrl)) ||
`${SPACES_PACKAGE_ID}.startos`
const certs = await sdk.getSslCertificate(effects, [caHostname]).const()
const [rootCa] = certs.slice(-1)
await mkdir(sdk.volumes.main.subpath('.startos'), { recursive: true })
await writeFile(
sdk.volumes.main.subpath(nodeExtraCaCertVolumeSubpath),
rootCa,
)
env.NODE_EXTRA_CA_CERTS = nodeExtraCaCertContainerPath
return sdk.Daemons.of(effects).addDaemon('spacesops', {
subcontainer: sub,
exec: {
// Keep the image entrypoint: it creates /data dirs, symlinks
// /app/data -> /data, and loads defaults for any UNSET vars before
// exec'ing the command.
command: ['/app/docker-entrypoint.sh', 'node', 'server.js'],
env,
cwd: '/app',
user: 'root',
},
ready: {
display: i18n('Web Interface'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('The web interface is ready'),
errorMessage: i18n('The web interface is not ready'),
}),
gracePeriod: 60_000,
},
requires: [],
})
})