Build SpacesOps StartOS package (v1.0.0:0)

Initial .s9pk for SpacesOps, targeting StartOS 0.4.0.x with SDK 1.5.1.

- Single managed daemon from spacesops/spacesops:v1.0.0 (x86_64 + aarch64),
  keeping the image entrypoint (/app/docker-entrypoint.sh node server.js).
  Forces PLATFORM_HOST=0.0.0.0 / PLATFORM_PORT=7264 so the StartOS proxy can
  reach the app. Single `ui` interface on 7264.
- Depends on the Spaces service (>=0.0.9:3) and auto-wires the spaced RPC creds:
  main.ts mounts the Spaces `main` volume read-only at /spaces-data, execs a
  read of its store.json inside the subcontainer, and injects SPACED_RPC_USER/
  PASSWORD + SPACED_RPC_URL=http://spaces.startos:7225. Throws to retry until
  Spaces is installed and seeded.
- Generates a Nostr operator keypair (nostr-tools, bundled by ncc) and a strong
  session secret in idempotent init tasks (.once() reads, allowWriteAfterConst
  merges). Actions: show-operator-credentials, import-operator-key,
  show-admin-credentials (surfaces the fixed admin/Whatever! login with a
  warning), configure-platform (optional relay/mode/CoinGecko/SUBSD).
- Install alert warns to install Spaces first and about the fixed admin
  credential. Backs up the `main` volume.
- Icon: icon.svg (source spacesops.svg). The `spaces` dependency uses
  assets/spaces-icon.png for its Marketplace metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:28:18 -04:00
