diff --git a/README.md b/README.md index f2f31ff..58cce32 100644 --- a/README.md +++ b/README.md @@ -38,27 +38,15 @@ through a browser-based terminal. | Field | Value | | --- | --- | | Spaces image | `docker.io/horologger/spaces` | -| PostgreSQL image | `docker.io/postgres:16.3` | -| Subspaces image | `docker.io/horologger/subs:v0.1.0` | +| Subspaces image | `docker.io/horologger/subs:v0.1.2` | | Certrelay image | `docker.io/horologger/certrelay:v0.2.3` | -| Indexer image | _**TODO** — prebuilt explorer-indexer image, not yet produced_ | -| Explorer image | _**TODO** — prebuilt explorer (SvelteKit) image_ | +| Nacho image | `docker.io/horologger/nacho:v1.0.0` | | Architectures | `linux/amd64`, `linux/arm64` | | Entrypoint | StartOS-managed (image entrypoints are **not** used directly) | The Spaces image bundles `spaced`, `space-cli`, `bitcoin-cli`, `gotty`, `node`, `npm`, `screen`, and a small shell environment. StartOS ignores the Spaces -image's `docker_entrypoint.sh`; daemons are defined in `startos/main.ts`. The -PostgreSQL image is the upstream `postgres:16.3`, launched via its official -`docker-entrypoint.sh`. - -> **Note (build-from-source removed):** earlier builds compiled the Go indexer -> and the SvelteKit explorer from source inside `golang:1.23-alpine` and -> `node:20-alpine` builder images. Those images and their build chains were -> removed to shrink the `.s9pk` and eliminate the multi-minute first-enable -> compile. They will be replaced with prebuilt images (see the **TODO** rows -> above). Until then, enabling the embedded explorer starts **PostgreSQL only** -> — the indexer and explorer-UI daemons are stubbed out in `startos/main.ts`. +image's `docker_entrypoint.sh`; daemons are defined in `startos/main.ts`. ## Volume and Data Layout @@ -66,15 +54,14 @@ PostgreSQL image is the upstream `postgres:16.3`, launched via its official | --- | --- | --- | | `/data` | `main` | Spaces data directory (`SPACED_DATA_DIR`), wallets, indexes, and `store.json` | | `/data/mainnet/.cookie` | `main` | Spaced RPC cookie (auto-generated by `spaced` at startup) | -| `/data/store.json` | `main` | StartOS-managed credentials (web-UI password, bitcoind RPC user/password, PostgreSQL user/password/database) | -| `/data/postgres` | `main` | PostgreSQL data directory (`PGDATA`). Owned by uid 999 (`postgres`). The whole `main` volume is backed up, so the database is included. | -| `/data/explorer-indexer` | `main` | **TODO** — reserved for the prebuilt indexer's runtime data. The build-from-source layout (source, `bin/sync`, `bin/goose`, marker) is gone; `reset-indexer-state` still wipes this path. | -| `/data/explorer-ui` | `main` | **TODO** — reserved for the prebuilt explorer's runtime data. The build-from-source layout (source, `build/`, `node_modules`, marker) is gone; `reset-explorer-state` still wipes this path. | +| `/data/store.json` | `main` | StartOS-managed credentials (Space-CLI Web UI password, bitcoind RPC user/password, spaced RPC user/password) and feature config (Subspaces toggles, Certrelay + Nacho settings). | | `/data/subspaces/data` | `main` | `subs` runtime data (`SUBS_DATA_DIR`) — handles, proofs, config.db. Persists across restarts and enable/disable toggles. | | `/data/subspaces/prover` | `main` | Working directory for the `subs-prover` daemon. | | `/data/subspaces/registry` | `main` | Working directory for the `registry-server` daemon. | | `/data/certrelay/data` | `main` | Certrelay data directory (`CERTRELAY_DATA_DIR`) — anchors, peer state, sqlite. | | `/data/bin/fabric` | `main` | The `fabric` CLI, copied from the certrelay image on each start so the gotty terminal (which has `/data/bin` on PATH) can resolve handles. | +| `/data/support.pdf` | `main` | Workshop PDF served by nacho. Uploaded/replaced via the **Upload Support PDF** action. Missing until an upload has been performed. | +| `/data/nacho/ignore_names.txt` | `main` | Comma-separated names hidden from the nacho UI (read at runtime). Created by the `nacho-setup` oneshot with the default (`fold,swifty`) on first start; managed by the **Configure Nacho** action. | ## Installation and First-Run Flow @@ -87,60 +74,41 @@ On the first install, StartOS: pair and creates a critical cross-service task on **Bitcoin** that runs bitcoind's `generate-rpc-dependent` action to register the credentials in `bitcoin.conf`. -3. Seeds `store.json.dbAuth` with a random PostgreSQL password (username - `postgres`, database `spacesprotocol_explorer`). No task is created — the - credentials are only used internally by the embedded daemon. -4. Runs the `postgres-chown` oneshot to create `/data/postgres` with the right - ownership, then launches the PostgreSQL daemon on loopback 5432. -5. Seeds `store.json.spacedAuth` with a random `spaces:` credential +3. Seeds `store.json.spacedAuth` with a random `spaces:` credential pair. Spaced is configured to require these via `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` (cookie auth is **not** used in this package), so the - gotty terminal's `spaces` alias and the indexer share a single auth path. -6. Seeds `store.json.enableExplorer = false`. The embedded explorer - (PostgreSQL + Go indexer) is **opt-in**. -7. Seeds `store.json` certrelay defaults: `certrelaySelfUrl = + gotty terminal's `spaces` alias and other clients share a single auth path. +4. Seeds `store.json` certrelay defaults: `certrelaySelfUrl = https://certrelay.spacesops.com`, `certrelayBootstrap = false`, `certrelayHealthcheckHandle = account-digital-useful.genesis@key`. -8. Launches `spaced` as a managed daemon (no `screen`, no shell auto-start) +5. Seeds `store.json` nacho defaults (`nachoWorkshopPdfLinkText = ''`) and + `store.json.enableSubspaces = false`, `store.json.enableSubsProver = false`. + The nacho ignore-names list lives in `/data/nacho/ignore_names.txt` (not + in the store); the `nacho-setup` oneshot creates that file with the + default (`fold,swifty`) on first daemon start if it doesn't exist. +6. Launches `spaced` as a managed daemon (no `screen`, no shell auto-start) and the `gotty` web terminal once the bashrc oneshot completes. -9. Runs `certrelay-setup` (creates `/data/certrelay/data`, copies the `fabric` +7. Runs `certrelay-setup` (creates `/data/certrelay/data`, copies the `fabric` CLI to `/data/bin/fabric`) and launches the **certrelay** daemon on 7778. Certrelay is **always on**; its `CERTRELAY_SPACED_RPC_URL` uses the local spaced credentials from `store.spacedAuth`. Configure its self URL etc. via the **Configure Certrelay** action. +8. Runs `nacho-setup` (mkdir `/data/nacho`; seeds `ignore_names.txt` with the + default `fold,swifty` if missing) and launches the **nacho** Expo daemon + on 8082. Always on. The user is **not** prompted to choose a chain or RPC mode — this package is mainnet-only. -### Enabling the embedded explorer - -Run the **Enable Embedded Explorer** action. That flips -`store.json.enableExplorer = true` and the service auto-restarts: - -8. Postgres-chown oneshot creates `/data/postgres` with correct ownership. -9. PostgreSQL daemon starts on loopback `127.0.0.1:5432`. - -> **TODO (prebuilt images pending):** the indexer and explorer-UI daemons are -> currently **stubbed out** in `startos/main.ts`. The build-from-source chain -> (`indexer-fetch` → `indexer-build` → `indexer-migrate` → `indexer`, and -> `explorer-fetch` → `explorer-install` → `explorer-ui`) was removed to shrink -> the `.s9pk` and cut startup time. Until prebuilt images are wired in, -> enabling the explorer starts **PostgreSQL only**; nothing populates the DB -> and the Explorer Web UI (port 3000) will not resolve. The `show-db-credentials`, -> `reset-db-state`, `reset-indexer-state`, and `reset-explorer-state` actions -> remain available. - -Run **Disable Embedded Explorer** to turn it back off; on-disk data at -`/data/postgres`, `/data/explorer-indexer`, and `/data/explorer-ui` is -preserved. - ## Configuration Management | StartOS-Managed | Upstream-Managed | | --- | --- | | Web-UI username / password (`admin` + generated password) | `spaced` runtime tuning via `SPACED_*` env vars in the image | | Bitcoin RPC username / password (registered on bitcoind) | Wallet creation, bidding, and registration -- all driven via `space-cli` inside the terminal | -| PostgreSQL username / password / database (loopback only) | `space-cli` flags and subcommands | +| Spaced RPC username / password (`SPACED_RPC_USER`/`SPACED_RPC_PASSWORD`) | `space-cli` flags and subcommands | +| Subspaces auth toggle + credentials (`SUBS_BASIC_AUTH_USER`/`SUBS_BASIC_AUTH_PASSWORD`, off by default) | | +| Subspaces Prover auth toggle + credentials (`SUBS_PROVER_BASIC_AUTH_USER`/`SUBS_PROVER_BASIC_AUTH_PASSWORD`, off by default) | | | Chain selection (locked to `mainnet`) | | | Spaced data directory and RPC bind | | @@ -149,14 +117,13 @@ preserved. | Interface | Port | Protocol | Exposure | Notes | | --- | --- | --- | --- | --- | | Space-CLI Web UI (gotty terminal) | 8080 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | Basic auth: `admin:` | -| Explorer Web UI | 3000 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | SvelteKit explorer. **TODO** — daemon stubbed pending the prebuilt explorer image; the interface is still registered but nothing listens yet. | -| Subspaces Web UI | 7777 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `subs` daemon (subsd). No built-in auth. Only useful while Subspaces is enabled. | -| Subs API | 7777 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | The subsd REST API (same daemon/port as above), exposed as a distinct `api` interface separate from the prover and registry. Only useful while Subspaces is enabled. | -| Subspaces Prover | 8888 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `subs-prover` RISC Zero prover server (no GPU). Its own StartOS interface. Only useful while Subspaces is enabled. | +| Subspaces Web UI | 7777 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `subs` daemon (subsd). Optional HTTP basic auth — off by default, gated by **Enable Subspaces Auth**. When on, both this UI and the Subs API are walled behind `SUBS_BASIC_AUTH_USER` / `SUBS_BASIC_AUTH_PASSWORD`. Only useful while Subspaces is enabled. | +| Subs API | 7777 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | The subsd REST API (same daemon/port as above), exposed as a distinct `api` interface separate from the prover and registry. Shares the optional Subspaces Auth wall with the Web UI (same port, same process). Only useful while Subspaces is enabled. | +| Subspaces Prover | 8888 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `subs-prover` RISC Zero prover server (no GPU). Its own StartOS interface. Optional HTTP basic auth — off by default, gated by **Enable Subspaces Prover Auth**. When on, `SUBS_PROVER_BASIC_AUTH_USER`/`SUBS_PROVER_BASIC_AUTH_PASSWORD` are enforced. Only useful while Subspaces (and the prover toggle) is enabled. | | Subspaces Registry | 8081 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `registry-server` for publishing/resolving handles (remapped from its upstream default 8080 to avoid colliding with the gotty terminal on 8080). Its own StartOS interface. Only useful while Subspaces is enabled. | | Certrelay | 7778 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | `certrelay` server (always on). Serves cryptographic proofs binding handles to owner keys. **Setup:** run **Configure Certrelay** and set `CERTRELAY_SELF_URL` to the publicly visible URL StartOS exposes for this interface. | -| Spaces API (spaced RPC) | 7225 | HTTP JSON-RPC | LAN / `.local` / Tor / clearnet (via StartOS) | spaced JSON-RPC, now bound to `0.0.0.0` and exposed as the **Spaces API** interface. Static-cred-authenticated via `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` from `store.json.spacedAuth`. Internal clients still connect over loopback. | -| PostgreSQL | 5432 | Postgres wire protocol | **Loopback only** (`listen_addresses=127.0.0.1`) | Username/password from `store.json.dbAuth`; full `DB_URL` exported into the web terminal | +| Nacho | 8082 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | nacho Expo dev server (always on). The ignore list is read at runtime from `/data/nacho/ignore_names.txt` (managed by **Configure Nacho**). `EXPO_PUBLIC_API_BASE_URL` is derived dynamically from the Subs API StartOS interface (`sdk.serviceInterface.getOwn('subs-api').const()`), preferring the `.local` URL. Falls back to `http://127.0.0.1:7777` only if no address info is available. | +| Spaces API (spaced RPC) | 7225 | HTTP JSON-RPC | LAN / `.local` / Tor / clearnet (via StartOS) | spaced JSON-RPC, bound to `0.0.0.0` and exposed as the **Spaces API** interface. Static-cred-authenticated via `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` from `store.json.spacedAuth`. Internal clients still connect over loopback. | ## Actions @@ -166,21 +133,26 @@ preserved. | `show-credentials` | Show Space-CLI Web UI Credentials | Hidden | Any | Surfaces the current `admin` username + masked password (launched by the first-install task) | | `show-password` | Show Space-CLI Web UI Password | Enabled | Any | Same as `show-credentials` but visible in the actions list, for routine re-display of the current admin credentials | | `set-bitcoin-rpc` | Set up Bitcoin RPC | Enabled | Any | Re-invokes bitcoind's `generate-rpc-dependent` with the stored credentials. Safe to call repeatedly. | +| `show-spaced-credentials` | Show Spaces API Credentials | Enabled | Any | Surfaces the spaced RPC `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` (from `store.spacedAuth`) plus a loopback connection URL (password masked, all copyable). | | `sync-status` | Sync Status | Enabled | Only running | Runs `space-cli getserverinfo` inside the daemon container and returns the JSON output | | `reset-spaced-state` | Reset Spaced State | Enabled | Any | Deletes `/data/mainnet/` so spaced resyncs its index from spaces' anchor. Preserves `store.json` (passwords + RPC credentials). Use when spaced crash-loops on a stale or corrupt index. | | `export-wallet` | Export Wallet | Enabled | Only running | Runs `space-cli exportwallet /data/mainnet/wallets_backup/default.json` and surfaces the resulting JSON as a masked/copyable result. The file is also persisted inside the volume at that path. | | `import-wallet` | Import Wallet | Enabled | Only running | Accepts a pasted JSON payload (textarea), writes it to `/data/mainnet/wallets_backup/default.json` (rotating the existing file to `.bakNNN`), rotates `/data/mainnet/wallets/default` to `.bakNNN`, then runs `space-cli importwallet` + `loadwallet`. | -| `enable-explorer` | Enable Embedded Explorer | Enabled (hidden when already on) | Any | Sets `store.enableExplorer = true` and triggers a service restart so the full daemon graph (postgres + indexer + indexer-sync HC) takes effect. | -| `disable-explorer` | Disable Embedded Explorer | Enabled (hidden when already off) | Any | Sets `store.enableExplorer = false` and triggers a service restart so the indexer and postgres daemons stop. On-disk data at `/data/postgres` and `/data/explorer-indexer` is preserved. | -| `show-db-credentials` | Show Database Credentials | Enabled | Any | Surfaces the PostgreSQL username, password, database, and full `DB_URL` (all copyable; secrets masked). Credentials exist regardless of whether the explorer is currently enabled. | -| `reset-db-state` | Reset Database State | Enabled | Any | Deletes `/data/postgres` so the next start re-initializes an empty database. Warning-gated. `store.json` is preserved. | -| `reset-indexer-state` | Reset Indexer State | Enabled | Any | Deletes `/data/explorer-indexer` so the next start re-fetches the indexer source and rebuilds the sync + goose binaries. Useful when bumping the pinned commit. PostgreSQL data is preserved. Warning-gated. | -| `reset-explorer-state` | Reset Explorer UI State | Enabled | Any | Deletes `/data/explorer-ui` so the next start re-fetches the SvelteKit source and rebuilds the bundle. PostgreSQL data is preserved. Warning-gated. | | `enable-subspaces` | Enable Subspaces | Enabled (hidden when already on) | Any | Sets `store.enableSubspaces = true` and triggers a service restart so the Subspaces chain (build → wallet → subs-prover → subs) takes effect. | | `disable-subspaces` | Disable Subspaces | Enabled (hidden when already off) | Any | Sets `store.enableSubspaces = false` and triggers a service restart so the subs and subs-prover daemons stop. On-disk data at `/data/subspaces` is preserved. | | `reset-subspaces-state` | Reset Subspaces State | Enabled | Any | Deletes `/data/subspaces` (runtime data only — binaries ship in the image). Use to wipe local handles/proofs. The spaces wallet on spaced is preserved. Warning-gated. | | `set-subs-prover` | Enable / Disable Subspaces Prover | Enabled | Any | Toggle (`store.enableSubsProver`, default **off**) that starts/stops only the `subs-prover` daemon, independent of the overall Subspaces toggle. When off, the daemon doesn't start but its interface (8888) stays registered. Saving restarts the service. | +| `enable-subs-auth` | Enable Subspaces Auth | Enabled (hidden when already on) | Any | Sets `store.subsAuthEnabled = true`. If no credentials exist yet, generates `{ username: 'spaces', password: random32 }` and stores them in `store.subsAuth`. Restart re-launches `subs` with `SUBS_BASIC_AUTH_USER`/`SUBS_BASIC_AUTH_PASSWORD` env vars set. | +| `disable-subs-auth` | Disable Subspaces Auth | Enabled (hidden when already off) | Any | Sets `store.subsAuthEnabled = false`. Stored credentials are preserved so a later re-enable does not regenerate them. Restart re-launches `subs` without the auth env vars. | +| `show-subs-credentials` | Show Subspaces Auth Credentials | Enabled | Any | Surfaces the current `SUBS_BASIC_AUTH_USER`/`SUBS_BASIC_AUTH_PASSWORD` (from `store.subsAuth`) plus a loopback connection URL. Reports whether auth is currently enforced. | +| `set-subs-credentials` | Set Subspaces Auth Credentials | Enabled | Any | Set or rotate `store.subsAuth` and enable auth (`store.subsAuthEnabled = true`). Username is prefilled with the current value; password field is blank — leave it blank to auto-generate a random 32-char password, or paste your own. Restarts the service so subs picks up `SUBS_BASIC_AUTH_USER`/`SUBS_BASIC_AUTH_PASSWORD`. | +| `enable-subs-prover-auth` | Enable Subspaces Prover Auth | Enabled (hidden when already on) | Any | Sets `store.subsProverAuthEnabled = true`. If no credentials exist yet, generates `{ username: 'spaces', password: random32 }` and stores them in `store.subsProverAuth`. Restart re-launches `subs-prover` with `SUBS_PROVER_BASIC_AUTH_USER`/`SUBS_PROVER_BASIC_AUTH_PASSWORD` env vars set. | +| `disable-subs-prover-auth` | Disable Subspaces Prover Auth | Enabled (hidden when already off) | Any | Sets `store.subsProverAuthEnabled = false`. Stored credentials are preserved. Restart re-launches `subs-prover` without the auth env vars. | +| `show-subs-prover-credentials` | Show Subspaces Prover Auth Credentials | Enabled | Any | Surfaces the current `SUBS_PROVER_BASIC_AUTH_USER`/`SUBS_PROVER_BASIC_AUTH_PASSWORD` (from `store.subsProverAuth`) plus a loopback connection URL. Reports whether auth is currently enforced. | +| `set-subs-prover-credentials` | Set Subspaces Prover Auth Credentials | Enabled | Any | Set or rotate `store.subsProverAuth`. Same UX as `set-subs-credentials` (blank password → auto-generated). Restarts the service only if prover auth is currently enabled. | | `configure-certrelay` | Configure Certrelay | Enabled | Any | Sets `CERTRELAY_SELF_URL` (text), `CERTRELAY_BOOTSTRAP` (toggle), and `CERTRELAY_HEALTHCHECK_HANDLE` (text) in `store.json`; prefilled with current values. Saving restarts the service so certrelay picks up the new config. | +| `configure-nacho` | Configure Nacho | Enabled | Any | Writes the Ignore Names list to `/data/nacho/ignore_names.txt` (read at runtime by nacho — survives daemon restarts independent of the store) and sets `EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT` in `store.json`. Prefill reads the file (with the package default as fallback) plus the current store value. Saving restarts the service. | +| `upload-support-pdf` | Upload Support PDF | Enabled | Any | Required `.pdf` file upload that overwrites `/data/support.pdf` on the main volume (read with `fs/promises`, piped via stdin into a temp `spaces` subcontainer's `cat > /data/support.pdf`). Split out from Configure Nacho because StartOS's form serializes an unselected `Value.file` as `{}` rather than `null`, which `Value.file(..., required: false).nullable()` rejects; a `required: true` dedicated action avoids the empty-form path entirely. | ## Backups and Restore @@ -193,15 +165,14 @@ the same idempotent init logic runs and reuses the existing credentials in | ID | Display | Grace period | Behaviour | | --- | --- | --- | --- | -| `postgres` (daemon `ready`) | Database | 60 s | TCP listen on `127.0.0.1:5432` | | `spaced` (daemon `ready`) | Spaced RPC | 120 s | TCP listen on `0.0.0.0:7225` (exposed as the Spaces API interface) | | `subs-prover` (daemon `ready`) | Subspaces Prover | 60 s | TCP listen on `0.0.0.0:8888`. Only present when Subspaces **and** the `set-subs-prover` toggle are enabled (toggle defaults off). Polled every 30 s; nothing depends on it. | | `subs-registry` (daemon `ready`) | Subspaces Registry | 60 s | TCP listen on `0.0.0.0:8081`. Only present when Subspaces is enabled. | | `subs` (daemon `ready`) | Subspaces Web UI | 60 s | TCP listen on `0.0.0.0:7777`. Only present when Subspaces is enabled. | | `certrelay` (daemon `ready`) | Certrelay | 120 s | TCP listen on `0.0.0.0:7778`. Always on. Polled every 30 s. | +| `nacho` (daemon `ready`) | Nacho | 120 s | TCP listen on `0.0.0.0:8082`. Always on. Polled every 30 s. | | `web-terminal` (daemon `ready`) | Web Interface | default | TCP listen on `0.0.0.0:8080` | | `sync` (standalone) | Spaced Sync | 30 s | Exec `space-cli --output-format json getserverinfo`; reports `success` when `ready=true && progress=100%`, otherwise `loading` with progress percentage | -| `indexer` / `explorer-ui` / `indexer-sync` | — | — | **TODO** — removed with the build-from-source chain. Will return when the prebuilt indexer + explorer images are wired in. | ## Limitations and Differences @@ -212,47 +183,28 @@ the same idempotent init logic runs and reuses the existing credentials in via `space-cli` (aliased as `spaces` inside the shell). 4. **Externally exposed ports** (each a StartOS interface): 8080 (gotty Space-CLI Web UI, always on), 7225 (Spaces API / spaced RPC, always on), - 7778 (Certrelay, always on), and — only while their feature is enabled — - 3000 (Explorer Web UI, currently stubbed), 7777 (Subspaces Web UI), 8888 - (Subspaces Prover), 8081 (Subspaces Registry). **Loopback-only:** PostgreSQL - (5432). The spaced RPC is authenticated (static creds) even though it's now - externally exposed. The Subspaces registry is remapped from its upstream - default 8080 to 8081 to avoid colliding with the gotty terminal. + 7778 (Certrelay, always on), 8082 (Nacho, always on), and — only while + Subspaces is enabled — 7777 (Subspaces Web UI + Subs API), 8888 (Subspaces + Prover, only if its sub-toggle is on), 8081 (Subspaces Registry). The + spaced RPC is authenticated (static creds). The Subspaces registry is + remapped from its upstream default 8080 to 8081 to avoid colliding with the + gotty terminal. 5. **Bitcoin Core 31.x is the only supported dependency.** Earlier majors are not allowed by the manifest version range. 6. **The web terminal is independent of spaced.** Gotty stays reachable even when `spaced` is crash-looping, so you can always shell in to diagnose. -7. **The embedded explorer is opt-in — and currently PostgreSQL-only.** Fresh - installs run in spaces-only mode (spaced + gotty). Run **Enable Embedded - Explorer** to start PostgreSQL; **Disable Embedded Explorer** stops it. The - indexer and explorer-UI daemons are **stubbed out** pending prebuilt images - (the build-from-source chain was removed to shrink the `.s9pk` and cut - startup time). So enabling the explorer today brings up an empty database - and nothing else. PostgreSQL data is preserved across toggles. -8. **PostgreSQL is embedded, not a separate service.** There is no - cross-service dependency on a postgres .s9pk; the database is local to this - package, lives at `/data/postgres`, and is intended for the (pending) - prebuilt indexer + explorer UI. Connect to it from inside the gotty - terminal with `psql $DB_URL`. -9. **Subspaces pointers data note.** When the prebuilt indexer is wired back - in, be aware our spaced binary predates the spaces pointers feature; the - prior build-from-source indexer was patched to treat `ptrs_root` as optional - and to skip the `getptrblockmeta` RPC. A future rebuild of - `horologger/spaces` with a newer spaced will let us drop those patches. -10. **Subspaces is opt-in.** Fresh installs do not run Subspaces. Run **Enable - Subspaces** to start it. It ships as the prebuilt `horologger/subs:v0.1.0` - image (no compile step), so enabling is fast — the three daemons start as - soon as the image is on the box. Enabling runs three daemons, each with its - own StartOS interface: **subs** (Web UI, 7777), **registry-server** (8081), - and — only if the separate **Enable / Disable Subspaces Prover** toggle is - on (default **off**) — **subs-prover** (8888, no GPU). The prover's - interface is always registered even when its daemon is off. subs loads the - existing `default` spaces - wallet at startup and does **not** create it — create it first from the - Space-CLI Web UI with `spaces createwallet` if you haven't. Subspaces is - independent of the Embedded Explorer toggle — you can run any combination - of {explorer, subspaces}. -11. **Certrelay is always on.** It ships as the prebuilt +7. **Subspaces is opt-in.** Fresh installs do not run Subspaces. Run **Enable + Subspaces** to start it. It ships as the prebuilt `horologger/subs:v0.1.2` + image (no compile step), so enabling is fast — the daemons start as soon + as the image is on the box. Enabling runs three daemons, each with its own + StartOS interface: **subs** (Web UI + Subs API, 7777), **registry-server** + (8081), and — only if the separate **Enable / Disable Subspaces Prover** + toggle is on (default **off**) — **subs-prover** (8888, no GPU). The + prover's interface is always registered even when its daemon is off. subs + loads the existing `default` spaces wallet at startup and does **not** + create it — create it first from the Space-CLI Web UI with `spaces + createwallet` if you haven't. +8. **Certrelay is always on.** It ships as the prebuilt `horologger/certrelay:v0.2.3` image (static musl binaries) and runs on 7778 from service start. It talks to the local spaced over loopback using the `store.spacedAuth` credentials. The bundled `fabric` CLI is copied to @@ -294,23 +246,20 @@ package_id: spaces upstream_version: subspacesplus images: spaces: docker.io/horologger/spaces:v0.0.9s - postgres: docker.io/postgres:16.3 - subspaces: docker.io/horologger/subs:v0.1.0 + subspaces: docker.io/horologger/subs:v0.1.2 certrelay: docker.io/horologger/certrelay:v0.2.3 - # TODO: prebuilt indexer image (was golang:1.23-alpine builder, removed) - # TODO: prebuilt explorer image (was node:20-alpine builder, removed) + nacho: docker.io/horologger/nacho:v1.0.0 architectures: [x86_64, aarch64] volumes: main: /data ports: ui: 8080 - explorer_ui: 3000 # interface registered; daemon stubbed (TODO prebuilt image) subspaces_ui: 7777 # external, only useful while subspaces enabled subspaces_prover: 8888 # external (its own interface), subspaces only subspaces_registry: 8081 # external (its own interface), subspaces only; remapped from upstream 8080 certrelay: 7778 # external (its own interface), always on + nacho: 8082 # external (its own interface), always on spaced_rpc: 7225 # external (Spaces API interface); spaced binds 0.0.0.0, auth-gated - postgres: 5432 # loopback only dependencies: - bitcoind spaced_env_vars: @@ -331,37 +280,43 @@ spaced_env_vars: - BTC_RPC_PASSWORD - APP_USER - APP_PASSWORD - - DB_URL -postgres_env_vars: - - POSTGRES_USER - - POSTGRES_PASSWORD - - POSTGRES_DB - - PGDATA -explorer: - default_enabled: false # opt-in via Enable Embedded Explorer action - store_field: enableExplorer - status: postgres_only # indexer + explorer-ui daemons stubbed; see TODOs - reset_actions: [reset-db-state, reset-indexer-state, reset-explorer-state] -indexer: - status: TODO # build-from-source (Go) removed; prebuilt image pending - prior_source_repo: spacesprotocol/explorer-indexer - data_dir: /data/explorer-indexer # reset-indexer-state still wipes this -explorer_ui: - status: TODO # build-from-source (SvelteKit) removed; prebuilt image pending - prior_source_repo: randomlogin/explorer - data_dir: /data/explorer-ui # reset-explorer-state still wipes this - port: 3000 # interface registered; daemon stubbed subspaces: default_enabled: false # opt-in via Enable Subspaces action store_field: enableSubspaces subs_prover_default_enabled: false # separate toggle: set-subs-prover (store.enableSubsProver) - image: horologger/subs:v0.1.0 # prebuilt; no compile step + image: horologger/subs:v0.1.2 # prebuilt; no compile step data_dir: /data/subspaces/data wallet: default # loaded by subs, NOT created (create via space-cli first) daemons: # three separate daemons, each its own interface subs: { bin: /usr/local/bin/subs, port: 7777 } subs-prover: { bin: /usr/local/bin/subs-prover, port: 8888, gpu: none } registry-server: { bin: /usr/local/bin/registry-server, port: 8081 } # remapped from upstream 8080 + auth: # optional HTTP basic auth in front of subs (Web UI + Subs API on 7777) + default_enabled: false + store_enabled_field: subsAuthEnabled + store_creds_field: subsAuth # { username, password }; persists across enable/disable + default_username: spaces + env_vars: # injected only when subsAuthEnabled=true AND subsAuth!=null + - SUBS_BASIC_AUTH_USER + - SUBS_BASIC_AUTH_PASSWORD + actions: + - enable-subs-auth + - disable-subs-auth + - show-subs-credentials + - set-subs-credentials + prover_auth: # optional HTTP basic auth in front of subs-prover (8888) + default_enabled: false + store_enabled_field: subsProverAuthEnabled + store_creds_field: subsProverAuth + default_username: spaces + env_vars: # injected only when subsProverAuthEnabled=true AND subsProverAuth!=null + - SUBS_PROVER_BASIC_AUTH_USER + - SUBS_PROVER_BASIC_AUTH_PASSWORD + actions: + - enable-subs-prover-auth + - disable-subs-prover-auth + - show-subs-prover-credentials + - set-subs-prover-credentials certrelay: always_on: true image: horologger/certrelay:v0.2.3 # prebuilt; static musl binaries @@ -380,41 +335,42 @@ certrelay: CERTRELAY_SELF_URL: https://certrelay.spacesops.com # default; set to your public URL CERTRELAY_BOOTSTRAP: 'false' # toggle via action CERTRELAY_HEALTHCHECK_HANDLE: account-digital-useful.genesis@key # via action - managed_env_vars: - - POSTGRES_URI - - BITCOIN_NODE_URI - - BITCOIN_NODE_USER - - BITCOIN_NODE_PASSWORD - - SPACES_NODE_URI - - RPC_USER - - RPC_PASSWORD - - ACTIVATION_BLOCK_HEIGHT - - FAST_SYNC_BLOCK_HEIGHT - - UPDATE_DB_INTERVAL - - MEMPOOL_CHUNK_SIZE - defaults: - ACTIVATION_BLOCK_HEIGHT: '871222' # spaces mainnet activation - FAST_SYNC_BLOCK_HEIGHT: '864000' - UPDATE_DB_INTERVAL: '5' - MEMPOOL_CHUNK_SIZE: '200' +nacho: + always_on: true + image: horologger/nacho:v1.0.0 # Expo dev server + port: 8082 + data_dir: /data/nacho + ignore_names_file: /data/nacho/ignore_names.txt # read at runtime; default 'fold,swifty' seeded by nacho-setup + configurable_via_actions: + - configure-nacho # ignore_names.txt + EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT + - upload-support-pdf # writes /data/support.pdf + env: + EXPO_PUBLIC_API_BASE_URL: + EXPO_PUBLIC_WORKSHOP_PDF_LINK_TEXT: '' # set via configure-nacho + EXPO_DEV_PORT: '8082' actions: - reset-password - show-credentials - show-password - set-bitcoin-rpc + - show-spaced-credentials - sync-status - reset-spaced-state - export-wallet - import-wallet - - enable-explorer - - disable-explorer - - show-db-credentials - - reset-db-state - - reset-indexer-state - - reset-explorer-state - enable-subspaces - disable-subspaces - set-subs-prover + - enable-subs-auth + - disable-subs-auth + - show-subs-credentials + - set-subs-credentials + - enable-subs-prover-auth + - disable-subs-prover-auth + - show-subs-prover-credentials + - set-subs-prover-credentials - reset-subspaces-state - configure-certrelay + - configure-nacho + - upload-support-pdf ``` diff --git a/startos/actions/configureNacho.ts b/startos/actions/configureNacho.ts new file mode 100644 index 0000000..801574e --- /dev/null +++ b/startos/actions/configureNacho.ts @@ -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 { + 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, + } + }, +) diff --git a/startos/actions/disableExplorer.ts b/startos/actions/disableExplorer.ts deleted file mode 100644 index 46da282..0000000 --- a/startos/actions/disableExplorer.ts +++ /dev/null @@ -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, - } - }, -) diff --git a/startos/actions/disableSubsAuth.ts b/startos/actions/disableSubsAuth.ts new file mode 100644 index 0000000..1fd6fa5 --- /dev/null +++ b/startos/actions/disableSubsAuth.ts @@ -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, + } + }, +) diff --git a/startos/actions/disableSubsProverAuth.ts b/startos/actions/disableSubsProverAuth.ts new file mode 100644 index 0000000..845299e --- /dev/null +++ b/startos/actions/disableSubsProverAuth.ts @@ -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, + } + }, +) diff --git a/startos/actions/enableExplorer.ts b/startos/actions/enableExplorer.ts deleted file mode 100644 index f6764dc..0000000 --- a/startos/actions/enableExplorer.ts +++ /dev/null @@ -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, - } - }, -) diff --git a/startos/actions/enableSubsAuth.ts b/startos/actions/enableSubsAuth.ts new file mode 100644 index 0000000..a548d59 --- /dev/null +++ b/startos/actions/enableSubsAuth.ts @@ -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, + } + }, +) diff --git a/startos/actions/enableSubsProverAuth.ts b/startos/actions/enableSubsProverAuth.ts new file mode 100644 index 0000000..628bd91 --- /dev/null +++ b/startos/actions/enableSubsProverAuth.ts @@ -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, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index e05cb53..03aa4f8 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -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) diff --git a/startos/actions/resetDbState.ts b/startos/actions/resetDbState.ts deleted file mode 100644 index 9f3dea3..0000000 --- a/startos/actions/resetDbState.ts +++ /dev/null @@ -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, - } - }, -) diff --git a/startos/actions/resetExplorerState.ts b/startos/actions/resetExplorerState.ts deleted file mode 100644 index d0e3b31..0000000 --- a/startos/actions/resetExplorerState.ts +++ /dev/null @@ -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, - } - }, -) diff --git a/startos/actions/resetIndexerState.ts b/startos/actions/resetIndexerState.ts deleted file mode 100644 index de65ae0..0000000 --- a/startos/actions/resetIndexerState.ts +++ /dev/null @@ -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, - } - }, -) diff --git a/startos/actions/setSubsCredentials.ts b/startos/actions/setSubsCredentials.ts new file mode 100644 index 0000000..188502a --- /dev/null +++ b/startos/actions/setSubsCredentials.ts @@ -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, + } + }, +) diff --git a/startos/actions/setSubsProverCredentials.ts b/startos/actions/setSubsProverCredentials.ts new file mode 100644 index 0000000..4eddfc0 --- /dev/null +++ b/startos/actions/setSubsProverCredentials.ts @@ -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, + } + }, +) diff --git a/startos/actions/showDbCredentials.ts b/startos/actions/showSpacedCredentials.ts similarity index 51% rename from startos/actions/showDbCredentials.ts rename to startos/actions/showSpacedCredentials.ts index 198e8c1..686e9f7 100644 --- a/startos/actions/showDbCredentials.ts +++ b/startos/actions/showSpacedCredentials.ts @@ -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'), diff --git a/startos/actions/showSubsCredentials.ts b/startos/actions/showSubsCredentials.ts new file mode 100644 index 0000000..da4b214 --- /dev/null +++ b/startos/actions/showSubsCredentials.ts @@ -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, + }, + ], + }, + } + }, +) diff --git a/startos/actions/showSubsProverCredentials.ts b/startos/actions/showSubsProverCredentials.ts new file mode 100644 index 0000000..8b71f04 --- /dev/null +++ b/startos/actions/showSubsProverCredentials.ts @@ -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, + }, + ], + }, + } + }, +) diff --git a/startos/actions/uploadSupportPdf.ts b/startos/actions/uploadSupportPdf.ts new file mode 100644 index 0000000..a6ddf2b --- /dev/null +++ b/startos/actions/uploadSupportPdf.ts @@ -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, + } + } + }, +) diff --git a/startos/fileModels/storeJson.ts b/startos/fileModels/storeJson.ts index e976d98..6d58d0b 100644 --- a/startos/fileModels/storeJson.ts +++ b/startos/fileModels/storeJson.ts @@ -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( diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index a2b7810..fc67930 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -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 /** diff --git a/startos/init/index.ts b/startos/init/index.ts index 5cb1fde..eac23d6 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -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, ) diff --git a/startos/init/taskSeedDb.ts b/startos/init/taskSeedDb.ts deleted file mode 100644 index b9b62d4..0000000 --- a/startos/init/taskSeedDb.ts +++ /dev/null @@ -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 }, - ) -}) diff --git a/startos/init/taskSeedEnableExplorer.ts b/startos/init/taskSeedEnableExplorer.ts deleted file mode 100644 index a935858..0000000 --- a/startos/init/taskSeedEnableExplorer.ts +++ /dev/null @@ -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 }, - ) -}) diff --git a/startos/init/taskSeedNacho.ts b/startos/init/taskSeedNacho.ts new file mode 100644 index 0000000..645e1bd --- /dev/null +++ b/startos/init/taskSeedNacho.ts @@ -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 }, + ) + } +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts index 24d1ed8..3882a52 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -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, ] }) diff --git a/startos/main.ts b/startos/main.ts index f7e4a5a..d713a19 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -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: '' }, mounts, 'indexer-sub') - // const explorerSub = await sdk.SubContainer.of( - // effects, { imageId: '' }, 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 < { 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 || '').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 || '').slice(0, 160), - stderr: (stderr || '').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 }) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 333ad02..4b90618 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -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:' }, 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:' }, 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, diff --git a/startos/utils.ts b/startos/utils.ts index 53f48ae..fad2beb 100644 --- a/startos/utils.ts +++ b/startos/utils.ts @@ -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 +} diff --git a/startos/versions/index.ts b/startos/versions/index.ts index bb3887d..e76b4af 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -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: [], }) diff --git a/startos/versions/v0.0.9.3.ts b/startos/versions/v0.1.1.2.ts similarity index 50% rename from startos/versions/v0.0.9.3.ts rename to startos/versions/v0.1.1.2.ts index e1c682d..e919889 100644 --- a/startos/versions/v0.0.9.3.ts +++ b/startos/versions/v0.1.1.2.ts @@ -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\`.