Release v0.1.1:2 with certrelay, nacho, and prebuilt subspaces.
Build Service / BuildPackage (push) Has been cancelled

Remove the embedded explorer/indexer stack, switch subspaces to prebuilt images, add certrelay and nacho as always-on services, expose Spaces and Subs APIs, and wire optional HTTP basic auth for subs and subs-prover with the correct SUBS_BASIC_AUTH_PASSWORD env var.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 12:06:16 -04:00
co-authored by Cursor
parent 931becd274
commit ad64a47d42
30 changed files with 1364 additions and 831 deletions
+167
View File
@@ -0,0 +1,167 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import {
dataDir,
NACHO_DEFAULT_IGNORE_NAMES,
NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT,
NACHO_DIR,
} from '../utils'
const { InputSpec, Value } = sdk
const IGNORE_NAMES_PATH = `${NACHO_DIR}/ignore_names.txt`
const mainMount = sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
})
// Reads /data/nacho/ignore_names.txt off the main volume via a temp
// subcontainer. Returns null if the file is missing/empty/unreadable so the
// prefill falls back to the package default.
async function readIgnoreNamesFile(effects: any): Promise<string | null> {
try {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
mainMount,
'nacho-read-ignore-names',
(subc) =>
subc.exec(
[
'sh',
'-c',
`if [ -f ${IGNORE_NAMES_PATH} ]; then cat ${IGNORE_NAMES_PATH}; fi`,
],
{ user: 'root' },
),
)
if (res.exitCode !== 0) return null
const value = (res.stdout ?? '').toString().trim()
return value.length > 0 ? value : null
} catch {
return null
}
}
const inputSpec = InputSpec.of({
ignoreNames: Value.text({
name: i18n('Ignore Names'),
description: i18n(
'Comma-separated list of names for the nacho UI to ignore (EXPO_PUBLIC_IGNORE_NAMES).',
),
warning: null,
footnote: null,
default: NACHO_DEFAULT_IGNORE_NAMES,
required: true,
masked: false,
placeholder: 'fold,swifty',
minLength: 0,
maxLength: null,
}),
workshopPdfLinkText: Value.text({
name: i18n('Workshop PDF Link Text'),
description: i18n(
'Display text for the workshop PDF link in the nacho UI (EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT). Leave empty to suppress the label.',
),
warning: null,
footnote: null,
default: NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT,
required: false,
masked: false,
placeholder: 'Workshop PDF',
minLength: 0,
maxLength: null,
}),
})
export const configureNacho = sdk.Action.withInput(
// id
'configure-nacho',
// metadata
async ({ effects }) => ({
name: i18n('Configure Nacho'),
description: i18n(
'Set the user-tunable nacho settings. The Ignore Names list is written to /data/nacho/ignore_names.txt (read by nacho at runtime). The Workshop PDF link text is set via EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT. Saving restarts the service. To upload a new Workshop PDF, use the separate "Upload Support PDF" action.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — current values. Ignore Names is sourced from the file on disk
// (the source of truth nacho actually reads), with the default as a fallback.
async ({ effects }) => {
const [store, fileValue] = await Promise.all([
storeJson.read().once(),
readIgnoreNamesFile(effects),
])
return {
ignoreNames: fileValue ?? NACHO_DEFAULT_IGNORE_NAMES,
workshopPdfLinkText:
store?.nachoWorkshopPdfLinkText ??
NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT,
}
},
// run
async ({ effects, input }) => {
// Persist the workshop PDF link text in the store (so it stays an
// EXPO_PUBLIC_* env var) and write the ignore names to its file on the
// main volume. mkdir -p first in case /data/nacho doesn't yet exist
// (e.g., the user runs this action before nacho-setup has executed).
await storeJson.merge(effects, {
nachoWorkshopPdfLinkText: input.workshopPdfLinkText ?? '',
})
const ignoreNames = input.ignoreNames
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
mainMount,
'nacho-write-ignore-names',
(subc) =>
subc.exec(
[
'sh',
'-c',
`set -eu; mkdir -p ${NACHO_DIR}; cat > ${IGNORE_NAMES_PATH} && chmod 644 ${IGNORE_NAMES_PATH}`,
],
{ input: ignoreNames, user: 'root' },
),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not write ignore_names.txt: ${error}', {
error:
((res.stderr ?? '').toString() || `exit ${res.exitCode}`).slice(
0,
300,
),
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Nacho configuration saved. Ignore Names written to /data/nacho/ignore_names.txt; the service is restarting to apply the new values.',
),
result: null,
}
},
)
-37
View File
@@ -1,37 +0,0 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const disableExplorer = sdk.Action.withoutInput(
// id
'disable-explorer',
// metadata
async ({ effects }) => {
const current = await storeJson.read((s) => s.enableExplorer).once()
return {
name: i18n('Disable Embedded Explorer'),
description: i18n(
'Stop the embedded PostgreSQL + Go indexer. Indexed data on disk (/data/postgres, /data/explorer-indexer) is preserved and will be reused if the explorer is re-enabled later. Service restarts automatically.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: current === true ? 'enabled' : 'hidden',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { enableExplorer: false })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Embedded explorer disabled. The service is restarting in spaces-only mode (spaced + web terminal). Run "Enable Embedded Explorer" to turn it back on.',
),
result: null,
}
},
)
+37
View File
@@ -0,0 +1,37 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const disableSubsAuth = sdk.Action.withoutInput(
// id
'disable-subs-auth',
// metadata
async ({ effects }) => {
const enabled = await storeJson.read((s) => s.subsAuthEnabled).once()
return {
name: i18n('Disable Subspaces Auth'),
description: i18n(
'Turn off HTTP basic auth in front of the Subspaces Web UI / Subs API. Stored credentials are preserved so re-enabling does not generate new ones; use "Set Subspaces Auth Credentials" to rotate. Service restarts.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: enabled === true ? 'enabled' : 'hidden',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { subsAuthEnabled: false })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Subspaces Auth disabled. The service is restarting; subs will serve unauthenticated again on port 7777. Stored credentials are kept for the next enable.',
),
result: null,
}
},
)
+37
View File
@@ -0,0 +1,37 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const disableSubsProverAuth = sdk.Action.withoutInput(
// id
'disable-subs-prover-auth',
// metadata
async ({ effects }) => {
const enabled = await storeJson.read((s) => s.subsProverAuthEnabled).once()
return {
name: i18n('Disable Subspaces Prover Auth'),
description: i18n(
'Turn off HTTP basic auth in front of the Subspaces Prover. Stored credentials are preserved so re-enabling does not generate new ones; use "Set Subspaces Prover Auth Credentials" to rotate. Service restarts.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: enabled === true ? 'enabled' : 'hidden',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { subsProverAuthEnabled: false })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Subspaces Prover Auth disabled. The service is restarting; subs-prover will serve unauthenticated again on port 8888. Stored credentials are kept for the next enable.',
),
result: null,
}
},
)
-37
View File
@@ -1,37 +0,0 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const enableExplorer = sdk.Action.withoutInput(
// id
'enable-explorer',
// metadata
async ({ effects }) => {
const current = await storeJson.read((s) => s.enableExplorer).once()
return {
name: i18n('Enable Embedded Explorer'),
description: i18n(
'Turn on the embedded PostgreSQL + Go indexer. Spaces protocol data (blocks, transactions, spaces, rollouts) will be indexed locally for use by the future explorer web UI. Service restarts automatically.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: current === true ? 'hidden' : 'enabled',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { enableExplorer: true })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Embedded explorer enabled. The service is restarting; the indexer will begin fetching blocks shortly. First-time start can take several minutes for the Go build.',
),
result: null,
}
},
)
+53
View File
@@ -0,0 +1,53 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { randomPassword } from '../utils'
const DEFAULT_USERNAME = 'spaces'
export const enableSubsAuth = sdk.Action.withoutInput(
// id
'enable-subs-auth',
// metadata
async ({ effects }) => {
const enabled = await storeJson.read((s) => s.subsAuthEnabled).once()
return {
name: i18n('Enable Subspaces Auth'),
description: i18n(
'Turn on HTTP basic auth in front of the Subspaces Web UI and the Subs API (both on port 7777). If no credentials have been set yet, a random password is generated (username defaults to "spaces"). Use "Show Subspaces Auth Credentials" afterwards to retrieve them. Service restarts so subs picks up the auth env vars.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: enabled === true ? 'hidden' : 'enabled',
}
},
// run
async ({ effects }) => {
const existing = await storeJson.read((s) => s.subsAuth).once()
const subsAuth = existing ?? {
username: DEFAULT_USERNAME,
password: randomPassword(),
}
await storeJson.merge(effects, {
subsAuth,
subsAuthEnabled: true,
})
return {
version: '1',
title: i18n('Success'),
message: existing
? i18n(
'Subspaces Auth enabled with the existing stored credentials. The service is restarting; use "Show Subspaces Auth Credentials" to view them.',
)
: i18n(
'Subspaces Auth enabled and a fresh credential pair was generated. The service is restarting; use "Show Subspaces Auth Credentials" to view them.',
),
result: null,
}
},
)
+53
View File
@@ -0,0 +1,53 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { randomPassword } from '../utils'
const DEFAULT_USERNAME = 'spaces'
export const enableSubsProverAuth = sdk.Action.withoutInput(
// id
'enable-subs-prover-auth',
// metadata
async ({ effects }) => {
const enabled = await storeJson.read((s) => s.subsProverAuthEnabled).once()
return {
name: i18n('Enable Subspaces Prover Auth'),
description: i18n(
'Turn on HTTP basic auth in front of the Subspaces Prover (port 8888). If no credentials have been set yet, a random password is generated (username defaults to "spaces"). Use "Show Subspaces Prover Auth Credentials" afterwards to retrieve them. Service restarts so subs-prover picks up the auth env vars.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: enabled === true ? 'hidden' : 'enabled',
}
},
// run
async ({ effects }) => {
const existing = await storeJson.read((s) => s.subsProverAuth).once()
const subsProverAuth = existing ?? {
username: DEFAULT_USERNAME,
password: randomPassword(),
}
await storeJson.merge(effects, {
subsProverAuth,
subsProverAuthEnabled: true,
})
return {
version: '1',
title: i18n('Success'),
message: existing
? i18n(
'Subspaces Prover Auth enabled with the existing stored credentials. The service is restarting; use "Show Subspaces Prover Auth Credentials" to view them.',
)
: i18n(
'Subspaces Prover Auth enabled and a fresh credential pair was generated. The service is restarting; use "Show Subspaces Prover Auth Credentials" to view them.',
),
result: null,
}
},
)
+22 -12
View File
@@ -1,41 +1,51 @@
import { sdk } from '../sdk'
import { configureCertrelay } from './configureCertrelay'
import { disableExplorer } from './disableExplorer'
import { configureNacho } from './configureNacho'
import { disableSubsAuth } from './disableSubsAuth'
import { disableSubsProverAuth } from './disableSubsProverAuth'
import { disableSubspaces } from './disableSubspaces'
import { enableExplorer } from './enableExplorer'
import { enableSubsAuth } from './enableSubsAuth'
import { enableSubsProverAuth } from './enableSubsProverAuth'
import { enableSubspaces } from './enableSubspaces'
import { exportWallet } from './exportWallet'
import { importWallet } from './importWallet'
import { resetDbState } from './resetDbState'
import { resetExplorerState } from './resetExplorerState'
import { resetIndexerState } from './resetIndexerState'
import { resetPassword } from './resetPassword'
import { resetSpacedState } from './resetSpacedState'
import { resetSubspacesState } from './resetSubspacesState'
import { setBitcoinRpc } from './setBitcoinRpc'
import { setSubsCredentials } from './setSubsCredentials'
import { setSubsProver } from './setSubsProver'
import { setSubsProverCredentials } from './setSubsProverCredentials'
import { showCredentials } from './showCredentials'
import { showDbCredentials } from './showDbCredentials'
import { showPassword } from './showPassword'
import { showSpacedCredentials } from './showSpacedCredentials'
import { showSubsCredentials } from './showSubsCredentials'
import { showSubsProverCredentials } from './showSubsProverCredentials'
import { syncStatus } from './syncStatus'
import { uploadSupportPdf } from './uploadSupportPdf'
export const actions = sdk.Actions.of()
.addAction(resetPassword)
.addAction(showCredentials)
.addAction(showPassword)
.addAction(setBitcoinRpc)
.addAction(showSpacedCredentials)
.addAction(syncStatus)
.addAction(resetSpacedState)
.addAction(exportWallet)
.addAction(importWallet)
.addAction(enableExplorer)
.addAction(disableExplorer)
.addAction(showDbCredentials)
.addAction(resetDbState)
.addAction(resetIndexerState)
.addAction(resetExplorerState)
.addAction(enableSubspaces)
.addAction(disableSubspaces)
.addAction(setSubsProver)
.addAction(enableSubsAuth)
.addAction(disableSubsAuth)
.addAction(showSubsCredentials)
.addAction(setSubsCredentials)
.addAction(enableSubsProverAuth)
.addAction(disableSubsProverAuth)
.addAction(showSubsProverCredentials)
.addAction(setSubsProverCredentials)
.addAction(resetSubspacesState)
.addAction(configureCertrelay)
.addAction(configureNacho)
.addAction(uploadSupportPdf)
-58
View File
@@ -1,58 +0,0 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, pgDataDir } from '../utils'
export const resetDbState = sdk.Action.withoutInput(
// id
'reset-db-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Database State'),
description: i18n(
'Wipe /data/postgres so PostgreSQL re-initializes from scratch.',
),
warning: i18n(
'This deletes all PostgreSQL data on disk. The next start will re-create an empty database. store.json (passwords + RPC credentials) is preserved.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-db',
(subc) => subc.exec(['rm', '-rf', pgDataDir], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe PostgreSQL state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'PostgreSQL data has been wiped. Start (or restart) the service to re-initialize the database.',
),
result: null,
}
},
)
-60
View File
@@ -1,60 +0,0 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, EXPLORER_DIR } from '../utils'
export const resetExplorerState = sdk.Action.withoutInput(
// id
'reset-explorer-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Explorer UI State'),
description: i18n(
'Wipe /data/explorer-ui so the next start re-fetches the explorer source and rebuilds it from scratch.',
),
warning: i18n(
'This deletes the cached explorer source and the built SvelteKit bundle. The next start will need internet access to re-download from GitHub and to fetch npm dependencies. PostgreSQL data is preserved.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
// TODO(prebuilt-explorer): switch back to the explorer image once it
// exists. Any image with `rm` works for the wipe; `spaces` is always present.
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-explorer',
(subc) => subc.exec(['rm', '-rf', EXPLORER_DIR], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe explorer state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Explorer cache has been wiped. Start (or restart) the service to re-fetch and rebuild from GitHub.',
),
result: null,
}
},
)
-58
View File
@@ -1,58 +0,0 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, INDEXER_DIR } from '../utils'
export const resetIndexerState = sdk.Action.withoutInput(
// id
'reset-indexer-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Indexer State'),
description: i18n(
'Wipe /data/explorer-indexer so the next start re-fetches the indexer source and rebuilds the sync + goose binaries.',
),
warning: i18n(
'This deletes the cached indexer source and compiled binaries. The next start will need internet access to re-download from GitHub and to fetch Go modules. PostgreSQL data (the indexed blocks themselves) is preserved — use Reset Database State if you also want to clear that.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-indexer',
(subc) => subc.exec(['rm', '-rf', INDEXER_DIR], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe indexer state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Indexer cache has been wiped. Start (or restart) the service to re-fetch and rebuild from GitHub.',
),
result: null,
}
},
)
+91
View File
@@ -0,0 +1,91 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { randomPassword } from '../utils'
const { InputSpec, Value } = sdk
const DEFAULT_USERNAME = 'spaces'
const inputSpec = InputSpec.of({
username: Value.text({
name: i18n('Username'),
description: i18n(
'Username for HTTP basic auth in front of subs (SUBS_BASIC_AUTH_USER).',
),
warning: null,
footnote: null,
default: DEFAULT_USERNAME,
required: true,
masked: false,
placeholder: 'spaces',
minLength: 1,
maxLength: null,
}),
password: Value.text({
name: i18n('Password'),
description: i18n(
'Password for HTTP basic auth in front of subs (SUBS_BASIC_AUTH_PASSWORD). Leave blank to auto-generate a random one.',
),
warning: null,
footnote: null,
default: null,
required: false,
masked: true,
placeholder: '(leave blank to auto-generate)',
minLength: 0,
maxLength: null,
}),
})
export const setSubsCredentials = sdk.Action.withInput(
// id
'set-subs-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Set Subspaces Auth Credentials'),
description: i18n(
'Set or rotate the HTTP basic auth credentials enforced in front of the Subspaces Web UI and Subs API. Auth is enabled automatically. Saving restarts the service.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — current username, never the password (the user explicitly types
// a new password or leaves blank to auto-generate)
async ({ effects }) => {
const subsAuth = await storeJson.read((s) => s.subsAuth).once()
return {
username: subsAuth?.username ?? DEFAULT_USERNAME,
password: null,
}
},
// run
async ({ effects, input }) => {
const password =
input.password && input.password.length > 0
? input.password
: randomPassword()
await storeJson.merge(effects, {
subsAuth: { username: input.username, password },
subsAuthEnabled: true,
})
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Subspaces Auth credentials saved. The service is restarting so subs picks up the new credentials.',
),
result: null,
}
},
)
@@ -0,0 +1,96 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { randomPassword } from '../utils'
const { InputSpec, Value } = sdk
const DEFAULT_USERNAME = 'spaces'
const inputSpec = InputSpec.of({
username: Value.text({
name: i18n('Username'),
description: i18n(
'Username for HTTP basic auth in front of subs-prover (SUBS_PROVER_BASIC_AUTH_USER).',
),
warning: null,
footnote: null,
default: DEFAULT_USERNAME,
required: true,
masked: false,
placeholder: 'spaces',
minLength: 1,
maxLength: null,
}),
password: Value.text({
name: i18n('Password'),
description: i18n(
'Password for HTTP basic auth in front of subs-prover (SUBS_PROVER_BASIC_AUTH_PASSWORD). Leave blank to auto-generate a random one.',
),
warning: null,
footnote: null,
default: null,
required: false,
masked: true,
placeholder: '(leave blank to auto-generate)',
minLength: 0,
maxLength: null,
}),
})
export const setSubsProverCredentials = sdk.Action.withInput(
// id
'set-subs-prover-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Set Subspaces Prover Auth Credentials'),
description: i18n(
'Set or rotate the HTTP basic auth credentials enforced in front of the Subspaces Prover. Saving restarts the service if auth is currently enabled.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — current username, never the password (the user explicitly types
// a new password or leaves blank to auto-generate)
async ({ effects }) => {
const subsProverAuth = await storeJson.read((s) => s.subsProverAuth).once()
return {
username: subsProverAuth?.username ?? DEFAULT_USERNAME,
password: null,
}
},
// run
async ({ effects, input }) => {
const password =
input.password && input.password.length > 0
? input.password
: randomPassword()
await storeJson.merge(effects, {
subsProverAuth: { username: input.username, password },
})
const enabled = await storeJson.read((s) => s.subsProverAuthEnabled).once()
return {
version: '1',
title: i18n('Success'),
message: enabled
? i18n(
'Subspaces Prover Auth credentials saved. The service is restarting so subs-prover picks up the new credentials.',
)
: i18n(
'Subspaces Prover Auth credentials saved. Auth is currently DISABLED — enable it with "Enable Subspaces Prover Auth" to enforce these credentials.',
),
result: null,
}
},
)
@@ -1,17 +1,17 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { POSTGRES_PORT } from '../utils'
import { spacedRpcPort } from '../utils'
export const showDbCredentials = sdk.Action.withoutInput(
export const showSpacedCredentials = sdk.Action.withoutInput(
// id
'show-db-credentials',
'show-spaced-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Database Credentials'),
name: i18n('Show Spaces API Credentials'),
description: i18n(
'Display the PostgreSQL username, password, database, and connection URL.',
'Display the spaced RPC username and password (SPACED_RPC_USER / SPACED_RPC_PASSWORD) used to authenticate against the Spaces API.',
),
warning: null,
allowedStatuses: 'any',
@@ -21,26 +21,25 @@ export const showDbCredentials = sdk.Action.withoutInput(
// run
async ({ effects }) => {
const dbAuth = await storeJson.read((s) => s.dbAuth).once()
const username = dbAuth?.username ?? ''
const password = dbAuth?.password ?? ''
const database = dbAuth?.database ?? ''
const url = dbAuth
? `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`
const spacedAuth = await storeJson.read((s) => s.spacedAuth).once()
const username = spacedAuth?.username ?? ''
const password = spacedAuth?.password ?? ''
const url = spacedAuth
? `http://${spacedAuth.username}:${spacedAuth.password}@127.0.0.1:${spacedRpcPort}`
: ''
return {
version: '1',
title: i18n('Show Database Credentials'),
title: i18n('Show Spaces API Credentials'),
message: i18n(
'Use these credentials to connect to the Spaces PostgreSQL database (loopback only inside the container).',
'Use these credentials to authenticate against the spaced JSON-RPC (the Spaces API). The connection URL shown uses the loopback address; for external access, substitute the host StartOS exposes for the Spaces API interface.',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
name: i18n('SPACED_RPC_USER'),
description: null,
value: username,
masked: false,
@@ -49,22 +48,13 @@ export const showDbCredentials = sdk.Action.withoutInput(
},
{
type: 'single',
name: i18n('Password'),
name: i18n('SPACED_RPC_PASSWORD'),
description: null,
value: password,
masked: true,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Database'),
description: null,
value: database,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Connection URL'),
+79
View File
@@ -0,0 +1,79 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { SUBSPACES_UI_PORT } from '../utils'
export const showSubsCredentials = sdk.Action.withoutInput(
// id
'show-subs-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Subspaces Auth Credentials'),
description: i18n(
'Display the HTTP basic auth username and password used in front of the Subspaces Web UI and Subs API. Returns blanks if no credentials have been set yet.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const [subsAuth, enabled] = await Promise.all([
storeJson.read((s) => s.subsAuth).once(),
storeJson.read((s) => s.subsAuthEnabled).once(),
])
const username = subsAuth?.username ?? ''
const password = subsAuth?.password ?? ''
const status =
enabled === true
? i18n('Auth is ENABLED — these credentials are enforced.')
: i18n(
'Auth is DISABLED — subs is serving unauthenticated. These credentials will take effect when enabled.',
)
const url = subsAuth
? `http://${subsAuth.username}:${subsAuth.password}@127.0.0.1:${SUBSPACES_UI_PORT}`
: ''
return {
version: '1',
title: i18n('Show Subspaces Auth Credentials'),
message: status,
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('SUBS_BASIC_AUTH_USER'),
description: null,
value: username,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('SUBS_BASIC_AUTH_PASSWORD'),
description: null,
value: password,
masked: true,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Connection URL'),
description: null,
value: url,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)
@@ -0,0 +1,79 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { SUBSPACES_PROVER_PORT } from '../utils'
export const showSubsProverCredentials = sdk.Action.withoutInput(
// id
'show-subs-prover-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Subspaces Prover Auth Credentials'),
description: i18n(
'Display the HTTP basic auth username and password used in front of the Subspaces Prover. Returns blanks if no credentials have been set yet.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const [subsProverAuth, enabled] = await Promise.all([
storeJson.read((s) => s.subsProverAuth).once(),
storeJson.read((s) => s.subsProverAuthEnabled).once(),
])
const username = subsProverAuth?.username ?? ''
const password = subsProverAuth?.password ?? ''
const status =
enabled === true
? i18n('Auth is ENABLED — these credentials are enforced.')
: i18n(
'Auth is DISABLED — subs-prover is serving unauthenticated. These credentials will take effect when enabled.',
)
const url = subsProverAuth
? `http://${subsProverAuth.username}:${subsProverAuth.password}@127.0.0.1:${SUBSPACES_PROVER_PORT}`
: ''
return {
version: '1',
title: i18n('Show Subspaces Prover Auth Credentials'),
message: status,
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('SUBS_PROVER_BASIC_AUTH_USER'),
description: null,
value: username,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('SUBS_PROVER_BASIC_AUTH_PASSWORD'),
description: null,
value: password,
masked: true,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Connection URL'),
description: null,
value: url,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)
+108
View File
@@ -0,0 +1,108 @@
import { readFile } from 'fs/promises'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir } from '../utils'
const { InputSpec, Value } = sdk
// Where to drop the uploaded PDF on the main volume. nacho (and any other
// daemon that mounts /data) can read it from this path.
const SUPPORT_PDF_PATH = `${dataDir}/support.pdf`
const inputSpec = InputSpec.of({
// `required: true` so the form refuses to submit without a file selected —
// works around the StartOS quirk where an empty file picker submits `{}`
// (which Value.file's nullable parser rejects).
pdf: Value.file({
name: i18n('Workshop PDF'),
description: i18n(
'PDF to write to /data/support.pdf (overwrites any existing file).',
),
warning: null,
extensions: ['.pdf'],
required: true,
}),
})
export const uploadSupportPdf = sdk.Action.withInput(
// id
'upload-support-pdf',
// metadata
async ({ effects }) => ({
name: i18n('Upload Support PDF'),
description: i18n(
'Upload a PDF that replaces /data/support.pdf on the main volume (overwrites if it exists). Used by nacho as the workshop PDF.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — file pickers can't be prefilled
async ({ effects }) => {},
// run
async ({ effects, input }) => {
try {
const pdfBuffer = await readFile(input.pdf.path)
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'upload-support-pdf',
(subc) =>
subc.exec(
[
'sh',
'-c',
`cat > ${SUPPORT_PDF_PATH} && chmod 644 ${SUPPORT_PDF_PATH}`,
],
{ input: pdfBuffer, user: 'root' },
),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not write support PDF: ${error}', {
error:
((res.stderr ?? '').toString() ||
`exit ${res.exitCode}`).slice(0, 300),
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Workshop PDF written to /data/support.pdf (${size} bytes).',
{ size: String(input.pdf.commitment.size) },
),
result: null,
}
} catch (e) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not write support PDF: ${error}', {
error: (e as Error)?.message ?? String(e),
}),
result: null,
}
}
},
)
+17 -9
View File
@@ -10,14 +10,6 @@ const shape = z.object({
})
.nullable()
.catch(null),
dbAuth: z
.object({
username: z.string(),
password: z.string(),
database: z.string(),
})
.nullable()
.catch(null),
spacedAuth: z
.object({
username: z.string(),
@@ -25,12 +17,28 @@ const shape = z.object({
})
.nullable()
.catch(null),
enableExplorer: z.boolean().nullable().catch(null),
enableSubspaces: z.boolean().nullable().catch(null),
enableSubsProver: z.boolean().nullable().catch(null),
subsAuth: z
.object({
username: z.string(),
password: z.string(),
})
.nullable()
.catch(null),
subsAuthEnabled: z.boolean().nullable().catch(null),
subsProverAuth: z
.object({
username: z.string(),
password: z.string(),
})
.nullable()
.catch(null),
subsProverAuthEnabled: z.boolean().nullable().catch(null),
certrelaySelfUrl: z.string().nullable().catch(null),
certrelayBootstrap: z.boolean().nullable().catch(null),
certrelayHealthcheckHandle: z.string().nullable().catch(null),
nachoWorkshopPdfLinkText: z.string().nullable().catch(null),
})
export const storeJson = FileHelper.json(
+91
View File
@@ -144,6 +144,37 @@ const dict = {
'Spaces API': 143,
'JSON-RPC API served by the spaced daemon. Authenticated with the spaced RPC credentials (SPACED_RPC_USER / SPACED_RPC_PASSWORD from store.spacedAuth). spaced binds 0.0.0.0 so external processes can reach this endpoint.':
144,
'Show Spaces API Credentials': 145,
'Display the spaced RPC username and password (SPACED_RPC_USER / SPACED_RPC_PASSWORD) used to authenticate against the Spaces API.':
146,
'Use these credentials to authenticate against the spaced JSON-RPC (the Spaces API). The connection URL shown uses the loopback address; for external access, substitute the host StartOS exposes for the Spaces API interface.':
147,
SPACED_RPC_USER: 148,
SPACED_RPC_PASSWORD: 149,
Nacho: 150,
'nacho is ready': 151,
'nacho is not ready': 152,
'Nacho Expo dev server. Configure the EXPO_PUBLIC_IGNORE_NAMES list via the "Configure Nacho" action. EXPO_PUBLIC_API_BASE_URL is derived dynamically from the Subs API StartOS interface (the .local URL is preferred).':
153,
'Configure Nacho': 154,
'Set the user-tunable nacho settings. The Ignore Names list is written to /data/nacho/ignore_names.txt (read by nacho at runtime). The Workshop PDF link text is set via EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT. Saving restarts the service. To upload a new Workshop PDF, use the separate "Upload Support PDF" action.':
155,
'Ignore Names': 156,
'Comma-separated list of names for the nacho UI to ignore (EXPO_PUBLIC_IGNORE_NAMES).':
157,
'Nacho configuration saved. Ignore Names written to /data/nacho/ignore_names.txt; the service is restarting to apply the new values.':
158,
'Could not write ignore_names.txt: ${error}': 167,
'Workshop PDF Link Text': 159,
'Display text for the workshop PDF link in the nacho UI (EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT). Leave empty to suppress the label.':
160,
'Workshop PDF': 161,
'PDF to write to /data/support.pdf (overwrites any existing file).': 162,
'Upload Support PDF': 163,
'Upload a PDF that replaces /data/support.pdf on the main volume (overwrites if it exists). Used by nacho as the workshop PDF.':
164,
'Could not write support PDF: ${error}': 165,
'Workshop PDF written to /data/support.pdf (${size} bytes).': 166,
// interfaces.ts
'Space-CLI Web UI': 12,
@@ -198,6 +229,66 @@ const dict = {
'Could not parse wallet JSON: ${error}': 49,
'Wallet imported and loaded.': 50,
'Could not import wallet: ${error}': 51,
'Enable Subspaces Auth': 168,
'Turn on HTTP basic auth in front of the Subspaces Web UI and the Subs API (both on port 7777). If no credentials have been set yet, a random password is generated (username defaults to "spaces"). Use "Show Subspaces Auth Credentials" afterwards to retrieve them. Service restarts so subs picks up the auth env vars.':
169,
'Subspaces Auth enabled with the existing stored credentials. The service is restarting; use "Show Subspaces Auth Credentials" to view them.':
170,
'Subspaces Auth enabled and a fresh credential pair was generated. The service is restarting; use "Show Subspaces Auth Credentials" to view them.':
171,
'Disable Subspaces Auth': 172,
'Turn off HTTP basic auth in front of the Subspaces Web UI / Subs API. Stored credentials are preserved so re-enabling does not generate new ones; use "Set Subspaces Auth Credentials" to rotate. Service restarts.':
173,
'Subspaces Auth disabled. The service is restarting; subs will serve unauthenticated again on port 7777. Stored credentials are kept for the next enable.':
174,
'Show Subspaces Auth Credentials': 175,
'Display the HTTP basic auth username and password used in front of the Subspaces Web UI and Subs API. Returns blanks if no credentials have been set yet.':
176,
'Auth is ENABLED — these credentials are enforced.': 177,
'Auth is DISABLED — subs is serving unauthenticated. These credentials will take effect when enabled.':
178,
SUBS_BASIC_AUTH_USER: 179,
SUBS_BASIC_AUTH_PASSWORD: 180,
'Set Subspaces Auth Credentials': 181,
'Username for HTTP basic auth in front of subs (SUBS_BASIC_AUTH_USER).': 183,
'Password for HTTP basic auth in front of subs (SUBS_BASIC_AUTH_PASSWORD). Leave blank to auto-generate a random one.':
185,
'Set or rotate the HTTP basic auth credentials enforced in front of the Subspaces Web UI and Subs API. Auth is enabled automatically. Saving restarts the service.':
186,
'Subspaces Auth credentials saved. The service is restarting so subs picks up the new credentials.':
187,
'Subspaces Auth credentials saved. Auth is currently DISABLED — enable it with "Enable Subspaces Auth" to enforce these credentials.':
188,
'Enable Subspaces Prover Auth': 189,
'Turn on HTTP basic auth in front of the Subspaces Prover (port 8888). If no credentials have been set yet, a random password is generated (username defaults to "spaces"). Use "Show Subspaces Prover Auth Credentials" afterwards to retrieve them. Service restarts so subs-prover picks up the auth env vars.':
190,
'Subspaces Prover Auth enabled with the existing stored credentials. The service is restarting; use "Show Subspaces Prover Auth Credentials" to view them.':
191,
'Subspaces Prover Auth enabled and a fresh credential pair was generated. The service is restarting; use "Show Subspaces Prover Auth Credentials" to view them.':
192,
'Disable Subspaces Prover Auth': 193,
'Turn off HTTP basic auth in front of the Subspaces Prover. Stored credentials are preserved so re-enabling does not generate new ones; use "Set Subspaces Prover Auth Credentials" to rotate. Service restarts.':
194,
'Subspaces Prover Auth disabled. The service is restarting; subs-prover will serve unauthenticated again on port 8888. Stored credentials are kept for the next enable.':
195,
'Show Subspaces Prover Auth Credentials': 196,
'Display the HTTP basic auth username and password used in front of the Subspaces Prover. Returns blanks if no credentials have been set yet.':
197,
'Auth is DISABLED — subs-prover is serving unauthenticated. These credentials will take effect when enabled.':
198,
SUBS_PROVER_BASIC_AUTH_USER: 199,
SUBS_PROVER_BASIC_AUTH_PASSWORD: 200,
'Set Subspaces Prover Auth Credentials': 201,
'Username for HTTP basic auth in front of subs-prover (SUBS_PROVER_BASIC_AUTH_USER).':
202,
'Password for HTTP basic auth in front of subs-prover (SUBS_PROVER_BASIC_AUTH_PASSWORD). Leave blank to auto-generate a random one.':
203,
'Set or rotate the HTTP basic auth credentials enforced in front of the Subspaces Prover. Saving restarts the service if auth is currently enabled.':
204,
'Subspaces Prover Auth credentials saved. The service is restarting so subs-prover picks up the new credentials.':
205,
'Subspaces Prover Auth credentials saved. Auth is currently DISABLED — enable it with "Enable Subspaces Prover Auth" to enforce these credentials.':
206,
} as const
/**
+2 -4
View File
@@ -6,9 +6,8 @@ import { sdk } from '../sdk'
import { versionGraph } from '../versions'
import { taskBtcAuth } from './taskBtcAuth'
import { taskSeedCertrelay } from './taskSeedCertrelay'
import { taskSeedDb } from './taskSeedDb'
import { taskSeedEnableExplorer } from './taskSeedEnableExplorer'
import { taskSeedEnableSubspaces } from './taskSeedEnableSubspaces'
import { taskSeedNacho } from './taskSeedNacho'
import { taskSeedSpacedAuth } from './taskSeedSpacedAuth'
import { taskSetPassword } from './taskSetPassword'
@@ -19,11 +18,10 @@ export const init = sdk.setupInit(
setDependencies,
actions,
taskBtcAuth,
taskSeedDb,
taskSeedSpacedAuth,
taskSeedEnableExplorer,
taskSeedEnableSubspaces,
taskSeedCertrelay,
taskSeedNacho,
taskSetPassword,
)
-20
View File
@@ -1,20 +0,0 @@
import { storeJson } from '../fileModels/storeJson'
import { sdk } from '../sdk'
import { POSTGRES_DB, POSTGRES_USER, randomPassword } from '../utils'
export const taskSeedDb = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.dbAuth).once()
if (existing) return
await storeJson.merge(
effects,
{
dbAuth: {
username: POSTGRES_USER,
password: randomPassword(),
database: POSTGRES_DB,
},
},
{ allowWriteAfterConst: true },
)
})
-13
View File
@@ -1,13 +0,0 @@
import { storeJson } from '../fileModels/storeJson'
import { sdk } from '../sdk'
export const taskSeedEnableExplorer = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.enableExplorer).once()
if (existing !== null && existing !== undefined) return
await storeJson.merge(
effects,
{ enableExplorer: false },
{ allowWriteAfterConst: true },
)
})
+21
View File
@@ -0,0 +1,21 @@
import { storeJson } from '../fileModels/storeJson'
import { sdk } from '../sdk'
import { NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT } from '../utils'
// Note: the nacho Ignore Names list is NOT seeded here. It lives in
// /data/nacho/ignore_names.txt and is created with the default on first
// daemon start by the nacho-setup oneshot in main.ts.
export const taskSeedNacho = sdk.setupOnInit(async (effects) => {
const store = await storeJson.read().once()
if (
store?.nachoWorkshopPdfLinkText === null ||
store?.nachoWorkshopPdfLinkText === undefined
) {
await storeJson.merge(
effects,
{ nachoWorkshopPdfLinkText: NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT },
{ allowWriteAfterConst: true },
)
}
})
+22 -22
View File
@@ -2,7 +2,7 @@ import { i18n } from './i18n'
import { sdk } from './sdk'
import {
CERTRELAY_PORT,
EXPLORER_PORT,
NACHO_PORT,
spacedRpcPort,
SUBSPACES_PROVER_PORT,
SUBSPACES_REGISTRY_PORT,
@@ -31,26 +31,6 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const uiReceipt = await uiMultiOrigin.export([ui])
const explorerMulti = sdk.MultiHost.of(effects, 'explorer-multi')
const explorerMultiOrigin = await explorerMulti.bindPort(EXPLORER_PORT, {
protocol: 'http',
})
const explorer = sdk.createInterface(effects, {
name: i18n('Explorer Web UI'),
id: 'explorer',
description: i18n(
'SvelteKit explorer for the Spaces protocol. Reads from the embedded PostgreSQL populated by the indexer. Only useful while the embedded explorer is enabled.',
),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const explorerReceipt = await explorerMultiOrigin.export([explorer])
const subspacesMulti = sdk.MultiHost.of(effects, 'subspaces-multi')
const subspacesMultiOrigin = await subspacesMulti.bindPort(SUBSPACES_UI_PORT, {
protocol: 'http',
@@ -172,13 +152,33 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const spacedApiReceipt = await spacedMultiOrigin.export([spacedApi])
const nachoMulti = sdk.MultiHost.of(effects, 'nacho-multi')
const nachoMultiOrigin = await nachoMulti.bindPort(NACHO_PORT, {
protocol: 'http',
})
const nacho = sdk.createInterface(effects, {
name: i18n('Nacho'),
id: 'nacho',
description: i18n(
'Nacho Expo dev server. Configure the EXPO_PUBLIC_IGNORE_NAMES list via the "Configure Nacho" action. EXPO_PUBLIC_API_BASE_URL is derived dynamically from the Subs API StartOS interface (the .local URL is preferred).',
),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const nachoReceipt = await nachoMultiOrigin.export([nacho])
return [
uiReceipt,
explorerReceipt,
subspacesReceipt,
proverReceipt,
registryReceipt,
certrelayReceipt,
spacedApiReceipt,
nachoReceipt,
]
})
+137 -266
View File
@@ -19,8 +19,12 @@ import {
CERTRELAY_PORT,
CERTRELAY_REMOTE_IP_HEADER,
dataDir,
pgDataDir,
POSTGRES_PORT,
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,
@@ -44,17 +48,15 @@ export const main = sdk.setupMain(async ({ effects }) => {
if (
!store?.password ||
!store?.btcAuth ||
!store?.dbAuth ||
!store?.spacedAuth
) {
// taskSetPassword + taskBtcAuth + taskSeedDb + taskSeedSpacedAuth all seed
// these in init; if they aren't populated yet, init hasn't finished — let
// StartOS restart us.
// 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, dbAuth, spacedAuth } = store
const enableExplorer = store.enableExplorer === true
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
@@ -81,7 +83,6 @@ export const main = sdk.setupMain(async ({ effects }) => {
BTC_RPC_PASSWORD: btcAuth.password,
APP_USER,
APP_PASSWORD,
DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`,
}
// Certrelay config — user-tunable bits live in store.json (set via the
@@ -92,6 +93,8 @@ export const main = sdk.setupMain(async ({ effects }) => {
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',
@@ -114,36 +117,6 @@ export const main = sdk.setupMain(async ({ effects }) => {
'terminal-sub',
)
const postgresSub = await sdk.SubContainer.of(
effects,
{ imageId: 'postgres' },
mounts,
'postgres-sub',
)
const postgresEnv = {
POSTGRES_USER: dbAuth.username,
POSTGRES_PASSWORD: dbAuth.password,
POSTGRES_DB: dbAuth.database,
PGDATA: pgDataDir,
}
// TODO(prebuilt-indexer / prebuilt-explorer): the Go indexer and SvelteKit
// explorer used to be built from source in dedicated builder images
// (golang:1.23-alpine, node:20-alpine). Those images + their build chains
// were removed to shrink the .s9pk and cut first-enable latency. When the
// prebuilt images exist, re-add their SubContainers + env here, e.g.:
// const indexerSub = await sdk.SubContainer.of(
// effects, { imageId: '<prebuilt-indexer-image>' }, mounts, 'indexer-sub')
// const explorerSub = await sdk.SubContainer.of(
// effects, { imageId: '<prebuilt-explorer-image>' }, mounts, 'explorer-sub')
// const indexerEnv = { POSTGRES_URI, BITCOIN_NODE_URI/USER/PASSWORD,
// SPACES_NODE_URI, RPC_USER/PASSWORD, ACTIVATION_BLOCK_HEIGHT,
// FAST_SYNC_BLOCK_HEIGHT, UPDATE_DB_INTERVAL, MEMPOOL_CHUNK_SIZE }
// const explorerEnv = { DB_URL (?sslmode=disable), PUBLIC_BTC_NETWORK, PORT }
// Also add the image ids to startos/manifest/index.ts and re-add the daemon
// definitions in the explorer branch below. See git history for prior shapes.
const subspacesSub = await sdk.SubContainer.of(
effects,
{ imageId: 'subspaces' },
@@ -151,6 +124,19 @@ export const main = sdk.setupMain(async ({ effects }) => {
'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,
@@ -163,13 +149,28 @@ export const main = sdk.setupMain(async ({ effects }) => {
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 = {
@@ -201,19 +202,63 @@ export const main = sdk.setupMain(async ({ effects }) => {
RUST_LOG: 'info',
}
const nachoSub = await 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.
// `.const(effects)` makes this reactive: if the interface address changes
// (clearnet enabled, Tor added, etc.), the service restarts and nacho
// picks up the new URL.
const subsApiIf = await sdk.serviceInterface
.getOwn(effects, '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 <<EOF',
'',
'┌─ 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 against the local certrelay. │',
'│ Docs: https://docs.spacesprotocol.org/ │',
'└──────────────────────────────────────────────────────────┘',
// 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 against the local certrelay.',
'Docs: https://docs.spacesprotocol.org/',
]),
'',
'EOF',
].join('\n')
@@ -264,6 +309,48 @@ export const main = sdk.setupMain(async ({ effects }) => {
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.
@@ -366,162 +453,6 @@ export const main = sdk.setupMain(async ({ effects }) => {
return c
}
if (!enableExplorer) {
// Spaces-only mode: spaced + gotty terminal. No PostgreSQL, no indexer.
// User can flip the toggle via the Enable Embedded Explorer action;
// store.json.enableExplorer is read via .const() so the merge triggers
// an automatic service restart and the full chain takes over.
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)
if (enableSubspaces) chain = withSubspaces(chain)
return chain
}
// enableExplorer = true: full chain — spaced + terminal + embedded
// PostgreSQL + Go indexer + indexer-sync health check.
let chain: any = sdk.Daemons.of(effects)
.addOneshot('bashrc', {
subcontainer: termSub,
@@ -533,40 +464,6 @@ SPACES_BASHRC_EOF`],
},
requires: [],
})
.addOneshot('postgres-chown', {
subcontainer: postgresSub,
exec: {
command: [
'bash',
'-c',
`mkdir -p ${pgDataDir} && chown -R postgres:postgres ${pgDataDir} && chmod 700 ${pgDataDir}`,
],
user: 'root',
},
requires: [],
})
.addDaemon('postgres', {
subcontainer: postgresSub,
exec: {
command: [
'docker-entrypoint.sh',
'postgres',
'-c',
'listen_addresses=127.0.0.1',
],
env: postgresEnv,
},
ready: {
display: i18n('Database'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, POSTGRES_PORT, {
successMessage: i18n('postgres is ready'),
errorMessage: i18n('postgres is not ready'),
}),
gracePeriod: 60_000,
},
requires: ['postgres-chown'],
})
.addDaemon('spaced', {
subcontainer: spacedSub,
exec: {
@@ -584,22 +481,6 @@ SPACES_BASHRC_EOF`],
},
requires: [],
})
// TODO(prebuilt-indexer): the Go indexer build-from-source chain
// (indexer-fetch + indexer-build + indexer-cleanup-legacy +
// indexer-migrate + `indexer` daemon) was removed to shrink the .s9pk
// and cut first-enable latency. Re-add an `indexer` daemon here that runs
// the prebuilt `sync` binary from the future indexer image (image id +
// binary path TBD) against PostgreSQL + spaced. Env it needs is the old
// indexerEnv (POSTGRES_URI, BITCOIN_NODE_*, SPACES_NODE_URI, RPC_USER/
// PASSWORD, ACTIVATION_BLOCK_HEIGHT, FAST_SYNC_BLOCK_HEIGHT,
// UPDATE_DB_INTERVAL, MEMPOOL_CHUNK_SIZE). A goose migration step must
// run before it. See git history (build-from-source) for the prior shape.
//
// TODO(prebuilt-explorer): the SvelteKit explorer build chain
// (explorer-fetch + explorer-install + `explorer-ui` daemon) was removed
// for the same reason. Re-add an `explorer-ui` daemon that runs the
// prebuilt explorer server from the provided explorer image (image id +
// command TBD) on port 3000, reading DB_URL + PUBLIC_BTC_NETWORK.
.addDaemon('web-terminal', {
subcontainer: termSub,
exec: {
@@ -645,11 +526,9 @@ SPACES_BASHRC_EOF`],
],
{},
)
const stderr = (probe.stderr ?? '').toString().trim()
const stdoutText = (probe.stdout ?? '').toString()
const stdoutTrimmed = stdoutText.trim()
if (probe.exitCode !== 0) {
return {
result: 'failure',
@@ -659,7 +538,6 @@ SPACES_BASHRC_EOF`],
}),
}
}
let parsed: {
ready?: boolean
progress?: number
@@ -679,14 +557,12 @@ SPACES_BASHRC_EOF`],
),
}
}
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',
@@ -696,7 +572,6 @@ SPACES_BASHRC_EOF`],
),
}
}
return {
result: 'loading',
message: i18n(
@@ -711,10 +586,9 @@ SPACES_BASHRC_EOF`],
} catch (e) {
return {
result: 'failure',
message: i18n(
'Spaced Sync health check crashed: ${error}',
{ error: (e as Error)?.message ?? String(e) },
),
message: i18n('Spaced Sync health check crashed: ${error}', {
error: (e as Error)?.message ?? String(e),
}),
}
}
},
@@ -723,11 +597,8 @@ SPACES_BASHRC_EOF`],
},
requires: ['spaced'],
})
// TODO(prebuilt-indexer): re-add the `indexer-sync` standalone health
// check once the prebuilt indexer daemon is wired back in. It queried
// MAX(blocks.height) (non-orphan) via psql and compared against spaced's
// tip from `space-cli getserverinfo`. See git history for the prior body.
chain = withCertrelay(chain)
chain = withNacho(chain)
if (enableSubspaces) chain = withSubspaces(chain)
return chain
})
+5 -14
View File
@@ -17,27 +17,18 @@ export const manifest = setupManifest({
source: { dockerTag: 'horologger/spaces:v0.0.9s' },
arch: ['x86_64', 'aarch64'],
},
postgres: {
source: { dockerTag: 'postgres:16.3' },
arch: ['x86_64', 'aarch64'],
},
// TODO(prebuilt-indexer): add the prebuilt explorer-indexer image here once
// it's produced, e.g. 'indexer-go': { source: { dockerTag:
// 'horologger/explorer-indexer:<tag>' }, arch: ['x86_64','aarch64'] }.
// The golang:1.23-alpine builder image + build-from-source chain were
// removed to shrink the .s9pk and cut first-enable latency.
//
// TODO(prebuilt-explorer): add the prebuilt explorer (SvelteKit) image here,
// replacing the node:20-alpine builder, e.g. 'explorer-ui': { source: {
// dockerTag: 'horologger/explorer:<tag>' }, arch: ['x86_64','aarch64'] }.
subspaces: {
source: { dockerTag: 'horologger/subs:v0.1.0' },
source: { dockerTag: 'horologger/subs:v0.1.2' },
arch: ['x86_64', 'aarch64'],
},
certrelay: {
source: { dockerTag: 'horologger/certrelay:v0.2.3' },
arch: ['x86_64', 'aarch64'],
},
nacho: {
source: { dockerTag: 'horologger/nacho:v1.0.0' },
arch: ['x86_64', 'aarch64'],
},
},
alerts: {
install: null,
+109 -36
View File
@@ -16,42 +16,6 @@ export const BITCOIND_RPC_PORT = 8332
export const SPACED_CHAIN = 'mainnet'
export const POSTGRES_PORT = 5432
export const POSTGRES_USER = 'postgres'
export const POSTGRES_DB = 'spacesprotocol_explorer'
export const pgDataDir = '/data/postgres'
export const INDEXER_REPO = 'spacesprotocol/explorer-indexer'
export const INDEXER_GIT_SHA = '00ae1e548734d93f1a8bb9f48d2290f459e12b35'
// Bump the suffix to force a full re-fetch + rebuild of /data/explorer-indexer.
export const INDEXER_BUILD_ID = `${INDEXER_GIT_SHA}-g3`
export const INDEXER_TARBALL_URL = `https://github.com/${INDEXER_REPO}/archive/${INDEXER_GIT_SHA}.tar.gz`
export const INDEXER_DIR = '/data/explorer-indexer'
export const INDEXER_BIN_DIR = '/data/explorer-indexer/bin'
export const INDEXER_SYNC_BIN = '/data/explorer-indexer/bin/sync'
export const INDEXER_GOOSE_BIN = '/data/explorer-indexer/bin/goose'
export const INDEXER_SCHEMA_DIR = '/data/explorer-indexer/sql/schema'
export const INDEXER_MARKER = '/data/explorer-indexer/.installed-sha'
// Spaces protocol mainnet activation block (per spacesprotocol/explorer-indexer
// env.example). Indexer skips fast-sync below FAST_SYNC and starts indexing
// spaces data at ACTIVATION.
export const INDEXER_ACTIVATION_HEIGHT = '871222'
export const INDEXER_FAST_SYNC_HEIGHT = '864000'
export const INDEXER_UPDATE_INTERVAL = '5'
export const INDEXER_MEMPOOL_CHUNK_SIZE = '200'
export const EXPLORER_REPO = 'randomlogin/explorer'
export const EXPLORER_GIT_SHA = 'c827da1754c3cba5c5507d2c29f21b8fa231344d'
// Bump suffix to force re-fetch + rebuild of /data/explorer-ui.
export const EXPLORER_BUILD_ID = `${EXPLORER_GIT_SHA}-e1`
export const EXPLORER_TARBALL_URL = `https://github.com/${EXPLORER_REPO}/archive/${EXPLORER_GIT_SHA}.tar.gz`
export const EXPLORER_DIR = '/data/explorer-ui'
export const EXPLORER_BUILD_DIR = '/data/explorer-ui/build'
export const EXPLORER_MARKER = '/data/explorer-ui/.installed-sha'
export const EXPLORER_PORT = 3000
export const EXPLORER_NETWORK = 'mainnet'
// Subspaces ships as the prebuilt horologger/subs image; binaries live at
// these paths inside it. No build-from-source step.
export const SUBSPACES_SUBS_BIN = '/usr/local/bin/subs'
@@ -88,6 +52,17 @@ export const CERTRELAY_DEFAULT_HEALTHCHECK_HANDLE =
'account-digital-useful.genesis@key'
export const CERTRELAY_DEFAULT_BOOTSTRAP = false
// Nacho (Expo dev server, horologger/nacho image). Always on; UI interface on
// 8082 (the image's exposed port, matches EXPO_DEV_PORT).
export const NACHO_PORT = 8082
export const NACHO_DIR = '/data/nacho'
// Last-resort fallback if StartOS hasn't yet populated the subs-api interface's
// address info at the moment main runs. In normal operation EXPO_PUBLIC_API_BASE_URL
// is derived dynamically from the subs-api StartOS interface — see main.ts.
export const NACHO_FALLBACK_API_BASE_URL = `http://127.0.0.1:${SUBSPACES_UI_PORT}`
export const NACHO_DEFAULT_IGNORE_NAMES = 'fold,swifty'
export const NACHO_DEFAULT_WORKSHOP_PDF_LINK_TEXT = ''
export function randomPassword() {
// bitcoind's generate-rpc-dependent action validates the password against
// /^[A-Za-z0-9_-]+$/, so the charset must stay in that set.
@@ -96,3 +71,101 @@ export function randomPassword() {
len: 32,
})
}
// Banner box rendering for the gotty terminal MOTD. Canonical width is 100
// monospaced cells: 1 cell each for the left/right side borders, plus 1-cell
// gutters on each side of text, leaving 96 cells of usable text. All public
// helpers here guarantee every line they emit is exactly BANNER_WIDTH cells.
//
// Use renderBanner() rather than hand-drawing rows so wrapping + padding stay
// correct when content changes. See bashrc in main.ts.
export const BANNER_WIDTH = 100
const BANNER_INNER = BANNER_WIDTH - 2 // 98 cells between the two side borders
const BANNER_TEXT = BANNER_INNER - 2 // 96 cells of usable text (1-cell gutters)
// Cell width assuming all code points are width-1 (true for ASCII, em-dash,
// and the box-drawing chars used here). Does NOT handle CJK or emoji — if you
// add any, switch to a wcwidth-aware count.
function cellWidth(s: string): number {
return Array.from(s).length
}
function padRight(s: string, width: number): string {
const w = cellWidth(s)
return w >= width ? s : s + ' '.repeat(width - w)
}
// Greedy word-wrap on whitespace runs. Lines that already fit pass through
// verbatim (preserving leading indent and any internal whitespace). Wrapping
// only kicks in when a line is wider than `width`; long single tokens get
// hard-broken so a long URL never overflows the box.
function wrapText(text: string, width: number): string[] {
if (cellWidth(text) <= width) return [text]
const out: string[] = []
const tokens = text.match(/\s+|\S+/g) ?? ['']
let line = ''
for (const tok of tokens) {
const isWs = /^\s/.test(tok)
const candidate = line + tok
if (cellWidth(candidate) <= width) {
line = candidate
continue
}
// Doesn't fit — flush current line and decide what to do with this token.
if (line) out.push(line)
if (isWs) {
// Discard the whitespace run at the wrap point; continuation starts flush.
line = ''
continue
}
// Non-whitespace token longer than width: hard-break.
let rest = tok
while (cellWidth(rest) > width) {
const arr = Array.from(rest)
out.push(arr.slice(0, width).join(''))
rest = arr.slice(width).join('')
}
line = rest
}
if (line) out.push(line)
if (out.length === 0) out.push('')
return out
}
function bannerTop(title?: string): string {
if (!title) return '┌' + '─'.repeat(BANNER_INNER) + '┐'
const head = '┌─ ' + title + ' '
const tail = BANNER_WIDTH - cellWidth(head) - 1
return head + '─'.repeat(Math.max(0, tail)) + '┐'
}
function bannerBottom(): string {
return '└' + '─'.repeat(BANNER_INNER) + '┘'
}
function bannerBlank(): string {
return '│' + ' '.repeat(BANNER_INNER) + '│'
}
function bannerRow(text: string): string[] {
return wrapText(text, BANNER_TEXT).map(
(line) => '│ ' + padRight(line, BANNER_TEXT) + ' │',
)
}
// Render a complete banner: top border, one or more content rows (text wrapped
// to fit; '' renders as a blank inner row), and bottom border. Every returned
// line is exactly BANNER_WIDTH cells wide.
export function renderBanner(
title: string | undefined,
lines: string[],
): string[] {
const out: string[] = [bannerTop(title)]
for (const l of lines) {
if (l === '') out.push(bannerBlank())
else out.push(...bannerRow(l))
}
out.push(bannerBottom())
return out
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_0_0_9_3 } from './v0.0.9.3'
import { v_0_1_1_2 } from './v0.1.1.2'
export const versionGraph = VersionGraph.of({
current: v_0_0_9_3,
current: v_0_1_1_2,
other: [],
})
@@ -1,14 +1,21 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
export const v_0_0_9_3 = VersionInfo.of({
version: '0.0.9:3',
export const v_0_1_1_2 = VersionInfo.of({
version: '0.1.1:2',
releaseNotes: {
en_US: `- Exposed the spaced JSON-RPC API as a new "Spaces API" StartOS interface (port 7225) and changed SPACED_RPC_BIND from 127.0.0.1 to 0.0.0.0 so external processes can reach it. It remains authenticated with the spaced RPC credentials (store.spacedAuth); internal clients still connect over loopback.
en_US: `- **Optional HTTP basic auth in front of the Subspaces Prover (port 8888).** Same pattern as the subs auth: off by default, gated by \`store.subsProverAuthEnabled\` + \`store.subsProverAuth\`, wired via \`SUBS_PROVER_BASIC_AUTH_USER\` / \`SUBS_PROVER_BASIC_AUTH_PASSWORD\` env vars on the \`subs-prover\` daemon. Four new actions: **Enable Subspaces Prover Auth**, **Disable Subspaces Prover Auth**, **Show Subspaces Prover Auth Credentials**, **Set Subspaces Prover Auth Credentials**. Independent of subs auth — each daemon has its own toggle and credential store.
- **Optional HTTP basic auth in front of the Subspaces Web UI and Subs API.** Both share port 7777 on the \`subs\` daemon, so a single toggle gates both. Off by default. Wired via \`SUBS_BASIC_AUTH_USER\` / \`SUBS_BASIC_AUTH_PASSWORD\` env vars injected into subs's environment only when the toggle is on AND credentials are stored. Four new actions manage the lifecycle: **Enable Subspaces Auth** (auto-generates a \`spaces\` / random32 credential pair on first enable; preserves them on subsequent enables), **Disable Subspaces Auth** (preserves credentials across the toggle), **Show Subspaces Auth Credentials** (surfaces user/password + a loopback URL, reports current enforcement state), **Set Subspaces Auth Credentials** (rotate; blank password auto-generates a random one; also enables auth). Stored as \`store.subsAuth\` and \`store.subsAuthEnabled\`. NOTE: the in-package nacho client will break against an authed subs until separately wired — that integration is deferred.
- **Nacho ignore list is now file-backed at \`/data/nacho/ignore_names.txt\`** instead of \`EXPO_PUBLIC_IGNORE_NAMES\`. Expo inlines \`EXPO_PUBLIC_*\` into the bundle at build time and connected clients cache that bundle aggressively, so env-var changes only landed after a hard reload. Moving the list to a runtime-read file means nacho picks up changes without a bundle rebuild. The \`nacho-setup\` oneshot creates the file with the default (\`fold,swifty\`) on first start; the **Configure Nacho** action writes the file directly and prefills the form by reading it back (with the default as fallback). The \`nachoIgnoreNames\` field has been removed from \`store.json\`.
- **Milestone release: this version ships with Nacho bundled in.** The \`horologger/nacho:v1.0.0\` Expo dev server runs alongside spaced as an always-on UI on port 8082, wired to the Subs API and configurable via the "Configure Nacho" and "Upload Support PDF" actions.
- **Removed the embedded explorer feature and its indexer entirely.** Deleted the Explorer Web UI interface (port 3000), the postgres image, the PostgreSQL daemon + chown oneshot, and all stubs for the prebuilt indexer / explorer-UI images. Dropped the actions: enable-explorer, disable-explorer, show-db-credentials, reset-db-state, reset-indexer-state, reset-explorer-state. Removed the \`dbAuth\` and \`enableExplorer\` fields from store.json + their seed tasks (taskSeedDb, taskSeedEnableExplorer). main.ts collapsed to a single always-on chain (no more enableExplorer branching). Drop the related EXPLORER_*, INDEXER_*, POSTGRES_* constants from utils.ts.
- Added the nacho service (prebuilt \`horologger/nacho:v1.0.0\`, Expo dev server). Always on; runs the image's entrypoint and exposes a UI interface on port 8082. Env: \`EXPO_PUBLIC_API_BASE_URL\` is derived dynamically from the Subs API StartOS interface (\`sdk.serviceInterface.getOwn('subs-api').const()\`) — preferring the .local URL — so it tracks whatever host:port StartOS exposes for the subs daemon; \`EXPO_PUBLIC_IGNORE_NAMES\` (defaults to "fold,swifty") and \`EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT\` (defaults to empty) are user-configurable via the new "Configure Nacho" action; the workshop PDF is uploaded via a separate "Upload Support PDF" action that overwrites /data/support.pdf on the main volume (split from Configure Nacho because the StartOS form serializes an unselected file input as {} rather than null, which Value.file's nullable parser rejects — a required-true dedicated action sidesteps the problem). Service auto-restarts when either the subs-api address info or the ignore-names value changes.
- Exposed the spaced JSON-RPC API as a new "Spaces API" StartOS interface (port 7225) and changed SPACED_RPC_BIND from 127.0.0.1 to 0.0.0.0 so external processes can reach it. It remains authenticated with the spaced RPC credentials (store.spacedAuth); internal clients still connect over loopback.
- New "Show Spaces API Credentials" action surfaces SPACED_RPC_USER / SPACED_RPC_PASSWORD (and a loopback connection URL) so you can authenticate external clients against the Spaces API.
- Added a dedicated "Subs API" interface (type api) on the subs daemon's port (7777), distinct from the Subspaces Prover (8888) and Subspaces Registry (8081) interfaces. The subs daemon is "subsd — an HTTP REST API server"; this surfaces that REST API as its own dashboard entry.
- New "Enable / Disable Subspaces Prover" action independently gates just the subs-prover daemon (store.enableSubsProver), **disabled by default**. When off, subs-prover does not start (it runs lengthy boot-time timing tests), but its interface on 8888 stays registered. It is added last and nothing depends on it, so its slow startup never blocks subs, the registry, or certrelay.
- Embedded Certrelay (prebuilt \`horologger/certrelay:v0.2.3\` image, static musl binaries). Always on — runs the \`certrelay\` server on port 7778 from service start, as its own StartOS interface. It serves cryptographic proofs binding Bitcoin-anchored handles to owner keys, talking to the local spaced over loopback using the store.spacedAuth credentials. The bundled \`fabric\` CLI is copied to /data/bin/fabric so it's runnable from the Space-CLI Web UI. New "Configure Certrelay" action sets CERTRELAY_SELF_URL, CERTRELAY_BOOTSTRAP, and CERTRELAY_HEALTHCHECK_HANDLE (stored in store.json; saving restarts the service). Set CERTRELAY_SELF_URL to the publicly visible URL StartOS exposes for the Certrelay interface.
- Removed the build-from-source chains for the Go indexer and the SvelteKit explorer, and dropped their builder images (\`golang:1.23-alpine\`, \`node:20-alpine\`). This shrinks the .s9pk and removes the multi-minute first-enable compile. The indexer + explorer-UI daemons are now stubbed with TODOs in startos/main.ts pending prebuilt images (indexer image not yet produced; explorer image to be provided). Enabling the embedded explorer currently starts PostgreSQL only. All explorer-indexer actions (show-db-credentials, reset-db-state, reset-indexer-state, reset-explorer-state) are retained.
- Embedded Subspaces support (prebuilt \`horologger/subs:v0.1.0\` image). Opt-in via the new "Enable Subspaces" action; "Disable Subspaces" stops it. No compile step — enabling starts three prebuilt daemons, each on its own StartOS interface: **subs** (Web UI, 7777), **subs-prover** (RISC Zero prover, no GPU, 8888), and **registry-server** (handle registry, 8081). subs loads the existing \`default\` spaces wallet at startup (it does not create one). Runtime data persists at /data/subspaces/data across restarts and toggles. New "Reset Subspaces State" action wipes local data. Adds a fifth manifest image (\`horologger/subs:v0.1.0\`, ~84 MB) — switched from building \`spacesops/subs\` from source with \`rust:1-slim\` to shrink the .s9pk and eliminate the multi-minute first-enable compile.
- Embedded Subspaces support (prebuilt \`horologger/subs:\` image). Opt-in via the new "Enable Subspaces" action; "Disable Subspaces" stops it. No compile step — enabling starts three prebuilt daemons, each on its own StartOS interface: **subs** (Web UI, 7777), **subs-prover** (RISC Zero prover, no GPU, 8888), and **registry-server** (handle registry, 8081). subs loads the existing \`default\` spaces wallet at startup (it does not create one). Runtime data persists at /data/subspaces/data across restarts and toggles. New "Reset Subspaces State" action wipes local data. Adds a fifth manifest image (\`horologger/subs:\`, ~84 MB) — switched from building \`spacesops/subs\` from source with \`rust:1-slim\` to shrink the .s9pk and eliminate the multi-minute first-enable compile.
- Five distinct interfaces now appear in the dashboard: **Space-CLI Web UI** (gotty terminal, 8080), **Explorer Web UI** (3000, opt-in), **Subspaces Web UI** (7777, opt-in), **Subspaces Prover** (8888, opt-in), **Subspaces Registry** (8081, opt-in). Internal "Web UI" references renamed to **Space-CLI Web UI** for clarity.
- Embedded explorer (PostgreSQL + Go indexer + SvelteKit web UI) is now **opt-in**. Fresh installs run in spaces-only mode (spaced + gotty terminal). New "Enable Embedded Explorer" action turns the whole bundle on; "Disable Embedded Explorer" turns it back off. Both auto-restart the service so the new daemon graph takes effect. Indexed data on disk is preserved across toggles.
- Embedded SvelteKit explorer web UI (randomlogin/explorer @ c827da175). Fetched into /data/explorer-ui on first enable, \`npm install\` + \`npm run build\` produce a SvelteKit node-adapter bundle, and \`node build\` runs as a managed daemon on port 3000. Exposed as a StartOS interface so it appears alongside the gotty terminal in the dashboard. Reads exclusively from the embedded PostgreSQL; does not talk to spaced or bitcoind directly. New "Reset Explorer UI State" action wipes the cache for a clean rebuild. Adds a fourth manifest image \`node:20-alpine\`.