co-authored by Claude Opus 4.7
parent 1e33944be4
commit 35c1013520
38 changed files with 2071 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import {
DEFAULT_COINGECKO_TOKEN_COINS,
DEFAULT_OPERATOR_RELAY,
DEFAULT_PLATFORM_MODE,
} from '../utils'
const { InputSpec, Value } = sdk
const inputSpec = InputSpec.of({
operatorRelay: Value.text({
name: i18n('Operator Nostr Relay'),
description: i18n(
'The Nostr relay SpacesOps publishes operator events to (OPERATOR_RELAY).',
),
warning: null,
footnote: null,
default: DEFAULT_OPERATOR_RELAY,
required: true,
masked: false,
placeholder: 'wss://relay.example.com',
minLength: 1,
maxLength: null,
}),
platformMode: Value.select({
name: i18n('Platform Mode'),
description: i18n(
'Sets PLATFORM_MODE. Only changes the UI theme color; "test" does not change behavior.',
),
warning: null,
footnote: null,
default: DEFAULT_PLATFORM_MODE,
values: {
prod: 'Production',
test: 'Test',
},
}),
coingeckoApiKey: Value.text({
name: i18n('CoinGecko API Key'),
description: i18n(
'Optional CoinGecko API key (COINGECKO_API_KEY) used for pricing features. Leave blank to disable pricing.',
),
warning: null,
footnote: null,
default: null,
required: false,
masked: true,
placeholder: null,
minLength: null,
maxLength: null,
}),
coingeckoTokenCoins: Value.text({
name: i18n('CoinGecko Token Coins'),
description: i18n(
'The CoinGecko coin id(s) to price against (COINGECKO_TOKEN_COINS).',
),
warning: null,
footnote: null,
default: DEFAULT_COINGECKO_TOKEN_COINS,
required: false,
masked: false,
placeholder: 'bitcoin',
minLength: null,
maxLength: null,
}),
subsdUrl: Value.text({
name: i18n('SUBSD URL'),
description: i18n(
'Optional SUBSD service URL (SUBSD_URI_VALUE) for the subname-purchase flow. Leave blank to disable.',
),
warning: null,
footnote: null,
default: null,
required: false,
masked: false,
placeholder: 'http://host:7244',
minLength: null,
maxLength: null,
}),
subsdUser: Value.text({
name: i18n('SUBSD RPC User'),
description: i18n('Optional SUBSD RPC username (SUBSD_RPC_USER).'),
warning: null,
footnote: null,
default: null,
required: false,
masked: false,
placeholder: null,
minLength: null,
maxLength: null,
}),
subsdPassword: Value.text({
name: i18n('SUBSD RPC Password'),
description: i18n('Optional SUBSD RPC password (SUBSD_RPC_PASSWORD).'),
warning: null,
footnote: null,
default: null,
required: false,
masked: true,
placeholder: null,
minLength: null,
maxLength: null,
}),
})
export const configurePlatform = sdk.Action.withInput(
// id
'configure-platform',
// metadata
async ({ effects }) => ({
name: i18n('Configure Platform'),
description: i18n(
'Set optional SpacesOps settings: Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend. Saving restarts the service so the new settings take effect.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — load current values from store
async ({ effects }) => {
const store = await storeJson.read().once()
return {
operatorRelay: store?.operatorRelay ?? DEFAULT_OPERATOR_RELAY,
platformMode: store?.platformMode ?? DEFAULT_PLATFORM_MODE,
coingeckoApiKey: store?.coingeckoApiKey ?? null,
coingeckoTokenCoins:
store?.coingeckoTokenCoins ?? DEFAULT_COINGECKO_TOKEN_COINS,
subsdUrl: store?.subsdUrl ?? null,
subsdUser: store?.subsdUser ?? null,
subsdPassword: store?.subsdPassword ?? null,
}
},
// run
async ({ effects, input }) => {
await storeJson.merge(effects, {
operatorRelay: input.operatorRelay,
platformMode: input.platformMode,
coingeckoApiKey: input.coingeckoApiKey || null,
coingeckoTokenCoins: input.coingeckoTokenCoins || null,
subsdUrl: input.subsdUrl || null,
subsdUser: input.subsdUser || null,
subsdPassword: input.subsdPassword || null,
})
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Platform configuration saved. The service is restarting to apply the new settings.',
),
result: null,
}
},
)
+101
View File
@@ -0,0 +1,101 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { isValidSecretHex, secretHexToPublicHex } from '../nostr'
import { sdk } from '../sdk'
const { InputSpec, Value } = sdk
const inputSpec = InputSpec.of({
secretHex: Value.text({
name: i18n('Operator Secret Key (hex)'),
description: i18n(
'A 64-character hex-encoded secp256k1 / Nostr secret key. The public key is derived automatically.',
),
warning: null,
footnote: null,
default: null,
required: true,
masked: true,
placeholder: '64 hexadecimal characters',
minLength: 64,
maxLength: 64,
patterns: [
{
regex: '^[0-9a-fA-F]{64}$',
description: i18n('Must be exactly 64 hexadecimal characters.'),
},
],
}),
})
export const importOperatorKey = sdk.Action.withInput(
// id
'import-operator-key',
// metadata
async ({ effects }) => ({
name: i18n('Import Operator Key'),
description: i18n(
'Replace the operator keypair with one you provide (hex secret key).',
),
warning: i18n(
'This changes the operator identity SpacesOps signs events with. Events already published under the old key stay under it. The service restarts to apply the new key.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// input
inputSpec,
// prefill — never pre-populate a secret
async ({ effects }) => {},
// run
async ({ effects, input }) => {
const secretHex = input.secretHex.trim().toLowerCase()
if (!isValidSecretHex(secretHex)) {
return {
version: '1',
title: i18n('Failure'),
message: i18n(
'The secret key must be exactly 64 hexadecimal characters.',
),
result: null,
}
}
let operatorPublicHex: string
try {
operatorPublicHex = secretHexToPublicHex(secretHex)
} catch (e) {
return {
version: '1',
title: i18n('Failure'),
message: i18n(
'Could not derive a public key from that secret: ${error}',
{
error: (e as Error).message,
},
),
result: null,
}
}
await storeJson.merge(effects, {
operatorSecretHex: secretHex,
operatorPublicHex,
})
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Operator key imported. The service is restarting to apply the new identity.',
),
result: null,
}
},
)
+11
View File
@@ -0,0 +1,11 @@
import { sdk } from '../sdk'
import { configurePlatform } from './configurePlatform'
import { importOperatorKey } from './importOperatorKey'
import { showAdminCredentials } from './showAdminCredentials'
import { showOperatorCredentials } from './showOperatorCredentials'
export const actions = sdk.Actions.of()
.addAction(showOperatorCredentials)
.addAction(importOperatorKey)
.addAction(showAdminCredentials)
.addAction(configurePlatform)
+52
View File
@@ -0,0 +1,52 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { ADMIN_PASSWORD, ADMIN_USER } from '../utils'
export const showAdminCredentials = sdk.Action.withoutInput(
// id
'show-admin-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Admin Credentials'),
description: i18n(
'Display the built-in admin username and password for the SpacesOps admin area.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => ({
version: '1',
title: i18n('Admin Credentials'),
message: i18n(
'WARNING: these are FIXED, well-known credentials baked into the image and cannot be changed without rebuilding it. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and never expose the admin routes to the public internet.',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
description: null,
value: ADMIN_USER,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Password'),
description: null,
value: ADMIN_PASSWORD,
masked: true,
copyable: true,
qr: false,
},
],
},
}),
)
@@ -0,0 +1,88 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { encodeNpub, encodeNsec } from '../nostr'
import { sdk } from '../sdk'
export const showOperatorCredentials = sdk.Action.withoutInput(
// id
'show-operator-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Operator Credentials'),
description: i18n(
'Display the Nostr operator keypair SpacesOps signs events with (npub, nsec, and hex public key).',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const store = await storeJson.read().once()
const secretHex = store?.operatorSecretHex ?? null
const publicHex = store?.operatorPublicHex ?? null
if (!secretHex || !publicHex) {
return {
version: '1',
title: i18n('Operator Credentials'),
message: i18n(
'The operator keypair has not been generated yet. Start the service once to generate it.',
),
result: null,
}
}
return {
version: '1',
title: i18n('Operator Credentials'),
message: i18n(
'SpacesOps signs operator events on Nostr with this keypair. Keep the secret (nsec / hex) private.',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Public Key (npub)'),
description: null,
value: encodeNpub(publicHex),
masked: false,
copyable: true,
qr: true,
},
{
type: 'single',
name: i18n('Public Key (hex)'),
description: null,
value: publicHex,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Secret Key (nsec)'),
description: null,
value: encodeNsec(secretHex),
masked: true,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Secret Key (hex)'),
description: null,
value: secretHex,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)
+5
View File
@@ -0,0 +1,5 @@
import { sdk } from './sdk'
export const { createBackup, restoreInit } = sdk.setupBackups(
async ({ effects }) => sdk.Backups.ofVolumes('main'),
)
+14
View File
@@ -0,0 +1,14 @@
import { sdk } from './sdk'
// Spaces 0.0.9:3 binds spaced to 0.0.0.0:7225 and exports the `spaces-api`
// interface with static RPC creds at the root of its `main` volume. SpacesOps
// reads those creds from the mounted Spaces volume in main.ts and connects to
// http://spaces.startos:7225. Dependency declarations do NOT gate startup —
// main.ts throws/retries until the creds are available.
export const setDependencies = sdk.setupDependencies(async ({ effects }) => ({
spaces: {
kind: 'running',
versionRange: '>=0.0.9:3',
healthChecks: ['spaced', 'sync'],
},
}))
+24
View File
@@ -0,0 +1,24 @@
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.object({
// Nostr operator keypair — generated once in init, or replaced via the
// "Import Operator Key" action. SpacesOps signs operator events with it.
operatorSecretHex: z.string().nullable().catch(null),
operatorPublicHex: z.string().nullable().catch(null),
// Strong replacement for the app's weak hardcoded session secret.
sessionSecret: z.string().nullable().catch(null),
// Optional config set via the "Configure Platform" action.
platformMode: z.enum(['prod', 'test']).nullable().catch(null),
operatorRelay: z.string().nullable().catch(null),
coingeckoApiKey: z.string().nullable().catch(null),
coingeckoTokenCoins: z.string().nullable().catch(null),
subsdUrl: z.string().nullable().catch(null),
subsdUser: z.string().nullable().catch(null),
subsdPassword: z.string().nullable().catch(null),
})
export const storeJson = FileHelper.json(
{ base: sdk.volumes.main, subpath: 'store.json' },
shape,
)
+60
View File
@@ -0,0 +1,60 @@
export const DEFAULT_LANG = 'en_US'
const dict = {
'Operator Nostr Relay': 0,
'The Nostr relay SpacesOps publishes operator events to (OPERATOR_RELAY).': 1,
'Platform Mode': 2,
'Sets PLATFORM_MODE. Only changes the UI theme color; "test" does not change behavior.': 3,
'CoinGecko API Key': 4,
'Optional CoinGecko API key (COINGECKO_API_KEY) used for pricing features. Leave blank to disable pricing.': 5,
'CoinGecko Token Coins': 6,
'The CoinGecko coin id(s) to price against (COINGECKO_TOKEN_COINS).': 7,
'SUBSD URL': 8,
'Optional SUBSD service URL (SUBSD_URI_VALUE) for the subname-purchase flow. Leave blank to disable.': 9,
'SUBSD RPC User': 10,
'Optional SUBSD RPC username (SUBSD_RPC_USER).': 11,
'SUBSD RPC Password': 12,
'Optional SUBSD RPC password (SUBSD_RPC_PASSWORD).': 13,
'Configure Platform': 14,
'Set optional SpacesOps settings: Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend. Saving restarts the service so the new settings take effect.': 15,
Success: 16,
'Platform configuration saved. The service is restarting to apply the new settings.': 17,
'Operator Secret Key (hex)': 18,
'A 64-character hex-encoded secp256k1 / Nostr secret key. The public key is derived automatically.': 19,
'Must be exactly 64 hexadecimal characters.': 20,
'Import Operator Key': 21,
'Replace the operator keypair with one you provide (hex secret key).': 22,
'This changes the operator identity SpacesOps signs events with. Events already published under the old key stay under it. The service restarts to apply the new key.': 23,
Failure: 24,
'The secret key must be exactly 64 hexadecimal characters.': 25,
'Could not derive a public key from that secret: ${error}': 26,
'Operator key imported. The service is restarting to apply the new identity.': 27,
'Show Admin Credentials': 28,
'Display the built-in admin username and password for the SpacesOps admin area.': 29,
'Admin Credentials': 30,
'WARNING: these are FIXED, well-known credentials baked into the image and cannot be changed without rebuilding it. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and never expose the admin routes to the public internet.': 31,
Username: 32,
Password: 33,
'Show Operator Credentials': 34,
'Display the Nostr operator keypair SpacesOps signs events with (npub, nsec, and hex public key).': 35,
'Operator Credentials': 36,
'The operator keypair has not been generated yet. Start the service once to generate it.': 37,
'SpacesOps signs operator events on Nostr with this keypair. Keep the secret (nsec / hex) private.': 38,
'Public Key (npub)': 39,
'Public Key (hex)': 40,
'Secret Key (nsec)': 41,
'Secret Key (hex)': 42,
'Web UI': 43,
'SpacesOps web platform for space ownership confirmation and subspace-name purchases. The app provides its own login; the admin area uses a fixed built-in credential — see "Show Admin Credentials".': 44,
'Starting SpacesOps!': 45,
'Web Interface': 46,
'The web interface is ready': 47,
'The web interface is not ready': 48,
} as const
/**
* Plumbing. DO NOT EDIT.
*/
export type I18nKey = keyof typeof dict
export type LangDict = Record<(typeof dict)[I18nKey], string>
export default dict
@@ -0,0 +1,3 @@
import { LangDict } from './default'
export default {} satisfies Record<string, LangDict>
+8
View File
@@ -0,0 +1,8 @@
/**
* Plumbing. DO NOT EDIT this file.
*/
import { setupI18n } from '@start9labs/start-sdk'
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
import translations from './dictionaries/translations'
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
+11
View File
@@ -0,0 +1,11 @@
/**
* Plumbing. DO NOT EDIT.
*/
export { createBackup } from './backups'
export { main } from './main'
export { init, uninit } from './init'
export { actions } from './actions'
import { buildManifest } from '@start9labs/start-sdk'
import { manifest as sdkManifest } from './manifest'
import { versionGraph } from './versions'
export const manifest = buildManifest(versionGraph, sdkManifest)
+20
View File
@@ -0,0 +1,20 @@
import { actions } from '../actions'
import { restoreInit } from '../backups'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { sdk } from '../sdk'
import { versionGraph } from '../versions'
import { taskOperatorKeys } from './taskOperatorKeys'
import { taskSessionSecret } from './taskSessionSecret'
export const init = sdk.setupInit(
restoreInit,
versionGraph,
setInterfaces,
setDependencies,
actions,
taskOperatorKeys,
taskSessionSecret,
)
export const uninit = sdk.setupUninit(versionGraph)
+24
View File
@@ -0,0 +1,24 @@
import { storeJson } from '../fileModels/storeJson'
import { sdk } from '../sdk'
import { secretHexToPublicHex } from '../nostr'
import { randomOperatorSecretHex } from '../utils'
// Generate the Nostr operator keypair once, if absent. SpacesOps requires
// OPERATOR_SECRET_HEX / OPERATOR_PUBLIC_HEX at startup (it exits otherwise).
// Idempotent: only acts when the secret is not yet present. Read with .once()
// — never .const() in init (it arms a write-after-const watcher).
export const taskOperatorKeys = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.operatorSecretHex).once()
if (existing) return
const operatorSecretHex = randomOperatorSecretHex()
// Derive before persisting; if derivation rejects the (astronomically rare)
// out-of-range scalar, nothing is written and StartOS retries init.
const operatorPublicHex = secretHexToPublicHex(operatorSecretHex)
await storeJson.merge(
effects,
{ operatorSecretHex, operatorPublicHex },
{ allowWriteAfterConst: true },
)
})
+16
View File
@@ -0,0 +1,16 @@
import { storeJson } from '../fileModels/storeJson'
import { sdk } from '../sdk'
import { randomSessionSecret } from '../utils'
// Generate a strong PLATFORM_SESSION_SECRET once, if absent. The app otherwise
// falls back to a weak hardcoded default. Idempotent; reads with .once().
export const taskSessionSecret = sdk.setupOnInit(async (effects) => {
const existing = await storeJson.read((s) => s.sessionSecret).once()
if (existing) return
await storeJson.merge(
effects,
{ sessionSecret: randomSessionSecret() },
{ allowWriteAfterConst: true },
)
})
+27
View File
@@ -0,0 +1,27 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n(
'SpacesOps web platform for space ownership confirmation and subspace-name purchases. The app provides its own login; the admin area uses a fixed built-in credential — see "Show Admin Credentials".',
),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const uiReceipt = await uiMultiOrigin.export([ui])
return [uiReceipt]
})
+131
View File
@@ -0,0 +1,131 @@
import { storeJson } from './fileModels/storeJson'
import { i18n } from './i18n'
import { sdk } from './sdk'
import {
dataDir,
DEFAULT_OPERATOR_RELAY,
DEFAULT_PLATFORM_MODE,
SPACED_RPC_URL,
SPACED_WALLETLOAD_NAME,
spacesDataDir,
uiPort,
} from './utils'
type SpacedAuth = { username: string; password: string }
export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting SpacesOps!'))
// Read with .const() so a store.json change (e.g. from Import Operator Key or
// Configure Platform) triggers an automatic service restart.
const store = await storeJson.read().const(effects)
if (
!store?.operatorSecretHex ||
!store?.operatorPublicHex ||
!store?.sessionSecret
) {
// taskOperatorKeys + taskSessionSecret seed these in init; if they aren't
// populated yet, init hasn't finished — let StartOS restart us.
throw new Error(
'SpacesOps store.json is not yet populated (operator keys / session secret missing).',
)
}
// Mount our own volume at /data AND the Spaces 'main' volume (read-only) at
// /spaces-data so we can read the spaced RPC credentials Spaces seeded there.
const mounts = sdk.Mounts.of()
.mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
})
.mountDependency({
dependencyId: 'spaces',
volumeId: 'main',
subpath: null,
mountpoint: spacesDataDir,
readonly: true,
type: 'directory',
})
const sub = await sdk.SubContainer.of(
effects,
{ imageId: 'spacesops' },
mounts,
'spacesops-sub',
)
// setupMain runs in the StartOS runtime, NOT inside the container, so it
// cannot fs-read the mounted Spaces volume directly. Exec a read inside the
// subcontainer instead (mirrors how spaces-startos execs space-cli for its
// sync health check). Throw if the creds aren't there yet — StartOS restarts
// us until Spaces is installed and its store.json is seeded.
const probe = await sub.exec(['cat', `${spacesDataDir}/store.json`])
if (probe.exitCode !== 0) {
throw new Error(
`Spaces RPC creds not available yet: could not read ${spacesDataDir}/store.json (exit ${probe.exitCode}). Is the Spaces service installed?`,
)
}
let spacesStore: { spacedAuth?: SpacedAuth | null }
try {
spacesStore = JSON.parse((probe.stdout ?? '').toString())
} catch {
throw new Error(
'Spaces RPC creds not available yet: Spaces store.json is not valid JSON.',
)
}
const spacedAuth = spacesStore.spacedAuth
if (!spacedAuth || !spacedAuth.username || !spacedAuth.password) {
throw new Error(
'Spaces RPC creds not available yet: spacedAuth missing from Spaces store.json. Wait for the Spaces service init to finish.',
)
}
const env: Record<string, string> = {
// The app defaults to 127.0.0.1:3000, which the StartOS proxy cannot reach.
PLATFORM_HOST: '0.0.0.0',
PLATFORM_PORT: String(uiPort),
PLATFORM_DB_PATH: `${dataDir}/local.db`,
PLATFORM_MODE: store.platformMode ?? DEFAULT_PLATFORM_MODE,
PLATFORM_SESSION_SECRET: store.sessionSecret,
OPERATOR_SECRET_HEX: store.operatorSecretHex,
OPERATOR_PUBLIC_HEX: store.operatorPublicHex,
OPERATOR_RELAY: store.operatorRelay ?? DEFAULT_OPERATOR_RELAY,
SPACED_RPC_URL,
SPACED_RPC_USER: spacedAuth.username,
SPACED_RPC_PASSWORD: spacedAuth.password,
SPACED_WALLETLOAD_NAME,
}
// Optional integrations — only injected when configured (Configure Platform).
if (store.coingeckoApiKey) env.COINGECKO_API_KEY = store.coingeckoApiKey
if (store.coingeckoTokenCoins)
env.COINGECKO_TOKEN_COINS = store.coingeckoTokenCoins
if (store.subsdUrl) env.SUBSD_URI_VALUE = store.subsdUrl
if (store.subsdUser) env.SUBSD_RPC_USER = store.subsdUser
if (store.subsdPassword) env.SUBSD_RPC_PASSWORD = store.subsdPassword
return sdk.Daemons.of(effects).addDaemon('spacesops', {
subcontainer: sub,
exec: {
// Keep the image entrypoint: it creates /data dirs, symlinks
// /app/data -> /data, and loads defaults for any UNSET vars before
// exec'ing the command.
command: ['/app/docker-entrypoint.sh', 'node', 'server.js'],
env,
cwd: '/app',
user: 'root',
},
ready: {
display: i18n('Web Interface'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('The web interface is ready'),
errorMessage: i18n('The web interface is not ready'),
}),
gracePeriod: 60_000,
},
requires: [],
})
})
+13
View File
@@ -0,0 +1,13 @@
export const short = {
en_US: 'Space ownership confirmation and subname sales.',
}
export const long = {
en_US:
'SpacesOps is a web platform for confirming Spaces ownership and selling subspace names. It talks to the Spaces spaced daemon over JSON-RPC, signs operator events on Nostr, and optionally integrates pricing and subname-purchase backends. This package auto-connects to the Spaces service running on the same server.',
}
export const depSpacesDescription = {
en_US:
'Provides the spaced JSON-RPC endpoint SpacesOps queries. SpacesOps auto-connects to it at spaces.startos:7225 using the Spaces RPC credentials, so Spaces must be installed and synced.',
}
+42
View File
@@ -0,0 +1,42 @@
import { setupManifest } from '@start9labs/start-sdk'
import { depSpacesDescription, long, short } from './i18n'
export const manifest = setupManifest({
id: 'spacesops',
title: 'SpacesOps',
license: 'MIT',
packageRepo: 'https://git.spacesops.com/spacesops/spacesops-startos',
upstreamRepo: 'https://git.spacesops.com/spacesops/spacesops',
marketingUrl: 'https://spacesops.com',
donationUrl: null,
docsUrls: [],
description: { short, long },
volumes: ['main'],
images: {
spacesops: {
source: { dockerTag: 'spacesops/spacesops:v1.0.0' },
arch: ['x86_64', 'aarch64'],
},
},
alerts: {
install: {
en_US:
'SpacesOps REQUIRES the Spaces service. Install and sync Spaces first — SpacesOps auto-connects to it at spaces.startos:7225 by reading the Spaces RPC credentials, and will not start until they are available.\n\nWARNING: the admin area is protected by a FIXED, well-known credential baked into this image (username "admin", password "Whatever!") that cannot be changed without rebuilding the image. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and do NOT expose its admin routes to the public internet. See the "Show Admin Credentials" action.',
},
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
spaces: {
description: depSpacesDescription,
optional: false,
metadata: {
title: 'Spaces',
icon: 'assets/spaces-icon.png',
},
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { getPublicKey, nip19 } from 'nostr-tools'
const HEX64 = /^[0-9a-f]{64}$/
function toBytes(secretHex: string): Uint8Array {
return Uint8Array.from(Buffer.from(secretHex.trim().toLowerCase(), 'hex'))
}
export function isValidSecretHex(hex: string): boolean {
return HEX64.test(hex.trim().toLowerCase())
}
// Deterministic secp256k1 x-only public key derivation (no randomness). Throws
// if the secret is not a valid curve scalar.
export function secretHexToPublicHex(secretHex: string): string {
return getPublicKey(toBytes(secretHex))
}
export function encodeNpub(publicHex: string): string {
return nip19.npubEncode(publicHex)
}
export function encodeNsec(secretHex: string): string {
return nip19.nsecEncode(toBytes(secretHex))
}
+9
View File
@@ -0,0 +1,9 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* Plumbing. DO NOT EDIT.
*
* The exported "sdk" const is used throughout this package codebase.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)
+44
View File
@@ -0,0 +1,44 @@
import { utils } from '@start9labs/start-sdk'
// The SpacesOps HTTP server. The app defaults to 127.0.0.1:3000; we override
// PLATFORM_HOST to 0.0.0.0 and PLATFORM_PORT to this so the StartOS reverse
// proxy can reach it.
export const uiPort = 7264
// Our own volume.
export const dataDir = '/data'
// Where the Spaces 'main' volume is mounted (read-only) so we can read the
// spaced RPC credentials Spaces seeded into its store.json.
export const spacesDataDir = '/spaces-data'
// The Spaces package this service depends on.
export const SPACES_PACKAGE_ID = 'spaces'
// Spaces exposes its spaced JSON-RPC as the `spaces-api` interface on 7225,
// reachable from a dependent package at this address.
export const SPACED_RPC_URL = 'http://spaces.startos:7225'
export const SPACED_WALLETLOAD_NAME = 'main'
// Admin Basic Auth is baked into the v1.0.0 image with NO env override. It
// cannot be changed without rebuilding the image. Surfaced (with a warning)
// via the "Show Admin Credentials" action.
export const ADMIN_USER = 'admin'
export const ADMIN_PASSWORD = 'Whatever!'
// Optional-config defaults (see the "Configure Platform" action).
export const DEFAULT_OPERATOR_RELAY = 'wss://relay.primal.net'
export const DEFAULT_PLATFORM_MODE = 'prod'
export const DEFAULT_COINGECKO_TOKEN_COINS = 'bitcoin'
// 32 random bytes as a 64-char hex string — a secp256k1/Nostr secret key. The
// odds of an out-of-range key are ~1 in 2^128; if getPublicKey rejects it,
// init throws and StartOS retries with fresh entropy.
export function randomOperatorSecretHex(): string {
return utils.getDefaultString({ charset: '0-9,a-f', len: 64 })
}
// A strong replacement for the app's weak hardcoded PLATFORM_SESSION_SECRET.
export function randomSessionSecret(): string {
return utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 48 })
}
+7
View File
@@ -0,0 +1,7 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_1_0_0_0 } from './v1.0.0.0'
export const versionGraph = VersionGraph.of({
current: v_1_0_0_0,
other: [],
})
+17
View File
@@ -0,0 +1,17 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
export const v_1_0_0_0 = VersionInfo.of({
version: '1.0.0:0',
releaseNotes: {
en_US: `Initial StartOS package for SpacesOps (upstream v1.0.0).
- Runs the SpacesOps web platform (Express + SQLite) from the prebuilt spacesops/spacesops:v1.0.0 image on x86_64 and aarch64.
- Depends on the Spaces service and auto-connects to its spaced JSON-RPC at spaces.startos:7225 by reading the Spaces RPC credentials from the mounted Spaces volume.
- Generates a Nostr operator keypair and a strong session secret on first install. "Show Operator Credentials" and "Import Operator Key" actions manage the keypair.
- "Show Admin Credentials" surfaces the fixed, well-known admin login baked into the image, with a warning to keep the service private.
- "Configure Platform" optionally sets the Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend.`,
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE,
},
})