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,
}
}
},
)